first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+306 -49
View File
@@ -2,12 +2,28 @@ use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use session_sharing_protocol::common::SessionId;
use ui_components::lightbox;
use galaxy_util::path::LineAndColumnArg;
use galaxyui::accessibility::AccessibilityVerbosity;
use galaxyui::geometry::rect::RectF;
use galaxyui::geometry::vector::Vector2F;
use galaxyui::platform::Cursor;
use galaxyui::{EntityId, WeakViewHandle, WindowId};
use super::global_actions::{ForkFromExchange, ForkedConversationDestination};
use super::tab_settings::{
VerticalTabsCompactSubtitle, VerticalTabsDisplayGranularity, VerticalTabsPrimaryInfo,
VerticalTabsTabItemMode, VerticalTabsViewMode,
};
use super::view::{OnboardingTutorial, WorkspaceBanner};
use crate::ai::agent::api::ServerConversationToken;
#[cfg(not(target_family = "wasm"))]
use crate::ai::agent::conversation::AIAgentHarness;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::AIAgentExchangeId;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::blocklist::PendingAttachment;
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
use crate::auth::auth_manager::LoginGatedFeature;
use crate::drive::items::WarpDriveItemId;
@@ -21,29 +37,15 @@ use crate::server::telemetry::{
AddTabWithShellSource, AgentModeEntrypoint, PaletteSource, SharingDialogSource,
};
use crate::settings_view::{SettingsAction as SettingsTabAction, SettingsSection};
use crate::tab::NewSessionMenuItem;
use crate::tab::{NewSessionMenuItem, SelectedTabColor};
use crate::tab_configs::TabConfig;
use crate::terminal::available_shells::AvailableShell;
use crate::terminal::view::inline_banner::ZeroStatePromptSuggestionType;
use crate::themes::theme::AnsiColorIdentifier;
use crate::themes::theme_chooser::ThemeChooserMode;
use crate::workflows::{WorkflowSelectionSource, WorkflowSource, WorkflowType};
use crate::workspace::tab_group::TabGroupId;
use crate::workspace::PaneViewLocator;
use session_sharing_protocol::common::SessionId;
use galaxyui::accessibility::AccessibilityVerbosity;
use galaxyui::geometry::rect::RectF;
use galaxyui::geometry::vector::Vector2F;
use galaxyui::platform::Cursor;
use galaxyui::{EntityId, WeakViewHandle, WindowId};
use ui_components::lightbox;
use super::global_actions::{ForkFromExchange, ForkedConversationDestination};
use super::tab_settings::{
VerticalTabsCompactSubtitle, VerticalTabsDisplayGranularity, VerticalTabsPrimaryInfo,
VerticalTabsTabItemMode, VerticalTabsViewMode,
};
use super::view::{OnboardingTutorial, WorkspaceBanner};
/// This enum determines how the search query is initialized when opening command search.
#[derive(Clone, Default, Debug)]
@@ -81,6 +83,27 @@ pub enum TabContextMenuAnchor {
VerticalTabsKebab,
}
/// Describes how the new-session dropdown menu was opened so the renderer
/// can pick the right anchor strategy.
#[derive(Debug, Clone, Copy)]
pub enum NewSessionMenuAnchor {
/// Menu was opened from the `+` add-tab button. When vertical tabs are
/// active, the renderer anchors below the button's save position;
/// otherwise the contained position is used directly.
AddTabButton(Vector2F),
/// Menu was opened by right-clicking the vertical tabs panel.
/// Always anchored at the contained pointer position.
Pointer(Vector2F),
}
impl NewSessionMenuAnchor {
pub fn position(&self) -> Vector2F {
match self {
Self::AddTabButton(position) | Self::Pointer(position) => *position,
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum VerticalTabsPaneContextMenuTarget {
ClickedPane(PaneViewLocator),
@@ -95,6 +118,12 @@ impl VerticalTabsPaneContextMenuTarget {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutoCloudHandoffTrigger {
MacOsSleep,
Uri,
}
#[derive(Debug, Clone)]
pub enum WorkspaceAction {
ActivateTab(usize),
@@ -112,11 +141,28 @@ pub enum WorkspaceAction {
RenamePane(PaneViewLocator),
ResetPaneName(PaneViewLocator),
RenameActiveTab,
/// Renames the focused pane in the active tab. Mirrors `RenameActiveTab`
/// so the action is reachable from the binding registry / Command Palette
/// (see #9351). The context-menu path keeps using `RenamePane(locator)`.
RenameActivePane,
SetActiveTabName(String),
/// Sets the manual color override for the active tab.
///
/// - `Color(_)` — apply that color.
/// - `Cleared` — explicitly clear (suppresses any directory default).
/// - `Unset` — remove the manual override (lets the directory default apply, if any).
SetActiveTabColor(SelectedTabColor),
ToggleTabRightClickMenu {
tab_index: usize,
anchor: TabContextMenuAnchor,
},
/// Toggles the multi-tab selection right-click menu.
/// Dispatched by the UI when the right-clicked tab is part of a multi-tab
/// selection (cmd-click or shift-click).
ToggleTabSelectionRightClickMenu {
tab_index: usize,
anchor: TabContextMenuAnchor,
},
ToggleVerticalTabsPaneContextMenu {
tab_index: usize,
target: VerticalTabsPaneContextMenuTarget,
@@ -134,6 +180,84 @@ pub enum WorkspaceAction {
CloseNonActiveTabs,
CloseTabsRight(usize),
CloseTabsRightActiveTab,
/// Close every tab that belongs to the given tab group.
CloseTabGroup(TabGroupId),
/// Toggle collapsed state for the given tab group.
ToggleTabGroupCollapsed(TabGroupId),
/// Opens an inline editor over the given group's header for renaming.
RenameTabGroup(TabGroupId),
/// Cancels any active rename (tab, pane, or group) without committing the
/// new name. Dispatched when clicking on the vtab panel background while a
/// rename editor is open.
CancelActiveRename,
/// Creates a new tab group containing the tab at the given index.
NewTabGroupFromTab(usize),
/// Moves the tab at `tab_index` into `group_id`, appending it to the
/// end of the group's contiguous run.
MoveTabToGroup {
tab_index: usize,
group_id: TabGroupId,
},
/// Removes the tab at the given index from its current group.
RemoveTabFromGroup(usize),
/// Selects every tab between the active tab and the shift-clicked row (inclusive).
ShiftSelectTabRange {
locator: PaneViewLocator,
},
/// Toggles whether the tab at `locator` is part of the active multi-selection.
/// Dispatched on cmd-click of a vertical tab row.
ToggleTabMultiSelection {
locator: PaneViewLocator,
},
/// Clears the tab multi-selection. Dispatched from the UI when the user takes
/// an action that should cancel any active selections.
ClearTabMultiSelection,
/// Creates a new tab group from the current tab multi-selection.
NewTabGroupFromSelectedTabs,
/// Context-aware "create group" entry point for the keybinding: groups
/// the multi-selection when 2+ tabs are selected, otherwise groups the
/// active tab.
NewTabGroupFromActiveOrSelectedTabs,
/// Moves every selected tab into `group_id`.
MoveSelectedTabsToGroup {
group_id: TabGroupId,
},
/// Removes every selected tab from its group (requires a single shared group).
RemoveSelectedTabsFromGroup,
/// Context-aware "remove from group" entry point for the keybinding:
/// removes the multi-selection from its shared group when 2+ tabs are
/// selected, otherwise removes the active tab.
RemoveActiveOrSelectedTabsFromGroup,
ToggleTabGroupRightClickMenu {
group_id: TabGroupId,
anchor: TabContextMenuAnchor,
},
UngroupTabs(TabGroupId),
NewTabInGroup(TabGroupId),
MoveTabGroupUp(TabGroupId),
MoveTabGroupDown(TabGroupId),
CloseTabsOutsideGroup(TabGroupId),
CloseTabsAboveGroup(TabGroupId),
CloseTabsBelowGroup(TabGroupId),
/// Pins the tab at the given index. If the tab is part of a group, it
/// is first extracted from the group and then pinned as ungrouped.
PinTab(usize),
/// Unpins the tab at the given index.
UnpinTab(usize),
/// Pins the active tab.
PinActiveTab,
/// Unpins the active tab.
UnpinActiveTab,
/// Pins the entire tab group: sets the group as pinned
/// and moves the group block to the end of the pinned region.
PinTabGroup(TabGroupId),
/// Unpins the entire tab group: clears the pinned flag on the group
/// and moves the group block to the start of the unpinned region.
UnpinTabGroup(TabGroupId),
/// Pins the active tab's group.
PinActiveTabGroup,
/// Unpins the active tab's group.
UnpinActiveTabGroup,
AddDefaultTab,
AddTerminalTab {
hide_homepage: bool,
@@ -149,12 +273,11 @@ pub enum WorkspaceAction {
/// Add a new tab running a local Docker sandbox via `sbx`.
AddDockerSandboxTab,
OpenNewSessionMenu {
position: Vector2F,
anchor: NewSessionMenuAnchor,
},
ToggleTabConfigsMenu,
ToggleNewSessionMenu {
position: Vector2F,
is_vertical_tabs: bool,
anchor: NewSessionMenuAnchor,
},
SelectNewSessionMenuItem(NewSessionMenuItem),
AutoupdateFailureLink,
@@ -211,6 +334,12 @@ pub enum WorkspaceAction {
color: AnsiColorIdentifier,
tab_index: usize,
},
/// Toggles the color for a tab group. Clears the color if it was already
/// set to `color`; otherwise applies `color` as the uniform group color.
ToggleTabGroupColor {
color: AnsiColorIdentifier,
group_id: TabGroupId,
},
OpenLaunchConfigSaveModal,
SelectTabConfig(TabConfig),
DispatchToSettingsTab(SettingsTabAction),
@@ -240,16 +369,16 @@ pub enum WorkspaceAction {
tab_index: usize,
tab_position: RectF,
},
HandoffPendingTransfer {
target_window_id: WindowId,
insertion_index: usize,
},
ReverseHandoff {
target_window_id: WindowId,
target_insertion_index: usize,
},
DropTab,
FinalizeDropTab,
StartGroupDrag(TabGroupId),
DragGroup {
group_id: TabGroupId,
/// The dragged group's painted rect.
position: RectF,
/// The position of the cursor while dragging a group.
cursor_position: Vector2F,
},
DropGroup,
/// Toggles the left panel. In Code Mode V1 this toggles Warp Drive.
/// In Code Mode V2 this toggles the left panel which contains both the project explorer and
/// Warp Drive. This happens as explicit action from the user.
@@ -266,6 +395,7 @@ pub enum WorkspaceAction {
OpenCodeReviewPanel(PaneViewLocator),
/// Toggles the vertical tabs panel. This happens as an explicit action from the user.
ToggleVerticalTabsPanel,
OpenVerticalTabsPanel,
ToggleVerticalTabsSettingsPopup,
SetVerticalTabsDisplayGranularity(VerticalTabsDisplayGranularity),
SetVerticalTabsTabItemMode(VerticalTabsTabItemMode),
@@ -333,6 +463,9 @@ pub enum WorkspaceAction {
CopySharedSessionLinkFromTab {
tab_index: usize,
},
OpenSharedSessionQrCode {
session_id: SessionId,
},
AddWindow,
AddWindowWithShell {
shell: AvailableShell,
@@ -391,6 +524,8 @@ pub enum WorkspaceAction {
},
OpenCloudAgentSetupGuide,
AttemptLoginGatedAIUpgrade,
/// Open the modal explaining Prompt Suggestions aren't available on the Free plan.
OpenPromptSuggestionsUnavailableModal,
/// Dismisses the Wayland crash recovery banner and opens a link to our docs page with more
/// information.
#[cfg(target_os = "linux")]
@@ -470,6 +605,8 @@ pub enum WorkspaceAction {
summarization_prompt: Option<String>,
/// Initial prompt to send in the forked conversation (sent after summarization if enabled).
initial_prompt: Option<String>,
/// Attachments (images/files) to send along with the initial prompt in the forked pane.
initial_attachments: Vec<PendingAttachment>,
/// Where to open the forked conversation.
destination: ForkedConversationDestination,
},
@@ -479,24 +616,64 @@ pub enum WorkspaceAction {
ContinueConversationLocally {
conversation_id: AIConversationId,
},
/// Continue a completed third-party cloud harness run in a local split pane.
#[cfg(not(target_family = "wasm"))]
ContinueThirdPartyConversationLocally {
task_id: AmbientAgentTaskId,
harness: AIAgentHarness,
},
/// Insert the /fork slash command into the active terminal's input.
InsertForkSlashCommand,
/// Open a local-to-cloud handoff pane next to the active conversation
/// (REMOTE-1486). Triggered by the `/move-to-cloud` slash command
/// and the footer chip of the same name. The dispatch site reads the
/// active conversation's `server_conversation_token` and gates on
/// `FeatureFlag::OzHandoff && FeatureFlag::HandoffLocalCloud`.
/// Falls through to splitting a fresh cloud-mode pane when the active
/// conversation isn't handoff-able (no synced server token, empty, or no
/// active conversation at all).
OpenLocalToCloudHandoffPane {
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
launch: Option<crate::ai::blocklist::handoff::PendingCloudLaunch>,
#[cfg(not(all(feature = "local_fs", not(target_family = "wasm"))))]
launch: Option<()>,
environment_id: Option<crate::server::ids::SyncId>,
entry_point: crate::ai::ambient_agents::telemetry::HandoffEntryPoint,
},
/// Automatically hand off the active running local agent conversation in the
/// given terminal view to Cloud Mode.
AutoHandoffActiveAgentToCloud {
terminal_view_id: EntityId,
conversation_id: AIConversationId,
trigger: AutoCloudHandoffTrigger,
},
/// Show the environment creation modal during `&` handoff compose when no
/// environments exist.
ShowHandoffEnvironmentCreationModal,
ShowCloudModeV2EnvironmentCreationModal,
/// Open the workspace modal for creating a new managed auth secret.
/// Dispatched by orchestration card pickers' "New API key…" item.
OpenCreateAuthSecretModal {
harness: warp_cli::agent::Harness,
},
/// Summarize the active AI conversation in the focused pane.
SummarizeAIConversation {
prompt: Option<String>,
/// Optional prompt to send after summarization completes successfully.
initial_prompt: Option<String>,
},
/// Queue a prompt to be sent after the current conversation finishes.
QueuePromptForConversation {
prompt: String,
},
/// Install the Warp CLI command to /usr/local/bin
/// Install the Oz CLI command to /usr/local/bin
#[cfg(target_os = "macos")]
InstallCLI,
/// Uninstall the Warp CLI command from /usr/local/bin
InstallOz,
/// Uninstall the Oz CLI command from /usr/local/bin
#[cfg(target_os = "macos")]
UninstallCLI,
UninstallOz,
/// Install the Warp Control CLI command to /usr/local/bin
#[cfg(target_os = "macos")]
InstallWarpctrl,
/// Uninstall the Warp Control CLI command from /usr/local/bin
#[cfg(target_os = "macos")]
UninstallWarpctrl,
UndoRevertInCodeReviewPane {
window_id: WindowId,
view_id: EntityId,
@@ -530,9 +707,13 @@ pub enum WorkspaceAction {
NavigatePrevPaneOrPanel,
NavigateNextPaneOrPanel,
ToggleProjectExplorer,
OpenProjectExplorer,
ToggleGlobalSearch,
ToggleHiddenFiles,
OpenGlobalSearch,
ToggleConversationListView,
OpenConversationListView,
OpenAgentManagementView,
/// Open the Build Plan Migration Modal (for debugging)
#[cfg(debug_assertions)]
OpenBuildPlanMigrationModal,
@@ -554,6 +735,28 @@ pub enum WorkspaceAction {
/// Reset the OpenWarp launch modal dismissed state (for debugging)
#[cfg(debug_assertions)]
ResetOpenWarpLaunchModalState,
/// Open the Orchestration Launch Modal (for debugging)
#[cfg(debug_assertions)]
OpenOrchestrationLaunchModal,
/// Reset the orchestration launch modal dismissed state (for debugging)
#[cfg(debug_assertions)]
ResetOrchestrationLaunchModalState,
/// Open the auto-handoff sleep modal (for debugging)
#[cfg(debug_assertions)]
OpenAutoHandoffSleepModal,
/// Reset the auto-handoff sleep modal shown state (for debugging)
#[cfg(debug_assertions)]
ResetAutoHandoffSleepModalState,
/// Trigger the auto-handoff-to-cloud flow in-process, as if the machine
/// were about to sleep (for debugging)
#[cfg(debug_assertions)]
TriggerAutoHandoffToCloud,
/// Open the Free AI Removal Modal (for debugging)
#[cfg(debug_assertions)]
OpenFreeAiRemovalModal,
/// Reset the free AI removal modal seen state (for debugging)
#[cfg(debug_assertions)]
ResetFreeAiRemovalModalState,
/// Install the opencode-warp plugin from GitHub into the global opencode config.
#[cfg(debug_assertions)]
InstallOpenCodeWarpPlugin,
@@ -587,9 +790,8 @@ pub enum WorkspaceAction {
conversation_id: AIConversationId,
terminal_view_id: Option<EntityId>,
},
/// Open an ambient agent session by joining its shared session.
/// Used when the sandbox is running or when we need to view a live session.
OpenAmbientAgentSession {
/// Open the canonical ambient agent conversation pane and attach it to a live session.
OpenOrAttachAmbientAgentConversation {
session_id: SessionId,
task_id: AmbientAgentTaskId,
},
@@ -682,7 +884,6 @@ impl From<&WorkspaceAction> for LoginGatedFeature {
impl WorkspaceAction {
pub fn blocked_for_anonymous_user(&self) -> bool {
use WorkspaceAction::*;
matches!(
self,
ImportToTeamDrive
@@ -696,13 +897,14 @@ impl WorkspaceAction {
}
/// Matches what actions require the app state to be saved, and which don't. We match all
/// actions directly, rather than using _, so we're forced to make a concious decision for each
/// actions directly, rather than using _, so we're forced to make a conscious decision for each
/// of them, rather than following some default.
pub fn should_save_app_state_on_action(&self) -> bool {
use WorkspaceAction::*;
match self {
#[cfg(not(target_family = "wasm"))]
ContinueConversationLocally { .. } => true,
#[cfg(not(target_family = "wasm"))]
ContinueThirdPartyConversationLocally { .. } => true,
ActivateTab(_)
| ActivateTabByNumber(_)
| ActivatePrevTab
@@ -715,19 +917,49 @@ impl WorkspaceAction {
| MoveTabLeft(_)
| MoveTabRight(_)
| DropTab
| DropGroup
| RenameTab(_)
| ResetTabName(_)
| RenamePane(_)
| ResetPaneName(_)
| RenameActiveTab
| RenameActivePane
| SetActiveTabName(_)
| SetActiveTabColor(_)
| CloseTab(_)
| CloseActiveTab
| CloseOtherTabs(_)
| CloseNonActiveTabs
| CloseTabsRight(_)
| CloseTabsRightActiveTab
| CloseTabGroup(_)
| ToggleTabGroupCollapsed(_)
| RenameTabGroup(_)
| NewTabGroupFromTab(_)
| MoveTabToGroup { .. }
| RemoveTabFromGroup(_)
| NewTabGroupFromSelectedTabs
| NewTabGroupFromActiveOrSelectedTabs
| MoveSelectedTabsToGroup { .. }
| RemoveSelectedTabsFromGroup
| RemoveActiveOrSelectedTabsFromGroup
| UngroupTabs(_)
| NewTabInGroup(_)
| MoveTabGroupUp(_)
| MoveTabGroupDown(_)
| CloseTabsOutsideGroup(_)
| CloseTabsAboveGroup(_)
| CloseTabsBelowGroup(_)
| PinTab(_)
| UnpinTab(_)
| PinActiveTab
| UnpinActiveTab
| PinTabGroup(_)
| UnpinTabGroup(_)
| PinActiveTabGroup
| UnpinActiveTabGroup
| ToggleTabColor { .. }
| ToggleTabGroupColor { .. }
| AddDefaultTab
| AddTerminalTab { .. }
| AddTabWithShell { .. }
@@ -751,7 +983,8 @@ impl WorkspaceAction {
| SummarizeAIConversation { .. }
| OpenRepository { .. }
| SelectTabConfig(_)
| ToggleVerticalTabsPanel => true, // actions that actually change a state of the state of user's
| ToggleVerticalTabsPanel
| OpenVerticalTabsPanel => true, // actions that actually change a state of the state of user's
// workspace would most likely require a save, so that if the app gets
// restarted, the user can continue working
AutoupdateFailureLink
@@ -786,6 +1019,8 @@ impl WorkspaceAction {
| ToggleSyntaxHighlighting
| OpenLaunchConfigSaveModal
| ToggleTabRightClickMenu { .. }
| ToggleTabSelectionRightClickMenu { .. }
| ToggleTabGroupRightClickMenu { .. }
| ToggleVerticalTabsPaneContextMenu { .. }
| OpenNewSessionMenu { .. }
| ToggleTabConfigsMenu
@@ -801,6 +1036,7 @@ impl WorkspaceAction {
| ClickedAIAssistantIcon
| ToggleAIAssistant
| OpenCloudAgentSetupGuide
| OpenPromptSuggestionsUnavailableModal
| ToggleKeybindingsPage
| ShowCommandSearch(_)
| ToggleMouseReporting
@@ -820,10 +1056,9 @@ impl WorkspaceAction {
| CreateTeamAIPrompt
| OpenInExplorer { .. }
| DragTab { .. }
| HandoffPendingTransfer { .. }
| ReverseHandoff { .. }
| StartTabDrag
| FinalizeDropTab
| DragGroup { .. }
| StartGroupDrag(_)
| ToggleLeftPanel
| ToggleGalaxyDrive
| OpenGalaxyDrive
@@ -873,6 +1108,7 @@ impl WorkspaceAction {
| StopSharingSessionFromTabMenu { .. }
| StopSharingAllSessionsInTab { .. }
| CopySharedSessionLinkFromTab { .. }
| OpenSharedSessionQrCode { .. }
| ReopenClosedSession
| FocusLeftPanel
| FocusRightPanel
@@ -886,7 +1122,6 @@ impl WorkspaceAction {
| RunCommand { .. }
| InsertInInput { .. }
| InsertForkSlashCommand
| QueuePromptForConversation { .. }
| AttemptLoginGatedAIUpgrade
| UndoTrash(_)
| OpenFilePath { .. }
@@ -900,17 +1135,25 @@ impl WorkspaceAction {
| OpenMCPServerCollection
| FocusTerminalViewInWorkspace { .. }
| FocusPane(..)
| ShiftSelectTabRange { .. }
| ToggleTabMultiSelection { .. }
| ClearTabMultiSelection
| CancelActiveRename
| StartNewConversation { .. }
| UndoRevertInCodeReviewPane { .. }
| JumpToLatestToast
| NavigatePrevPaneOrPanel
| NavigateNextPaneOrPanel
| ToggleProjectExplorer
| OpenProjectExplorer
| ToggleGlobalSearch
| ToggleHiddenFiles
| OpenGlobalSearch
| ToggleConversationListView
| OpenConversationListView
| ToggleNotificationMailbox { .. }
| ToggleAgentManagementView
| OpenAgentManagementView
| ViewAgentRunsForEnvironment { .. }
| ToggleAIDocumentPane { .. }
| HideAIDocumentPanes
@@ -918,7 +1161,7 @@ impl WorkspaceAction {
| ShowRewindConfirmationDialog { .. }
| ExecuteRewindAIConversation { .. }
| ExecuteDeleteConversation { .. }
| OpenAmbientAgentSession { .. }
| OpenOrAttachAmbientAgentConversation { .. }
| OpenConversationTranscriptViewer { .. }
| OpenLightbox { .. }
| UpdateLightboxImage { .. }
@@ -933,6 +1176,11 @@ impl WorkspaceAction {
| TabConfigSidecarRemoveConfig { .. }
| OpenSettingsFile
| FixSettingsWithOz { .. }
| OpenLocalToCloudHandoffPane { .. }
| AutoHandoffActiveAgentToCloud { .. }
| ShowHandoffEnvironmentCreationModal
| ShowCloudModeV2EnvironmentCreationModal
| OpenCreateAuthSecretModal { .. }
| OpenNetworkLogPane => false,
#[cfg(debug_assertions)]
ShowHoaOnboardingFlow => false,
@@ -946,6 +1194,13 @@ impl WorkspaceAction {
| ResetOzLaunchModalState
| OpenOpenWarpLaunchModal
| ResetOpenWarpLaunchModalState
| OpenOrchestrationLaunchModal
| ResetOrchestrationLaunchModalState
| OpenAutoHandoffSleepModal
| ResetAutoHandoffSleepModalState
| TriggerAutoHandoffToCloud
| OpenFreeAiRemovalModal
| ResetFreeAiRemovalModalState
| InstallOpenCodeWarpPlugin
| UseLocalOpenCodeWarpPlugin => false,
#[cfg(not(target_family = "wasm"))]
@@ -953,7 +1208,9 @@ impl WorkspaceAction {
#[cfg(target_os = "macos")]
SampleProcess => false,
#[cfg(target_os = "macos")]
InstallCLI | UninstallCLI => false,
InstallOz | UninstallOz => false,
#[cfg(target_os = "macos")]
InstallWarpctrl | UninstallWarpctrl => false,
#[cfg(feature = "local_fs")]
FileRenamed { .. } => false, // File rename doesn't change workspace state
#[cfg(feature = "local_fs")]
+6 -1
View File
@@ -1,3 +1,5 @@
use warpui::EntityId;
use super::WorkspaceAction;
use crate::pane_group::TerminalPaneId;
use crate::workspace::tab_settings::{
@@ -5,7 +7,6 @@ use crate::workspace::tab_settings::{
VerticalTabsViewMode,
};
use crate::workspace::PaneViewLocator;
use galaxyui::EntityId;
#[test]
fn vertical_tabs_view_mode_change_does_not_save_workspace_state() {
@@ -74,4 +75,8 @@ fn pane_name_actions_save_workspace_state() {
assert!(WorkspaceAction::RenamePane(locator).should_save_app_state_on_action());
assert!(WorkspaceAction::ResetPaneName(locator).should_save_app_state_on_action());
// GH-9351: the keyboard-bindable variant must persist app state on the
// same conditions as the locator-based one, since both ultimately drive
// `rename_pane` which mutates `pane_configuration`.
assert!(WorkspaceAction::RenameActivePane.should_save_app_state_on_action());
}
+23 -13
View File
@@ -1,9 +1,10 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::{Arc, Weak},
};
use std::collections::HashMap;
use std::path::Path;
#[cfg(test)]
use std::path::PathBuf;
use std::sync::{Arc, Weak};
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxyui::{Entity, EntityId, ModelContext, SingletonEntity, WindowId};
use crate::terminal::model::session::Session;
@@ -28,8 +29,8 @@ struct WindowActiveSession {
/// The [`Session`] model for the active session. This is a weak reference so that it doesn't
/// prevent cleaning up the session when it closes, in case no other session is activated.
session: Option<Weak<Session>>,
/// The active session's working directory, if it's local.
path_if_local: Option<PathBuf>,
/// The active session's working directory (local or remote).
working_directory: Option<LocalOrRemotePath>,
/// The [`EntityId`]` for the [`TerminalView`] for the active session, if there is one.
terminal_view_id: Option<EntityId>,
}
@@ -52,8 +53,17 @@ impl ActiveSession {
pub fn path_if_local(&self, window_id: WindowId) -> Option<&Path> {
self.window_sessions
.get(&window_id)?
.path_if_local
.as_deref()
.working_directory
.as_ref()
.and_then(|wd| wd.to_local_path())
}
/// The current working directory of the active session (local or remote).
pub fn working_directory(&self, window_id: WindowId) -> Option<&LocalOrRemotePath> {
self.window_sessions
.get(&window_id)?
.working_directory
.as_ref()
}
/// Set the current session, for use in tests.
@@ -69,7 +79,7 @@ impl ActiveSession {
self.set_session_state(
window_id,
Some(session),
path_if_local.map(Into::into),
path_if_local.map(|p| LocalOrRemotePath::Local(p.into())),
terminal_view_id,
ctx,
);
@@ -79,7 +89,7 @@ impl ActiveSession {
&mut self,
window_id: WindowId,
session: Option<Arc<Session>>,
path_if_local: Option<PathBuf>,
working_directory: Option<LocalOrRemotePath>,
terminal_view_id: Option<EntityId>,
ctx: &mut ModelContext<Self>,
) {
@@ -100,8 +110,8 @@ impl ActiveSession {
}
}
if window_state.path_if_local != path_if_local {
window_state.path_if_local = path_if_local;
if window_state.working_directory != working_directory {
window_state.working_directory = working_directory;
ctx.notify();
}
+443
View File
@@ -0,0 +1,443 @@
use std::collections::HashSet;
use galaxy_core::send_telemetry_from_ctx;
use warpui::{
AppContext, Entity, EntityId, ModelContext, SingletonEntity, TypedActionView, ViewHandle,
WindowId,
};
use super::{
AutoCloudHandoffTrigger, OneTimeModalModel, ToastStack, Workspace, WorkspaceAction,
WorkspaceRegistry,
};
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
use crate::ai::agent::conversation::{AIConversation, AIConversationId};
use crate::ai::ambient_agents::telemetry::CloudAgentTelemetryEvent;
use crate::ai::blocklist::orchestration_topology::has_local_orchestrated_children;
use crate::settings::AISettings;
use crate::system::{SystemStats, SystemStatsEvent};
use crate::terminal::view::TerminalView;
use crate::view_components::DismissibleToast;
use crate::BlocklistAIHistoryModel;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AutoCloudHandoffSkipReason {
EmptyConversation,
NotInProgress,
MissingServerConversationToken,
SharedSessionViewer,
CloudHandoffUnavailable,
OrchestratorWithLocalChildren,
AlreadyAttempted,
NoFocusedConversation,
TerminalNotFound { terminal_view_id: EntityId },
CloudPane,
LongRunningCommand,
ConversationNotLoaded { conversation_id: AIConversationId },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AutoCloudHandoffEligibility {
pub(crate) is_empty: bool,
pub(crate) is_in_progress: bool,
pub(crate) has_server_conversation_token: bool,
pub(crate) is_viewing_shared_session: bool,
pub(crate) can_handoff_to_cloud: bool,
pub(crate) already_attempted: bool,
/// True when the focused conversation is an orchestrator with at least one
/// active local child agent. Handing such a session off to the cloud would
/// fork only the parent and orphan its local children, so we skip it.
pub(crate) has_local_orchestrated_children: bool,
}
impl AutoCloudHandoffEligibility {
pub(crate) fn from_conversation(
conversation: &AIConversation,
can_handoff_to_cloud: bool,
already_attempted: bool,
has_local_orchestrated_children: bool,
) -> Self {
Self {
is_empty: conversation.is_empty(),
is_in_progress: conversation.status().is_in_progress(),
has_server_conversation_token: conversation.server_conversation_token().is_some(),
is_viewing_shared_session: conversation.is_viewing_shared_session(),
can_handoff_to_cloud,
already_attempted,
has_local_orchestrated_children,
}
}
pub(crate) fn skip_reason(self) -> Option<AutoCloudHandoffSkipReason> {
if self.already_attempted {
return Some(AutoCloudHandoffSkipReason::AlreadyAttempted);
}
if self.is_viewing_shared_session {
return Some(AutoCloudHandoffSkipReason::SharedSessionViewer);
}
if self.is_empty {
return Some(AutoCloudHandoffSkipReason::EmptyConversation);
}
if !self.is_in_progress {
return Some(AutoCloudHandoffSkipReason::NotInProgress);
}
if self.has_local_orchestrated_children {
return Some(AutoCloudHandoffSkipReason::OrchestratorWithLocalChildren);
}
if !self.has_server_conversation_token {
return Some(AutoCloudHandoffSkipReason::MissingServerConversationToken);
}
if !self.can_handoff_to_cloud {
return Some(AutoCloudHandoffSkipReason::CloudHandoffUnavailable);
}
None
}
}
pub(crate) struct AutoCloudHandoffRequest {
workspace: ViewHandle<Workspace>,
terminal_view_id: EntityId,
conversation_id: AIConversationId,
trigger: AutoCloudHandoffTrigger,
}
/// A focused local agent conversation that passed every auto-handoff
/// precondition, resolved to the views needed to dispatch the handoff.
struct AutoCloudHandoffCandidate {
window_id: WindowId,
workspace: ViewHandle<Workspace>,
terminal_view_id: EntityId,
conversation_id: AIConversationId,
}
impl AutoCloudHandoffRequest {
fn dispatch(&self, ctx: &mut AppContext) {
self.workspace.update(ctx, |workspace, ctx| {
workspace.handle_action(
&WorkspaceAction::AutoHandoffActiveAgentToCloud {
terminal_view_id: self.terminal_view_id,
conversation_id: self.conversation_id,
trigger: self.trigger,
},
ctx,
);
});
}
}
pub(crate) struct AutoCloudHandoffController {
attempted_conversation_ids: HashSet<AIConversationId>,
/// Set at sleep time when an eligible in-progress local agent run would have
/// been handed off but `auto_handoff_on_sleep_enabled` is off. Consumed on
/// wake to surface the discoverability modal.
pending_sleep_prompt: bool,
/// True between `CpuWillSleep` and `CpuWasAwakened`. Used to decide whether
/// a handoff success toast can be shown right away or must wait for wake.
is_system_sleeping: bool,
/// Window of an automatic handoff that succeeded while the system was
/// sleeping. Consumed on wake to show the success toast once the user can
/// actually see it.
pending_success_toast_window: Option<WindowId>,
}
impl AutoCloudHandoffController {
pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
ctx.subscribe_to_model(&SystemStats::handle(ctx), |controller, _, event, ctx| {
controller.handle_system_stats_event(event, ctx);
});
Self {
attempted_conversation_ids: HashSet::new(),
pending_sleep_prompt: false,
is_system_sleeping: false,
pending_success_toast_window: None,
}
}
/// Marks the attempt as succeeded and surfaces the success toast:
/// immediately when the system is awake (e.g. the fork RPC resolved after
/// wake), otherwise deferred until `CpuWasAwakened` so the ephemeral
/// toast's dismissal timeout doesn't elapse while the user is away.
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
pub(crate) fn record_handoff_succeeded(
&mut self,
conversation_id: AIConversationId,
window_id: WindowId,
ctx: &mut ModelContext<Self>,
) {
self.attempted_conversation_ids.insert(conversation_id);
if self.is_system_sleeping {
self.pending_success_toast_window = Some(window_id);
} else {
Self::show_success_toast(window_id, ctx);
}
}
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
pub(crate) fn record_handoff_failed(&mut self, conversation_id: AIConversationId) {
self.attempted_conversation_ids.remove(&conversation_id);
}
fn handle_system_stats_event(
&mut self,
event: &SystemStatsEvent,
ctx: &mut ModelContext<Self>,
) {
match event {
SystemStatsEvent::CpuWillSleep => {
self.is_system_sleeping = true;
self.handle_cpu_will_sleep(ctx);
}
SystemStatsEvent::CpuWasAwakened => {
self.is_system_sleeping = false;
self.maybe_show_success_toast(ctx);
self.maybe_show_sleep_prompt(ctx);
}
}
}
/// On wake, shows the success toast for an automatic handoff that
/// completed while the system was sleeping.
fn maybe_show_success_toast(&mut self, ctx: &mut ModelContext<Self>) {
if let Some(window_id) = self.pending_success_toast_window.take() {
Self::show_success_toast(window_id, ctx);
}
}
fn show_success_toast(window_id: WindowId, ctx: &mut ModelContext<Self>) {
log::info!("auto handoff: showing success toast in window {window_id:?}");
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::success("Handed session off to the cloud".to_owned()),
window_id,
ctx,
);
});
}
/// At sleep time, hands the focused eligible local agent run off to the
/// cloud when `auto_handoff_on_sleep_enabled` is on. When the setting is
/// off, records a pending discoverability prompt instead; the modal is
/// surfaced on wake by [`Self::maybe_show_sleep_prompt`] and shown at most
/// once per user (enforced by `OneTimeModalModel`).
fn handle_cpu_will_sleep(&mut self, ctx: &mut ModelContext<Self>) {
self.pending_sleep_prompt = false;
let candidate = match self.evaluate_handoff_candidate(ctx) {
Ok(candidate) => candidate,
Err(reason) => {
log::info!("auto handoff: skipping at sleep: {reason:?}");
return;
}
};
if AISettings::as_ref(ctx).is_auto_handoff_on_sleep_enabled(ctx) {
self.dispatch_handoff(candidate, AutoCloudHandoffTrigger::MacOsSleep, ctx);
} else {
log::info!(
"auto-handoff sleep prompt: recorded pending prompt for conversation {:?} in terminal {:?}",
candidate.conversation_id,
candidate.terminal_view_id,
);
self.pending_sleep_prompt = true;
}
}
/// On wake, surfaces the discoverability modal recorded at sleep time, as
/// long as the setting is still off. The modal itself is once-ever per
/// user; `OneTimeModalModel` enforces that.
fn maybe_show_sleep_prompt(&mut self, ctx: &mut ModelContext<Self>) {
if !std::mem::take(&mut self.pending_sleep_prompt) {
log::info!(
"auto-handoff sleep prompt: nothing to show on wake, no pending prompt was recorded at sleep"
);
return;
}
if AISettings::as_ref(ctx).is_auto_handoff_on_sleep_enabled(ctx) {
log::info!(
"auto-handoff sleep prompt: not showing on wake, auto-handoff-on-sleep was enabled in the meantime"
);
return;
}
let shown = OneTimeModalModel::handle(ctx).update(ctx, |model, ctx| {
model.check_and_trigger_auto_handoff_sleep_modal(ctx)
});
if shown {
log::info!("auto-handoff sleep prompt: showing modal on wake");
send_telemetry_from_ctx!(CloudAgentTelemetryEvent::SleepPromptShown, ctx);
} else {
log::info!(
"auto-handoff sleep prompt: not showing on wake, modal was already shown once"
);
}
}
fn trigger(&mut self, trigger: AutoCloudHandoffTrigger, ctx: &mut ModelContext<Self>) {
if !Self::is_trigger_enabled(trigger, ctx) {
log::info!(
"auto handoff: skipping {trigger:?} trigger, auto-handoff-on-sleep is disabled"
);
return;
}
match self.evaluate_handoff_candidate(ctx) {
Ok(candidate) => self.dispatch_handoff(candidate, trigger, ctx),
Err(reason) => log::info!("auto handoff: skipping {trigger:?} trigger: {reason:?}"),
}
}
/// Resolves the focused local agent conversation and checks every
/// precondition shared by automatic handoff and the sleep discoverability
/// prompt. Returns the resolved candidate, or the first reason it must be
/// skipped.
fn evaluate_handoff_candidate(
&self,
ctx: &ModelContext<Self>,
) -> Result<AutoCloudHandoffCandidate, AutoCloudHandoffSkipReason> {
let Some((terminal_view_id, conversation_id)) = Self::last_focused_local_conversation(ctx)
else {
return Err(AutoCloudHandoffSkipReason::NoFocusedConversation);
};
let Some((window_id, workspace, terminal_view)) =
Self::find_workspace_and_terminal(terminal_view_id, ctx)
else {
return Err(AutoCloudHandoffSkipReason::TerminalNotFound { terminal_view_id });
};
if terminal_view
.as_ref(ctx)
.ambient_agent_view_model()
.is_some()
{
return Err(AutoCloudHandoffSkipReason::CloudPane);
}
if terminal_view.as_ref(ctx).has_active_long_running_command() {
return Err(AutoCloudHandoffSkipReason::LongRunningCommand);
}
let history = BlocklistAIHistoryModel::as_ref(ctx);
let Some(conversation) = history.conversation(&conversation_id) else {
return Err(AutoCloudHandoffSkipReason::ConversationNotLoaded { conversation_id });
};
let can_handoff_to_cloud = AISettings::as_ref(ctx).is_cloud_handoff_enabled(ctx);
if let Some(reason) = AutoCloudHandoffEligibility::from_conversation(
conversation,
can_handoff_to_cloud,
self.attempted_conversation_ids.contains(&conversation_id),
has_local_orchestrated_children(history, conversation_id),
)
.skip_reason()
{
return Err(reason);
}
Ok(AutoCloudHandoffCandidate {
window_id,
workspace,
terminal_view_id,
conversation_id,
})
}
/// Marks the candidate as attempted and emits the handoff request.
fn dispatch_handoff(
&mut self,
candidate: AutoCloudHandoffCandidate,
trigger: AutoCloudHandoffTrigger,
ctx: &mut ModelContext<Self>,
) {
self.attempted_conversation_ids
.insert(candidate.conversation_id);
log::info!(
"Triggering auto handoff to cloud for conversation {:?} in window {:?} via {trigger:?}",
candidate.conversation_id,
candidate.window_id,
);
ctx.emit(AutoCloudHandoffRequest {
workspace: candidate.workspace,
terminal_view_id: candidate.terminal_view_id,
conversation_id: candidate.conversation_id,
trigger,
});
}
fn last_focused_local_conversation(
ctx: &ModelContext<Self>,
) -> Option<(EntityId, AIConversationId)> {
let active_agent_views = ActiveAgentViewsModel::as_ref(ctx);
let conversation_id = match active_agent_views.get_last_focused_conversation()? {
ConversationOrTaskId::ConversationId(conversation_id) => conversation_id,
ConversationOrTaskId::TaskId(_) => return None,
};
// The last-focused terminal id can go stale (e.g. its pane was closed
// or swapped) while the conversation lives on in another view. Prefer
// the history model's owner mapping — it's the same mapping the
// handoff flow validates against — then the agent-view registry, and
// only fall back to the last-focused id.
let terminal_view_id = BlocklistAIHistoryModel::as_ref(ctx)
.terminal_surface_id_for_conversation(&conversation_id)
.or_else(|| {
active_agent_views.get_terminal_view_id_for_conversation(conversation_id, ctx)
})
.or_else(|| active_agent_views.get_last_focused_terminal_id())?;
Some((terminal_view_id, conversation_id))
}
fn is_trigger_enabled(trigger: AutoCloudHandoffTrigger, ctx: &ModelContext<Self>) -> bool {
match trigger {
AutoCloudHandoffTrigger::MacOsSleep | AutoCloudHandoffTrigger::Uri => {
AISettings::as_ref(ctx).is_auto_handoff_on_sleep_enabled(ctx)
}
}
}
fn find_workspace_and_terminal(
terminal_view_id: EntityId,
ctx: &ModelContext<Self>,
) -> Option<(WindowId, ViewHandle<Workspace>, ViewHandle<TerminalView>)> {
WorkspaceRegistry::as_ref(ctx)
.all_workspaces(ctx)
.into_iter()
.find_map(|(window_id, workspace)| {
let terminal_view = workspace.as_ref(ctx).terminal_view(terminal_view_id, ctx)?;
Some((window_id, workspace, terminal_view))
})
}
}
impl Entity for AutoCloudHandoffController {
type Event = AutoCloudHandoffRequest;
}
impl SingletonEntity for AutoCloudHandoffController {}
pub(crate) fn init(app: &mut AppContext) {
let controller = app.add_singleton_model(AutoCloudHandoffController::new);
app.subscribe_to_model(&controller, |_, request, ctx| {
request.dispatch(ctx);
});
}
/// Triggers an auto-handoff to the cloud. This is the entry point for the
/// `warp://.../auto_handoff_to_cloud` URI action; the real macOS sleep path
/// goes through the `SystemStats` subscription instead.
///
/// Callers that dispatch from inside an in-progress workspace view update
/// (e.g. the debug palette entry) must defer past that update before calling
/// this: `update_view` temporarily removes the dispatching workspace from its
/// window, so the synchronous workspace lookup here would otherwise miss it.
pub(crate) fn trigger_auto_handoff_to_cloud(
trigger: AutoCloudHandoffTrigger,
ctx: &mut AppContext,
) {
AutoCloudHandoffController::handle(ctx).update(ctx, |controller, ctx| {
controller.trigger(trigger, ctx);
});
}
#[cfg(test)]
#[path = "auto_handoff_tests.rs"]
mod tests;
+109
View File
@@ -0,0 +1,109 @@
use super::{AutoCloudHandoffEligibility, AutoCloudHandoffSkipReason};
fn eligibility() -> AutoCloudHandoffEligibility {
AutoCloudHandoffEligibility {
is_empty: false,
is_in_progress: true,
has_server_conversation_token: true,
is_viewing_shared_session: false,
can_handoff_to_cloud: true,
already_attempted: false,
has_local_orchestrated_children: false,
}
}
#[test]
fn eligible_running_synced_conversation_is_not_skipped() {
assert_eq!(eligibility().skip_reason(), None);
}
#[test]
fn auto_handoff_skips_orchestrator_with_local_children() {
let eligibility = AutoCloudHandoffEligibility {
has_local_orchestrated_children: true,
..eligibility()
};
assert_eq!(
eligibility.skip_reason(),
Some(AutoCloudHandoffSkipReason::OrchestratorWithLocalChildren)
);
}
#[test]
fn auto_handoff_skips_empty_conversations() {
let eligibility = AutoCloudHandoffEligibility {
is_empty: true,
..eligibility()
};
assert_eq!(
eligibility.skip_reason(),
Some(AutoCloudHandoffSkipReason::EmptyConversation)
);
}
#[test]
fn auto_handoff_skips_idle_conversations() {
let eligibility = AutoCloudHandoffEligibility {
is_in_progress: false,
..eligibility()
};
assert_eq!(
eligibility.skip_reason(),
Some(AutoCloudHandoffSkipReason::NotInProgress)
);
}
#[test]
fn auto_handoff_skips_unsynced_conversations() {
let eligibility = AutoCloudHandoffEligibility {
has_server_conversation_token: false,
..eligibility()
};
assert_eq!(
eligibility.skip_reason(),
Some(AutoCloudHandoffSkipReason::MissingServerConversationToken)
);
}
#[test]
fn auto_handoff_skips_shared_session_viewers() {
let eligibility = AutoCloudHandoffEligibility {
is_viewing_shared_session: true,
..eligibility()
};
assert_eq!(
eligibility.skip_reason(),
Some(AutoCloudHandoffSkipReason::SharedSessionViewer)
);
}
#[test]
fn auto_handoff_skips_already_attempted_conversations() {
let eligibility = AutoCloudHandoffEligibility {
already_attempted: true,
..eligibility()
};
assert_eq!(
eligibility.skip_reason(),
Some(AutoCloudHandoffSkipReason::AlreadyAttempted)
);
}
#[test]
fn auto_handoff_skips_conversations_that_cannot_handoff_to_cloud() {
let eligibility = AutoCloudHandoffEligibility {
can_handoff_to_cloud: false,
..eligibility()
};
assert_eq!(
eligibility.skip_reason(),
Some(AutoCloudHandoffSkipReason::CloudHandoffUnavailable)
);
}
@@ -1,11 +1,13 @@
use std::collections::HashSet;
use chrono::{Duration, Utc};
use galaxy_core::settings::Setting;
use warpui::{Entity, ModelContext, SingletonEntity};
use crate::ai::request_usage_model::{
AIRequestUsageModel, AIRequestUsageModelEvent, BonusGrant, BonusGrantScope,
};
use crate::terminal::general_settings::GeneralSettings;
use chrono::{Duration, Utc};
use galaxy_core::settings::Setting;
use galaxyui::{Entity, ModelContext, SingletonEntity};
use std::collections::HashSet;
pub struct BonusGrantNotificationModel {
/// In-memory tracking of grants shown during this session. This prevents duplicate
@@ -27,7 +29,7 @@ impl SingletonEntity for BonusGrantNotificationModel {}
impl BonusGrantNotificationModel {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
ctx.subscribe_to_model(&AIRequestUsageModel::handle(ctx), |me, event, ctx| {
ctx.subscribe_to_model(&AIRequestUsageModel::handle(ctx), |me, _, event, ctx| {
if let AIRequestUsageModelEvent::RequestUsageUpdated = event {
me.check_for_new_bonus_grants(ctx);
}
+121 -53
View File
@@ -7,11 +7,54 @@ use command::blocking::Command;
use galaxy_core::channel::ChannelState;
use galaxy_util::path::ShellFamily;
/// Compute the target path where the symlink should be installed, based on channel
fn cli_install_target_path() -> PathBuf {
/// Compute the target path where the Oz CLI symlink should be installed, based on channel
fn oz_install_target_path() -> PathBuf {
PathBuf::from("/usr/local/bin").join(ChannelState::channel().cli_command_name())
}
/// Compute the target path where the Warp Control symlink should be installed, based on channel
fn warpctrl_install_target_path() -> PathBuf {
PathBuf::from("/usr/local/bin").join(ChannelState::channel().warpctrl_command_name())
}
/// Compute the source path of the warpctrl wrapper inside the current app bundle.
///
/// Oz commands are part of the shared executable's normal argument parser, so
/// Oz can symlink directly to the current executable. Warp Control has a
/// separate parser selected by the hidden `--warpctrl` flag, so its installed
/// symlink must target the bundled wrapper that injects that flag. Without it,
/// Warp Control subcommands such as `tab` would reach the normal parser and be
/// rejected as unknown.
fn warpctrl_bundle_source_path() -> Result<PathBuf> {
let current_binary =
std::env::current_exe().context("Failed to get current executable path")?;
let bundle_root = current_binary
.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent())
.ok_or_else(|| anyhow!("Current executable is not inside a bundled app"))?;
Ok(bundle_root
.join("Contents/Resources/bin")
.join(ChannelState::channel().warpctrl_command_name()))
}
fn path_resolves_to(path: &Path, expected_path: &Path) -> bool {
let Ok(path) = path.canonicalize() else {
return false;
};
let Ok(expected_path) = expected_path.canonicalize() else {
return false;
};
path == expected_path
}
/// Whether the installed Warp Control command resolves to this app bundle's wrapper.
pub fn is_warpctrl_installed() -> bool {
let Ok(source) = warpctrl_bundle_source_path() else {
return false;
};
path_resolves_to(&warpctrl_install_target_path(), &source)
}
/// Create a symlink with elevated privileges using osascript
///
/// This function uses macOS's osascript to prompt for administrator privileges
@@ -89,87 +132,112 @@ fn remove_file_with_admin(target: &Path) -> Result<()> {
Ok(())
}
/// Install the CLI by creating a symlink (channel-specific target)
/// Install a channel-specific CLI symlink.
///
/// This function:
/// 1. Detects the current Warp channel and finds the appropriate binary
/// 2. Attempts to create a symlink without admin privileges first
/// 3. Falls back to prompting for admin privileges if needed
/// 4. Handles existing installations and edge cases
pub fn install_cli() -> Result<()> {
let cli_path = cli_install_target_path();
let current_binary =
std::env::current_exe().context("Failed to get current executable path")?;
// Check if target file exists and handle conflicts
if cli_path.exists() && !cli_path.is_symlink() {
/// The target must either be absent or already be a symlink. Installation first
/// attempts to create the symlink without elevated privileges, then falls back
/// to prompting for administrator privileges.
fn install_symlink(source: &Path, target: &Path, command_name: &str) -> Result<()> {
if target.exists() && !target.is_symlink() {
return Err(anyhow!(
"Cannot install: {:?} exists but is not a symlink. Please remove it manually first.",
cli_path
"Cannot install {command_name}: {:?} exists but is not a symlink. Please remove it manually first.",
target
));
}
// Try to create symlink without admin privileges first
let symlink_result = symlink(&current_binary, &cli_path);
match symlink_result {
match symlink(source, target) {
Ok(_) => {
log::debug!(
"CLI installed successfully without admin privileges: {:?} -> {}",
cli_path,
current_binary.display()
"{command_name} installed successfully without admin privileges: {:?} -> {}",
target,
source.display()
);
}
Err(_) => {
log::debug!("Symlink creation failed, trying with admin privileges");
create_symlink_with_admin(&current_binary, &cli_path)
log::debug!("{command_name} symlink creation failed, trying with admin privileges");
create_symlink_with_admin(source, target)
.context("Failed to create symlink even with admin privileges")?;
log::debug!("CLI installed successfully with admin privileges");
log::debug!("{command_name} installed successfully with admin privileges");
}
}
Ok(())
}
/// Uninstall the CLI by removing the symlink (channel-specific target)
/// Uninstall a channel-specific CLI symlink.
///
/// This function:
/// 1. Verifies that the target is actually a symlink (safety check)
/// 2. Attempts to remove without admin privileges first
/// 3. Falls back to prompting for admin privileges if needed
pub fn uninstall_cli() -> Result<()> {
let cli_path = cli_install_target_path();
if !cli_path.exists() {
return Err(anyhow!("Oz command is not currently installed."));
/// The target must be a symlink so uninstalling cannot remove an unrelated
/// file. Removal first runs without elevated privileges, then falls back to
/// prompting for administrator privileges.
fn uninstall_symlink(target: &Path, command_name: &str) -> Result<()> {
if !target.exists() {
return Err(anyhow!("{command_name} is not currently installed."));
}
// Safety check: verify it's actually a symlink before removing
if !cli_path.is_symlink() {
if !target.is_symlink() {
return Err(anyhow!(
"Cannot uninstall: {:?} exists but is not a symlink. Please remove it manually.",
cli_path
"Cannot uninstall {command_name}: {:?} exists but is not a symlink. Please remove it manually.",
target
));
}
// Try to remove without admin privileges first
let remove_result = fs::remove_file(&cli_path);
match remove_result {
match fs::remove_file(target) {
Ok(_) => {
log::debug!("CLI uninstalled successfully without admin privileges");
log::debug!("{command_name} uninstalled successfully without admin privileges");
}
Err(_) => {
log::debug!("File removal failed, trying with admin privileges");
remove_file_with_admin(&cli_path)
log::debug!("{command_name} file removal failed, trying with admin privileges");
remove_file_with_admin(target)
.context("Failed to remove symlink even with admin privileges")?;
log::debug!("CLI uninstalled successfully with admin privileges");
log::debug!("{command_name} uninstalled successfully with admin privileges");
}
}
Ok(())
}
/// Install the Oz CLI by symlinking the shared Warp executable into /usr/local/bin.
///
/// The normal argument parser dispatches Oz subcommands directly. It also uses
/// the `oz`-prefixed invocation name to print CLI help rather than launch the
/// GUI when no subcommand is provided.
pub fn install_oz() -> Result<()> {
let oz_path = oz_install_target_path();
let current_binary =
std::env::current_exe().context("Failed to get current executable path")?;
install_symlink(&current_binary, &oz_path, "Oz CLI")
}
/// Uninstall the Oz CLI by removing the symlink from /usr/local/bin
pub fn uninstall_oz() -> Result<()> {
uninstall_symlink(&oz_install_target_path(), "Oz command")
}
/// Install Warp Control by symlinking its bundled wrapper into /usr/local/bin.
///
/// The wrapper contains no control implementation. It resolves this installed
/// symlink back into the app bundle, launches the shared Warp executable, and
/// injects `--warpctrl` so startup selects the separate Warp Control parser
/// before normal parsing or GUI startup.
pub fn install_warpctrl() -> Result<()> {
let warpctrl_path = warpctrl_install_target_path();
let warpctrl_source = warpctrl_bundle_source_path()?;
if !warpctrl_source.exists() {
return Err(anyhow!(
"Cannot install Warp Control CLI: bundled wrapper not found at {}",
warpctrl_source.display()
));
}
install_symlink(&warpctrl_source, &warpctrl_path, "Warp Control CLI")
}
/// Uninstall the Warp Control CLI by removing the symlink from /usr/local/bin
pub fn uninstall_warpctrl() -> Result<()> {
uninstall_symlink(&warpctrl_install_target_path(), "Warp Control command")
}
#[cfg(test)]
#[path = "cli_install_tests.rs"]
mod tests;
+21
View File
@@ -0,0 +1,21 @@
use std::fs;
use std::os::unix::fs::symlink;
use anyhow::Result;
use super::*;
#[test]
fn path_resolves_to_detects_matching_symlink() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let source = temp_dir.path().join("source");
let target = temp_dir.path().join("target");
fs::write(&source, "wrapper")?;
assert!(!path_resolves_to(&target, &source));
symlink(&source, &target)?;
assert!(path_resolves_to(&target, &source));
Ok(())
}
@@ -1,26 +1,22 @@
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{
Align, ChildAnchor, Container, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentOffsetBounds, Stack,
};
use galaxyui::fonts::Weight;
use galaxyui::platform::Cursor;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::ui_components::text::Span;
use galaxyui::{
elements::{
Align, ChildAnchor, Container, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentOffsetBounds, Stack,
},
fonts::Weight,
platform::Cursor,
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
text::Span,
},
AppContext, Element, Entity, EntityId, SingletonEntity, TypedActionView, View, ViewContext,
};
use pathfinder_geometry::vector::vec2f;
use crate::{
appearance::Appearance,
pane_group::PaneId,
ui_components::dialog::{dialog_styles, Dialog},
workspace::TabMovement,
};
use crate::appearance::Appearance;
use crate::pane_group::PaneId;
use crate::ui_components::dialog::{dialog_styles, Dialog};
use crate::workspace::TabMovement;
#[allow(clippy::enum_variant_names)]
#[derive(Copy, Clone)]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
//! Unit tests for [`CrossWindowTabDrag`] placeholder-collapse policy.
//!
//! These focus on [`CrossWindowTabDrag::collapsed_source_placeholder_index`],
//! which decides whether the source window's horizontal tab bar collapses the
//! detached-placeholder slot to zero width. The regression these guard against
//! is the horizontal "fuzzy shake": collapsing the placeholder while the cursor
//! is reordering it back in the source window removed the visible drop zone and
//! made the slot oscillate every frame.
use warpui::geometry::vector::{vec2f, Vector2F};
use warpui::WindowId;
use super::CrossWindowTabDrag;
const SOURCE_TAB_INDEX: usize = 2;
fn begin_multi_tab_drag(
drag: &mut CrossWindowTabDrag,
source_window_id: WindowId,
preview_window_id: WindowId,
) {
drag.begin_multi_tab_drag(
source_window_id,
SOURCE_TAB_INDEX,
Vector2F::zero(),
vec2f(800.0, 600.0),
Vector2F::zero(),
preview_window_id,
false,
vec2f(120.0, 34.0),
);
}
#[test]
fn no_active_drag_keeps_all_slots_full_width() {
let drag = CrossWindowTabDrag::new();
assert_eq!(
drag.collapsed_source_placeholder_index(WindowId::from_usize(1)),
None
);
}
#[test]
fn multi_tab_drag_collapses_only_the_source_window_placeholder() {
let source = WindowId::from_usize(1);
let preview = WindowId::from_usize(2);
let other = WindowId::from_usize(3);
let mut drag = CrossWindowTabDrag::new();
begin_multi_tab_drag(&mut drag, source, preview);
// The source window collapses its detached placeholder while the tab is
// floating in the preview window.
assert_eq!(
drag.collapsed_source_placeholder_index(source),
Some(SOURCE_TAB_INDEX)
);
// The preview and unrelated windows never collapse a slot.
assert_eq!(drag.collapsed_source_placeholder_index(preview), None);
assert_eq!(drag.collapsed_source_placeholder_index(other), None);
}
#[test]
fn source_reorder_keeps_placeholder_full_width() {
let source = WindowId::from_usize(1);
let preview = WindowId::from_usize(2);
let mut drag = CrossWindowTabDrag::new();
begin_multi_tab_drag(&mut drag, source, preview);
// Cursor returns to the source's own tab bar: the placeholder is reordered
// in place like an in-window drag and must stay full width. Collapsing it
// here is what produced the horizontal "fuzzy shake".
drag.set_reordering_in_source_for_test(true);
assert_eq!(drag.collapsed_source_placeholder_index(source), None);
// Leaving the source again restores the zero-width collapse.
drag.set_reordering_in_source_for_test(false);
assert_eq!(
drag.collapsed_source_placeholder_index(source),
Some(SOURCE_TAB_INDEX)
);
}
#[test]
fn single_tab_drag_never_collapses_a_slot() {
let source = WindowId::from_usize(1);
let mut drag = CrossWindowTabDrag::new();
// A single-tab window is its own floating preview; there is no separate
// placeholder to collapse.
drag.begin_single_tab_drag(
source,
Vector2F::zero(),
vec2f(800.0, 600.0),
Vector2F::zero(),
false,
vec2f(120.0, 34.0),
);
assert_eq!(drag.collapsed_source_placeholder_index(source), None);
}
@@ -1,22 +1,20 @@
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{
Align, ChildAnchor, ChildView, Container, OffsetPositioning, ParentAnchor, ParentOffsetBounds,
Stack,
};
use galaxyui::keymap::{FixedBinding, Keystroke};
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::{
elements::{
Align, ChildAnchor, ChildView, Container, OffsetPositioning, ParentAnchor,
ParentOffsetBounds, Stack,
},
keymap::{FixedBinding, Keystroke},
ui_components::components::{UiComponent, UiComponentStyles},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use pathfinder_geometry::vector::vec2f;
use crate::{
ai::agent::conversation::AIConversationId,
appearance::Appearance,
ui_components::dialog::{dialog_styles, Dialog},
view_components::action_button::{
ActionButton, DangerPrimaryTheme, KeystrokeSource, NakedTheme,
},
use crate::ai::agent::conversation::AIConversationId;
use crate::appearance::Appearance;
use crate::ui_components::dialog::{dialog_styles, Dialog};
use crate::view_components::action_button::{
ActionButton, DangerPrimaryTheme, KeystrokeSource, NakedTheme,
};
pub fn init(app: &mut AppContext) {
+42 -17
View File
@@ -1,23 +1,24 @@
use crate::auth;
use crate::network::NetworkStatus;
use crate::persistence::ModelEvent;
use crate::server::server_api::auth::AuthClient;
use crate::terminal::alt_screen_reporting::AltScreenReporting;
use crate::terminal::general_settings::GeneralSettings;
use crate::{app_state::get_app_state, server::server_api::ServerApiProvider};
use std::path::PathBuf;
use ::settings::ToggleableSetting;
use galaxy_core::execution_mode::AppExecutionMode;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::AIAgentExchangeId;
use crate::root_view::OpenPath;
use crate::undo_close::UndoCloseStack;
use crate::workspace::{Workspace, WorkspaceAction};
use crate::GlobalResourceHandlesProvider;
use galaxy_graphql::mutations::create_anonymous_user::AnonymousUserType;
use galaxyui::windowing::WindowManager;
use galaxyui::{AppContext, SingletonEntity, TypedActionView};
use std::path::PathBuf;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::AIAgentExchangeId;
use crate::app_state::get_app_state;
use crate::network::NetworkStatus;
use crate::persistence::ModelEvent;
use crate::root_view::OpenPath;
use crate::server::server_api::ServerApiProvider;
use crate::terminal::alt_screen_reporting::AltScreenReporting;
use crate::terminal::general_settings::GeneralSettings;
use crate::undo_close::UndoCloseStack;
use crate::workspace::cross_window_tab_drag::CrossWindowTabDrag;
use crate::workspace::{Workspace, WorkspaceAction};
use crate::{auth, GlobalResourceHandlesProvider};
/// Specifies where a forked conversation should be opened.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
@@ -32,6 +33,16 @@ pub enum ForkedConversationDestination {
}
impl ForkedConversationDestination {
/// Fork destination from an Enter (`false`) / Cmd-or-Ctrl+Enter (`true`) trigger: Enter
/// opens a new split pane, Cmd/Ctrl+Enter opens a new tab. Shared by all fork-style commands.
pub fn for_fork_trigger(cmd_or_ctrl_enter: bool) -> Self {
if cmd_or_ctrl_enter {
Self::NewTab
} else {
Self::SplitPane
}
}
pub fn is_new_tab(&self) -> bool {
matches!(self, Self::NewTab)
}
@@ -127,6 +138,18 @@ fn save_app(_: &(), ctx: &mut AppContext) {
return;
}
// While a cross-window tab drag is active, the dragged tab's pane group
// is in flight between source and preview windows and `get_app_state`
// would produce a snapshot with zero windows. Persisting that snapshot
// wipes the on-disk session via `save_app_state`'s delete-then-insert
// transaction. `save_app` fires from window move / focus / resize /
// close callbacks (see `app_callbacks` in `lib.rs`), all of which run
// during a drag, so we have to short-circuit at this boundary. The
// first save after the drag finalizes will rewrite the snapshot.
if CrossWindowTabDrag::as_ref(ctx).is_active() {
return;
}
let Some(model_event_sender) = GlobalResourceHandlesProvider::as_ref(ctx)
.get()
.model_event_sender
@@ -160,9 +183,10 @@ fn toggle_debug_network_status(_: &(), ctx: &mut AppContext) {
fn create_anonymous_user(_: &(), ctx: &mut AppContext) {
log::info!("Creating anonymous user");
let anonymous_user_type = AnonymousUserType::NativeClientAnonymousUser;
let server_api = ServerApiProvider::handle(ctx).read(ctx, |provider, _ctx| provider.get());
let auth_client =
ServerApiProvider::handle(ctx).read(ctx, |provider, _ctx| provider.get_auth_client());
let result =
galaxyui::r#async::block_on(server_api.create_anonymous_user(None, anonymous_user_type));
galaxyui::r#async::block_on(auth_client.create_anonymous_user(None, anonymous_user_type));
match result {
Ok(user) => log::info!("Successfully created anonymous user {user:?}"),
Err(err) => log::error!("Failed to create anonymous user: {err:?}"),
@@ -218,6 +242,7 @@ fn fork_ai_conversation(params: &ForkAIConversationParams, ctx: &mut AppContext)
summarize_after_fork: params.summarize_after_fork,
summarization_prompt: params.summarization_prompt.clone(),
initial_prompt: params.initial_prompt.clone(),
initial_attachments: vec![],
destination: params.destination,
},
);
+2 -15
View File
@@ -1,5 +1,5 @@
use settings::Setting as _;
use galaxyui::keymap::FixedBinding;
use galaxyui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
use crate::chip_configurator::{
@@ -7,15 +7,11 @@ use crate::chip_configurator::{
ChipConfiguratorAction, ChipConfiguratorLayout, ChipEditorModalConfig, ChipEditorMouseHandles,
ChipEditorSectionsConfig, ConfigurableItem, ControlItemRenderer,
};
use crate::report_if_error;
use crate::settings::AISettings;
use crate::workspace::header_toolbar_item::HeaderToolbarItemKind;
use crate::workspace::tab_settings::{
HeaderToolbarChipSelection, TabSettings, TabSettingsChangedEvent,
};
use crate::Appearance;
use settings::Setting as _;
use crate::{report_if_error, Appearance};
const MODAL_TITLE: &str = "Edit toolbar";
@@ -179,15 +175,6 @@ fn sync_show_hide_settings<V: View>(
.set_value(code_review_placed, ctx));
});
}
let notifications_placed = placed.contains(&&HeaderToolbarItemKind::NotificationsMailbox);
if *AISettings::as_ref(ctx).show_agent_notifications != notifications_placed {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.show_agent_notifications
.set_value(notifications_placed, ctx));
});
}
}
impl HeaderToolbarInlineEditor {
+2 -3
View File
@@ -1,4 +1,6 @@
use serde::{Deserialize, Serialize};
use settings::Setting as _;
use warpui::{AppContext, SingletonEntity};
use crate::auth::AuthStateProvider;
use crate::features::FeatureFlag;
@@ -6,9 +8,6 @@ use crate::settings::AISettings;
use crate::ui_components::icons::Icon;
use crate::workspace::tab_settings::TabSettings;
use galaxyui::{AppContext, SingletonEntity};
use settings::Setting as _;
/// A configurable item in the vertical tabs header toolbar.
///
/// Each variant represents a panel toggle button that can be placed on either
@@ -1,5 +1,12 @@
use std::path::PathBuf;
use markdown_parser::{
FormattedText, FormattedTextFragment, FormattedTextLine, FormattedTextStyles, Hyperlink,
};
use pathfinder_color::ColorU;
use settings::Setting;
use galaxy_core::ui::theme::phenomenon::PhenomenonStyle;
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{
Align, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex,
FormattedTextElement, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius,
@@ -19,9 +26,7 @@ use markdown_parser::{
FormattedText, FormattedTextFragment, FormattedTextLine, FormattedTextStyles, Hyperlink,
};
use galaxy_core::ui::theme::{phenomenon::PhenomenonStyle, Fill};
use pathfinder_color::ColorU;
use super::{tab_config_step, welcome_banner};
use crate::appearance::Appearance;
use crate::settings::AISettings;
use crate::tab_configs::session_config::{is_git_repo, SessionConfigSelection, SessionType};
@@ -36,11 +41,6 @@ use crate::view_components::callout_bubble::{
};
use crate::workspace::tab_settings::TabSettings;
use settings::Setting;
use super::tab_config_step;
use super::welcome_banner;
const CALLOUT_WIDTH: f32 = 480.;
struct HoaPrimaryButtonTheme;
+1 -3
View File
@@ -3,10 +3,8 @@ mod tab_config_step;
mod welcome_banner;
pub use hoa_onboarding_flow::{init, HoaOnboardingFlow, HoaOnboardingFlowEvent, HoaOnboardingStep};
use galaxyui::AppContext;
use galaxy_core::user_preferences::GetUserPreferences;
use galaxyui::AppContext;
const HAS_COMPLETED_HOA_ONBOARDING_KEY: &str = "HasCompletedHOAOnboarding";
@@ -5,8 +5,7 @@ use galaxyui::elements::{
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::geometry::vector::Vector2F;
use galaxyui::Element;
use galaxyui::EventContext;
use galaxyui::{Element, EventContext};
use crate::appearance::Appearance;
use crate::tab_configs::session_config::SessionType;
@@ -1,4 +1,6 @@
use galaxy_core::ui::theme::{phenomenon::PhenomenonStyle, Fill};
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::theme::phenomenon::PhenomenonStyle;
use galaxy_core::ui::theme::Fill;
use galaxyui::assets::asset_cache::AssetSource;
use galaxyui::elements::{
CacheOption, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
@@ -6,15 +8,12 @@ use galaxyui::elements::{
ParentElement, ParentOffsetBounds, Radius, Stack, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::Element;
use pathfinder_geometry::vector::vec2f;
use galaxyui::{Element, ViewHandle};
use crate::appearance::Appearance;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::ActionButton;
use galaxyui::ViewHandle;
const BANNER_WIDTH: f32 = 420.;
const HERO_HEIGHT: f32 = 92.;
const HERO_IMAGE_PATH: &str = "async/png/onboarding/hoa_welcome_banner.png";
@@ -85,15 +84,23 @@ pub fn render_welcome_banner(
);
// "New" badge
let badge = Container::new(
Text::new_inline("New".to_string(), appearance.ui_font_family(), 14.)
.with_color(PhenomenonStyle::modal_badge_text())
.finish(),
let text = Text::new_inline("New".to_string(), appearance.ui_font_family(), 14.)
.with_color(PhenomenonStyle::modal_badge_text())
.finish();
let badge = ConstrainedBox::new(
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min)
.with_child(text)
.finish(),
)
.with_horizontal_padding(8.)
.with_background(Fill::Solid(PhenomenonStyle::modal_badge_background()))
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.finish(),
)
.with_horizontal_padding(8.)
.with_vertical_padding(2.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_background(Fill::Solid(PhenomenonStyle::modal_badge_background()))
.with_height(24.)
.finish();
// Title
+1 -7
View File
@@ -1,17 +1,11 @@
use std::sync::Arc;
use galaxyui::assets::asset_cache::{AssetCache, AssetSource, AssetState};
use galaxyui::image_cache::ImageType;
use galaxyui::keymap::{FixedBinding, Keystroke};
use galaxyui::prelude::*;
use galaxyui::{AppContext, BlurContext, Element, Entity, SingletonEntity, View, ViewContext};
pub use lightbox::LightboxImage;
use pathfinder_geometry::vector::Vector2F;
use ui_components::{lightbox, Component as _};
use crate::appearance::Appearance;
pub use lightbox::LightboxImage;
pub fn init(app: &mut AppContext) {
use galaxyui::keymap::macros::*;
let view_id = id!(LightboxView::ui_name());
+230 -85
View File
@@ -1,9 +1,11 @@
mod action;
mod active_session;
pub(crate) mod auto_handoff;
pub mod bonus_grant_notification_model;
#[cfg(target_os = "macos")]
mod cli_install;
pub(crate) mod cli_install;
mod close_session_confirmation_dialog;
pub(crate) mod cross_window_tab_drag;
pub mod delete_conversation_confirmation_dialog;
mod global_actions;
pub mod header_toolbar_editor;
@@ -16,90 +18,65 @@ mod one_time_modal_model;
mod registry;
pub mod rewind_confirmation_dialog;
pub mod sync_inputs;
pub mod tab_group;
pub mod tab_settings;
mod toast_stack;
pub mod util;
pub mod view;
use crate::ai::blocklist::NEW_AGENT_PANE_LABEL;
use crate::ai::skills::SkillManager;
use crate::ai::AIRequestUsageModel;
use crate::channel::Channel;
use crate::code;
use crate::features::FeatureFlag;
use crate::modal;
use crate::notebooks;
use crate::pane_group::TabBarHoverIndex;
use crate::server::telemetry::AgentModeEntrypoint;
use crate::server::telemetry::PaletteSource;
use crate::settings::AISettings;
use crate::settings_view::{self, flags, SettingsSection};
use crate::tab::uses_vertical_tabs;
use crate::tab_configs;
use galaxyui::SingletonEntity;
use crate::channel::ChannelState;
use crate::util::bindings::{self, cmd_or_ctrl_shift, is_binding_pty_compliant, CustomAction};
use crate::palette::PaletteMode;
use galaxy_core::context_flag::ContextFlag;
use galaxyui::accessibility::AccessibilityVerbosity;
use galaxyui::elements::DropTargetData;
use galaxyui::keymap::FixedBinding;
use galaxyui::keymap::{BindingDescription, EditableBinding};
use galaxyui::AppContext;
use serde::{Deserialize, Serialize};
pub use action::{
CommandSearchOptions, InitContent, RestoreConversationLayout, TabContextMenuAnchor,
VerticalTabsPaneContextMenuTarget, WorkspaceAction,
AutoCloudHandoffTrigger, CommandSearchOptions, InitContent, RestoreConversationLayout,
TabContextMenuAnchor, VerticalTabsPaneContextMenuTarget, WorkspaceAction,
};
pub use active_session::ActiveSession;
pub use global_actions::{
ForkAIConversationParams, ForkFromExchange, ForkedConversationDestination,
};
use serde::{Deserialize, Serialize};
pub use util::{active_terminal_in_window, PaneViewLocator, TabMovement};
pub use view::{
Workspace, NEW_SESSION_MENU_BUTTON_POSITION_ID, NEW_TAB_BUTTON_POSITION_ID,
PANEL_HEADER_HEIGHT, TAB_BAR_HEIGHT, TOTAL_TAB_BAR_HEIGHT, WORKSPACE_PADDING,
};
use galaxy_core::context_flag::ContextFlag;
use warpui::accessibility::AccessibilityVerbosity;
use warpui::elements::DropTargetData;
use warpui::keymap::{BindingDescription, EditableBinding, FixedBinding};
use warpui::AppContext;
use crate::ai::blocklist::NEW_AGENT_PANE_LABEL;
use crate::channel::{Channel, ChannelState};
use crate::features::FeatureFlag;
use crate::palette::PaletteMode;
use crate::server::telemetry::{AgentModeEntrypoint, PaletteSource};
use crate::settings_view::{self, flags, SettingsSection};
use crate::tab::{uses_vertical_tabs, NewSessionMenuItem};
use crate::util::bindings::{self, cmd_or_ctrl_shift, is_binding_pty_compliant, CustomAction};
use crate::{code, modal, notebooks, tab_configs};
// Helper function to access panel header corner radius from other modules
pub fn panel_header_corner_radius() -> galaxyui::elements::CornerRadius {
galaxyui::elements::CornerRadius::with_top(galaxyui::elements::Radius::Pixels(8.))
}
/// Returns `true` when `WorkspaceAction::SendFeedback` will launch the guided
/// feedback skill in a new agent pane. When `false`, the action falls back to
/// opening the GitHub issue form in the browser.
///
/// Kept in sync with the availability check in `Workspace::send_feedback` so
/// the command palette label and the menu item behavior never diverge.
pub fn is_feedback_skill_available(ctx: &AppContext) -> bool {
AISettings::as_ref(ctx).is_any_ai_enabled(ctx)
&& AIRequestUsageModel::as_ref(ctx).has_any_ai_remaining(ctx)
&& SkillManager::as_ref(ctx)
.active_bundled_skill("feedback", ctx)
.is_some()
}
pub use one_time_modal_model::OneTimeModalModel;
pub use registry::WorkspaceRegistry;
pub use toast_stack::ToastStack;
use crate::workspace::view::{
LEFT_PANEL_AGENT_CONVERSATIONS_BINDING_NAME, LEFT_PANEL_GLOBAL_SEARCH_BINDING_NAME,
LEFT_PANEL_PROJECT_EXPLORER_BINDING_NAME, LEFT_PANEL_WARP_DRIVE_BINDING_NAME,
NEW_AGENT_TAB_BINDING_NAME, NEW_AMBIENT_AGENT_TAB_BINDING_NAME, NEW_TAB_BINDING_NAME,
NEW_TERMINAL_TAB_BINDING_NAME, OPEN_GLOBAL_SEARCH_BINDING_NAME,
NEW_AGENT_TAB_BINDING_NAME, NEW_AMBIENT_AGENT_TAB_BINDING_NAME, NEW_FILE_BINDING_NAME,
NEW_TAB_BINDING_NAME, NEW_TERMINAL_TAB_BINDING_NAME, OPEN_GLOBAL_SEARCH_BINDING_NAME,
TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, TOGGLE_NOTIFICATION_MAILBOX_BINDING_NAME,
TOGGLE_PROJECT_EXPLORER_BINDING_NAME, TOGGLE_RIGHT_PANEL_BINDING_NAME,
TOGGLE_TAB_CONFIGS_MENU_BINDING_NAME, TOGGLE_VERTICAL_TABS_PANEL_BINDING_NAME,
TOGGLE_WARP_DRIVE_BINDING_NAME,
};
pub use one_time_modal_model::OneTimeModalModel;
pub use registry::WorkspaceRegistry;
pub use toast_stack::ToastStack;
pub fn init(app: &mut AppContext) {
app.add_singleton_model(|_| WorkspaceRegistry::new());
app.add_singleton_model(|_| cross_window_tab_drag::CrossWindowTabDrag::new());
use galaxyui::keymap::macros::*;
app.register_binding_validator::<Workspace>(is_binding_pty_compliant);
@@ -113,8 +90,11 @@ pub fn init(app: &mut AppContext) {
tab_configs::session_config_modal::init(app);
view::launch_modal::oz_launch::init(app);
view::openwarp_launch_modal::init(app);
view::orchestration_launch_modal::init(app);
view::auto_handoff_sleep_modal::init(app);
view::cloud_agent_capacity_modal::init(app);
view::codex_modal::init(app);
view::free_ai_removal_modal::init(app);
view::free_tier_limit_hit_modal::init(app);
view::global_search::view::GlobalSearchView::init(app);
view::right_panel::RightPanelView::init(app);
@@ -227,6 +207,48 @@ pub fn init(app: &mut AppContext) {
WorkspaceAction::ResetOpenWarpLaunchModalState,
)
.with_context_predicate(id!("Workspace")),
EditableBinding::new(
"workspace:open_orchestration_launch_modal",
"[Debug] Open Orchestration Launch Modal",
WorkspaceAction::OpenOrchestrationLaunchModal,
)
.with_context_predicate(id!("Workspace")),
EditableBinding::new(
"workspace:reset_orchestration_launch_modal_state",
"[Debug] Reset Orchestration Launch Modal State",
WorkspaceAction::ResetOrchestrationLaunchModalState,
)
.with_context_predicate(id!("Workspace")),
EditableBinding::new(
"workspace:open_auto_handoff_sleep_modal",
"[Debug] Open Auto-Handoff Sleep Modal",
WorkspaceAction::OpenAutoHandoffSleepModal,
)
.with_context_predicate(id!("Workspace")),
EditableBinding::new(
"workspace:reset_auto_handoff_sleep_modal_state",
"[Debug] Reset Auto-Handoff Sleep Modal State",
WorkspaceAction::ResetAutoHandoffSleepModalState,
)
.with_context_predicate(id!("Workspace")),
EditableBinding::new(
"workspace:trigger_auto_handoff_to_cloud",
"[Debug] Trigger Auto-Handoff to Cloud",
WorkspaceAction::TriggerAutoHandoffToCloud,
)
.with_context_predicate(id!("Workspace")),
EditableBinding::new(
"workspace:open_free_ai_removal_modal",
"[Debug] Open Free AI Removal Modal",
WorkspaceAction::OpenFreeAiRemovalModal,
)
.with_context_predicate(id!("Workspace")),
EditableBinding::new(
"workspace:reset_free_ai_removal_modal_state",
"[Debug] Reset Free AI Removal Modal State",
WorkspaceAction::ResetFreeAiRemovalModalState,
)
.with_context_predicate(id!("Workspace")),
EditableBinding::new(
"workspace:install_opencode_warp_plugin",
"[Debug] Install OpenCode Galaxy plugin",
@@ -293,14 +315,16 @@ pub fn init(app: &mut AppContext) {
id!("Workspace"),
)
.with_enabled(|| ContextFlag::CreateNewSession.is_enabled()),
FixedBinding::custom(
CustomAction::NewFile,
WorkspaceAction::NewCodeFile,
"New File",
id!("Workspace") & !id!("Workspace_ViewOnlySharedSession"),
),
]);
app.register_editable_bindings([EditableBinding::new(
NEW_FILE_BINDING_NAME,
BindingDescription::new("New File"),
WorkspaceAction::NewCodeFile,
)
.with_custom_action(CustomAction::NewFile)
.with_context_predicate(id!("Workspace") & !id!("Workspace_ViewOnlySharedSession"))]);
if FeatureFlag::UIZoom.is_enabled() {
app.register_fixed_bindings([
FixedBinding::custom(
@@ -411,7 +435,7 @@ pub fn init(app: &mut AppContext) {
)
.with_context_predicate(id!("Workspace"))
.with_group(bindings::BindingGroup::Settings.as_str())
.with_key_binding("ctrl-shift->"),
.with_key_binding("alt-shift->"),
EditableBinding::new(
"workspace:decrease_font_size",
"Decrease font size",
@@ -419,7 +443,7 @@ pub fn init(app: &mut AppContext) {
)
.with_context_predicate(id!("Workspace"))
.with_group(bindings::BindingGroup::Settings.as_str())
.with_key_binding("ctrl-shift-<"),
.with_key_binding("alt-shift-<"),
EditableBinding::new(
"workspace:reset_font_size",
"Reset font size to default",
@@ -594,13 +618,6 @@ pub fn init(app: &mut AppContext) {
.with_context_predicate(id!("Workspace"))
.with_group(bindings::BindingGroup::Navigation.as_str())
.with_custom_action(CustomAction::ActivateNextPane),
EditableBinding::new(
"workspace:toggle_mouse_reporting",
"Toggle Mouse Reporting",
WorkspaceAction::ToggleMouseReporting,
)
.with_group(bindings::BindingGroup::Settings.as_str())
.with_context_predicate(id!("Workspace")),
EditableBinding::new(
"workspace:create_team_notebook",
BindingDescription::new("Create a new team notebook")
@@ -735,6 +752,14 @@ pub fn init(app: &mut AppContext) {
.with_group(bindings::BindingGroup::Navigation.as_str())
.with_enabled(|| FeatureFlag::VerticalTabs.is_enabled())
.with_key_binding(cmd_or_ctrl_shift("b")),
EditableBinding::new(
LEFT_PANEL_PROJECT_EXPLORER_BINDING_NAME,
BindingDescription::new("Left Panel: Project explorer"),
WorkspaceAction::ToggleProjectExplorer,
)
.with_group(bindings::BindingGroup::Navigation.as_str())
.with_context_predicate(id!("Workspace") & id!(flags::SHOW_PROJECT_EXPLORER))
.with_custom_action(CustomAction::ToggleProjectExplorer),
EditableBinding::new(
LEFT_PANEL_AGENT_CONVERSATIONS_BINDING_NAME,
BindingDescription::new("Left Panel: Agent conversations"),
@@ -744,14 +769,6 @@ pub fn init(app: &mut AppContext) {
.with_context_predicate(id!("Workspace") & id!(flags::SHOW_CONVERSATION_HISTORY))
.with_enabled(|| FeatureFlag::AgentViewConversationListView.is_enabled())
.with_custom_action(CustomAction::ToggleConversationListView),
EditableBinding::new(
LEFT_PANEL_PROJECT_EXPLORER_BINDING_NAME,
BindingDescription::new("Left Panel: Project explorer"),
WorkspaceAction::ToggleProjectExplorer,
)
.with_group(bindings::BindingGroup::Navigation.as_str())
.with_context_predicate(id!("Workspace") & id!(flags::SHOW_PROJECT_EXPLORER))
.with_custom_action(CustomAction::ToggleProjectExplorer),
EditableBinding::new(
LEFT_PANEL_GLOBAL_SEARCH_BINDING_NAME,
BindingDescription::new("Left Panel: Global search"),
@@ -761,6 +778,15 @@ pub fn init(app: &mut AppContext) {
.with_context_predicate(id!("Workspace") & id!(flags::SHOW_GLOBAL_SEARCH))
.with_enabled(|| FeatureFlag::GlobalSearch.is_enabled())
.with_custom_action(CustomAction::ToggleGlobalSearch),
EditableBinding::new(
"file_tree:toggle_hidden_files",
BindingDescription::new("Toggle hidden files in Project Explorer"),
WorkspaceAction::ToggleHiddenFiles,
)
.with_group(bindings::BindingGroup::Navigation.as_str())
.with_context_predicate(id!("Workspace") & id!(flags::SHOW_PROJECT_EXPLORER))
.with_mac_key_binding("cmd-shift->")
.with_linux_or_windows_key_binding("ctrl-shift->"),
EditableBinding::new(
LEFT_PANEL_WARP_DRIVE_BINDING_NAME,
BindingDescription::new("Left Panel: Galaxy Drive"),
@@ -912,6 +938,112 @@ pub fn init(app: &mut AppContext) {
.with_custom_action(CustomAction::RenameTab)
.with_context_predicate(id!("Workspace"))]);
// Pane rename — same shape as RenameActiveTab but acts on the focused pane
// in the active tab. Ships with no default keybinding so it surfaces in
// Settings → Keyboard shortcuts as remappable; resolves issue #9351, where
// the action existed only in the right-click context menu and was not
// reachable via the binding registry.
app.register_editable_bindings([EditableBinding::new(
"workspace:rename_active_pane",
"Rename the current pane",
WorkspaceAction::RenameActivePane,
)
.with_group(bindings::BindingGroup::Settings.as_str())
.with_context_predicate(id!("Workspace"))]);
// Tab grouping bindings (keyless by default; gated on `GroupedTabs`).
app.register_editable_bindings([
EditableBinding::new(
"workspace:new_tab_group",
"Create new tab group",
// Reuse the new-session dropdown's action, not a dedicated variant.
WorkspaceAction::SelectNewSessionMenuItem(NewSessionMenuItem::CreateNewTabGroup),
)
.with_enabled(|| FeatureFlag::GroupedTabs.is_enabled())
.with_group(bindings::BindingGroup::Navigation.as_str())
.with_context_predicate(id!("Workspace") & !id!("Workspace_PaneDragging")),
EditableBinding::new(
"workspace:new_tab_group_from_active_or_selected_tabs",
"Create tab group from active or selected tab(s)",
WorkspaceAction::NewTabGroupFromActiveOrSelectedTabs,
)
.with_enabled(|| FeatureFlag::GroupedTabs.is_enabled())
.with_group(bindings::BindingGroup::Navigation.as_str())
.with_context_predicate(id!("Workspace") & !id!("Workspace_PaneDragging")),
// Gated on `Workspace_ActiveOrSelectedTabsInGroup`: offered only when
// there's an unambiguous group to leave — a single-group multi-selection,
// or (with no selection) a grouped active tab. Mixed selections aren't
// offered, matching the multi-tab right-click menu.
EditableBinding::new(
"workspace:remove_active_or_selected_tabs_from_group",
"Remove active or selected tab(s) from group",
WorkspaceAction::RemoveActiveOrSelectedTabsFromGroup,
)
.with_enabled(|| FeatureFlag::GroupedTabs.is_enabled())
.with_group(bindings::BindingGroup::Navigation.as_str())
.with_context_predicate(
id!("Workspace")
& id!("Workspace_ActiveOrSelectedTabsInGroup")
& !id!("Workspace_PaneDragging"),
),
]);
// Tab/group pinning bindings (keyless by default; gated on `PinnedTabs`).
// Pin/unpin are split into separate entries so the palette label tracks
// the active tab/group's current state.
app.register_editable_bindings([
EditableBinding::new(
"workspace:pin_active_tab",
"Pin current tab",
WorkspaceAction::PinActiveTab,
)
.with_enabled(|| FeatureFlag::PinnedTabs.is_enabled())
.with_group(bindings::BindingGroup::Navigation.as_str())
.with_context_predicate(
id!("Workspace") & !id!("Workspace_ActiveTabPinned") & !id!("Workspace_PaneDragging"),
),
EditableBinding::new(
"workspace:unpin_active_tab",
"Unpin current tab",
WorkspaceAction::UnpinActiveTab,
)
.with_enabled(|| FeatureFlag::PinnedTabs.is_enabled())
.with_group(bindings::BindingGroup::Navigation.as_str())
.with_context_predicate(
id!("Workspace") & id!("Workspace_ActiveTabPinned") & !id!("Workspace_PaneDragging"),
),
EditableBinding::new(
"workspace:pin_active_tab_group",
"Pin current tab group",
WorkspaceAction::PinActiveTabGroup,
)
.with_enabled(|| {
FeatureFlag::PinnedTabs.is_enabled() && FeatureFlag::GroupedTabs.is_enabled()
})
.with_group(bindings::BindingGroup::Navigation.as_str())
.with_context_predicate(
id!("Workspace")
& id!("Workspace_ActiveTabInGroup")
& !id!("Workspace_ActiveTabGroupPinned")
& !id!("Workspace_PaneDragging"),
),
EditableBinding::new(
"workspace:unpin_active_tab_group",
"Unpin current tab group",
WorkspaceAction::UnpinActiveTabGroup,
)
.with_enabled(|| {
FeatureFlag::PinnedTabs.is_enabled() && FeatureFlag::GroupedTabs.is_enabled()
})
.with_group(bindings::BindingGroup::Navigation.as_str())
.with_context_predicate(
id!("Workspace")
& id!("Workspace_ActiveTabInGroup")
& id!("Workspace_ActiveTabGroupPinned")
& !id!("Workspace_PaneDragging"),
),
]);
app.register_editable_bindings([
EditableBinding::new(
"workspace:terminate_app",
@@ -1082,25 +1214,43 @@ pub fn init(app: &mut AppContext) {
.with_context_predicate(id!("Workspace") & id!(flags::ENABLE_WARP_DRIVE))]);
}
// CLI install/uninstall actions (macOS only)
// Oz and Warp Control CLI install/uninstall actions (macOS only)
#[cfg(target_os = "macos")]
{
app.register_editable_bindings([
EditableBinding::new(
"workspace:install_cli",
"Install Oz CLI command",
WorkspaceAction::InstallCLI,
"Install Oz CLI globally for use outside of Warp",
WorkspaceAction::InstallOz,
)
.with_group(bindings::BindingGroup::Settings.as_str())
.with_context_predicate(id!("Workspace")),
EditableBinding::new(
"workspace:uninstall_cli",
"Uninstall Oz CLI command",
WorkspaceAction::UninstallCLI,
"Undo global Oz CLI installation (oz will still work within Warp)",
WorkspaceAction::UninstallOz,
)
.with_group(bindings::BindingGroup::Settings.as_str())
.with_context_predicate(id!("Workspace")),
]);
if FeatureFlag::WarpControlCli.is_enabled() {
app.register_editable_bindings([
EditableBinding::new(
"workspace:install_warpctrl",
"Install Warp Control CLI globally for use outside of Warp",
WorkspaceAction::InstallWarpctrl,
)
.with_group(bindings::BindingGroup::Settings.as_str())
.with_context_predicate(id!("Workspace")),
EditableBinding::new(
"workspace:uninstall_warpctrl",
"Undo global Warp Control CLI installation (warpctrl will still work within Warp)",
WorkspaceAction::UninstallWarpctrl,
)
.with_group(bindings::BindingGroup::Settings.as_str())
.with_context_predicate(id!("Workspace")),
]);
}
}
if FeatureFlag::Changelog.is_enabled() {
@@ -1336,7 +1486,6 @@ pub fn init(app: &mut AppContext) {
}
fn add_open_setting_pages_as_editable_binding(app: &mut AppContext) {
use galaxyui::keymap::macros::*;
// Add the ability to open setting modals to the command palette.
app.register_editable_bindings([
@@ -1481,7 +1630,6 @@ fn add_open_setting_pages_as_editable_binding(app: &mut AppContext) {
}
fn add_overflow_menu_items_as_editable_binding(app: &mut AppContext) {
use galaxyui::keymap::macros::*;
// Add the ability to open all overflow menu items to the command palette.
app.register_editable_bindings([
@@ -1506,9 +1654,7 @@ fn add_overflow_menu_items_as_editable_binding(app: &mut AppContext) {
.with_context_predicate(id!("Workspace")),
EditableBinding::new(
"workspace:send_feedback",
BindingDescription::new("Send feedback (opens external link)").with_dynamic_override(
|ctx| is_feedback_skill_available(ctx).then(|| "Send feedback with Oz".into()),
),
BindingDescription::new("Send feedback (opens external link)"),
WorkspaceAction::SendFeedback,
)
.with_context_predicate(id!("Workspace")),
@@ -1536,7 +1682,6 @@ pub struct TabBarDropTargetData {
#[derive(PartialEq, Copy, Clone, Debug)]
pub struct VerticalTabsPaneDropTargetData {
pub tab_bar_location: TabBarLocation,
pub tab_hover_index: TabBarHoverIndex,
}
#[derive(PartialEq, Copy, Clone, Debug, Serialize, Deserialize)]
+14 -14
View File
@@ -1,20 +1,20 @@
use settings::Setting as _;
use galaxy_core::ui::theme::Fill;
use warpui::elements::{Align, Container, Empty, Flex, MouseStateHandle, ParentElement};
use warpui::fonts::Weight;
use warpui::keymap::FixedBinding;
use warpui::modals::{AlertDialogWithCallbacks, AppModalCallback};
use warpui::platform::Cursor;
use warpui::ui_components::button::ButtonVariant;
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::ui_components::text::Span;
use warpui::{
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
};
use crate::appearance::Appearance;
use crate::terminal::general_settings::{GeneralSettings, GeneralSettingsChangedEvent};
use crate::ui_components::dialog::{dialog_styles, Dialog};
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{Align, Container, Empty, Flex, ParentElement};
use galaxyui::keymap::FixedBinding;
use galaxyui::modals::{AlertDialogWithCallbacks, AppModalCallback};
use galaxyui::ui_components::components::{Coords, UiComponent};
use galaxyui::{
elements::MouseStateHandle,
fonts::Weight,
platform::Cursor,
ui_components::{button::ButtonVariant, components::UiComponentStyles, text::Span},
Element, Entity, TypedActionView, View,
};
use galaxyui::{AppContext, ModelHandle, SingletonEntity, ViewContext};
use settings::Setting as _;
pub(super) fn init(app: &mut AppContext) {
use galaxyui::keymap::macros::*;
+423 -11
View File
@@ -1,15 +1,30 @@
use std::future::Future;
use ai::api_keys::ApiKeyManager;
use settings::Setting as _;
use galaxy_core::features::FeatureFlag;
use galaxy_core::send_telemetry_from_ctx;
use warp_util::sync::Condition;
use warpui::{AppContext, Entity, ModelContext, SingletonEntity, WindowId};
use super::hoa_onboarding;
use super::view::free_ai_removal_modal::{
FreeAiRemovalModalTelemetryEvent, FreeAiRemovalModalVariant,
};
use crate::ai::blocklist::agent_view::toolbar_item::AgentToolbarItemKind;
use crate::ai::{AIRequestUsageModel, AIRequestUsageModelEvent};
use crate::auth::auth_manager::AuthManagerEvent;
use crate::auth::AuthManager;
use crate::auth::{AuthManager, AuthStateProvider};
use crate::channel::{Channel, ChannelState};
use crate::root_view::has_completed_local_onboarding;
use crate::settings::cloud_preferences_syncer::{
CloudPreferencesSyncer, CloudPreferencesSyncerEvent,
};
use crate::settings::{AISettings, CodeSettings};
use crate::terminal::general_settings::GeneralSettings;
use galaxy_core::features::FeatureFlag;
use galaxyui::{Entity, ModelContext, SingletonEntity, WindowId};
use settings::Setting as _;
use crate::terminal::session_settings::{AgentToolbarChipSelection, SessionSettings};
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::workspaces::workspace::CustomerType;
/// A generic model for managing one-time modals that should be shown to users only once.
///
@@ -23,8 +38,26 @@ pub struct OneTimeModalModel {
is_oz_launch_modal_open: bool,
/// Whether the OpenWarp launch modal is currently being shown.
is_openwarp_launch_modal_open: bool,
is_orchestration_launch_modal_open: bool,
/// Whether the auto-handoff sleep discoverability modal is currently being shown.
is_auto_handoff_sleep_modal_open: bool,
/// Set while the auto-handoff sleep modal is closed and reset while it is
/// open, so async work (e.g. auto-resume-after-error) can wait for the
/// modal to close. Mirrors the `Condition` pattern used by
/// `NetworkStatus::pending_reconnect`.
auto_handoff_sleep_modal_closed: Condition,
/// Whether the free-AI-removal notice modal is currently being shown.
is_free_ai_removal_modal_open: bool,
/// Whether the HOA onboarding flow is currently being shown.
is_hoa_onboarding_open: bool,
/// Whether the initial one-time modal checks have run. The seen markers are
/// cloud-synced settings, so event-driven re-checks must wait for the initial
/// cloud preferences load to avoid acting on stale values.
has_completed_initial_modal_checks: bool,
/// Whether `UserWorkspaces` has emitted `TeamsChanged`, meaning workspace billing
/// data reflects more than the local cache and "no workspace" can be trusted to
/// mean a solo (Free) user rather than not-yet-loaded data.
has_fetched_workspaces: bool,
/// The window ID where the currently open one-time modal should be displayed.
/// This is captured when a modal is first opened and ensures the modal stays on that window.
target_window_id: Option<WindowId>,
@@ -35,17 +68,32 @@ impl OneTimeModalModel {
// Subscribe to UserWorkspaces to detect when sunsetted_to_build_ts changes
ctx.subscribe_to_model(
&crate::workspaces::user_workspaces::UserWorkspaces::handle(ctx),
|me, event, ctx| {
|me, _, event, ctx| {
use crate::workspaces::user_workspaces::UserWorkspacesEvent;
if let UserWorkspacesEvent::SunsettedToBuildDataUpdated = event {
// When sunsetted_to_build_ts is updated, check if we should show the modal
me.check_and_trigger_build_plan_migration_modal(ctx);
match event {
UserWorkspacesEvent::SunsettedToBuildDataUpdated => {
// When sunsetted_to_build_ts is updated, check if we should show the modal
me.check_and_trigger_build_plan_migration_modal(ctx);
}
UserWorkspacesEvent::TeamsChanged => {
me.has_fetched_workspaces = true;
me.maybe_recheck_free_ai_removal_modal(ctx);
}
_ => {}
}
},
);
// The base-credit allowance that gates the free-AI-removal notice loads
// asynchronously, so re-evaluate the notice whenever request usage updates.
ctx.subscribe_to_model(&AIRequestUsageModel::handle(ctx), |me, _, event, ctx| {
if let AIRequestUsageModelEvent::RequestUsageUpdated = event {
me.maybe_recheck_free_ai_removal_modal(ctx);
}
});
// Subscribe to auth manager events to automatically trigger modal when user becomes onboarded
ctx.subscribe_to_model(&AuthManager::handle(ctx), |_, event, ctx| {
ctx.subscribe_to_model(&AuthManager::handle(ctx), |_, _, event, ctx| {
let AuthManagerEvent::AuthComplete = event else {
return;
};
@@ -57,10 +105,12 @@ impl OneTimeModalModel {
// must all await initial load to be triggered, else we risk reading a stale triggered value.
ctx.subscribe_to_model(
&CloudPreferencesSyncer::handle(ctx),
move |me, event, ctx| {
move |me, _, event, ctx| {
if let CloudPreferencesSyncerEvent::InitialLoadCompleted = event {
ctx.unsubscribe_from_model(&CloudPreferencesSyncer::handle(ctx));
me.has_completed_initial_modal_checks = true;
me.check_and_trigger_all_modals(ctx);
maybe_ensure_handoff_chip_in_toolbar(ctx);
}
},
);
@@ -72,7 +122,16 @@ impl OneTimeModalModel {
{
log::warn!("Failed to mark Oz launch modal as dismissed: {e}");
}
if let Err(e) = settings
.did_check_to_trigger_orchestration_launch_modal
.set_value(true, ctx)
{
log::warn!("Failed to mark orchestration launch modal as dismissed: {e}");
}
});
// Accounts created after the removal of free AI go through the new
// onboarding and are treated as already-noticed (no modal).
mark_free_ai_removal_notice_seen(ctx);
GeneralSettings::handle(ctx).update(ctx, |settings, ctx| {
if let Err(e) = settings
.did_check_to_trigger_openwarp_launch_modal
@@ -84,11 +143,22 @@ impl OneTimeModalModel {
}
});
// The auto-handoff sleep modal starts closed, so its close condition
// starts satisfied.
let auto_handoff_sleep_modal_closed = Condition::new();
auto_handoff_sleep_modal_closed.set();
Self {
is_build_plan_migration_modal_open: false,
is_oz_launch_modal_open: false,
is_openwarp_launch_modal_open: false,
is_orchestration_launch_modal_open: false,
is_auto_handoff_sleep_modal_open: false,
auto_handoff_sleep_modal_closed,
is_free_ai_removal_modal_open: false,
is_hoa_onboarding_open: false,
has_completed_initial_modal_checks: false,
has_fetched_workspaces: false,
target_window_id: None,
}
}
@@ -116,6 +186,80 @@ impl OneTimeModalModel {
self.set_openwarp_launch_modal_open(false, ctx);
}
pub fn is_orchestration_launch_modal_open(&self) -> bool {
self.is_orchestration_launch_modal_open && self.target_window_id.is_some()
}
pub fn mark_orchestration_launch_modal_dismissed(&mut self, ctx: &mut ModelContext<Self>) {
self.set_orchestration_launch_modal_open(false, ctx);
}
/// Returns whether the auto-handoff sleep discoverability modal is currently open.
pub fn is_auto_handoff_sleep_modal_open(&self) -> bool {
self.is_auto_handoff_sleep_modal_open && self.target_window_id.is_some()
}
pub fn mark_auto_handoff_sleep_modal_dismissed(&mut self, ctx: &mut ModelContext<Self>) {
self.set_auto_handoff_sleep_modal_open(false, ctx);
}
/// Triggers the auto-handoff sleep discoverability modal. Unlike the launch
/// modals, this is not called on startup: the auto-handoff controller calls
/// it on wake when a sleep interrupted an in-progress local agent run that
/// would have been handed off had `auto_handoff_on_sleep_enabled` been on.
/// Shows at most once per user (tracked by a synced private setting).
/// Returns true when the modal was opened.
pub fn check_and_trigger_auto_handoff_sleep_modal(
&mut self,
ctx: &mut ModelContext<Self>,
) -> bool {
let ai_settings = AISettings::as_ref(ctx);
if *ai_settings.did_show_auto_handoff_sleep_modal {
return false;
}
AISettings::handle(ctx).update(ctx, |settings, ctx| {
if let Err(e) = settings
.did_show_auto_handoff_sleep_modal
.set_value(true, ctx)
{
log::warn!("Failed to mark auto-handoff sleep modal as shown: {e}");
}
});
let should_show = !matches!(ChannelState::channel(), Channel::Integration);
self.set_auto_handoff_sleep_modal_open(should_show, ctx);
should_show
}
/// Sets whether the auto-handoff sleep modal is open. `pub(crate)` so the
/// debug palette action can force the modal open.
pub(crate) fn set_auto_handoff_sleep_modal_open(
&mut self,
is_open: bool,
ctx: &mut ModelContext<Self>,
) -> bool {
if self.is_auto_handoff_sleep_modal_open != is_open {
self.is_auto_handoff_sleep_modal_open = is_open;
if is_open {
self.auto_handoff_sleep_modal_closed.reset();
} else {
self.auto_handoff_sleep_modal_closed.set();
}
ctx.emit(OneTimeModalEvent::VisibilityChanged { is_open });
return true;
}
false
}
/// Returns a future that resolves immediately if the auto-handoff sleep
/// modal is closed, or when it next closes if currently open. The future
/// reads live modal state at poll time, so it can be created ahead of the
/// modal opening.
pub fn wait_until_auto_handoff_sleep_modal_closed(&self) -> impl Future<Output = ()> {
self.auto_handoff_sleep_modal_closed.wait()
}
/// Returns whether the HOA onboarding flow is currently open.
pub fn is_hoa_onboarding_open(&self) -> bool {
self.is_hoa_onboarding_open && self.target_window_id.is_some()
@@ -129,7 +273,10 @@ impl OneTimeModalModel {
pub fn is_any_modal_open(&self) -> bool {
(self.is_oz_launch_modal_open
|| self.is_openwarp_launch_modal_open
|| self.is_orchestration_launch_modal_open
|| self.is_auto_handoff_sleep_modal_open
|| self.is_build_plan_migration_modal_open
|| self.is_free_ai_removal_modal_open
|| self.is_hoa_onboarding_open)
&& self.target_window_id.is_some()
}
@@ -144,6 +291,11 @@ impl OneTimeModalModel {
self.set_openwarp_launch_modal_open(true, ctx);
}
#[cfg(debug_assertions)]
pub fn force_open_orchestration_launch_modal(&mut self, ctx: &mut ModelContext<Self>) {
self.set_orchestration_launch_modal_open(true, ctx);
}
pub fn update_target_window_id(&mut self, window_id: WindowId, ctx: &mut ModelContext<Self>) {
let was_any_modal_visible = self.is_any_modal_open();
self.target_window_id = Some(window_id);
@@ -176,6 +328,19 @@ impl OneTimeModalModel {
false
}
fn set_orchestration_launch_modal_open(
&mut self,
is_open: bool,
ctx: &mut ModelContext<Self>,
) -> bool {
if self.is_orchestration_launch_modal_open != is_open {
self.is_orchestration_launch_modal_open = is_open;
ctx.emit(OneTimeModalEvent::VisibilityChanged { is_open });
return true;
}
false
}
fn check_and_trigger_all_modals(&mut self, ctx: &mut ModelContext<Self>) {
// Never show one-time modals on WASM.
if cfg!(target_family = "wasm") {
@@ -202,6 +367,14 @@ impl OneTimeModalModel {
return;
}
if self.check_and_trigger_orchestration_launch_modal(ctx) {
return;
}
if self.check_and_trigger_free_ai_removal_modal(ctx) {
return;
}
if self.check_and_trigger_hoa_onboarding(ctx) {
return;
}
@@ -209,6 +382,108 @@ impl OneTimeModalModel {
self.check_and_trigger_build_plan_migration_modal(ctx);
}
/// Returns whether the free-AI-removal notice modal is currently open.
pub fn is_free_ai_removal_modal_open(&self) -> bool {
self.is_free_ai_removal_modal_open && self.target_window_id.is_some()
}
pub fn mark_free_ai_removal_modal_dismissed(&mut self, ctx: &mut ModelContext<Self>) {
self.set_free_ai_removal_modal_open(false, ctx);
}
#[cfg(debug_assertions)]
pub fn force_open_free_ai_removal_modal(&mut self, ctx: &mut ModelContext<Self>) {
self.set_free_ai_removal_modal_open(true, ctx);
}
fn set_free_ai_removal_modal_open(
&mut self,
is_open: bool,
ctx: &mut ModelContext<Self>,
) -> bool {
if self.is_free_ai_removal_modal_open != is_open {
self.is_free_ai_removal_modal_open = is_open;
ctx.emit(OneTimeModalEvent::VisibilityChanged { is_open });
return true;
}
false
}
/// Re-evaluates the free-AI-removal notice outside the initial startup check, e.g.
/// when workspace billing data arrives after startup.
fn maybe_recheck_free_ai_removal_modal(&mut self, ctx: &mut ModelContext<Self>) {
if !self.has_completed_initial_modal_checks || self.is_any_modal_open() {
return;
}
self.check_and_trigger_free_ai_removal_modal(ctx);
}
fn check_and_trigger_free_ai_removal_modal(&mut self, ctx: &mut ModelContext<Self>) -> bool {
// Gated on the OpenWarpNewSettingsModes rollout flag (the server experiment
// that previously gated this was removed in C1).
if !FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
return false;
}
if *AISettings::as_ref(ctx).did_check_to_trigger_free_ai_removal_modal {
return false;
}
// Anonymous users have no BYOK or upgrade path; leave them unmarked so the
// decision is made after they sign in.
if AuthStateProvider::as_ref(ctx)
.get()
.is_anonymous_or_logged_out()
{
return false;
}
let customer_type = UserWorkspaces::as_ref(ctx)
.current_workspace()
.map(|workspace| workspace.billing_metadata.customer_type);
let is_warp_ai_enabled = *AISettings::as_ref(ctx).is_any_ai_enabled;
let has_byok_or_byoe = ApiKeyManager::as_ref(ctx).has_any_key();
let completed_new_onboarding = has_completed_local_onboarding(ctx);
let has_zero_base_credits = AIRequestUsageModel::as_ref(ctx).request_limit() == 0;
let decision = free_ai_removal_modal_decision(
customer_type,
is_warp_ai_enabled,
has_byok_or_byoe,
completed_new_onboarding,
has_zero_base_credits,
self.has_fetched_workspaces,
);
if decision == FreeAiRemovalModalDecision::Defer {
return false;
}
AISettings::handle(ctx).update(ctx, |settings, ctx| {
if let Err(e) = settings
.did_check_to_trigger_free_ai_removal_modal
.set_value(true, ctx)
{
log::warn!("Failed to mark free AI removal modal as seen: {e}");
}
});
if decision == FreeAiRemovalModalDecision::MarkSeenSilently {
return false;
}
let should_show = !matches!(ChannelState::channel(), Channel::Integration);
if should_show {
send_telemetry_from_ctx!(
FreeAiRemovalModalTelemetryEvent::Shown {
variant: FreeAiRemovalModalVariant::Notice,
},
ctx
);
}
self.set_free_ai_removal_modal_open(should_show, ctx);
should_show
}
fn set_hoa_onboarding_open(&mut self, is_open: bool, ctx: &mut ModelContext<Self>) -> bool {
if self.is_hoa_onboarding_open != is_open {
self.is_hoa_onboarding_open = is_open;
@@ -295,6 +570,33 @@ impl OneTimeModalModel {
should_show_openwarp_modal
}
fn check_and_trigger_orchestration_launch_modal(
&mut self,
ctx: &mut ModelContext<Self>,
) -> bool {
if !FeatureFlag::OrchestrationLaunchModal.is_enabled() {
return false;
}
let ai_settings = AISettings::as_ref(ctx);
if *ai_settings.did_check_to_trigger_orchestration_launch_modal {
return false;
}
AISettings::handle(ctx).update(ctx, |settings, ctx| {
if let Err(e) = settings
.did_check_to_trigger_orchestration_launch_modal
.set_value(true, ctx)
{
log::warn!("Failed to mark orchestration launch modal as dismissed: {e}");
}
});
let should_show = !matches!(ChannelState::channel(), Channel::Integration);
self.set_orchestration_launch_modal_open(should_show, ctx);
should_show
}
pub fn is_build_plan_migration_modal_open(&self) -> bool {
self.is_build_plan_migration_modal_open && self.target_window_id.is_some()
}
@@ -325,7 +627,6 @@ impl OneTimeModalModel {
&mut self,
ctx: &mut ModelContext<Self>,
) -> bool {
use crate::workspaces::user_workspaces::UserWorkspaces;
// Check if already dismissed
let general_settings = GeneralSettings::as_ref(ctx);
@@ -374,6 +675,113 @@ impl OneTimeModalModel {
}
}
/// One-time migration: if the user has a custom agent toolbar layout that
/// predates the handoff-to-cloud chip, append the chip so they get the
/// new feature without losing their customization.
///
/// Users on `Default` already see the chip via `AgentToolbarItemKind::default_right()`.
fn maybe_ensure_handoff_chip_in_toolbar(ctx: &mut ModelContext<OneTimeModalModel>) {
if !FeatureFlag::OzHandoff.is_enabled()
|| !FeatureFlag::HandoffLocalCloud.is_enabled()
|| !cfg!(all(feature = "local_fs", not(target_family = "wasm")))
{
return;
}
let session_settings = SessionSettings::as_ref(ctx);
if *session_settings.did_add_handoff_chip_to_toolbar {
return;
}
// Mark as done so future app starts skip this path.
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
if let Err(e) = settings
.did_add_handoff_chip_to_toolbar
.set_value(true, ctx)
{
log::warn!("Failed to mark handoff chip toolbar migration as done: {e}");
}
});
// `Default` already includes the chip — nothing to do.
let selection = SessionSettings::as_ref(ctx)
.agent_footer_chip_selection
.clone();
let AgentToolbarChipSelection::Custom { mut left, right } = selection else {
return;
};
let handoff = AgentToolbarItemKind::HandoffToCloud;
if left.contains(&handoff) || right.contains(&handoff) {
return;
}
left.push(handoff);
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
if let Err(e) = settings
.agent_footer_chip_selection
.set_value(AgentToolbarChipSelection::Custom { left, right }, ctx)
{
log::warn!("Failed to add handoff chip to toolbar: {e}");
}
});
}
/// Marks the free-AI-removal notice as seen without showing it.
pub fn mark_free_ai_removal_notice_seen(app: &mut AppContext) {
AISettings::handle(app).update(app, |settings, ctx| {
if let Err(e) = settings
.did_check_to_trigger_free_ai_removal_modal
.set_value(true, ctx)
{
log::warn!("Failed to mark free AI removal notice as seen: {e}");
}
});
}
/// The outcome of evaluating the free-AI-removal notice conditions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FreeAiRemovalModalDecision {
/// Show the modal and write the seen marker.
Show,
/// Write the seen marker without showing the modal.
MarkSeenSilently,
/// Not enough data to decide; re-evaluate on the next billing/experiments update.
Defer,
}
fn free_ai_removal_modal_decision(
customer_type: Option<CustomerType>,
is_warp_ai_enabled: bool,
has_byok_or_byoe: bool,
completed_new_onboarding: bool,
has_zero_base_credits: bool,
workspaces_fetched: bool,
) -> FreeAiRemovalModalDecision {
if !is_warp_ai_enabled || has_byok_or_byoe || completed_new_onboarding {
return FreeAiRemovalModalDecision::MarkSeenSilently;
}
// Restrict to a Free (or confirmed solo) user; anyone else is paid (silently
// marked) or not-yet-known (deferred).
match customer_type {
Some(CustomerType::Free) => {}
// A missing workspace usually means billing data hasn't loaded yet; only treat
// it as a solo Free user once a server fetch has confirmed there is none, so a
// paid user's modal decision never runs against absent data.
None if workspaces_fetched => {}
None | Some(CustomerType::Unknown) => return FreeAiRemovalModalDecision::Defer,
Some(_) => return FreeAiRemovalModalDecision::MarkSeenSilently,
}
// Some ICPs still receive base AI credits on the Free plan; don't spook them with
// the notice. Only show once the base allowance is gone, and defer (rather than
// mark seen) otherwise so it re-evaluates if the allowance later drops to zero.
if has_zero_base_credits {
FreeAiRemovalModalDecision::Show
} else {
FreeAiRemovalModalDecision::Defer
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OneTimeModalEvent {
VisibilityChanged { is_open: bool },
@@ -384,3 +792,7 @@ impl Entity for OneTimeModalModel {
}
impl SingletonEntity for OneTimeModalModel {}
#[cfg(test)]
#[path = "one_time_modal_model_tests.rs"]
mod tests;
@@ -0,0 +1,214 @@
use futures::FutureExt;
use warpui::{App, SingletonEntity};
use super::{free_ai_removal_modal_decision, FreeAiRemovalModalDecision, OneTimeModalModel};
use crate::test_util::terminal::{add_window_with_terminal, initialize_app_for_terminal_view};
use crate::workspaces::workspace::CustomerType;
#[test]
fn wait_until_auto_handoff_sleep_modal_closed_tracks_modal_state() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |_, ctx| {
OneTimeModalModel::handle(ctx).update(ctx, |model, ctx| {
// Resolves immediately while the modal is closed.
assert!(model
.wait_until_auto_handoff_sleep_modal_closed()
.now_or_never()
.is_some());
// The auto-resume path creates its wait future before the
// modal opens (e.g. while offline during sleep); it must
// still observe the modal that opens later.
let pending_probe = model.wait_until_auto_handoff_sleep_modal_closed();
let resolving_waiter = model.wait_until_auto_handoff_sleep_modal_closed();
model.set_auto_handoff_sleep_modal_open(true, ctx);
// Pending while the modal is open, because the future reads
// live modal state at poll time.
assert!(pending_probe.now_or_never().is_none());
model.mark_auto_handoff_sleep_modal_dismissed(ctx);
// An existing waiter resolves once the modal closes.
assert!(resolving_waiter.now_or_never().is_some());
});
});
});
}
#[test]
fn test_free_ai_removal_modal_decision_matrix() {
struct Case {
name: &'static str,
customer_type: Option<CustomerType>,
is_warp_ai_enabled: bool,
has_byok_or_byoe: bool,
completed_new_onboarding: bool,
has_zero_base_credits: bool,
workspaces_fetched: bool,
expected: FreeAiRemovalModalDecision,
}
let cases = [
Case {
name: "free user with AI enabled and no base credits sees the modal",
customer_type: Some(CustomerType::Free),
is_warp_ai_enabled: true,
has_byok_or_byoe: false,
completed_new_onboarding: false,
has_zero_base_credits: true,
workspaces_fetched: false,
expected: FreeAiRemovalModalDecision::Show,
},
Case {
name: "free user who still receives base credits defers (ICP)",
customer_type: Some(CustomerType::Free),
is_warp_ai_enabled: true,
has_byok_or_byoe: false,
completed_new_onboarding: false,
has_zero_base_credits: false,
workspaces_fetched: false,
expected: FreeAiRemovalModalDecision::Defer,
},
Case {
name: "free user with AI disabled is marked seen silently",
customer_type: Some(CustomerType::Free),
is_warp_ai_enabled: false,
has_byok_or_byoe: false,
completed_new_onboarding: false,
has_zero_base_credits: true,
workspaces_fetched: false,
expected: FreeAiRemovalModalDecision::MarkSeenSilently,
},
Case {
name: "free user with a BYO key or endpoint is marked seen silently",
customer_type: Some(CustomerType::Free),
is_warp_ai_enabled: true,
has_byok_or_byoe: true,
completed_new_onboarding: false,
has_zero_base_credits: true,
workspaces_fetched: true,
expected: FreeAiRemovalModalDecision::MarkSeenSilently,
},
Case {
name: "free user who completed the new onboarding is marked seen silently",
customer_type: Some(CustomerType::Free),
is_warp_ai_enabled: true,
has_byok_or_byoe: false,
completed_new_onboarding: true,
has_zero_base_credits: true,
workspaces_fetched: true,
expected: FreeAiRemovalModalDecision::MarkSeenSilently,
},
Case {
name: "paid (Build) user is marked seen silently",
customer_type: Some(CustomerType::Build),
is_warp_ai_enabled: true,
has_byok_or_byoe: false,
completed_new_onboarding: false,
has_zero_base_credits: true,
workspaces_fetched: false,
expected: FreeAiRemovalModalDecision::MarkSeenSilently,
},
Case {
name: "paid (BuildMax) user is marked seen silently",
customer_type: Some(CustomerType::BuildMax),
is_warp_ai_enabled: true,
has_byok_or_byoe: false,
completed_new_onboarding: false,
has_zero_base_credits: true,
workspaces_fetched: true,
expected: FreeAiRemovalModalDecision::MarkSeenSilently,
},
Case {
name: "enterprise user is marked seen silently",
customer_type: Some(CustomerType::Enterprise),
is_warp_ai_enabled: true,
has_byok_or_byoe: false,
completed_new_onboarding: false,
has_zero_base_credits: true,
workspaces_fetched: true,
expected: FreeAiRemovalModalDecision::MarkSeenSilently,
},
Case {
name: "legacy paid (Prosumer) user is marked seen silently",
customer_type: Some(CustomerType::Prosumer),
is_warp_ai_enabled: true,
has_byok_or_byoe: false,
completed_new_onboarding: false,
has_zero_base_credits: true,
workspaces_fetched: true,
expected: FreeAiRemovalModalDecision::MarkSeenSilently,
},
Case {
name: "unknown customer type defers until billing data resolves",
customer_type: Some(CustomerType::Unknown),
is_warp_ai_enabled: true,
has_byok_or_byoe: false,
completed_new_onboarding: false,
has_zero_base_credits: true,
workspaces_fetched: true,
expected: FreeAiRemovalModalDecision::Defer,
},
Case {
name: "missing workspace defers before the first server fetch",
customer_type: None,
is_warp_ai_enabled: true,
has_byok_or_byoe: false,
completed_new_onboarding: false,
has_zero_base_credits: true,
workspaces_fetched: false,
expected: FreeAiRemovalModalDecision::Defer,
},
Case {
name: "missing workspace after a server fetch with no base credits is a solo free user",
customer_type: None,
is_warp_ai_enabled: true,
has_byok_or_byoe: false,
completed_new_onboarding: false,
has_zero_base_credits: true,
workspaces_fetched: true,
expected: FreeAiRemovalModalDecision::Show,
},
Case {
name: "solo user who still receives base credits defers (ICP)",
customer_type: None,
is_warp_ai_enabled: true,
has_byok_or_byoe: false,
completed_new_onboarding: false,
has_zero_base_credits: false,
workspaces_fetched: true,
expected: FreeAiRemovalModalDecision::Defer,
},
Case {
name: "missing workspace with AI disabled is marked seen silently",
customer_type: None,
is_warp_ai_enabled: false,
has_byok_or_byoe: false,
completed_new_onboarding: false,
has_zero_base_credits: true,
workspaces_fetched: true,
expected: FreeAiRemovalModalDecision::MarkSeenSilently,
},
];
for case in cases {
assert_eq!(
free_ai_removal_modal_decision(
case.customer_type,
case.is_warp_ai_enabled,
case.has_byok_or_byoe,
case.completed_new_onboarding,
case.has_zero_base_credits,
case.workspaces_fetched,
),
case.expected,
"case failed: {}",
case.name,
);
}
}
+18 -20
View File
@@ -1,27 +1,25 @@
use galaxy_core::ui::{color::coloru_with_opacity, theme::Fill};
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::color::coloru_with_opacity;
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{
Align, ChildAnchor, ConstrainedBox, Container, CrossAxisAlignment, Flex, Hoverable,
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Stack,
Text,
};
use galaxyui::fonts::Weight;
use galaxyui::keymap::{FixedBinding, Keystroke};
use galaxyui::platform::Cursor;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{
elements::{
Align, ChildAnchor, ConstrainedBox, Container, CrossAxisAlignment, Flex, Hoverable,
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds,
Stack, Text,
},
fonts::Weight,
keymap::{FixedBinding, Keystroke},
platform::Cursor,
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
},
AppContext, Element, Entity, EntityId, SingletonEntity, TypedActionView, View, ViewContext,
};
use pathfinder_geometry::vector::vec2f;
use crate::{
ai::agent::{conversation::AIConversationId, AIAgentExchangeId},
appearance::Appearance,
ui_components::dialog::{dialog_styles, Dialog},
ui_components::icons::Icon,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::AIAgentExchangeId;
use crate::appearance::Appearance;
use crate::ui_components::dialog::{dialog_styles, Dialog};
use crate::ui_components::icons::Icon;
pub fn init(app: &mut AppContext) {
use galaxyui::keymap::macros::*;
+3 -3
View File
@@ -1,10 +1,10 @@
use std::collections::{HashMap, HashSet};
use galaxyui::{keymap::EditableBinding, AppContext, Entity, EntityId, SingletonEntity, WindowId};
use crate::util::bindings::{BindingGroup, CustomAction};
use galaxyui::keymap::EditableBinding;
use galaxyui::{AppContext, Entity, EntityId, SingletonEntity, WindowId};
use super::WorkspaceAction;
use crate::util::bindings::{BindingGroup, CustomAction};
pub fn init(app: &mut AppContext) {
use galaxyui::keymap::macros::*;
+55
View File
@@ -0,0 +1,55 @@
//! Tab group data model. Gated at runtime by `FeatureFlag::GroupedTabs`.
use uuid::Uuid;
use warpui::elements::DraggableState;
use crate::tab::SelectedTabColor;
/// Stable identity for a tab group.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct TabGroupId(pub Uuid);
impl TabGroupId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
}
impl Default for TabGroupId {
fn default() -> Self {
Self::new()
}
}
/// A named group of tabs in the vertical tabs panel.
/// Member tabs reference their group via `TabData::group_id`.
#[derive(Clone)]
pub struct TabGroup {
pub id: TabGroupId,
pub name: Option<String>,
pub color: SelectedTabColor,
pub collapsed: bool,
pub draggable_state: DraggableState,
/// True when this whole group is pinned to the front of the tab list.
pub pinned: bool,
}
impl TabGroup {
/// Creates a new, untitled, expanded tab group with a fresh id.
pub fn new() -> Self {
Self {
id: TabGroupId::new(),
name: None,
color: SelectedTabColor::default(),
collapsed: false,
draggable_state: Default::default(),
pinned: false,
}
}
}
impl Default for TabGroup {
fn default() -> Self {
Self::new()
}
}
+34 -7
View File
@@ -1,8 +1,9 @@
use std::collections::HashMap;
use std::path::Path;
use settings::macros::define_settings_group;
use settings::{RespectUserSyncSetting, SupportedPlatforms, SyncToCloud};
use galaxy_core::ui::theme::AnsiColorIdentifier;
use settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud};
#[derive(
Default,
@@ -200,8 +201,7 @@ settings::macros::implement_setting_for_enum!(
impl DirectoryTabColors {
/// Returns the configured tab color for a directory using longest-prefix matching.
/// Returns `None` if no configured directory is a prefix of `dir`.
pub fn color_for_directory(&self, dir: &Path) -> Option<DirectoryTabColor> {
let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
pub fn color_for_directory(&self, canonical_dir: &Path) -> Option<DirectoryTabColor> {
self.0
.iter()
.filter_map(|(configured_path, color)| {
@@ -220,13 +220,19 @@ impl DirectoryTabColors {
/// Returns a new value with the given directory's color updated.
pub fn with_color(&self, path: &Path, color: DirectoryTabColor) -> Self {
let mut map = self.0.clone();
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
map.insert(canonical.to_string_lossy().to_string(), color);
map.insert(canonical_directory_key(path), color);
Self(map)
}
}
/// Canonicalizes `path` into the string key used in [`DirectoryTabColors`].
pub fn canonical_directory_key(path: &Path) -> String {
dunce::canonicalize(path)
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.to_string()
}
#[derive(
Clone,
Debug,
@@ -261,12 +267,15 @@ impl HeaderToolbarChipSelection {
}
pub fn right_items(&self) -> Vec<super::header_toolbar_item::HeaderToolbarItemKind> {
use super::header_toolbar_item::HeaderToolbarItemKind;
match self {
Self::Default => HeaderToolbarItemKind::default_right(),
Self::Custom { right, .. } => right.clone(),
}
}
pub fn contains_item(&self, item: &super::header_toolbar_item::HeaderToolbarItemKind) -> bool {
self.left_items().contains(item) || self.right_items().contains(item)
}
}
settings::macros::implement_setting_for_enum!(
@@ -482,6 +491,24 @@ define_settings_group!(TabSettings, settings: [
toml_path: "appearance.vertical_tabs.enabled",
description: "Whether to display tabs vertically instead of horizontally.",
},
show_vertical_tab_panel_in_restored_windows: ShowVerticalTabPanelInRestoredWindows {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "appearance.vertical_tabs.show_panel_in_restored_windows",
description: "When restoring a window, open the vertical tabs panel even if it was closed when the session was saved.",
},
hide_title_bar_search_bar_in_vertical_tabs: HideTitleBarSearchBarInVerticalTabs {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "appearance.vertical_tabs.hide_title_bar_search_bar",
description: "When using the vertical tab layout, hide the search bar in the title bar. Search stays available via the command palette and keyboard shortcuts.",
},
use_latest_user_prompt_as_conversation_title_in_tab_names: UseLatestUserPromptAsConversationTitleInTabNames {
type: bool,
default: false,
+99 -2
View File
@@ -1,7 +1,8 @@
use settings::Setting;
use super::*;
use crate::test_util::settings::initialize_settings_for_tests;
use galaxyui::{App, SingletonEntity};
use settings::Setting;
use crate::workspace::header_toolbar_item::HeaderToolbarItemKind;
#[test]
fn use_latest_user_prompt_as_conversation_title_in_tab_names_defaults_to_false() {
@@ -29,3 +30,99 @@ fn use_latest_user_prompt_as_conversation_title_in_tab_names_uses_vertical_tabs_
"use_latest_prompt_as_title"
);
}
#[test]
fn show_vertical_tab_panel_in_restored_windows_defaults_to_false() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
TabSettings::handle(&app).read(&app, |settings, _ctx| {
assert!(!*settings.show_vertical_tab_panel_in_restored_windows);
});
});
}
#[test]
fn show_vertical_tab_panel_in_restored_windows_uses_vertical_tabs_path() {
assert_eq!(
ShowVerticalTabPanelInRestoredWindows::toml_path(),
Some("appearance.vertical_tabs.show_panel_in_restored_windows")
);
assert_eq!(
ShowVerticalTabPanelInRestoredWindows::hierarchy(),
Some("appearance.vertical_tabs")
);
assert_eq!(
ShowVerticalTabPanelInRestoredWindows::toml_key(),
"show_panel_in_restored_windows"
);
}
#[test]
fn hide_title_bar_search_bar_in_vertical_tabs_defaults_to_false() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
TabSettings::handle(&app).read(&app, |settings, _ctx| {
assert!(!*settings.hide_title_bar_search_bar_in_vertical_tabs);
});
});
}
#[test]
fn hide_title_bar_search_bar_in_vertical_tabs_uses_vertical_tabs_path() {
assert_eq!(
HideTitleBarSearchBarInVerticalTabs::toml_path(),
Some("appearance.vertical_tabs.hide_title_bar_search_bar")
);
assert_eq!(
HideTitleBarSearchBarInVerticalTabs::hierarchy(),
Some("appearance.vertical_tabs")
);
assert_eq!(
HideTitleBarSearchBarInVerticalTabs::toml_key(),
"hide_title_bar_search_bar"
);
}
#[test]
fn header_toolbar_chip_selection_default_contains_code_review() {
let config = HeaderToolbarChipSelection::Default;
assert!(config.contains_item(&HeaderToolbarItemKind::CodeReview));
}
#[test]
fn header_toolbar_chip_selection_custom_without_code_review_reports_absent() {
let config = HeaderToolbarChipSelection::Custom {
left: vec![
HeaderToolbarItemKind::TabsPanel,
HeaderToolbarItemKind::ToolsPanel,
],
right: vec![HeaderToolbarItemKind::NotificationsMailbox],
};
assert!(!config.contains_item(&HeaderToolbarItemKind::CodeReview));
assert!(config.contains_item(&HeaderToolbarItemKind::TabsPanel));
assert!(config.contains_item(&HeaderToolbarItemKind::ToolsPanel));
assert!(config.contains_item(&HeaderToolbarItemKind::NotificationsMailbox));
assert!(!config.contains_item(&HeaderToolbarItemKind::AgentManagement));
}
#[test]
fn header_toolbar_chip_selection_custom_with_code_review_on_left_reports_present() {
let config = HeaderToolbarChipSelection::Custom {
left: vec![HeaderToolbarItemKind::CodeReview],
right: vec![],
};
assert!(config.contains_item(&HeaderToolbarItemKind::CodeReview));
}
#[test]
fn header_toolbar_chip_selection_custom_empty_reports_all_absent() {
let config = HeaderToolbarChipSelection::Custom {
left: vec![],
right: vec![],
};
for item in HeaderToolbarItemKind::all_items() {
assert!(!config.contains_item(&item));
}
}
+2 -4
View File
@@ -1,9 +1,7 @@
use galaxyui::{Entity, ModelContext, SingletonEntity, WindowId};
use crate::{
view_components::{DismissibleToast, ToastType},
workspace::WorkspaceAction,
};
use crate::view_components::{DismissibleToast, ToastType};
use crate::workspace::WorkspaceAction;
/// A global model that provides an interface to open a workspace-level
/// toast. This allows callers to add a toast from any context that has
+38 -7
View File
@@ -1,14 +1,14 @@
use galaxyui::{
elements::MouseStateHandle, AppContext, EntityId, SingletonEntity, ViewContext, ViewHandle,
WindowId,
};
use serde::{Deserialize, Serialize};
use galaxyui::elements::MouseStateHandle;
use galaxyui::{AppContext, EntityId, SingletonEntity, ViewContext, ViewHandle, WindowId};
use super::OneTimeModalModel;
use crate::appearance::Appearance;
use crate::pane_group::PaneId;
use crate::terminal::TerminalView;
use crate::window_settings::WindowSettings;
use crate::{
appearance::Appearance, pane_group::PaneId, terminal::TerminalView, workspace::Workspace,
};
use crate::workspace::tab_group::TabGroupId;
use crate::workspace::Workspace;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
/// What composes a pane (i.e. the pane group and the pane itself).
@@ -122,6 +122,7 @@ pub struct WorkspaceState {
pub is_codex_modal_open: bool,
pub is_cloud_agent_capacity_modal_open: bool,
pub is_free_tier_limit_hit_modal_open: bool,
pub is_prompt_suggestions_unavailable_modal_open: bool,
pub is_tab_config_params_modal_open: bool,
pub is_session_config_modal_open: bool,
pub is_new_worktree_modal_open: bool,
@@ -130,6 +131,8 @@ pub struct WorkspaceState {
pub is_transcript_details_panel_open: bool,
tab_being_renamed: Option<usize>, // The index of the tab being renamed
pane_being_renamed: Option<PaneViewLocator>,
/// The tab group whose header is currently being renamed inline.
tab_group_being_renamed: Option<TabGroupId>,
}
impl WorkspaceState {
@@ -147,6 +150,7 @@ impl WorkspaceState {
|| self.is_changelog_modal_open
|| self.tab_being_renamed.is_some()
|| self.pane_being_renamed.is_some()
|| self.tab_group_being_renamed.is_some()
|| self.is_reward_modal_open
|| self.is_launch_config_save_modal_open
|| self.is_command_search_open
@@ -162,6 +166,7 @@ impl WorkspaceState {
|| self.is_codex_modal_open
|| self.is_cloud_agent_capacity_modal_open
|| self.is_free_tier_limit_hit_modal_open
|| self.is_prompt_suggestions_unavailable_modal_open
|| self.is_tab_config_params_modal_open
|| self.is_session_config_modal_open
|| self.is_new_worktree_modal_open
@@ -188,6 +193,7 @@ impl WorkspaceState {
self.is_changelog_modal_open = false;
self.tab_being_renamed = None;
self.pane_being_renamed = None;
self.tab_group_being_renamed = None;
self.is_reward_modal_open = false;
self.is_launch_config_save_modal_open = false;
self.is_command_search_open = false;
@@ -205,6 +211,7 @@ impl WorkspaceState {
self.is_codex_modal_open = false;
self.is_cloud_agent_capacity_modal_open = false;
self.is_free_tier_limit_hit_modal_open = false;
self.is_prompt_suggestions_unavailable_modal_open = false;
self.is_tab_config_params_modal_open = false;
self.is_session_config_modal_open = false;
self.is_new_worktree_modal_open = false;
@@ -231,6 +238,7 @@ impl WorkspaceState {
pub fn set_tab_being_renamed(&mut self, index: usize) {
self.tab_being_renamed = Some(index);
self.pane_being_renamed = None;
self.tab_group_being_renamed = None;
}
pub fn clear_tab_being_renamed(&mut self) {
@@ -252,6 +260,7 @@ impl WorkspaceState {
pub fn set_pane_being_renamed(&mut self, pane: PaneViewLocator) {
self.pane_being_renamed = Some(pane);
self.tab_being_renamed = None;
self.tab_group_being_renamed = None;
}
pub fn clear_pane_being_renamed(&mut self) {
@@ -261,6 +270,28 @@ impl WorkspaceState {
pub fn pane_being_renamed(&self) -> Option<PaneViewLocator> {
self.pane_being_renamed
}
pub fn is_tab_group_being_renamed(&self, group_id: TabGroupId) -> bool {
self.tab_group_being_renamed == Some(group_id)
}
pub fn is_any_tab_group_being_renamed(&self) -> bool {
self.tab_group_being_renamed.is_some()
}
pub fn set_tab_group_being_renamed(&mut self, group_id: TabGroupId) {
self.tab_group_being_renamed = Some(group_id);
self.tab_being_renamed = None;
self.pane_being_renamed = None;
}
pub fn clear_tab_group_being_renamed(&mut self) {
self.tab_group_being_renamed = None;
}
pub fn tab_group_being_renamed(&self) -> Option<TabGroupId> {
self.tab_group_being_renamed
}
}
/// Used to represent left and right movement for tabs in WorkspaceActions
+7728 -1634
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
mod view;
pub use view::{init, AutoHandoffSleepModal, AutoHandoffSleepModalEvent};
@@ -0,0 +1,282 @@
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::theme::Fill;
use warpui::assets::asset_cache::AssetSource;
use warpui::elements::{
Align, CacheOption, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Expanded, Flex, Image, MainAxisSize, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Radius, Stack, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::keymap::FixedBinding;
use warpui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::appearance::Appearance;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{
ActionButton, ActionButtonTheme, ButtonSize, PrimaryTheme, SecondaryTheme,
};
const MODAL_WIDTH: f32 = 420.;
const HERO_HEIGHT: f32 = 92.;
const HERO_IMAGE_PATH: &str = "async/png/onboarding/auto_handoff_sleep_banner.png";
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_fixed_bindings([FixedBinding::new(
"escape",
AutoHandoffSleepModalAction::Dismiss,
id!(AutoHandoffSleepModal::ui_name()),
)]);
}
#[derive(Clone, Debug)]
pub enum AutoHandoffSleepModalAction {
Enable,
Dismiss,
}
#[derive(Clone, Debug)]
pub enum AutoHandoffSleepModalEvent {
/// User clicked "Enable" — turn on auto-handoff-on-sleep and close.
Enable,
/// User dismissed the modal without enabling.
Dismiss,
}
struct CloseButtonTheme;
impl ActionButtonTheme for CloseButtonTheme {
fn background(&self, hovered: bool, appearance: &Appearance) -> Option<Fill> {
if hovered {
Some(appearance.theme().surface_overlay_1())
} else {
None
}
}
fn text_color(
&self,
_hovered: bool,
_background: Option<Fill>,
_appearance: &Appearance,
) -> ColorU {
ColorU::white()
}
}
pub struct AutoHandoffSleepModal {
close_button: ViewHandle<ActionButton>,
enable_button: ViewHandle<ActionButton>,
dismiss_button: ViewHandle<ActionButton>,
}
impl AutoHandoffSleepModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let close_button = ctx.add_view(|_ctx| {
ActionButton::new("", CloseButtonTheme)
.with_icon(Icon::X)
.with_size(ButtonSize::Small)
.on_click(|ctx| ctx.dispatch_typed_action(AutoHandoffSleepModalAction::Dismiss))
});
let enable_button = ctx.add_view(|_ctx| {
ActionButton::new("Enable", PrimaryTheme)
.with_full_width(true)
.with_size(ButtonSize::Default)
.on_click(|ctx| ctx.dispatch_typed_action(AutoHandoffSleepModalAction::Enable))
});
let dismiss_button = ctx.add_view(|_ctx| {
ActionButton::new("Dismiss", SecondaryTheme)
.with_full_width(true)
.with_size(ButtonSize::Default)
.on_click(|ctx| ctx.dispatch_typed_action(AutoHandoffSleepModalAction::Dismiss))
});
Self {
close_button,
enable_button,
dismiss_button,
}
}
fn render_hero(&self) -> Box<dyn Element> {
let hero = ConstrainedBox::new(
Image::new(
AssetSource::Bundled {
path: HERO_IMAGE_PATH,
},
CacheOption::Original,
)
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(8.)))
.cover()
.top_aligned()
.finish(),
)
.with_width(MODAL_WIDTH)
.with_height(HERO_HEIGHT)
.finish();
let close_el = Container::new(ChildView::new(&self.close_button).finish())
.with_uniform_padding(4.)
.with_padding_right(2.)
.finish();
let mut hero_stack = Stack::new();
hero_stack.add_child(hero);
hero_stack.add_positioned_child(
close_el,
OffsetPositioning::offset_from_parent(
vec2f(-4., 0.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::TopRight,
ChildAnchor::TopRight,
),
);
hero_stack.finish()
}
fn render_badge(appearance: &Appearance) -> Box<dyn Element> {
let red = appearance.theme().terminal_colors().normal.red;
let text_color: ColorU = red.into();
let background_color = appearance.theme().ansi_overlay_2(red);
let text = Text::new_inline(
"Run Connection Lost".to_string(),
appearance.ui_font_family(),
14.,
)
.with_color(text_color)
.finish();
ConstrainedBox::new(
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min)
.with_child(text)
.finish(),
)
.with_horizontal_padding(8.)
.with_background(Fill::Solid(background_color))
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.finish(),
)
.with_height(24.)
.finish()
}
fn render_title(appearance: &Appearance) -> Box<dyn Element> {
Text::new("Enable auto-handoff?", appearance.ui_font_family(), 20.)
.with_color(
appearance
.theme()
.main_text_color(appearance.theme().surface_3())
.into_solid(),
)
.with_style(Properties::default().weight(Weight::Semibold))
.finish()
}
fn render_description(appearance: &Appearance) -> Box<dyn Element> {
Text::new(
"Give Warp the option to automatically move active local agents to the cloud when \
your computer sleeps.",
appearance.ui_font_family(),
14.,
)
.with_color(
appearance
.theme()
.sub_text_color(appearance.theme().surface_3())
.into_solid(),
)
.finish()
}
fn render_body(&self, appearance: &Appearance) -> Box<dyn Element> {
let footer = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(12.)
.with_child(Expanded::new(1., ChildView::new(&self.enable_button).finish()).finish())
.with_child(Expanded::new(1., ChildView::new(&self.dismiss_button).finish()).finish())
.finish();
Container::new(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(12.)
.with_child(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(8.)
.with_child(Self::render_badge(appearance))
.with_child(Self::render_title(appearance))
.finish(),
)
.with_child(Self::render_description(appearance))
.with_child(footer)
.finish(),
)
.with_horizontal_padding(32.)
.with_vertical_padding(32.)
.with_background(appearance.theme().surface_3())
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
.finish()
}
}
impl Entity for AutoHandoffSleepModal {
type Event = AutoHandoffSleepModalEvent;
}
impl View for AutoHandoffSleepModal {
fn ui_name() -> &'static str {
"AutoHandoffSleepModal"
}
fn on_focus(&mut self, _focus_ctx: &warpui::FocusContext, ctx: &mut ViewContext<Self>) {
ctx.focus_self();
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let card = ConstrainedBox::new(
Container::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(self.render_hero())
.with_child(self.render_body(appearance))
.finish(),
)
.with_background(appearance.theme().surface_3())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish(),
)
.with_width(MODAL_WIDTH)
.finish();
Container::new(Align::new(card).finish())
.with_background(Fill::Solid(ColorU::new(97, 97, 97, 255)).with_opacity(50))
.finish()
}
}
impl TypedActionView for AutoHandoffSleepModal {
type Action = AutoHandoffSleepModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
AutoHandoffSleepModalAction::Enable => {
ctx.emit(AutoHandoffSleepModalEvent::Enable);
}
AutoHandoffSleepModalAction::Dismiss => {
ctx.emit(AutoHandoffSleepModalEvent::Dismiss);
}
}
}
}
@@ -1,9 +1,3 @@
use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
use crate::terminal::general_settings::GeneralSettings;
use crate::ui_components::blended_colors;
use crate::view_components::{Dropdown, DropdownEvent, DropdownItem, ToastFlavor};
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
use crate::workspaces::workspace::CustomerType;
use asset_macro::bundled_or_fetched_asset;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
@@ -28,6 +22,13 @@ use pathfinder_geometry::vector::vec2f;
use settings::Setting as _;
use thousands::Separable;
use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
use crate::terminal::general_settings::GeneralSettings;
use crate::ui_components::blended_colors;
use crate::view_components::{Dropdown, DropdownEvent, DropdownItem, ToastFlavor};
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
use crate::workspaces::workspace::CustomerType;
const BUTTON_DIAMETER: f32 = 20.;
const DROPDOWN_WIDTH: f32 = 160.;
const MODAL_HEIGHT: f32 = 540.;
@@ -1,9 +1,3 @@
use crate::auth::AuthStateProvider;
use crate::pricing::PricingInfoModel;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::workspaces::workspace::CustomerType;
use asset_macro::bundled_or_fetched_asset;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
@@ -25,8 +19,13 @@ use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use thousands::Separable;
use crate::send_telemetry_from_ctx;
use crate::TelemetryEvent;
use crate::auth::AuthStateProvider;
use crate::pricing::PricingInfoModel;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::workspaces::workspace::CustomerType;
use crate::{send_telemetry_from_ctx, TelemetryEvent};
const MODAL_WIDTH: f32 = 360.;
const MODAL_HEIGHT: f32 = 532.;
+4 -3
View File
@@ -1,6 +1,3 @@
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, ActionButtonTheme};
use asset_macro::bundled_or_fetched_asset;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
@@ -20,6 +17,10 @@ use galaxyui::{
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, ActionButtonTheme};
/// White button theme for the Codex modal CTA.
struct WhiteButtonTheme;
+140 -65
View File
@@ -1,31 +1,38 @@
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
use crate::ai::active_agent_views_model::ConversationOrTaskId;
use crate::ai::agent_conversations_model::ConversationOrTask;
use crate::ai::conversation_status_ui::{render_status_element, STATUS_ELEMENT_PADDING};
use crate::appearance::Appearance;
use crate::drive::sharing::dialog::SharingDialog;
use crate::menu::Menu;
use crate::ui_components::icons::Icon;
use crate::ui_components::menu_button::{icon_button_with_context_menu, MenuDirection};
use crate::util::time_format::format_approx_duration_from_now_utc;
use crate::util::truncation::truncate_from_end;
use crate::workspace::view::conversation_list::view::ConversationListViewAction;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::color::coloru_with_opacity;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_util::path::user_friendly_path;
use galaxyui::elements::{
AnchorPair, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, DispatchEventResult, Element, EventHandler, Flex, Highlight, Hoverable,
MainAxisAlignment, MainAxisSize, MouseInBehavior, MouseStateHandle, OffsetPositioning,
OffsetType, ParentAnchor, ParentElement, ParentOffsetBounds, PositionedElementOffsetBounds,
PositioningAxis, Radius, SavePosition, Shrinkable, Stack, Text, XAxisAnchor, YAxisAnchor,
CrossAxisAlignment, DispatchEventResult, Element, EventHandler, Fill as ElementFill, Flex,
Highlight, Hoverable, MainAxisAlignment, MainAxisSize, MouseInBehavior, MouseStateHandle,
OffsetPositioning, OffsetType, ParentAnchor, ParentElement, ParentOffsetBounds,
PositionedElementOffsetBounds, PositioningAxis, Radius, SavePosition, Shrinkable, Stack, Text,
XAxisAnchor, YAxisAnchor,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::platform::Cursor;
use galaxyui::text_layout::TextStyle;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::ui_components::text_input::TextInput;
use galaxyui::{AppContext, SingletonEntity, ViewHandle};
use pathfinder_geometry::vector::vec2f;
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
use crate::ai::agent_conversations_model::{
AgentConversationEntry, AgentConversationEntryId, AgentConversationProvenance,
};
use crate::ai::conversation_status_ui::STATUS_ELEMENT_PADDING;
use crate::appearance::Appearance;
use crate::drive::sharing::dialog::SharingDialog;
use crate::editor::EditorView;
use crate::menu::Menu;
use crate::ui_components::agent_icon::agent_conversation_entry_icon_variant;
use crate::ui_components::icon_with_status::render_icon_with_status;
use crate::ui_components::icons::Icon;
use crate::ui_components::menu_button::{icon_button_with_context_menu, MenuDirection};
use crate::util::time_format::format_approx_duration_from_now_utc;
use crate::util::truncation::truncate_from_end;
use crate::workspace::view::conversation_list::view::ConversationListViewAction;
/// Maximum length for tooltip text before truncation
const MAX_TOOLTIP_LENGTH: usize = 80;
@@ -36,13 +43,24 @@ const ICON_SPACING: f32 = 4.;
/// Offset for the sharing dialog from the item row
const DIALOG_OFFSET_PIXELS: f32 = -16.;
/// Total size of the agent icon-with-status component rendered in each conversation list
/// row.
const LIST_ITEM_AGENT_SIZE: f32 = 22.;
/// Extra overhang past the default overlay position, as a fraction of
/// `LIST_ITEM_AGENT_SIZE`. Pushes the badge all the way to the bounding box's BR corner;
/// the conversation list reads better with the status sitting slightly further out than
/// on the other surfaces.
const LIST_ITEM_OVERLAY_EXTRA_OVERHANG: f32 = 0.05;
/// Generate a position ID for a conversation list item
fn conversation_item_position_id(id: &ConversationOrTaskId) -> String {
fn conversation_item_position_id(id: &AgentConversationEntryId) -> String {
match id {
ConversationOrTaskId::ConversationId(conv_id) => {
AgentConversationEntryId::Conversation(conv_id) => {
format!("conversation_list_item_{conv_id}")
}
ConversationOrTaskId::TaskId(task_id) => format!("conversation_list_task_{task_id}"),
AgentConversationEntryId::AmbientRun(task_id) => {
format!("conversation_list_task_{task_id}")
}
}
}
@@ -53,6 +71,7 @@ pub const STATIC_ITEM_MIN_HEIGHT: f32 = 42.;
#[derive(Clone, Default)]
pub struct ItemState {
pub mouse_state: MouseStateHandle,
pub title_mouse_state: MouseStateHandle,
pub overflow_button_state: MouseStateHandle,
}
@@ -66,7 +85,7 @@ pub enum OverflowMenuDisplay {
}
pub struct ItemProps<'a> {
pub conversation: &'a ConversationOrTask<'a>,
pub conversation: &'a AgentConversationEntry,
pub highlight_indices: Option<&'a Vec<usize>>,
pub is_selected: bool,
pub is_focused_conversation: bool,
@@ -74,7 +93,10 @@ pub struct ItemProps<'a> {
pub state: &'a ItemState,
pub overflow_menu: &'a ViewHandle<Menu<ConversationListViewAction>>,
pub overflow_menu_display: OverflowMenuDisplay,
pub conversation_id: ConversationOrTaskId,
pub conversation_id: AgentConversationEntryId,
pub is_renaming: bool,
pub can_rename: bool,
pub rename_editor: Option<&'a ViewHandle<EditorView>>,
pub sharing_dialog: &'a ViewHandle<SharingDialog>,
pub is_share_dialog_open: bool,
pub list_position_id: &'a str,
@@ -147,7 +169,10 @@ pub fn render_static_item(props: StaticItemProps<'_>, app: &AppContext) -> Box<d
},
Some(MouseInBehavior {
fire_on_synthetic_events: false,
fire_when_covered: true,
// `fire_when_covered: false` makes hover ignore the row when it's
// covered by an overlay (e.g. a modal), so the selection/tooltip
// don't leak through — matching how clicks are already blocked.
fire_when_covered: false,
}),
)
.finish()
@@ -164,6 +189,9 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
overflow_menu,
overflow_menu_display,
conversation_id,
is_renaming,
can_rename,
rename_editor,
sharing_dialog,
is_share_dialog_open,
list_position_id,
@@ -176,8 +204,12 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
let font_size = appearance.ui_font_size();
let title_font_size = font_size + 2.;
let mut title_text = Text::new_inline(conversation.title(app), font_family, title_font_size)
.with_color(theme.main_text_color(theme.background()).into());
let mut title_text = Text::new_inline(
conversation.display.title.clone(),
font_family,
title_font_size,
)
.with_color(theme.main_text_color(theme.background()).into());
if let Some(indices) = highlight_indices {
if !indices.is_empty() {
@@ -194,33 +226,48 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
}
}
let status_element_size = font_size + STATUS_ELEMENT_PADDING * 2.;
let icon_element: Box<dyn Element> = if conversation.is_ambient_agent_conversation() {
ConstrainedBox::new(
Icon::Cloud
.to_galaxyui_icon(theme.sub_text_color(theme.background()))
.finish(),
)
.with_width(status_element_size)
.with_height(status_element_size)
.finish()
let title_element: Box<dyn Element> =
if let Some(rename_editor) = rename_editor.filter(|_| is_renaming) {
render_inline_rename_editor(rename_editor, appearance)
} else {
title_text.finish()
};
let title_element = if can_rename && !is_renaming {
let title_mouse_state = state.title_mouse_state.clone();
Hoverable::new(title_mouse_state, move |_| title_element)
.on_double_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ConversationListViewAction::StartRename {
id: conversation_id,
});
})
.with_cursor(Cursor::PointingHand)
.finish()
} else {
render_status_element(&conversation.status(app), font_size, appearance)
title_element
};
let status_element_size = font_size + STATUS_ELEMENT_PADDING * 2.;
let icon_element = render_icon_with_status(
agent_conversation_entry_icon_variant(conversation),
LIST_ITEM_AGENT_SIZE,
LIST_ITEM_OVERLAY_EXTRA_OVERHANG,
theme,
theme.background(),
);
let icon_and_title_row = Shrinkable::new(
1.0,
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(ICON_SPACING)
.with_child(icon_element)
.with_child(Shrinkable::new(1.0, title_text.finish()).finish())
.with_child(Shrinkable::new(1.0, title_element).finish())
.finish(),
)
.finish();
let timestamp = Text::new_inline(
format_approx_duration_from_now_utc(conversation.last_updated()),
format_approx_duration_from_now_utc(conversation.display.last_updated),
font_family,
font_size - 2.,
)
@@ -266,10 +313,8 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
.with_child(bottom_row)
.finish();
// Use shared logic from ConversationOrTask to determine open action
let open_action = conversation.get_open_action(None, app);
let title = conversation.title(app);
let tooltip_text = truncate_from_end(&title, MAX_TOOLTIP_LENGTH);
let can_open = conversation.capabilities.can_open;
let tooltip_text = truncate_from_end(&conversation.display.title, MAX_TOOLTIP_LENGTH);
let overflow_button_state = state.overflow_button_state.clone();
let hoverable = Hoverable::new(state.mouse_state.clone(), move |_| {
let container = Container::new(row)
@@ -287,7 +332,9 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
let mut stack = Stack::new().with_child(container.finish());
// We show the overflow menu button when the item is selected, or the overflow menu is already open.
if is_selected || !matches!(overflow_menu_display, OverflowMenuDisplay::Closed) {
if !is_renaming
&& (is_selected || !matches!(overflow_menu_display, OverflowMenuDisplay::Closed))
{
let button_style = UiComponentStyles::default()
.set_background(theme.surface_2().into())
.set_border_color(theme.surface_3().into());
@@ -326,7 +373,10 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
}
// Hide the tooltip when the overflow menu is being shown so that they don't overlap.
if is_selected && matches!(overflow_menu_display, OverflowMenuDisplay::Closed) {
if !is_renaming
&& is_selected
&& matches!(overflow_menu_display, OverflowMenuDisplay::Closed)
{
let tooltip = ui_builder.tool_tip(tooltip_text).build().finish();
let (parent_anchor, child_anchor, offset_x) = if tooltip_opens_right {
(ParentAnchor::MiddleRight, ChildAnchor::MiddleLeft, 4.)
@@ -347,7 +397,7 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
let list_position_id = list_position_id.to_string();
move |ctx, _, position| {
let Some(parent_bounds) = ctx.element_position_by_id(&list_position_id) else {
log::warn!("Could not retreive the position of the conversation list for overflow menu display.");
log::warn!("Could not retrieve the position of the conversation list for overflow menu display.");
return;
};
@@ -360,7 +410,7 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
})
.with_defer_events_to_children();
let hoverable_element = if open_action.is_some() {
let hoverable_element = if can_open && !is_renaming {
hoverable
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
@@ -381,7 +431,10 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
},
Some(MouseInBehavior {
fire_on_synthetic_events: false,
fire_when_covered: true,
// `fire_when_covered: false` makes hover ignore the row when
// it's covered by an overlay (e.g. a modal), so the
// selection/tooltip don't leak through — matching clicks.
fire_when_covered: false,
}),
)
.finish();
@@ -415,26 +468,48 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
SavePosition::new(item_stack.finish(), &position_id).finish()
}
fn render_inline_rename_editor(
rename_editor: &ViewHandle<EditorView>,
appearance: &Appearance,
) -> Box<dyn Element> {
TextInput::new(
rename_editor.clone(),
UiComponentStyles::default()
.set_background(ElementFill::None)
.set_border_radius(CornerRadius::with_all(Radius::Pixels(0.)))
.set_border_width(0.)
.set_font_size(appearance.ui_font_size() + 2.),
)
.build()
.finish()
}
/// Returns the secondary label for a conversation list item:
/// - For local conversations: the working directory.
/// - For tasks: the source (Linear, Slack, CLI, etc.)
fn format_item_subtext(conversation: &ConversationOrTask, app: &AppContext) -> Option<String> {
match conversation {
ConversationOrTask::Task(task) => {
task.source.as_ref().map(|s| s.display_name().to_string())
}
ConversationOrTask::Conversation(metadata) => {
// If this conversation is active (with an expanded agent view),
// we use the terminal session's live working directory.
let live_pwd = ActiveAgentViewsModel::as_ref(app)
.get_active_session_for_conversation(metadata.nav_data.id, app)
.and_then(|session| session.as_ref(app).current_working_directory().cloned());
let pwd = live_pwd.or_else(|| metadata.nav_data.initial_working_directory.clone());
pwd.map(|pwd| {
let home_dir = dirs::home_dir().and_then(|p| p.to_str().map(String::from));
user_friendly_path(&pwd, home_dir.as_deref()).into_owned()
})
}
fn format_item_subtext(conversation: &AgentConversationEntry, app: &AppContext) -> Option<String> {
if matches!(
conversation.provenance,
AgentConversationProvenance::AmbientRun
) {
return conversation
.display
.source
.as_ref()
.map(|source| source.display_name().to_string());
}
let live_pwd = conversation
.identity
.local_conversation_id
.and_then(|conversation_id| {
ActiveAgentViewsModel::as_ref(app)
.get_active_session_for_conversation(conversation_id, app)
.and_then(|session| session.as_ref(app).current_working_directory().cloned())
});
let pwd = live_pwd.or_else(|| conversation.display.working_directory.clone());
pwd.map(|pwd| {
let home_dir = dirs::home_dir().and_then(|p| p.to_str().map(String::from));
user_friendly_path(&pwd, home_dir.as_deref()).into_owned()
})
}
+299 -151
View File
@@ -1,39 +1,11 @@
use pathfinder_geometry::vector::Vector2F;
use std::collections::{HashMap, HashSet};
use std::ops::Range;
use std::sync::{Arc, Mutex};
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_conversations_model::{AgentConversationsModel, ConversationOrTask};
use crate::ai::agent_management::telemetry::{AgentManagementTelemetryEvent, OpenedFrom};
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
use crate::appearance::Appearance;
use crate::drive::sharing::dialog::SharingDialog;
use crate::drive::sharing::ShareableObject;
use crate::editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
PropagateHorizontalNavigationKeys, SingleLineEditorOptions, TextOptions,
};
use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields};
use crate::server::telemetry::SharingDialogSource;
use crate::view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme};
use crate::view_components::DismissibleToast;
use crate::workspace::global_actions::ForkedConversationDestination;
use crate::workspace::header_toolbar_item::HeaderToolbarItemKind;
use crate::workspace::tab_settings::TabSettings;
use crate::workspace::view::conversation_list::item::{
render_item, render_static_item, ItemProps, ItemState, OverflowMenuDisplay, StaticItemProps,
STATIC_ITEM_MIN_HEIGHT,
};
use crate::workspace::ToastStack;
use crate::workspace::WorkspaceAction;
use pathfinder_geometry::vector::Vector2F;
use galaxy_core::features::FeatureFlag;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::Icon;
use super::view_model::{ConversationEntry, ConversationListViewModel};
use galaxy_editor::editor::NavigationKey;
use galaxyui::elements::{
Border, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius,
@@ -52,6 +24,35 @@ use galaxyui::{
ViewContext, ViewHandle, WindowId,
};
use super::view_model::{ConversationEntry, ConversationListViewModel};
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_conversations_model::{
AgentConversationEntryId, AgentConversationNavigationSubject, AgentConversationsModel,
};
use crate::ai::agent_management::telemetry::{AgentManagementTelemetryEvent, OpenedFrom};
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
use crate::ai::conversation_rename::rename_conversation;
use crate::appearance::Appearance;
use crate::drive::sharing::dialog::SharingDialog;
use crate::drive::sharing::ShareableObject;
use crate::editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
PropagateHorizontalNavigationKeys, SingleLineEditorOptions, TextOptions,
};
use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields};
use crate::server::telemetry::SharingDialogSource;
use crate::view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme};
use crate::view_components::DismissibleToast;
use crate::workspace::global_actions::ForkedConversationDestination;
use crate::workspace::header_toolbar_item::HeaderToolbarItemKind;
use crate::workspace::tab_settings::TabSettings;
use crate::workspace::view::conversation_list::item::{
render_item, render_static_item, ItemProps, ItemState, OverflowMenuDisplay, StaticItemProps,
STATIC_ITEM_MIN_HEIGHT,
};
use crate::workspace::{ToastStack, WorkspaceAction};
const VIEW_ALL_LABEL: &str = "View all";
/// Maximum number of past items to show before the user toggles "view all".
const INITIAL_MAX_PAST_ITEMS: usize = 10;
@@ -60,7 +61,7 @@ const INITIAL_MAX_PAST_ITEMS: usize = 10;
struct StateHandles {
list_state: UniformListState,
scroll_state: ScrollStateHandle,
item_states: HashMap<ConversationOrTaskId, ItemState>,
item_states: HashMap<AgentConversationEntryId, ItemState>,
start_new_conversation_item: ItemState,
list_hover: MouseStateHandle,
zero_state_button: MouseStateHandle,
@@ -93,7 +94,11 @@ pub enum ConversationSection {
#[derive(Clone, Debug)]
enum ListItem {
SectionHeader(ConversationSection),
Conversation(ConversationEntry),
Conversation {
entry: ConversationEntry,
/// The section the conversation is rendered under.
section: ConversationSection,
},
/// The "+ New conversation" item at the end of the active section.
StartNewConversation,
ToggleViewAllButton,
@@ -101,7 +106,7 @@ enum ListItem {
#[derive(Clone, Copy)]
struct OverflowMenuState {
conversation_id: ConversationOrTaskId,
conversation_id: AgentConversationEntryId,
/// When `Some`, the menu was opened via right-click and should be
/// positioned at the cursor location rather than the kebab button.
position: Option<Vector2F>,
@@ -114,19 +119,19 @@ pub enum ConversationListViewAction {
terminal_view_id: Option<EntityId>,
},
ToggleOverflowMenu {
conversation_id: ConversationOrTaskId,
conversation_id: AgentConversationEntryId,
/// When `Some`, the menu was opened via right-click and should be
/// positioned where the right click took place.
position: Option<Vector2F>,
},
OpenShareDialog {
conversation_id: ConversationOrTaskId,
conversation_id: AgentConversationEntryId,
},
DeleteFromOverflowMenu {
conversation_id: ConversationOrTaskId,
conversation_id: AgentConversationEntryId,
},
OpenItem {
id: ConversationOrTaskId,
id: AgentConversationEntryId,
},
ArrowUp,
ArrowDown,
@@ -137,9 +142,14 @@ pub enum ConversationListViewAction {
ToggleSection(ConversationSection),
ToggleViewAll,
ForkConversation {
conversation_id: ConversationOrTaskId,
conversation_id: AgentConversationEntryId,
destination: ForkedConversationDestination,
},
StartRename {
id: AgentConversationEntryId,
},
FinishRename,
CancelRename,
}
pub enum Event {
@@ -162,8 +172,10 @@ pub struct ConversationListView {
overflow_menu_state: Option<OverflowMenuState>,
/// Sharing dialog for conversations.
sharing_dialog: ViewHandle<SharingDialog>,
rename_editor: ViewHandle<EditorView>,
renaming_conversation_id: Option<AIConversationId>,
/// Track which conversation the share dialog is open for.
share_dialog_open_for: Option<ConversationOrTaskId>,
share_dialog_open_for: Option<AgentConversationEntryId>,
selected_index: Option<usize>,
collapsed_sections: HashSet<ConversationSection>,
/// Cached flat list of items (headers + conversations) for rendering and navigation.
@@ -232,6 +244,30 @@ impl ConversationListView {
ctx.subscribe_to_view(&query_editor, |me, _handle, event, ctx| {
me.handle_query_editor_event(event, ctx);
});
let rename_editor = ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
EditorView::single_line(
SingleLineEditorOptions {
text: TextOptions::ui_text(Some(appearance.ui_font_size() + 2.), appearance),
select_all_on_focus: true,
clear_selections_on_blur: true,
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
propagate_horizontal_navigation_keys: PropagateHorizontalNavigationKeys::Always,
..Default::default()
},
ctx,
)
});
ctx.subscribe_to_view(&rename_editor, |me, _, event, ctx| match event {
EditorEvent::Blurred | EditorEvent::Enter => {
me.finish_rename(ctx);
}
EditorEvent::Escape => {
me.cancel_rename(ctx);
}
_ => {}
});
// We use this as both the "view all" and "show less" button
// (switching out the text on-toggle).
@@ -276,6 +312,8 @@ impl ConversationListView {
item_overflow_menu,
overflow_menu_state: None,
sharing_dialog,
rename_editor,
renaming_conversation_id: None,
share_dialog_open_for: None,
selected_index: None,
collapsed_sections: HashSet::new(),
@@ -291,11 +329,15 @@ impl ConversationListView {
/// Rebuilds the flat list of items based on sections and collapse state.
fn rebuild_list_items(&mut self, ctx: &mut ViewContext<Self>) {
let active_views_model = ActiveAgentViewsModel::as_ref(ctx);
let active_ids = if FeatureFlag::ActiveConversationRequiresInteraction.is_enabled() {
active_views_model.get_all_active_conversation_ids(ctx)
} else {
active_views_model.get_all_open_conversation_ids(ctx)
};
let active_ids: HashSet<_> =
if FeatureFlag::ActiveConversationRequiresInteraction.is_enabled() {
active_views_model.get_all_active_conversation_ids(ctx)
} else {
active_views_model.get_all_open_conversation_ids(ctx)
}
.into_iter()
.map(AgentConversationEntryId::from)
.collect();
let focused_new_conversation =
active_views_model.maybe_get_focused_new_conversation(ctx.window_id(), ctx);
@@ -305,33 +347,58 @@ impl ConversationListView {
let mut active_items = Vec::new();
let mut past_items = Vec::new();
for entry in model.filtered_items() {
let list_item = ListItem::Conversation(entry.clone());
if active_ids.contains(&entry.id) {
active_items.push(list_item);
let local_conversation_entry_id = model
.get_item_by_id(&entry.id, ctx)
.and_then(|entry| entry.identity.local_conversation_id)
.map(AgentConversationEntryId::Conversation);
let is_active = active_ids.contains(&entry.id)
|| local_conversation_entry_id.is_some_and(|id| active_ids.contains(&id));
if is_active {
active_items.push(ListItem::Conversation {
entry: entry.clone(),
section: ConversationSection::Active,
});
} else {
past_items.push(list_item);
past_items.push(ListItem::Conversation {
entry: entry.clone(),
section: ConversationSection::Past,
});
}
}
// If the focused conversation is a new/empty conversation that's not already in the list,
// add it as a regular conversation entry so it participates in the sort.
if let Some(new_conv_id) = focused_new_conversation {
let conv_id = ConversationOrTaskId::ConversationId(new_conv_id);
let already_in_list = active_items
.iter()
.any(|item| matches!(item, ListItem::Conversation(entry) if entry.id == conv_id));
let conv_id = AgentConversationEntryId::Conversation(new_conv_id);
let already_in_list = active_items.iter().any(
|item| matches!(item, ListItem::Conversation { entry, .. } if entry.id == conv_id),
);
if !already_in_list {
active_items.push(ListItem::Conversation(ConversationEntry {
id: conv_id,
highlight_indices: vec![],
}));
active_items.push(ListItem::Conversation {
entry: ConversationEntry {
id: conv_id,
highlight_indices: vec![],
},
section: ConversationSection::Active,
});
}
}
// Sort active items by last opened time (most recently opened first).
active_items.sort_by(|a, b| {
let get_time = |item: &ListItem| match item {
ListItem::Conversation(entry) => active_views_model.get_last_opened_time(&entry.id),
ListItem::Conversation { entry, .. } => {
let entry_time = active_views_model
.get_last_opened_time(&ConversationOrTaskId::from(entry.id));
let local_time = model
.get_item_by_id(&entry.id, ctx)
.and_then(|item| item.identity.local_conversation_id)
.and_then(|id| {
active_views_model
.get_last_opened_time(&ConversationOrTaskId::ConversationId(id))
});
entry_time.max(local_time)
}
_ => None,
};
get_time(b).cmp(&get_time(a))
@@ -389,16 +456,31 @@ impl ConversationListView {
self.list_items.get(index)
}
/// Finds the flat index of a conversation or task by ID, or None if not found.
fn get_index_of_conversation_id(&self, conversation_id: ConversationOrTaskId) -> Option<usize> {
fn get_index_of_conversation_id(
&self,
conversation_id: AgentConversationEntryId,
) -> Option<usize> {
self.list_items.iter().position(|item| match item {
ListItem::Conversation(entry) => entry.id == conversation_id,
ListItem::Conversation { entry, .. } => entry.id == conversation_id,
ListItem::SectionHeader(_)
| ListItem::StartNewConversation
| ListItem::ToggleViewAllButton => false,
})
}
/// Whether the given entry is currently shown in the Active section.
fn is_in_active_section(&self, conversation_id: AgentConversationEntryId) -> bool {
self.list_items.iter().any(|item| {
matches!(
item,
ListItem::Conversation {
entry,
section: ConversationSection::Active,
} if entry.id == conversation_id
)
})
}
pub fn on_left_panel_focused(&mut self, ctx: &mut ViewContext<Self>) {
// Focus the search bar when the panel is opened.
ctx.focus(&self.query_editor);
@@ -406,8 +488,9 @@ impl ConversationListView {
// Select the focused conversation if there is one.
let focused_conversation =
ActiveAgentViewsModel::as_ref(ctx).get_focused_conversation(ctx.window_id());
self.selected_index =
focused_conversation.and_then(|id| self.get_index_of_conversation_id(id));
self.selected_index = focused_conversation
.map(AgentConversationEntryId::from)
.and_then(|id| self.get_index_of_conversation_id(id));
if let Some(index) = self.selected_index {
self.state_handles.list_state.scroll_to(index);
@@ -444,7 +527,7 @@ impl ConversationListView {
fn is_selectable(&self, index: usize) -> bool {
self.get_list_item(index).is_some_and(|item| match item {
ListItem::Conversation(_) | ListItem::StartNewConversation => true,
ListItem::Conversation { .. } | ListItem::StartNewConversation => true,
ListItem::SectionHeader(_) | ListItem::ToggleViewAllButton => false,
})
}
@@ -531,10 +614,9 @@ impl ConversationListView {
self.focus_query_editor(ctx);
}
/// Send telemetry for opening a conversation or task
fn send_open_telemetry(id: &ConversationOrTaskId, ctx: &mut ViewContext<Self>) {
fn send_open_telemetry(id: &AgentConversationEntryId, ctx: &mut ViewContext<Self>) {
match id {
ConversationOrTaskId::ConversationId(conversation_id) => {
AgentConversationEntryId::Conversation(conversation_id) => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::ConversationOpened {
conversation_id: conversation_id.to_string(),
@@ -543,7 +625,7 @@ impl ConversationListView {
ctx
);
}
ConversationOrTaskId::TaskId(task_id) => {
AgentConversationEntryId::AmbientRun(task_id) => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::CloudRunOpened {
task_id: task_id.to_string(),
@@ -569,14 +651,12 @@ impl ConversationListView {
ListItem::StartNewConversation => {
ctx.emit(Event::NewConversationInNewTab);
}
ListItem::Conversation(entry) => {
let model = self.view_model.as_ref(ctx);
let Some(item) = model.get_item_by_id(&entry.id, ctx) else {
return;
};
// Use shared logic from ConversationOrTask to determine click action
if let Some(action) = item.get_open_action(None, ctx) {
ListItem::Conversation { entry, .. } => {
if let Some(action) = AgentConversationsModel::resolve_open_action(
AgentConversationNavigationSubject::Entry(entry.id),
None,
ctx,
) {
Self::send_open_telemetry(&entry.id, ctx);
ctx.dispatch_typed_action(&action);
}
@@ -614,6 +694,58 @@ impl ConversationListView {
ctx.notify();
}
fn start_rename(&mut self, id: AgentConversationEntryId, ctx: &mut ViewContext<Self>) {
let Some(entry) = self.view_model.as_ref(ctx).get_item_by_id(&id, ctx) else {
return;
};
let Some(conversation_id) = entry.identity.local_conversation_id else {
return;
};
// Renaming requires the conversation to be loaded in the history model, which holds
// for active conversations and any conversation open in an agent view (an open
// conversation stays in the Past section until a prompt is sent).
let is_open = ActiveAgentViewsModel::as_ref(ctx)
.get_terminal_view_id_for_conversation(conversation_id, ctx)
.is_some();
if !self.is_in_active_section(id) && !is_open {
return;
}
self.overflow_menu_state = None;
self.renaming_conversation_id = Some(conversation_id);
self.selected_index = self.get_index_of_conversation_id(id);
let title = entry.display.title;
self.rename_editor.update(ctx, |editor, ctx| {
editor.clear_buffer_and_reset_undo_stack(ctx);
editor.insert_selected_text(&title, ctx);
});
ctx.focus(&self.rename_editor);
ctx.notify();
}
fn finish_rename(&mut self, ctx: &mut ViewContext<Self>) {
let Some(conversation_id) = self.renaming_conversation_id.take() else {
return;
};
let title = self.rename_editor.as_ref(ctx).buffer_text(ctx);
self.rename_editor.update(ctx, |editor, ctx| {
editor.clear_buffer_and_reset_undo_stack(ctx);
});
rename_conversation(conversation_id, title, ctx);
ctx.notify();
}
fn cancel_rename(&mut self, ctx: &mut ViewContext<Self>) {
if self.renaming_conversation_id.take().is_none() {
return;
}
self.rename_editor.update(ctx, |editor, ctx| {
editor.clear_buffer_and_reset_undo_stack(ctx);
});
ctx.notify();
}
fn toggle_section_collapse(
&mut self,
section: ConversationSection,
@@ -851,6 +983,7 @@ impl TypedActionView for ConversationListView {
terminal_view_id,
} => {
let window_id = ctx.window_id();
// A conversation can only be deleted once it's done.
let conversation_is_done = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(conversation_id)
.is_none_or(|c| c.status().is_done());
@@ -867,12 +1000,12 @@ impl TypedActionView for ConversationListView {
return;
}
let id = ConversationOrTaskId::ConversationId(*conversation_id);
let id = AgentConversationEntryId::Conversation(*conversation_id);
let conversation_title = self
.view_model
.as_ref(ctx)
.get_item_by_id(&id, ctx)
.map(|c| c.title(ctx).to_string())
.map(|entry| entry.display.title)
.unwrap_or_else(|| "Conversation".to_string());
ctx.emit(Event::ShowDeleteConfirmationDialog {
conversation_id: *conversation_id,
@@ -896,41 +1029,27 @@ impl TypedActionView for ConversationListView {
});
let conversation_id = *conversation_id;
let is_ambient_agent_conversation =
matches!(conversation_id, ConversationOrTaskId::TaskId(_));
let Some(entry) = self
.view_model
.as_ref(ctx)
.get_item_by_id(&conversation_id, ctx)
else {
return;
};
let mut delete_item = MenuItemFields::new("Delete")
.with_override_text_color(Appearance::as_ref(ctx).theme().ansi_fg_red())
.with_on_select_action(ConversationListViewAction::DeleteFromOverflowMenu {
conversation_id,
})
.with_disabled(is_ambient_agent_conversation);
if is_ambient_agent_conversation {
delete_item = delete_item
.with_tooltip("Ambient agent conversations cannot be deleted");
.with_disabled(!entry.capabilities.can_delete);
if !entry.capabilities.can_delete {
delete_item =
delete_item.with_tooltip("This conversation cannot be deleted");
}
// Check if conversation is shareable:
// - For tasks: check if there's an associated conversation_id
// - For conversations: check if synced to cloud
let is_shareable = match conversation_id {
ConversationOrTaskId::TaskId(task_id) => {
if let Some(ConversationOrTask::Task(task)) =
AgentConversationsModel::as_ref(ctx).get_task(&task_id)
{
task.conversation_id.is_some()
} else {
false
}
}
ConversationOrTaskId::ConversationId(conv_id) => {
BlocklistAIHistoryModel::as_ref(ctx)
.can_conversation_be_shared(&conv_id)
}
};
// Only show share item if the conversation is shareable
let share_item = if is_shareable {
let share_item = if entry.capabilities.can_share {
Some(
MenuItemFields::new("Share conversation")
.with_on_select_action(
@@ -944,7 +1063,7 @@ impl TypedActionView for ConversationListView {
let fork_items: Option<[MenuItem<ConversationListViewAction>; 2]> =
// Forking from a closed ambient agent conversation is not supported at this point.
if !is_ambient_agent_conversation {
if entry.capabilities.can_fork_locally {
Some([
MenuItemFields::new("Fork in new pane")
.with_on_select_action(
@@ -988,27 +1107,13 @@ impl TypedActionView for ConversationListView {
ConversationListViewAction::OpenShareDialog { conversation_id } => {
// Clear selection state when opening share dialog
self.selected_index = None;
// Resolve the AIConversationId for the shareable object
let ai_conversation_id: Option<AIConversationId> = match conversation_id {
ConversationOrTaskId::TaskId(task_id) => {
// For tasks, look up the associated conversation_id by server token
if let Some(ConversationOrTask::Task(task)) =
AgentConversationsModel::as_ref(ctx).get_task(task_id)
{
task.conversation_id.as_ref().and_then(|token_str| {
let server_token = ServerConversationToken::new(token_str.clone());
BlocklistAIHistoryModel::as_ref(ctx)
.find_conversation_id_by_server_token(&server_token)
})
} else {
None
}
}
ConversationOrTaskId::ConversationId(conv_id) => Some(*conv_id),
};
let Some(ai_conversation_id) = ai_conversation_id else {
let Some(ai_conversation_id) = self
.view_model
.as_ref(ctx)
.get_item_by_id(conversation_id, ctx)
.filter(|entry| entry.capabilities.can_share)
.and_then(|entry| entry.identity.local_conversation_id)
else {
return;
};
@@ -1025,16 +1130,25 @@ impl TypedActionView for ConversationListView {
ctx.notify();
}
ConversationListViewAction::DeleteFromOverflowMenu { conversation_id } => {
let ConversationOrTaskId::ConversationId(ai_conversation_id) = conversation_id
let Some(entry) = self
.view_model
.as_ref(ctx)
.get_item_by_id(conversation_id, ctx)
else {
// For now, delete is only implemented for non-ambient conversations.
return;
};
let Some(ai_conversation_id) = entry.identity.local_conversation_id else {
return;
};
if !entry.capabilities.can_delete {
return;
};
let conversation =
BlocklistAIHistoryModel::as_ref(ctx).conversation(ai_conversation_id);
BlocklistAIHistoryModel::as_ref(ctx).conversation(&ai_conversation_id);
if let Some(conversation) = conversation {
// Same gate as the deletion path above.
if !conversation.status().is_done() && !conversation.is_empty() {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
@@ -1053,29 +1167,21 @@ impl TypedActionView for ConversationListView {
self.selected_index = None;
let item = self
.view_model
.as_ref(ctx)
.get_item_by_id(conversation_id, ctx);
let terminal_view_id = item
.as_ref()
.and_then(|item| item.navigation_data().and_then(|nav| nav.terminal_view_id));
let conversation_title = item
.as_ref()
.map(|c| c.title(ctx).to_string())
.unwrap_or_else(|| "Conversation".to_string());
let terminal_view_id = ActiveAgentViewsModel::as_ref(ctx)
.get_terminal_view_id_for_conversation(ai_conversation_id, ctx);
let conversation_title = entry.display.title;
ctx.emit(Event::ShowDeleteConfirmationDialog {
conversation_id: *ai_conversation_id,
conversation_id: ai_conversation_id,
conversation_title,
terminal_view_id,
});
}
ConversationListViewAction::OpenItem { id } => {
let model = self.view_model.as_ref(ctx);
let Some(item) = model.get_item_by_id(id, ctx) else {
return;
};
let Some(action) = item.get_open_action(None, ctx) else {
let Some(action) = AgentConversationsModel::resolve_open_action(
AgentConversationNavigationSubject::Entry(*id),
None,
ctx,
) else {
return;
};
@@ -1139,20 +1245,35 @@ impl TypedActionView for ConversationListView {
conversation_id,
destination,
} => {
let ConversationOrTaskId::ConversationId(ai_conversation_id) = conversation_id
let Some(ai_conversation_id) = self
.view_model
.as_ref(ctx)
.get_item_by_id(conversation_id, ctx)
.filter(|entry| entry.capabilities.can_fork_locally)
.and_then(|entry| entry.identity.local_conversation_id)
else {
return;
};
ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation {
conversation_id: *ai_conversation_id,
conversation_id: ai_conversation_id,
fork_from_exchange: None,
summarize_after_fork: false,
summarization_prompt: None,
initial_prompt: None,
initial_attachments: vec![],
destination: *destination,
});
}
ConversationListViewAction::StartRename { id } => {
self.start_rename(*id, ctx);
}
ConversationListViewAction::FinishRename => {
self.finish_rename(ctx);
}
ConversationListViewAction::CancelRename => {
self.cancel_rename(ctx);
}
}
}
}
@@ -1203,9 +1324,14 @@ impl View for ConversationListView {
let list_items = self.list_items.clone();
let overflow_menu = self.item_overflow_menu.clone();
let overflow_menu_state = self.overflow_menu_state;
let focused_conversation =
ActiveAgentViewsModel::as_ref(app).get_focused_conversation(self.window_id);
let focused_conversation = ActiveAgentViewsModel::as_ref(app)
.get_focused_conversation(self.window_id)
.map(AgentConversationEntryId::from);
let sharing_dialog = self.sharing_dialog.clone();
let rename_editor = self.rename_editor.clone();
let renaming_conversation_id = self.renaming_conversation_id;
let open_conversation_ids =
ActiveAgentViewsModel::as_ref(app).get_all_open_conversation_ids(app);
let share_dialog_open_for = self.share_dialog_open_for;
let list_position_id = self.get_position_id();
let tooltip_opens_right = TabSettings::as_ref(app)
@@ -1245,16 +1371,35 @@ impl View for ConversationListView {
app,
))
}
ListItem::Conversation(entry) => {
ListItem::Conversation { entry, section } => {
let conversation = model.get_item_by_id(&entry.id, app)?;
let is_focused_conversation = focused_conversation
.is_some_and(|focused| entry.id == focused);
let local_conversation_entry_id = conversation
.identity
.local_conversation_id
.map(AgentConversationEntryId::Conversation);
let is_focused_conversation =
focused_conversation.is_some_and(|focused| {
entry.id == focused
|| local_conversation_entry_id == Some(focused)
});
let state = item_states.get(&entry.id)?;
let highlight_ref = if entry.highlight_indices.is_empty() {
None
} else {
Some(&entry.highlight_indices)
};
let local_conversation_id =
conversation.identity.local_conversation_id;
let is_renaming = renaming_conversation_id.is_some()
&& local_conversation_id == renaming_conversation_id;
// Renaming is allowed for active conversations and for open
// ones (an open conversation stays in the Past section until
// a prompt is sent).
let can_rename = local_conversation_id.is_some_and(|id| {
*section == ConversationSection::Active
|| open_conversation_ids
.contains(&ConversationOrTaskId::ConversationId(id))
});
let overflow_menu_display = match overflow_menu_state {
Some(s) if s.conversation_id == entry.id => {
@@ -1279,6 +1424,9 @@ impl View for ConversationListView {
overflow_menu: &overflow_menu,
overflow_menu_display,
conversation_id: entry.id,
is_renaming,
can_rename,
rename_editor: is_renaming.then_some(&rename_editor),
sharing_dialog: &sharing_dialog,
is_share_dialog_open,
list_position_id: &list_position_id,
@@ -1,23 +1,23 @@
use crate::ai::active_agent_views_model::ConversationOrTaskId;
use crate::ai::agent_conversations_model::{
AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter,
ConversationOrTask, CreatedOnFilter, CreatorFilter, OwnerFilter, SessionStatus, SourceFilter,
StatusFilter,
};
use fuzzy_match::match_indices_case_insensitive;
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
use crate::ai::agent_conversations_model::{
AgentConversationEntry, AgentConversationEntryId, AgentConversationsModel,
AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter, ConversationUpdateKind,
CreatedOnFilter, CreatorFilter, OwnerFilter, SourceFilter, StatusFilter,
};
pub struct ConversationListViewModelEvent;
#[derive(Clone, Debug)]
pub struct ConversationEntry {
pub id: ConversationOrTaskId,
pub id: AgentConversationEntryId,
pub highlight_indices: Vec<usize>,
}
pub struct ConversationListViewModel {
conversations_model: ModelHandle<AgentConversationsModel>,
cached_conversation_or_task_ids: Vec<ConversationOrTaskId>,
cached_entry_ids: Vec<AgentConversationEntryId>,
filtered_items: Vec<ConversationEntry>,
search_query: String,
}
@@ -30,20 +30,27 @@ impl ConversationListViewModel {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
let conversations_model = AgentConversationsModel::handle(ctx);
ctx.subscribe_to_model(&conversations_model, |me, event, ctx| {
ctx.subscribe_to_model(&conversations_model, |me, _, event, ctx| {
match event {
// These events change the set of items in the list, so we need
// to rebuild the cached ID list.
AgentConversationsModelEvent::ConversationsLoaded
| AgentConversationsModelEvent::NewTasksReceived
| AgentConversationsModelEvent::TasksUpdated
| AgentConversationsModelEvent::TaskManuallyOpened => {
| AgentConversationsModelEvent::TasksUpdated => {
me.refresh_cached_items(ctx);
}
// Status changes don't affect the set of IDs (status is read
// at render time via get_item_by_id); just signal a re-render.
AgentConversationsModelEvent::ConversationUpdated => {
ctx.emit(ConversationListViewModelEvent);
AgentConversationsModelEvent::ConversationUpdated { kind } => {
if matches!(
kind,
ConversationUpdateKind::MetadataChanged
| ConversationUpdateKind::TitleChanged
) {
me.refresh_cached_items(ctx);
} else {
ctx.emit(ConversationListViewModelEvent);
}
}
// Artifact updates don't affect the conversation list
AgentConversationsModelEvent::ConversationArtifactsUpdated { .. } => {}
@@ -52,7 +59,7 @@ impl ConversationListViewModel {
let mut model = Self {
conversations_model,
cached_conversation_or_task_ids: Vec::new(),
cached_entry_ids: Vec::new(),
filtered_items: Vec::new(),
search_query: String::new(),
};
@@ -62,15 +69,15 @@ impl ConversationListViewModel {
/// Rebuilds the cached list of IDs from the current task/conversation set.
///
/// The cache stores only `ConversationOrTaskId`s; per-item fields like
/// The cache stores only `AgentConversationEntryId`s; per-item fields like
/// status, title, and last-updated are read fresh at render time via
/// `get_item_by_id`. Callers should therefore avoid invoking this on
/// events that only mutate per-item state (e.g. `ConversationUpdated`);
/// emitting `ConversationListViewModelEvent` is sufficient there.
fn refresh_cached_items(&mut self, ctx: &mut ModelContext<Self>) {
let model = self.conversations_model.as_ref(ctx);
self.cached_conversation_or_task_ids = model
.get_tasks_and_conversations(
self.cached_entry_ids = model
.get_entries(
&AgentManagementFilters {
owners: OwnerFilter::PersonalOnly,
status: StatusFilter::All,
@@ -83,28 +90,9 @@ impl ConversationListViewModel {
},
ctx,
)
// Expired and Unavailable ambient agent sessions can't be opened, so we filter them out.
// Regular conversations have None session_status
.filter(|item| {
item.get_session_status()
.is_none_or(|status| status == SessionStatus::Available)
})
// Only show user-initiated sources (Slack, Linear, Interactive) or tasks that have
// been manually opened from the management page.
.filter(|item| {
let is_user_initiated = item.source().is_some_and(|s| s.is_user_initiated());
let is_manually_opened = match item {
ConversationOrTask::Task(task) => model.is_task_manually_opened(&task.task_id),
ConversationOrTask::Conversation(_) => false,
};
is_user_initiated || is_manually_opened
})
.map(|item| match item {
ConversationOrTask::Task(task) => ConversationOrTaskId::TaskId(task.task_id),
ConversationOrTask::Conversation(conv) => {
ConversationOrTaskId::ConversationId(conv.nav_data.id)
}
})
.into_iter()
.filter(|entry| entry.capabilities.can_open)
.map(|entry| entry.id)
.collect();
self.apply_search_filter(ctx);
@@ -127,7 +115,7 @@ impl ConversationListViewModel {
if search_query.is_empty() {
self.filtered_items = self
.cached_conversation_or_task_ids
.cached_entry_ids
.iter()
.map(|id| ConversationEntry {
id: *id,
@@ -136,27 +124,22 @@ impl ConversationListViewModel {
.collect();
} else {
let mut matched_items: Vec<(i64, ConversationEntry)> = self
.cached_conversation_or_task_ids
.cached_entry_ids
.iter()
.filter_map(|id| {
let item = match id {
ConversationOrTaskId::TaskId(task_id) => {
conversations_model.get_task(task_id)
}
ConversationOrTaskId::ConversationId(conv_id) => {
conversations_model.get_conversation(conv_id)
}
}?;
let item = conversations_model.get_entry_by_id(id, ctx)?;
match_indices_case_insensitive(&item.title(ctx), &search_query).map(|result| {
(
result.score,
ConversationEntry {
id: *id,
highlight_indices: result.matched_indices,
},
)
})
match_indices_case_insensitive(&item.display.title, &search_query).map(
|result| {
(
result.score,
ConversationEntry {
id: *id,
highlight_indices: result.matched_indices,
},
)
},
)
})
.collect();
@@ -167,7 +150,7 @@ impl ConversationListViewModel {
/// Returns the total number of conversations in the model before any filtering is applied.
pub fn unfiltered_item_count(&self) -> usize {
self.cached_conversation_or_task_ids.len()
self.cached_entry_ids.len()
}
/// Returns the filtered items with their highlight indices.
@@ -175,20 +158,17 @@ impl ConversationListViewModel {
&self.filtered_items
}
/// Look up a conversation or task by ID.
pub fn get_item_by_id<'a>(
/// Look up a normalized conversation entry by ID.
pub fn get_item_by_id(
&self,
id: &ConversationOrTaskId,
ctx: &'a AppContext,
) -> Option<ConversationOrTask<'a>> {
id: &AgentConversationEntryId,
ctx: &AppContext,
) -> Option<AgentConversationEntry> {
let model = self.conversations_model.as_ref(ctx);
match id {
ConversationOrTaskId::TaskId(task_id) => model.get_task(task_id),
ConversationOrTaskId::ConversationId(conv_id) => model.get_conversation(conv_id),
}
model.get_entry_by_id(id, ctx)
}
pub fn current_ids(&self) -> impl Iterator<Item = &ConversationOrTaskId> {
pub fn current_ids(&self) -> impl Iterator<Item = &AgentConversationEntryId> {
self.filtered_items.iter().map(|item| &item.id)
}
}
+1 -2
View File
@@ -1,9 +1,8 @@
use galaxy_cli::RecoveryMechanism;
use galaxyui::{AppContext, SingletonEntity as _, ViewContext};
use crate::crash_recovery::CrashRecovery;
use super::{Workspace, WorkspaceBannerFields};
use crate::crash_recovery::CrashRecovery;
pub fn banner_metadata(ctx: &AppContext) -> Option<WorkspaceBannerFields> {
let crash_recovery = CrashRecovery::as_ref(ctx);
@@ -0,0 +1,433 @@
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use serde_json::{json, Value};
use strum_macros::{EnumDiscriminants, EnumIter};
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
use warpui::elements::{
Align, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
DropShadow, Flex, FormattedTextElement, MainAxisAlignment, MainAxisSize, MouseStateHandle,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Stack,
};
use warpui::fonts::Weight;
use warpui::keymap::FixedBinding;
use warpui::platform::Cursor;
use warpui::ui_components::button::ButtonVariant;
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
use crate::auth::AuthStateProvider;
use crate::settings_view::SettingsSection;
use crate::ui_components::blended_colors;
use crate::workspace::WorkspaceAction;
use crate::workspaces::user_workspaces::UserWorkspaces;
const MODAL_WIDTH: f32 = 480.;
const CORNER_RADIUS: f32 = 12.;
const PANEL_PADDING: f32 = 24.;
const CLOSE_BUTTON_DIAMETER: f32 = 20.;
const NOTICE_TITLE_TEXT: &str = "Warp is no longer providing inference on the free plan.";
const NOTICE_BODY_TEXT: &str = "To keep using Warp's AI features, please upgrade to a paid plan, \
bring your own API key or endpoint, or log in with your Grok subscription.";
const NOTICE_BONUS_CREDITS_TEXT: &str = "If you have any unused bonus credits, AI will keep \
working until these run out.";
const PROMPT_SUGGESTIONS_TITLE_TEXT: &str = "How to use AI features in Warp";
const PROMPT_SUGGESTIONS_BODY_TEXT: &str = "To use AI features in Warp, subscribe to a paid plan, \
add an API key (OpenAI, Anthropic, or Google), add a custom inference endpoint (OpenRouter, \
LiteLLM), or log in using your SuperGrok subscription.";
/// Which surface opened the modal. Selects the copy and disambiguates telemetry;
/// the layout and CTAs are identical across variants.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FreeAiRemovalModalVariant {
/// One-time notice shown to Free users when Warp-provided AI is removed from their plan.
Notice,
/// Shown on demand when a Free user activates Prompt Suggestions while out of credits.
PromptSuggestions,
}
impl FreeAiRemovalModalVariant {
fn title(self) -> &'static str {
match self {
Self::Notice => NOTICE_TITLE_TEXT,
Self::PromptSuggestions => PROMPT_SUGGESTIONS_TITLE_TEXT,
}
}
fn body(self) -> &'static str {
match self {
Self::Notice => NOTICE_BODY_TEXT,
Self::PromptSuggestions => PROMPT_SUGGESTIONS_BODY_TEXT,
}
}
/// Secondary note rendered under the body. The on-demand Prompt Suggestions
/// variant only fires once the user is already out of credits, so the
/// bonus-credits note doesn't apply there.
fn secondary(self) -> Option<&'static str> {
match self {
Self::Notice => Some(NOTICE_BONUS_CREDITS_TEXT),
Self::PromptSuggestions => None,
}
}
fn as_str(self) -> &'static str {
match self {
Self::Notice => "notice",
Self::PromptSuggestions => "prompt_suggestions",
}
}
}
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_fixed_bindings([FixedBinding::new(
"escape",
FreeAiRemovalModalAction::Close,
id!("FreeAiRemovalModal"),
)]);
}
#[derive(Clone, Debug)]
pub enum FreeAiRemovalModalAction {
Close,
SetUpByok,
Upgrade,
}
#[derive(Clone, Copy, Debug)]
pub enum FreeAiRemovalModalEvent {
Close,
}
#[derive(Default)]
struct StateHandles {
close_button: MouseStateHandle,
byok_button: MouseStateHandle,
upgrade_button: MouseStateHandle,
}
/// Notice shown to Free-plan users about the removal of Warp-provided AI. The
/// `variant` selects the copy: a one-time rollout notice, or an on-demand prompt
/// when a Free user activates a gated feature (e.g. Prompt Suggestions).
pub struct FreeAiRemovalModal {
variant: FreeAiRemovalModalVariant,
state_handles: StateHandles,
}
impl FreeAiRemovalModal {
pub fn new(variant: FreeAiRemovalModalVariant, _ctx: &mut ViewContext<Self>) -> Self {
Self {
variant,
state_handles: Default::default(),
}
}
fn upgrade_url(ctx: &ViewContext<Self>) -> String {
if let Some(team) = UserWorkspaces::as_ref(ctx).current_team() {
UserWorkspaces::upgrade_link_for_team(team.uid)
} else {
let user_id = AuthStateProvider::as_ref(ctx)
.get()
.user_id()
.unwrap_or_default();
UserWorkspaces::upgrade_link(user_id)
}
}
fn render_buttons(&self, appearance: &Appearance) -> Box<dyn Element> {
let byok_button = appearance
.ui_builder()
.button(
ButtonVariant::Secondary,
self.state_handles.byok_button.clone(),
)
.with_style(UiComponentStyles {
font_size: Some(14.),
height: Some(32.),
..Default::default()
})
.with_centered_text_label("Bring your own AI".to_string())
.build()
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(FreeAiRemovalModalAction::SetUpByok);
})
.finish();
let upgrade_button = appearance
.ui_builder()
.button(
ButtonVariant::Accent,
self.state_handles.upgrade_button.clone(),
)
.with_style(UiComponentStyles {
font_size: Some(14.),
height: Some(32.),
..Default::default()
})
.with_centered_text_label("View pricing".to_string())
.build()
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(FreeAiRemovalModalAction::Upgrade);
})
.finish();
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(8.)
.with_child(byok_button)
.with_child(upgrade_button)
.finish()
}
fn render_close_button(&self, appearance: &Appearance) -> Box<dyn Element> {
appearance
.ui_builder()
.close_button(
CLOSE_BUTTON_DIAMETER,
self.state_handles.close_button.clone(),
)
.build()
.on_click(|ctx, _, _| ctx.dispatch_typed_action(FreeAiRemovalModalAction::Close))
.finish()
}
}
impl Entity for FreeAiRemovalModal {
type Event = FreeAiRemovalModalEvent;
}
impl View for FreeAiRemovalModal {
fn ui_name() -> &'static str {
"FreeAiRemovalModal"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let bg = blended_colors::neutral_1(theme);
let font_family = appearance.ui_font_family();
let title = FormattedTextElement::from_str(self.variant.title(), font_family, 18.)
.with_color(blended_colors::text_main(theme, bg))
.with_weight(Weight::Bold)
.finish();
let body_color = blended_colors::text_sub(theme, bg);
let body = FormattedTextElement::from_str(self.variant.body(), font_family, 14.)
.with_color(body_color)
.finish();
let mut content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(Container::new(title).with_margin_bottom(12.).finish());
// Tighten the gap below the body when a secondary note follows; otherwise
// keep the full gap above the buttons.
let body_margin_bottom = if self.variant.secondary().is_some() {
8.
} else {
20.
};
content.add_child(
Container::new(body)
.with_margin_bottom(body_margin_bottom)
.finish(),
);
if let Some(secondary_text) = self.variant.secondary() {
let secondary = FormattedTextElement::from_str(secondary_text, font_family, 14.)
.with_color(body_color)
.finish();
content.add_child(Container::new(secondary).with_margin_bottom(20.).finish());
}
let content = content.with_child(self.render_buttons(appearance)).finish();
let mut modal = Stack::new();
modal.add_child(
Container::new(
ConstrainedBox::new(content)
.with_width(MODAL_WIDTH)
.finish(),
)
.with_background_color(bg)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(CORNER_RADIUS)))
.with_border(Border::all(1.).with_border_fill(theme.outline()))
.with_uniform_padding(PANEL_PADDING)
.with_drop_shadow(DropShadow::default())
.finish(),
);
modal.add_positioned_child(
self.render_close_button(appearance),
OffsetPositioning::offset_from_parent(
vec2f(-8., 8.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::TopRight,
ChildAnchor::TopRight,
),
);
let mut stack = Stack::new();
stack.add_positioned_child(
modal.finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
Container::new(Align::new(stack.finish()).finish())
.with_background(Fill::Solid(ColorU::new(97, 97, 97, 255)).with_opacity(50))
.finish()
}
}
impl TypedActionView for FreeAiRemovalModal {
type Action = FreeAiRemovalModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
FreeAiRemovalModalAction::Close => {
send_telemetry_from_ctx!(
FreeAiRemovalModalTelemetryEvent::Dismissed {
variant: self.variant,
},
ctx
);
ctx.emit(FreeAiRemovalModalEvent::Close);
}
FreeAiRemovalModalAction::SetUpByok => {
send_telemetry_from_ctx!(
FreeAiRemovalModalTelemetryEvent::CtaClicked {
variant: self.variant,
cta: FreeAiRemovalModalCta::SetUpByok,
},
ctx
);
// Deferred so the close-driven refocus below doesn't steal focus from
// the settings page this opens.
ctx.dispatch_typed_action_deferred(WorkspaceAction::ShowSettingsPageWithSearch {
search_query: "api".to_string(),
section: Some(SettingsSection::WarpAgent),
});
ctx.emit(FreeAiRemovalModalEvent::Close);
}
FreeAiRemovalModalAction::Upgrade => {
send_telemetry_from_ctx!(
FreeAiRemovalModalTelemetryEvent::CtaClicked {
variant: self.variant,
cta: FreeAiRemovalModalCta::Upgrade,
},
ctx
);
let upgrade_url = Self::upgrade_url(ctx);
ctx.open_url(&upgrade_url);
ctx.emit(FreeAiRemovalModalEvent::Close);
}
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum FreeAiRemovalModalCta {
SetUpByok,
Upgrade,
}
impl FreeAiRemovalModalCta {
fn as_str(&self) -> &'static str {
match self {
Self::SetUpByok => "set_up_byok",
Self::Upgrade => "upgrade",
}
}
}
#[derive(Debug, EnumDiscriminants)]
#[strum_discriminants(derive(EnumIter))]
pub enum FreeAiRemovalModalTelemetryEvent {
Shown {
variant: FreeAiRemovalModalVariant,
},
Dismissed {
variant: FreeAiRemovalModalVariant,
},
CtaClicked {
variant: FreeAiRemovalModalVariant,
cta: FreeAiRemovalModalCta,
},
}
impl TelemetryEvent for FreeAiRemovalModalTelemetryEvent {
fn name(&self) -> &'static str {
FreeAiRemovalModalTelemetryEventDiscriminants::from(self).name()
}
fn payload(&self) -> Option<Value> {
match self {
Self::Shown { variant } | Self::Dismissed { variant } => Some(json!({
"variant": variant.as_str(),
})),
Self::CtaClicked { variant, cta } => Some(json!({
"variant": variant.as_str(),
"cta": cta.as_str(),
})),
}
}
fn description(&self) -> &'static str {
FreeAiRemovalModalTelemetryEventDiscriminants::from(self).description()
}
fn enablement_state(&self) -> EnablementState {
FreeAiRemovalModalTelemetryEventDiscriminants::from(self).enablement_state()
}
fn contains_ugc(&self) -> bool {
match self {
Self::Shown { .. } | Self::Dismissed { .. } | Self::CtaClicked { .. } => false,
}
}
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
galaxy_core::telemetry::enum_events::<Self>()
}
}
impl TelemetryEventDesc for FreeAiRemovalModalTelemetryEventDiscriminants {
fn name(&self) -> &'static str {
match self {
Self::Shown => "FreeAiRemovalModal.Shown",
Self::Dismissed => "FreeAiRemovalModal.Dismissed",
Self::CtaClicked => "FreeAiRemovalModal.CtaClicked",
}
}
fn description(&self) -> &'static str {
match self {
Self::Shown => "The free AI removal notice modal was shown to the user",
Self::Dismissed => "The user dismissed the free AI removal notice modal",
Self::CtaClicked => "The user clicked a CTA in the free AI removal notice modal",
}
}
fn enablement_state(&self) -> EnablementState {
match self {
Self::Shown | Self::Dismissed | Self::CtaClicked => EnablementState::Always,
}
}
}
galaxy_core::register_telemetry_event!(FreeAiRemovalModalTelemetryEvent);
@@ -1,10 +1,3 @@
use crate::ai::{AIRequestUsageModel, AIRequestUsageModelEvent};
use crate::auth::AuthStateProvider;
use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::TelemetryEvent;
use asset_macro::bundled_or_fetched_asset;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::appearance::Appearance;
@@ -27,6 +20,14 @@ use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use thousands::Separable;
use crate::ai::{AIRequestUsageModel, AIRequestUsageModelEvent};
use crate::auth::AuthStateProvider;
use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::TelemetryEvent;
const BUTTON_DIAMETER: f32 = 20.;
const MODAL_HEIGHT: f32 = 440.;
const LEFT_PANEL_WIDTH: f32 = 360.;
@@ -71,6 +72,7 @@ impl FreeTierLimitHitModal {
ctx.emit(FreeTierLimitHitModalEvent::MaybeOpen);
}
AIRequestUsageModelEvent::RequestBonusRefunded { .. } => {}
AIRequestUsageModelEvent::AmbientCreditsBannerDismissed => {}
},
);
@@ -1,8 +1,25 @@
use warp_ripgrep::search::Submatch;
use warp_util::local_or_remote_path::LocalOrRemotePath;
pub struct SearchConfig {
pub use_regex: bool,
pub use_case_sensitivity: bool,
}
/// A single global search match: one line in one file, which may live on
/// the local filesystem or on a remote host.
#[derive(Clone, Debug)]
pub struct GlobalSearchMatch {
pub location: LocalOrRemotePath,
pub line_number: u32,
/// Original 1-based character column in the file. This is captured
/// before display-only whitespace trimming so opening a result navigates
/// to the correct location.
pub column_num: Option<usize>,
pub line_text: String,
pub submatches: Vec<Submatch>,
}
#[cfg_attr(not(target_family = "wasm"), path = "model.rs")]
#[cfg_attr(target_family = "wasm", path = "model_wasm.rs")]
pub mod model;
+362 -44
View File
@@ -1,5 +1,7 @@
use crate::workspace::view::global_search::view::GlobalSearchEvent;
use crate::workspace::view::global_search::SearchConfig;
use std::collections::HashMap;
use std::future::Future;
use std::path::PathBuf;
use anyhow::Result;
use futures::StreamExt as _;
use galaxy_ripgrep::search::{Match as RipgrepMatch, Submatch};
@@ -8,15 +10,62 @@ use galaxyui::{Entity, ModelContext, ModelSpawner};
use instant::Instant;
use num_traits::SaturatingSub;
use regex::escape;
use std::path::PathBuf;
use remote_server::manager::{HostRequestError, RemoteServerManager, RipgrepSearchParams};
use remote_server::proto::RipgrepSearchSuccess;
use remote_server::protocol::RequestId;
use remote_server::HostId;
use string_offset::ByteOffset;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxy_util::remote_path::RemotePath;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::{Entity, ModelContext, ModelSpawner, SingletonEntity};
use crate::workspace::view::global_search::view::GlobalSearchEvent;
use crate::workspace::view::global_search::{GlobalSearchMatch, SearchConfig};
const START_BATCH_AFTER_COUNT: usize = 50;
const MAX_BATCH_SIZE: usize = 512;
const MAX_BATCH_AGE_MS: u64 = 4000;
/// Client-requested cap on remote matches per host. The daemon clamps this
/// to its own server-side cap; both bound the single-frame response size.
const REMOTE_MAX_MATCH_COUNT: u32 = 5_000;
/// Aggregate state for one logical search across all of its sources
/// (one local ripgrep run plus one remote request per searched host).
struct ActiveSearch {
search_id: u32,
remaining_sources: usize,
completed_sources: usize,
local_source_failed: bool,
remote_source_failures: usize,
total_match_count: usize,
/// True when any remote source hit the server-side match cap.
capped: bool,
}
#[derive(Clone, Copy)]
enum SearchSource {
Local,
Remote,
}
/// Result of one search source (the local ripgrep run, or one remote
/// host's request) that ran to completion.
struct SourceResult {
match_count: usize,
capped: bool,
}
pub struct GlobalSearch {
search_handle: Option<SpawnedFutureHandle>,
/// Spawned local/remote search tasks for the current search.
search_handles: Vec<SpawnedFutureHandle>,
/// Request ids of remote searches started for the current search, so
/// they can be aborted daemon-side when the query changes. May contain
/// ids of already-resolved requests; aborting those is a no-op.
in_flight_remote_requests: Vec<RequestId>,
/// Aggregate completion state for the current search.
active_search: Option<ActiveSearch>,
// track the search ID so that we only show results for the current search
next_search_id: u32,
}
@@ -28,7 +77,7 @@ impl Entity for GlobalSearch {
async fn flush_batch(
spawner: &ModelSpawner<GlobalSearch>,
search_id: u32,
batch: &mut Vec<RipgrepMatch>,
batch: &mut Vec<GlobalSearchMatch>,
) {
if batch.is_empty() {
return;
@@ -46,35 +95,47 @@ async fn flush_batch(
impl GlobalSearch {
pub fn new() -> Self {
GlobalSearch {
search_handle: None,
search_handles: Vec::new(),
in_flight_remote_requests: Vec::new(),
active_search: None,
next_search_id: 1,
}
}
pub fn abort_search(&mut self) {
if let Some(handle) = self.search_handle.take() {
pub fn abort_search(&mut self, ctx: &mut ModelContext<Self>) {
for handle in self.search_handles.drain(..) {
handle.abort();
}
self.active_search = None;
// Cancel in-flight remote searches daemon-side as well: queries
// change on every debounced edit, so without this the daemon piles
// up wasted ripgrep runs.
let request_ids = std::mem::take(&mut self.in_flight_remote_requests);
if !request_ids.is_empty() {
RemoteServerManager::handle(ctx).update(ctx, |manager, _| {
for request_id in &request_ids {
manager.abort_host_request(request_id);
}
});
}
}
pub fn run_search(
&mut self,
pattern: String,
roots: Vec<PathBuf>,
roots: Vec<LocalOrRemotePath>,
search_config: SearchConfig,
ctx: &mut ModelContext<Self>,
) {
if let Some(handle) = self.search_handle.take() {
if !self.search_handles.is_empty() {
log::info!("GlobalSearch: aborting previous search");
handle.abort();
}
self.abort_search(ctx);
let search_id = self.next_search_id;
self.next_search_id += 1;
ctx.emit(GlobalSearchEvent::Started { search_id });
let spawner = ctx.spawner();
let effective_pattern = if search_config.use_regex {
pattern
} else {
@@ -83,36 +144,263 @@ impl GlobalSearch {
let ignore_case = !search_config.use_case_sensitivity;
let multiline = effective_pattern.contains('\n');
let handle = ctx.spawn(
// Split roots into the local filesystem source and one remote
// source per host.
let mut local_roots: Vec<PathBuf> = Vec::new();
let mut remote_roots: HashMap<HostId, Vec<StandardizedPath>> = HashMap::new();
for root in roots {
match root {
LocalOrRemotePath::Local(path) => local_roots.push(path),
LocalOrRemotePath::Remote(remote) => {
remote_roots
.entry(remote.host_id)
.or_default()
.push(remote.path);
}
}
}
let remote_host_count = remote_roots.len();
ctx.emit(GlobalSearchEvent::Started {
search_id,
remote_host_count,
});
let source_count = usize::from(!local_roots.is_empty()) + remote_roots.len();
if source_count == 0 {
ctx.emit(GlobalSearchEvent::Completed {
search_id,
total_match_count: 0,
capped: false,
local_source_failed: false,
remote_source_failures: 0,
});
return;
}
self.active_search = Some(ActiveSearch {
search_id,
remaining_sources: source_count,
completed_sources: 0,
local_source_failed: false,
remote_source_failures: 0,
total_match_count: 0,
capped: false,
});
if !local_roots.is_empty() {
self.spawn_local_search(
search_id,
effective_pattern.clone(),
local_roots,
ignore_case,
multiline,
ctx,
);
}
for (host_id, paths) in remote_roots {
let params = RipgrepSearchParams {
pattern: effective_pattern.clone(),
roots: paths,
ignore_case,
multiline,
max_matches: REMOTE_MAX_MATCH_COUNT,
};
self.spawn_remote_search(search_id, host_id, params, ctx);
}
}
fn spawn_local_search(
&mut self,
search_id: u32,
pattern: String,
roots: Vec<PathBuf>,
ignore_case: bool,
multiline: bool,
ctx: &mut ModelContext<Self>,
) {
let spawner = ctx.spawner();
self.spawn_source(
search_id,
SearchSource::Local,
async move {
Self::run_galaxy_ripgrep_cli(
let result = Self::run_galaxy_ripgrep_cli(
search_id,
effective_pattern,
pattern,
roots,
ignore_case,
multiline,
spawner,
)
.await
},
move |_, result, ctx| match result {
Ok(total_match_count) => {
ctx.emit(GlobalSearchEvent::Completed {
search_id,
total_match_count,
});
}
Err(err) => {
log::error!("GlobalSearch: galaxy_ripgrep CLI search failed or aborted: {err}");
ctx.emit(GlobalSearchEvent::Failed {
search_id,
error: "Global search failed.".to_string(),
});
.await;
match result {
Ok(match_count) => Some(SourceResult {
match_count,
capped: false,
}),
Err(err) => {
log::error!(
"GlobalSearch: galaxy_ripgrep CLI search failed or aborted: {err}"
);
None
}
}
},
ctx,
);
}
self.search_handle = Some(handle);
fn spawn_remote_search(
&mut self,
search_id: u32,
host_id: HostId,
params: RipgrepSearchParams,
ctx: &mut ModelContext<Self>,
) {
let pending = RemoteServerManager::handle(ctx).update(ctx, |manager, _| {
manager.start_ripgrep_search(&host_id, params)
});
self.in_flight_remote_requests
.push(pending.request_id().clone());
let spawner = ctx.spawner();
self.spawn_source(
search_id,
SearchSource::Remote,
async move {
match pending.result().await {
Ok(success) => {
let capped = success.capped;
let mut items = Self::remote_matches_to_global(&host_id, success);
let match_count = items.len();
flush_batch(&spawner, search_id, &mut items).await;
Some(SourceResult {
match_count,
capped,
})
}
// An abort is initiated by a newer search (or a reset),
// which already replaced the aggregate state; the stale
// search-id guard drops this outcome regardless.
Err(HostRequestError::Aborted) => None,
Err(err) => {
log::warn!("GlobalSearch: remote search failed for host {host_id}: {err}");
None
}
}
},
ctx,
);
}
/// Spawns one search source (the local ripgrep run, or one remote
/// host's request) and routes its outcome into the shared completion
/// accounting. Sources emit their matches via `Progress`/`ProgressBatch`
/// while running and log their own failures.
fn spawn_source(
&mut self,
search_id: u32,
source_kind: SearchSource,
source: impl Future<Output = Option<SourceResult>> + Send + 'static,
ctx: &mut ModelContext<Self>,
) {
let task = ctx.spawn(source, move |me, outcome, ctx| {
me.handle_source_completed(search_id, source_kind, outcome, ctx);
});
self.search_handles.push(task);
}
/// Converts a remote search response into per-submatch result rows,
/// attaching the originating host to each match location.
fn remote_matches_to_global(
host_id: &HostId,
success: RipgrepSearchSuccess,
) -> Vec<GlobalSearchMatch> {
success
.matches
.into_iter()
.filter_map(|m| {
let path = match StandardizedPath::try_new(&m.file_path) {
Ok(path) => path,
Err(err) => {
log::warn!("GlobalSearch: dropping remote match with invalid path: {err}");
return None;
}
};
let submatches = m
.submatches
.into_iter()
.map(|s| Submatch {
byte_start: ByteOffset::from(s.byte_start as usize),
byte_end: ByteOffset::from(s.byte_end as usize),
})
.collect();
Some(GlobalSearchMatch {
location: LocalOrRemotePath::Remote(RemotePath::new(host_id.clone(), path)),
line_number: m.line_number,
column_num: None,
line_text: m.line_text,
submatches,
})
})
.flat_map(Self::expand_submatches)
.collect()
}
/// Records the completion of one search source (`None` when the source
/// failed; the source already logged the failure). When all sources have
/// finished, emits `Completed` (or `Failed` when every source failed).
fn handle_source_completed(
&mut self,
search_id: u32,
source_kind: SearchSource,
outcome: Option<SourceResult>,
ctx: &mut ModelContext<Self>,
) {
let Some(active) = self.active_search.as_mut() else {
return;
};
if active.search_id != search_id {
return;
}
match outcome {
Some(SourceResult {
match_count,
capped,
}) => {
active.completed_sources += 1;
active.total_match_count += match_count;
active.capped |= capped;
}
None => match source_kind {
SearchSource::Local => active.local_source_failed = true,
SearchSource::Remote => active.remote_source_failures += 1,
},
}
active.remaining_sources = active.remaining_sources.saturating_sub(1);
if active.remaining_sources > 0 {
return;
}
let active = self
.active_search
.take()
.expect("active search was checked above");
if active.completed_sources == 0 {
ctx.emit(GlobalSearchEvent::Failed {
search_id,
error: "Global search failed.".to_string(),
});
} else {
ctx.emit(GlobalSearchEvent::Completed {
search_id,
total_match_count: active.total_match_count,
capped: active.capped,
local_source_failed: active.local_source_failed,
remote_source_failures: active.remote_source_failures,
});
}
}
async fn run_galaxy_ripgrep_cli(
@@ -135,14 +423,14 @@ impl GlobalSearch {
let mut total_match_count: usize = 0;
let mut num_unbatched_emitted: usize = 0;
let mut batch: Vec<RipgrepMatch> = Vec::new();
let mut batch: Vec<GlobalSearchMatch> = Vec::new();
let mut last_batch_flush_at = Instant::now();
while let Some(raw_match) = stream.next().await {
// Expand each submatch into its own result row (matching
// the old per-submatch behavior). Each row gets the line
// text trimmed up to that particular submatch.
for per_submatch in Self::expand_submatches(raw_match) {
for per_submatch in Self::expand_submatches(Self::local_match_to_global(raw_match)) {
total_match_count += 1;
if num_unbatched_emitted < START_BATCH_AFTER_COUNT {
@@ -178,40 +466,65 @@ impl GlobalSearch {
Ok(total_match_count)
}
/// Expand a single ripgrep match (which may contain multiple submatches
fn local_match_to_global(m: RipgrepMatch) -> GlobalSearchMatch {
GlobalSearchMatch {
location: LocalOrRemotePath::Local(m.file_path),
line_number: m.line_number,
column_num: None,
line_text: m.line_text,
submatches: m.submatches,
}
}
/// Expand a single match (which may contain multiple submatches
/// on the same line) into one result per submatch. Each result gets the
/// line text trimmed of leading whitespace up to that submatch.
fn expand_submatches(m: RipgrepMatch) -> Vec<RipgrepMatch> {
fn expand_submatches(m: GlobalSearchMatch) -> Vec<GlobalSearchMatch> {
if m.submatches.len() <= 1 {
let submatch = m.submatches.into_iter().next();
let column_num = Self::column_from_submatch(&m.line_text, submatch.as_ref());
return vec![Self::trim_leading_whitespace_for_submatch(
&m.line_text,
m.file_path,
m.location,
m.line_number,
m.submatches.into_iter().next(),
column_num,
submatch,
)];
}
m.submatches
.into_iter()
.map(|sub| {
let column_num = Self::column_from_submatch(&m.line_text, Some(&sub));
Self::trim_leading_whitespace_for_submatch(
&m.line_text,
m.file_path.clone(),
m.location.clone(),
m.line_number,
column_num,
Some(sub),
)
})
.collect()
}
/// Returns the original 1-based character column for a submatch.
fn column_from_submatch(line_text: &str, submatch: Option<&Submatch>) -> Option<usize> {
let byte_start = submatch?.byte_start.as_usize();
if byte_start > line_text.len() || !line_text.is_char_boundary(byte_start) {
return None;
}
Some(line_text[..byte_start].chars().count() + 1)
}
/// Trim leading whitespace from a line up to the given submatch,
/// adjusting the submatch offset accordingly.
fn trim_leading_whitespace_for_submatch(
original_line: &str,
file_path: PathBuf,
location: LocalOrRemotePath,
line_number: u32,
column_num: Option<usize>,
submatch: Option<Submatch>,
) -> RipgrepMatch {
) -> GlobalSearchMatch {
let submatch_start = submatch
.as_ref()
.map(|s| s.byte_start)
@@ -239,9 +552,10 @@ impl GlobalSearch {
Vec::new()
};
RipgrepMatch {
file_path,
GlobalSearchMatch {
location,
line_number,
column_num,
line_text: trimmed_line,
submatches,
}
@@ -253,3 +567,7 @@ impl Default for GlobalSearch {
Self::new()
}
}
#[cfg(test)]
#[path = "model_tests.rs"]
mod tests;
@@ -0,0 +1,121 @@
use remote_server::proto::{RipgrepSearchMatch, RipgrepSearchSubmatch, RipgrepSearchSuccess};
use remote_server::HostId;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use super::GlobalSearch;
fn host() -> HostId {
HostId::new("test-host".to_string())
}
fn proto_match(
path: &str,
line_number: u32,
line_text: &str,
submatches: Vec<(u64, u64)>,
) -> RipgrepSearchMatch {
RipgrepSearchMatch {
file_path: path.to_string(),
line_number,
line_text: line_text.to_string(),
submatches: submatches
.into_iter()
.map(|(byte_start, byte_end)| RipgrepSearchSubmatch {
byte_start,
byte_end,
})
.collect(),
}
}
#[test]
fn remote_matches_become_remote_locations_on_the_host() {
let success = RipgrepSearchSuccess {
matches: vec![proto_match(
"/repo/src/main.rs",
7,
"fn main() {}",
vec![(3, 7)],
)],
capped: false,
};
let results = GlobalSearch::remote_matches_to_global(&host(), success);
assert_eq!(results.len(), 1);
match &results[0].location {
LocalOrRemotePath::Remote(remote) => {
assert_eq!(remote.host_id, host());
assert_eq!(remote.path.as_str(), "/repo/src/main.rs");
}
LocalOrRemotePath::Local(_) => panic!("expected a remote location"),
}
assert_eq!(results[0].line_number, 7);
assert_eq!(results[0].column_num, Some(4));
assert_eq!(results[0].line_text, "fn main() {}");
}
#[test]
fn remote_matches_expand_one_row_per_submatch() {
let success = RipgrepSearchSuccess {
matches: vec![proto_match(
"/repo/a.rs",
1,
"foo foo",
vec![(0, 3), (4, 7)],
)],
capped: false,
};
let results = GlobalSearch::remote_matches_to_global(&host(), success);
assert_eq!(results.len(), 2);
assert!(results.iter().all(|m| m.submatches.len() == 1));
}
#[test]
fn remote_matches_with_invalid_paths_are_dropped() {
let success = RipgrepSearchSuccess {
matches: vec![
proto_match("relative/path.rs", 1, "x", vec![(0, 1)]),
proto_match("/repo/ok.rs", 2, "x", vec![(0, 1)]),
],
capped: false,
};
let results = GlobalSearch::remote_matches_to_global(&host(), success);
assert_eq!(results.len(), 1);
assert_eq!(results[0].location.display_path(), "/repo/ok.rs");
}
#[test]
fn remote_match_leading_whitespace_is_trimmed_per_submatch() {
// Leading whitespace before the submatch is trimmed and offsets adjusted,
// matching local search behavior.
let success = RipgrepSearchSuccess {
matches: vec![proto_match("/repo/a.rs", 1, " foo", vec![(4, 7)])],
capped: false,
};
let results = GlobalSearch::remote_matches_to_global(&host(), success);
assert_eq!(results.len(), 1);
assert_eq!(results[0].line_text, "foo");
assert_eq!(results[0].column_num, Some(5));
assert_eq!(results[0].submatches[0].byte_start.as_usize(), 0);
assert_eq!(results[0].submatches[0].byte_end.as_usize(), 3);
}
#[test]
fn remote_match_column_counts_characters_not_bytes() {
let success = RipgrepSearchSuccess {
matches: vec![proto_match("/repo/a.rs", 1, "€foo", vec![(3, 6)])],
capped: false,
};
let results = GlobalSearch::remote_matches_to_global(&host(), success);
assert_eq!(results.len(), 1);
assert_eq!(results[0].column_num, Some(2));
}
@@ -1,8 +1,8 @@
use std::path::PathBuf;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use warpui::{Entity, ModelContext};
use crate::workspace::view::global_search::view::GlobalSearchEvent;
use crate::workspace::view::global_search::SearchConfig;
use galaxyui::{Entity, ModelContext};
pub struct GlobalSearch {}
@@ -15,12 +15,12 @@ impl GlobalSearch {
GlobalSearch {}
}
pub fn abort_search(&mut self) {}
pub fn abort_search(&mut self, _ctx: &mut ModelContext<Self>) {}
pub fn run_search(
&mut self,
_pattern: String,
_root: Vec<PathBuf>,
_roots: Vec<LocalOrRemotePath>,
_search_config: SearchConfig,
_ctx: &mut ModelContext<Self>,
) {
+232 -142
View File
@@ -5,34 +5,22 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use crate::coding_panel_enablement_state::CodingPanelEnablementState;
use async_channel::Sender;
use galaxy_editor::editor::NavigationKey;
use galaxy_ripgrep::search::{Match as RipgrepMatch, Submatch};
use instant::Instant;
use pathfinder_geometry::vector::vec2f;
use remote_server::HostId;
use string_offset::{ByteOffset, CharCounter};
use crate::code::icon_from_file_path;
use crate::debounce::debounce;
use crate::editor::{
EditorOptions, EditorView, Event as EditorEvent, InteractionState,
PropagateAndNoOpNavigationKeys, PropagateHorizontalNavigationKeys, TextOptions,
};
use crate::search::ItemHighlightState as SearchHighlightState;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon as UiIcon;
use crate::ui_components::item_highlight::{ImageOrIcon, ItemHighlightState};
use crate::ui_components::render_file_search_row::{render_file_search_row, FileSearchRowOptions};
use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme};
use crate::workspace::view::global_search::model::GlobalSearch;
use crate::workspace::view::global_search::SearchConfig;
use crate::TelemetryEvent;
use galaxy_core::r#async::debounce;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::{AnsiColorIdentifier, Fill as ThemeFill};
use galaxy_core::ui::Icon;
use galaxy_editor::editor::NavigationKey;
use galaxy_ripgrep::search::Submatch;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxy_util::remote_path::RemotePath;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::elements::{
Border, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, DispatchEventResult, Empty, EventHandler, Fill, Flex, FormattedTextElement,
@@ -52,6 +40,23 @@ use galaxyui::{
ViewHandle, WeakViewHandle,
};
use crate::code::icon_from_file_path;
use crate::coding_panel_enablement_state::CodingPanelEnablementState;
use crate::editor::{
EditorOptions, EditorView, Event as EditorEvent, InteractionState,
PropagateAndNoOpNavigationKeys, PropagateHorizontalNavigationKeys, TextOptions,
};
use crate::search::ItemHighlightState as SearchHighlightState;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon as UiIcon;
use crate::ui_components::item_highlight::{ImageOrIcon, ItemHighlightState};
use crate::ui_components::render_file_search_row::{render_file_search_row, FileSearchRowOptions};
use crate::util::path::{display_name_with_host, display_path_with_host};
use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme};
use crate::workspace::view::global_search::model::GlobalSearch;
use crate::workspace::view::global_search::{GlobalSearchMatch, SearchConfig};
use crate::TelemetryEvent;
const BORDER_RADIUS: f32 = 6.;
const BORDER_WIDTH: f32 = 1.;
const DO_NOT_TRUNCATE_CHAR_COUNT: usize = 40;
@@ -77,19 +82,19 @@ enum FocusMode {
#[derive(Debug, Clone)]
pub enum GlobalSearchAction {
SelectRow {
directory_path: PathBuf,
file_path: PathBuf,
directory_path: LocalOrRemotePath,
file_path: LocalOrRemotePath,
match_index: Option<usize>,
},
ToggleFileCollapsed {
directory_path: PathBuf,
file_path: PathBuf,
directory_path: LocalOrRemotePath,
file_path: LocalOrRemotePath,
},
ToggleDirectoryCollapsed {
directory_path: PathBuf,
directory_path: LocalOrRemotePath,
},
OpenMatch {
path: PathBuf,
location: LocalOrRemotePath,
line_number: u32,
column_num: Option<usize>,
},
@@ -108,18 +113,27 @@ pub enum GlobalSearchAction {
pub enum GlobalSearchEvent {
Started {
search_id: u32,
remote_host_count: usize,
},
Progress {
search_id: u32,
result: RipgrepMatch,
result: GlobalSearchMatch,
},
ProgressBatch {
search_id: u32,
items: Vec<RipgrepMatch>,
items: Vec<GlobalSearchMatch>,
},
Completed {
search_id: u32,
total_match_count: usize,
/// True when a remote source hit the server-side match cap.
capped: bool,
/// Whether the local search source failed while another source
/// completed. Results from the surviving sources remain valid.
local_source_failed: bool,
/// Number of remote host search sources that failed while another
/// source completed. Results from the surviving sources remain valid.
remote_source_failures: usize,
},
Failed {
search_id: u32,
@@ -130,7 +144,7 @@ pub enum GlobalSearchEvent {
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
pub enum Event {
OpenMatch {
path: PathBuf,
location: LocalOrRemotePath,
line_number: u32,
column_num: Option<usize>,
},
@@ -167,14 +181,14 @@ enum RowIndexType {
/// A root directory containing matched files.
struct DirectoryEntry {
path: PathBuf,
path: LocalOrRemotePath,
is_collapsed: bool,
mouse_state: MouseStateHandle,
matched_paths: MatchedPaths,
}
impl DirectoryEntry {
fn new(path: PathBuf) -> Self {
fn new(path: LocalOrRemotePath) -> Self {
Self {
path,
is_collapsed: false,
@@ -207,7 +221,7 @@ impl DirectoryEntry {
/// Collection of matched files within a directory.
struct MatchedPaths {
paths: Vec<MatchedPath>,
index_by_path: HashMap<PathBuf, usize>,
index_by_path: HashMap<LocalOrRemotePath, usize>,
}
impl MatchedPaths {
@@ -223,21 +237,21 @@ impl MatchedPaths {
self.paths.iter().map(|p| p.visible_count()).sum()
}
/// Gets or creates a MatchedPath entry for the given file path.
/// Gets or creates a MatchedPath entry for the given file location.
/// Returns a mutable reference to the entry and its index.
fn get_or_create(&mut self, path: &Path) -> (&mut MatchedPath, usize) {
fn get_or_create(&mut self, path: &LocalOrRemotePath) -> (&mut MatchedPath, usize) {
if let Some(&index) = self.index_by_path.get(path) {
(&mut self.paths[index], index)
} else {
let index = self.paths.len();
self.paths.push(MatchedPath::new(path.to_path_buf()));
self.index_by_path.insert(path.to_path_buf(), index);
self.paths.push(MatchedPath::new(path.clone()));
self.index_by_path.insert(path.clone(), index);
(&mut self.paths[index], index)
}
}
/// Gets a mutable MatchedPath entry by file path.
fn get_mut(&mut self, path: &Path) -> Option<&mut MatchedPath> {
/// Gets a mutable MatchedPath entry by file location.
fn get_mut(&mut self, path: &LocalOrRemotePath) -> Option<&mut MatchedPath> {
self.index_by_path
.get(path)
.copied()
@@ -247,14 +261,14 @@ impl MatchedPaths {
/// A file containing matches.
struct MatchedPath {
path: PathBuf,
path: LocalOrRemotePath,
is_collapsed: bool,
mouse_state: MouseStateHandle,
matches: Vec<Match>,
}
impl MatchedPath {
fn new(path: PathBuf) -> Self {
fn new(path: LocalOrRemotePath) -> Self {
Self {
path,
is_collapsed: false,
@@ -279,15 +293,22 @@ impl MatchedPath {
struct Match {
line_text: String,
line_number: u32,
column_num: Option<usize>,
submatches: Vec<Submatch>,
mouse_state: MouseStateHandle,
}
impl Match {
fn new(line_text: String, line_number: u32, submatches: Vec<Submatch>) -> Self {
fn new(
line_text: String,
line_number: u32,
column_num: Option<usize>,
submatches: Vec<Submatch>,
) -> Self {
Self {
line_text,
line_number,
column_num,
submatches,
mouse_state: MouseStateHandle::default(),
}
@@ -299,17 +320,20 @@ pub struct GlobalSearchView {
query_editor: ViewHandle<EditorView>,
query_change_tx: Sender<()>,
/// All terminal working directories for display grouping (preserved as-is)
root_directories: Vec<PathBuf>,
/// Deduplicated roots for ripgrep search (excludes nested subdirectories)
search_roots: Vec<PathBuf>,
root_directories: Vec<LocalOrRemotePath>,
/// Deduplicated roots for search (excludes nested subdirectories)
search_roots: Vec<LocalOrRemotePath>,
last_searched_pattern: Option<String>,
directory_entries: Vec<DirectoryEntry>,
directory_path_to_directory_index_entry: HashMap<PathBuf, usize>,
directory_path_to_directory_index_entry: HashMap<LocalOrRemotePath, usize>,
selected_row: Option<RowIndex>,
total_match_count: usize,
is_search_in_progress: bool,
capped_matches: bool,
last_error: Option<String>,
/// When the current search started, for completion telemetry.
search_started_at: Option<Instant>,
active_search_remote_host_count: usize,
scroll_state: ScrollStateHandle,
uniform_list_state: UniformListState,
handle: WeakViewHandle<GlobalSearchView>,
@@ -353,12 +377,12 @@ impl TypedActionView for GlobalSearchView {
self.toggle_directory_collapsed(directory_path, ctx);
}
GlobalSearchAction::OpenMatch {
path,
location,
line_number,
column_num,
} => {
ctx.emit(Event::OpenMatch {
path: path.clone(),
location: location.clone(),
line_number: *line_number,
column_num: *column_num,
});
@@ -486,24 +510,6 @@ impl TypedActionView for GlobalSearchView {
}
impl GlobalSearchView {
/// Calculate the 1-indexed column number from the first submatch.
/// Returns None if there are no submatches or the line is empty.
fn column_from_submatches(line_text: &str, submatches: &[Submatch]) -> Option<usize> {
if line_text.is_empty() || submatches.is_empty() {
return None;
}
let first_submatch = &submatches[0];
let max_byte = ByteOffset::from(line_text.len());
let start_b = first_submatch.byte_start.min(max_byte);
let mut char_counter = CharCounter::new(line_text);
let start_char = char_counter.char_offset(start_b)?;
// Return 1-indexed column number
Some(start_char.as_usize() + 1)
}
/// Convert submatch byte ranges into character indices for highlighting.
fn highlight_indices_from_submatches(line_text: &str, submatches: &[Submatch]) -> Vec<usize> {
if line_text.is_empty() || submatches.is_empty() {
@@ -705,6 +711,8 @@ impl GlobalSearchView {
is_search_in_progress: false,
capped_matches: false,
last_error: None,
search_started_at: None,
active_search_remote_host_count: 0,
scroll_state: ScrollStateHandle::default(),
uniform_list_state: UniformListState::new(),
handle,
@@ -746,32 +754,30 @@ impl GlobalSearchView {
ctx.notify();
}
/// Returns an iterator over all directory paths where the given file path should appear.
/// A file matches a directory if the file path starts with that directory.
/// Returns an iterator over all directory locations where the given file should appear.
/// A file matches a directory if the file location starts with that directory
/// (remote files only match directories on the same host).
fn find_matching_directories<'a>(
&'a self,
file_path: &'a Path,
) -> impl Iterator<Item = &'a PathBuf> {
location: &'a LocalOrRemotePath,
) -> impl Iterator<Item = &'a LocalOrRemotePath> {
self.root_directories
.iter()
.filter(move |root| file_path.starts_with(root))
.filter(move |root| location.starts_with(root))
}
fn apply_progress_item(&mut self, result: RipgrepMatch, ctx: &mut ViewContext<Self>) {
fn apply_progress_item(&mut self, result: GlobalSearchMatch, ctx: &mut ViewContext<Self>) {
if self.total_match_count >= MAX_MATCH_COUNT {
return;
}
let file_path = result.file_path.clone();
let location = result.location.clone();
// Find all directories that this file belongs to
let mut matching_directories = self.find_matching_directories(&file_path).peekable();
let mut matching_directories = self.find_matching_directories(&location).peekable();
if matching_directories.peek().is_none() {
// File doesn't match any root directory, skip it
let file_path_name = file_path
.file_name()
.map(|name| name.to_string_lossy())
.unwrap_or_else(|| std::borrow::Cow::Borrowed("<unknown>"));
let file_path_name = location.file_name().unwrap_or("<unknown>");
log::warn!("[Global search] file {file_path_name} was not found in directories");
return;
}
@@ -795,12 +801,13 @@ impl GlobalSearchView {
// Get or create the matched path entry within this directory
let dir_entry = &mut directory_entries[dir_index];
let (matched_path, _path_index) = dir_entry.matched_paths.get_or_create(&file_path);
let (matched_path, _path_index) = dir_entry.matched_paths.get_or_create(&location);
// Add the match
matched_path.matches.push(Match::new(
result.line_text.clone(),
result.line_number,
result.column_num,
result.submatches.clone(),
));
}
@@ -813,11 +820,16 @@ impl GlobalSearchView {
}
fn abort_search(&mut self, ctx: &mut ViewContext<Self>) {
self.capped_matches = true;
self.cancel_search(ctx);
}
fn cancel_search(&mut self, ctx: &mut ViewContext<Self>) {
self.is_search_in_progress = false;
self.current_search_id = None;
self.search_started_at = None;
self.find_model.update(ctx, |model, _| {
model.abort_search();
self.find_model.update(ctx, |model, model_ctx| {
model.abort_search(model_ctx);
});
}
fn handle_debounced_query_change(&mut self, _event: (), ctx: &mut ViewContext<Self>) {
@@ -840,8 +852,7 @@ impl GlobalSearchView {
| EditorEvent::BufferReinitialized => {
let query_text = self.query_editor.as_ref(ctx).buffer_text(ctx);
if query_text.is_empty() {
self.current_search_id = None;
self.is_search_in_progress = false;
self.cancel_search(ctx);
self.reset_search_state(true);
ctx.notify();
return;
@@ -881,8 +892,7 @@ impl GlobalSearchView {
let pattern = self.query_editor.as_ref(ctx).buffer_text(ctx);
if pattern.is_empty() {
self.current_search_id = None;
self.is_search_in_progress = false;
self.cancel_search(ctx);
self.reset_search_state(true);
ctx.notify();
return;
@@ -924,10 +934,15 @@ impl GlobalSearchView {
fn handle_find_model_event(&mut self, event: &GlobalSearchEvent, ctx: &mut ViewContext<Self>) {
match event {
GlobalSearchEvent::Started { search_id } => {
GlobalSearchEvent::Started {
search_id,
remote_host_count,
} => {
send_telemetry_from_ctx!(TelemetryEvent::GlobalSearchQueryStarted, ctx);
self.current_search_id = Some(*search_id);
self.search_started_at = Some(Instant::now());
self.active_search_remote_host_count = *remote_host_count;
self.is_search_in_progress = true;
self.reset_search_state(false);
@@ -958,6 +973,9 @@ impl GlobalSearchView {
GlobalSearchEvent::Completed {
search_id,
total_match_count,
capped,
local_source_failed,
remote_source_failures,
} => {
if Some(*search_id) != self.current_search_id {
return;
@@ -965,6 +983,21 @@ impl GlobalSearchView {
self.is_search_in_progress = false;
self.total_match_count = *total_match_count;
self.capped_matches |= capped;
if let Some(started_at) = self.search_started_at.take() {
send_telemetry_from_ctx!(
TelemetryEvent::GlobalSearchQueryCompleted {
duration_ms: started_at.elapsed().as_millis() as u64,
remote_host_count: self.active_search_remote_host_count,
total_match_count: *total_match_count,
capped: self.capped_matches,
local_source_failed: *local_source_failed,
remote_source_failures: *remote_source_failures,
},
ctx
);
}
ctx.notify();
}
GlobalSearchEvent::Failed { search_id, error } => {
@@ -973,6 +1006,7 @@ impl GlobalSearchView {
}
self.is_search_in_progress = false;
self.search_started_at = None;
self.reset_search_state(false);
self.last_error = Some(error.clone());
ctx.notify();
@@ -980,11 +1014,53 @@ impl GlobalSearchView {
}
}
pub fn set_root_directories(&mut self, roots: Vec<PathBuf>, _ctx: &mut ViewContext<Self>) {
pub fn set_root_directories(
&mut self,
roots: Vec<LocalOrRemotePath>,
_ctx: &mut ViewContext<Self>,
) {
// Ancestor-dedup search roots so we don't search the same file twice
// when terminal directories are nested (e.g. `~/code` + `~/code/a`).
// Shared with `FileTreeView` for consistency.
self.search_roots = galaxy_util::path::group_roots_by_common_ancestor(&roots).roots;
// Local and remote roots share `group_roots_by_common_ancestor` with
// `FileTreeView` for consistency; remote roots are grouped per host
// and deduped within each host independently.
let local_roots: Vec<PathBuf> = roots
.iter()
.filter_map(|root| root.to_local_path().map(Path::to_path_buf))
.collect();
let deduped_local = galaxy_util::path::group_roots_by_common_ancestor(&local_roots).roots;
let mut remote_roots_by_host: Vec<(HostId, Vec<StandardizedPath>)> = Vec::new();
for root in &roots {
let LocalOrRemotePath::Remote(remote) = root else {
continue;
};
match remote_roots_by_host
.iter_mut()
.find(|(host_id, _)| host_id == &remote.host_id)
{
Some((_, paths)) => paths.push(remote.path.clone()),
None => {
remote_roots_by_host.push((remote.host_id.clone(), vec![remote.path.clone()]))
}
}
}
let deduped_remote = remote_roots_by_host
.into_iter()
.flat_map(|(host_id, paths)| {
galaxy_util::path::group_roots_by_common_ancestor(&paths)
.roots
.into_iter()
.map(move |path| {
LocalOrRemotePath::Remote(RemotePath::new(host_id.clone(), path))
})
});
self.search_roots = deduped_local
.into_iter()
.map(LocalOrRemotePath::Local)
.chain(deduped_remote)
.collect();
self.root_directories = roots;
}
@@ -1030,7 +1106,7 @@ impl GlobalSearchView {
match &row_index.index_type {
RowIndexType::DirectoryHeader => {
self.render_directory_header_from_entry(index, dir_entry, appearance, theme)
self.render_directory_header_from_entry(index, dir_entry, appearance, theme, app)
}
RowIndexType::FileHeader { path_index } => {
let Some(matched_path) = dir_entry.matched_paths.paths.get(*path_index) else {
@@ -1071,7 +1147,7 @@ impl GlobalSearchView {
fn render_file_header(
&self,
index: usize,
directory_path: &Path,
directory_path: &LocalOrRemotePath,
matched_path: &MatchedPath,
appearance: &Appearance,
theme: &galaxy_core::ui::theme::GalaxyTheme,
@@ -1083,14 +1159,14 @@ impl GlobalSearchView {
let file_path = matched_path.path.clone();
let match_count = matched_path.matches.len();
let directory_path_for_select = directory_path.to_path_buf();
let directory_path_for_select = directory_path.clone();
let file_path_clone = file_path.clone();
let directory_path_for_toggle = directory_path.to_path_buf();
let directory_path_for_toggle = directory_path.clone();
let display_path = file_path
.strip_prefix(directory_path)
.unwrap_or(file_path.as_path());
let display_path = display_path.to_path_buf();
let display_path = directory_path
.strip_repo_prefix(&file_path)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(file_path.display_path()));
Hoverable::new(file_mouse_state, move |mouse_state| {
let item_highlight_state = ItemHighlightState::new(is_selected, mouse_state);
@@ -1113,7 +1189,7 @@ impl GlobalSearchView {
.finish();
let chevron_container = Container::new(chevron_icon).with_margin_right(8.).finish();
let tooltip_text = file_path.to_string_lossy().to_string();
let tooltip_text = file_path.display_path();
let header_text_fill = match list_highlight_state {
ItemHighlightState::None => {
@@ -1150,7 +1226,7 @@ impl GlobalSearchView {
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish();
let icon_from_file_path = icon_from_file_path(&file_path.to_string_lossy(), appearance)
let icon_from_file_path = icon_from_file_path(&file_path.display_path(), appearance)
.map(ImageOrIcon::Image)
.unwrap_or(ImageOrIcon::Icon(Icon::File));
@@ -1244,7 +1320,7 @@ impl GlobalSearchView {
fn render_match_row(
&self,
index: usize,
directory_path: &Path,
directory_path: &LocalOrRemotePath,
matched_path: &MatchedPath,
matched: &Match,
match_index: usize,
@@ -1253,17 +1329,14 @@ impl GlobalSearchView {
) -> Box<dyn Element> {
let is_selected = self.is_row_at_index_selected(index);
let line_number = matched.line_number;
let column_num = matched.column_num;
let line_text = matched.line_text.clone();
let submatches = matched.submatches.clone();
let mouse_state = matched.mouse_state.clone();
let directory_path_for_select = directory_path.to_path_buf();
let directory_path_for_select = directory_path.clone();
let file_path_for_select = matched_path.path.clone();
let path_for_click = matched_path.path.clone();
// Clone for the on_click closure since line_text and submatches are moved into Hoverable
let line_text_for_click = line_text.clone();
let submatches_for_click = submatches.clone();
let location_for_click = matched_path.path.clone();
Hoverable::new(mouse_state, move |mouse_state| {
let list_highlight_state = ItemHighlightState::new(is_selected, mouse_state);
@@ -1319,12 +1392,8 @@ impl GlobalSearchView {
file_path: file_path_for_select.clone(),
match_index: Some(match_index),
});
let column_num = GlobalSearchView::column_from_submatches(
&line_text_for_click,
&submatches_for_click,
);
ctx.dispatch_typed_action(GlobalSearchAction::OpenMatch {
path: path_for_click.clone(),
location: location_for_click.clone(),
line_number,
column_num,
});
@@ -1483,18 +1552,21 @@ impl GlobalSearchView {
.sum()
}
/// Gets or creates a DirectoryEntry for the given path.
/// Gets or creates a DirectoryEntry for the given location.
/// Returns a mutable reference to the entry and its index.
#[allow(dead_code)] // Will be used in later PRs
fn get_or_create_directory_entry(&mut self, path: &Path) -> (&mut DirectoryEntry, usize) {
fn get_or_create_directory_entry(
&mut self,
path: &LocalOrRemotePath,
) -> (&mut DirectoryEntry, usize) {
if let Some(&index) = self.directory_path_to_directory_index_entry.get(path) {
(&mut self.directory_entries[index], index)
} else {
let index = self.directory_entries.len();
self.directory_entries
.push(DirectoryEntry::new(path.to_path_buf()));
.push(DirectoryEntry::new(path.clone()));
self.directory_path_to_directory_index_entry
.insert(path.to_path_buf(), index);
.insert(path.clone(), index);
(&mut self.directory_entries[index], index)
}
}
@@ -1503,8 +1575,8 @@ impl GlobalSearchView {
/// Returns None if the paths are not found
fn path_to_row_index(
&self,
directory_path: &Path,
file_path: &Path,
directory_path: &LocalOrRemotePath,
file_path: &LocalOrRemotePath,
match_index: Option<usize>,
) -> Option<RowIndex> {
let &directory_index = self
@@ -1527,15 +1599,15 @@ impl GlobalSearchView {
})
}
/// Gets the directory path for a given RowIndex.
fn directory_path_for_row_index(&self, row: &RowIndex) -> Option<&PathBuf> {
/// Gets the directory location for a given RowIndex.
fn directory_path_for_row_index(&self, row: &RowIndex) -> Option<&LocalOrRemotePath> {
self.directory_entries
.get(row.directory_index)
.map(|e| &e.path)
}
/// Gets the file path for a given RowIndex (if it refers to a file or match).
fn file_path_for_row_index(&self, row: &RowIndex) -> Option<&PathBuf> {
/// Gets the file location for a given RowIndex (if it refers to a file or match).
fn file_path_for_row_index(&self, row: &RowIndex) -> Option<&LocalOrRemotePath> {
let dir_entry = self.directory_entries.get(row.directory_index)?;
match &row.index_type {
RowIndexType::DirectoryHeader => None,
@@ -1724,12 +1796,10 @@ impl GlobalSearchView {
let Some(matched) = matched_path.matches.get(*match_index) else {
return;
};
let column_num =
Self::column_from_submatches(&matched.line_text, &matched.submatches);
ctx.emit(Event::OpenMatch {
path: matched_path.path.clone(),
location: matched_path.path.clone(),
line_number: matched.line_number,
column_num,
column_num: matched.column_num,
});
}
RowIndexType::DirectoryHeader => {
@@ -1740,7 +1810,11 @@ impl GlobalSearchView {
}
}
fn toggle_directory_collapsed(&mut self, directory_path: &Path, ctx: &mut ViewContext<Self>) {
fn toggle_directory_collapsed(
&mut self,
directory_path: &LocalOrRemotePath,
ctx: &mut ViewContext<Self>,
) {
let Some(&dir_idx) = self
.directory_path_to_directory_index_entry
.get(directory_path)
@@ -1774,8 +1848,8 @@ impl GlobalSearchView {
fn toggle_file_collapsed(
&mut self,
directory_path: &Path,
file_path: &PathBuf,
directory_path: &LocalOrRemotePath,
file_path: &LocalOrRemotePath,
ctx: &mut ViewContext<Self>,
) {
// Get directory index
@@ -1853,7 +1927,8 @@ impl GlobalSearchView {
index: usize,
dir_entry: &DirectoryEntry,
appearance: &Appearance,
theme: &galaxy_core::ui::theme::GalaxyTheme,
theme: &galaxy_core::ui::theme::WarpTheme,
app: &AppContext,
) -> Box<dyn Element> {
let is_selected = self.is_row_at_index_selected(index);
let mouse_state = dir_entry.mouse_state.clone();
@@ -1861,15 +1936,13 @@ impl GlobalSearchView {
let is_collapsed = dir_entry.is_collapsed;
let directory_path = &dir_entry.path;
// Get the display name (last component of the path)
let display_name = directory_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| directory_path.to_string_lossy().to_string());
let directory_path = directory_path.clone();
let display_name = if directory_path.display_name().is_empty() {
display_path_with_host(directory_path, false, app)
} else {
display_name_with_host(directory_path, app)
};
let directory_path_for_click = directory_path.clone();
let tooltip_text = directory_path.to_string_lossy().to_string();
let tooltip_text = display_path_with_host(directory_path, false, app);
Hoverable::new(mouse_state, move |mouse_state| {
let list_highlight_state = ItemHighlightState::new(is_selected, mouse_state);
@@ -1995,8 +2068,15 @@ impl View for GlobalSearchView {
fn render(&self, app: &AppContext) -> Box<dyn Element> {
match self.enablement {
CodingPanelEnablementState::RemoteSession { .. } => {
return self.render_remote_state(app);
CodingPanelEnablementState::PendingRemoteSession => {
return self.render_remote_loading_state(app);
}
CodingPanelEnablementState::RemoteSession { has_remote_server } => {
// Remote-server sessions can search via the daemon; sessions
// without one (tmux / subshell SSH) stay unavailable.
if !has_remote_server {
return self.render_remote_state(app);
}
}
CodingPanelEnablementState::UnsupportedSession => {
return self.render_unsupported_session_state(app);
@@ -2066,8 +2146,10 @@ impl View for GlobalSearchView {
let files = self.unique_match_count();
let file_word = if files == 1 { "file" } else { "files" };
let message = if self.is_search_in_progress && self.total_match_count == 0 {
"".to_string()
let message = if let Some(error) = &self.last_error {
error.clone()
} else if self.is_search_in_progress && self.total_match_count == 0 {
"Searching…".to_string()
} else if !self.is_search_in_progress && self.total_match_count == 0 {
"No results found. Review your gitignore files.".to_string()
} else {
@@ -2256,7 +2338,7 @@ impl GlobalSearchView {
self.render_zero_state(
Icon::AlertTriangle,
"Global search unavailable",
"Global search requries access to your local workspace. Open a new session or navigate to an active session to view.",
"Global search requires access to your local workspace. Open a new session or navigate to an active session to view.",
app,
)
}
@@ -2265,7 +2347,15 @@ impl GlobalSearchView {
self.render_zero_state(
Icon::AlertTriangle,
"Global search unavailable",
"Global search requires access to your local workspace, which isn't supported in remote sessions",
"Global search isn't available for this remote session.",
app,
)
}
fn render_remote_loading_state(&self, app: &AppContext) -> Box<dyn Element> {
self.render_zero_state(
Icon::Loading,
"Connecting to remote session",
"Global search will be available once the connection is ready.",
app,
)
}
@@ -1,7 +1,9 @@
use std::rc::Rc;
use warpui::ViewContext;
use super::Slide;
use crate::server::telemetry::TelemetryEvent;
use galaxyui::ViewContext;
use std::rc::Rc;
/// A callback function for custom CTA button actions.
type CustomCallback<S> = Rc<dyn Fn(&mut ViewContext<super::LaunchModal<S>>)>;
+10 -9
View File
@@ -2,14 +2,12 @@
pub mod cta_button;
pub mod oz_launch;
use std::collections::HashMap;
use markdown_parser::{parse_markdown, FormattedText, FormattedTextLine};
// Re-export slide types for convenience
pub use oz_launch::OzLaunchSlide;
use crate::settings::PrivacySettings;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, PrimaryTheme, SecondaryTheme};
use crate::workspace::view::launch_modal::cta_button::{CTAButton, CTAButtonAction};
use pathfinder_color::ColorU;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
use galaxyui::assets::asset_cache::AssetSource;
@@ -28,9 +26,12 @@ use galaxyui::ui_components::components::UiComponent;
use galaxyui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use markdown_parser::{parse_markdown, FormattedText, FormattedTextLine};
use pathfinder_color::ColorU;
use std::collections::HashMap;
use crate::settings::PrivacySettings;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, PrimaryTheme, SecondaryTheme};
use crate::workspace::view::launch_modal::cta_button::{CTAButton, CTAButtonAction};
pub fn init<S: Slide>(app: &mut AppContext) {
use galaxyui::keymap::macros::*;
@@ -1,3 +1,9 @@
use asset_macro::bundled_or_fetched_asset;
use markdown_parser::{FormattedTextFragment, FormattedTextLine};
use galaxy_core::send_telemetry_from_ctx;
use warpui::assets::asset_cache::AssetSource;
use warpui::{AppContext, SingletonEntity};
use super::{CTAButton, CheckboxConfig, LaunchModalEvent, Slide};
use crate::ai::ambient_agents::telemetry::{CloudAgentTelemetryEvent, CloudModeEntryPoint};
use crate::terminal::view::OnboardingIntention;
@@ -6,11 +12,6 @@ use crate::workspace::action::WorkspaceAction;
use crate::workspace::view::OnboardingTutorial;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::workspaces::workspace::{AdminEnablementSetting, UgcCollectionEnablementSetting};
use asset_macro::bundled_or_fetched_asset;
use galaxy_core::send_telemetry_from_ctx;
use galaxyui::assets::asset_cache::AssetSource;
use galaxyui::{AppContext, SingletonEntity};
use markdown_parser::{FormattedTextFragment, FormattedTextLine};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum OzLaunchSlide {
+114 -59
View File
@@ -1,38 +1,54 @@
use std::collections::HashSet;
use std::path::PathBuf;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::{send_telemetry_from_ctx, ui::Icon};
use galaxy_core::ui::Icon;
use galaxy_util::path::LineAndColumnArg;
use galaxyui::elements::{
resizable_state_handle, ChildView, ConstrainedBox, Container, CrossAxisAlignment, DragBarSide,
Element, Empty, Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement,
Resizable, ResizableStateHandle, Shrinkable,
};
use galaxyui::platform::Cursor;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{
elements::{
resizable_state_handle, ChildView, ConstrainedBox, Container, CrossAxisAlignment,
DragBarSide, Element, Empty, Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle,
ParentElement, Resizable, ResizableStateHandle, Shrinkable,
},
platform::Cursor,
ui_components::components::{Coords, UiComponent, UiComponentStyles},
AppContext, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle, WeakViewHandle,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_conversations_model::AgentConversationsModel;
use crate::appearance::Appearance;
use crate::code::buffer_location::LocalOrRemotePath;
#[cfg(feature = "local_fs")]
use crate::code::file_tree::FileTreeEvent;
use crate::code::file_tree::FileTreeView;
use crate::coding_panel_enablement_state::CodingPanelEnablementState;
use crate::drive::panel::{DrivePanel, DrivePanelEvent};
use crate::drive::panel::{
DrivePanel, DrivePanelEvent, MAX_SIDEBAR_WIDTH_RATIO, MIN_SIDEBAR_WIDTH,
};
use crate::pane_group::pane::view::header::components::HEADER_EDGE_PADDING;
use crate::pane_group::pane::view::header::PANE_HEADER_HEIGHT;
use crate::pane_group::working_directories::WorkingDirectory;
use crate::pane_group::{PaneGroup, WorkingDirectoriesEvent, WorkingDirectoriesModel};
use crate::pane_group::{
PaneGroup, WorkingDirectoriesEvent, WorkingDirectoriesModel, {self},
};
#[cfg(feature = "local_fs")]
use crate::server::telemetry::CodePanelsFileOpenEntrypoint;
use crate::server::telemetry::{FileTreeSource, WarpDriveSource};
use crate::settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier};
use crate::terminal::resizable_data::{ModalType, ResizableData};
use crate::ui_components::buttons::{icon_button, icon_button_with_color};
use crate::ui_components::icons;
use crate::util::bindings::keybinding_name_to_display_string;
#[cfg(feature = "local_fs")]
use crate::util::file::external_editor::EditorSettings;
#[cfg(feature = "local_fs")]
use crate::util::openable_file_type::resolve_file_target_with_editor_choice;
use crate::util::openable_file_type::FileTarget;
#[cfg(feature = "local_fs")]
use crate::util::openable_file_type::{
is_markdown_file, resolve_file_target_with_editor_choice, EditorLayout,
};
use crate::workspace::view::conversation_list::view::{
ConversationListView, Event as ConversationListViewEvent,
};
@@ -45,28 +61,15 @@ use crate::workspace::view::{
OPEN_GLOBAL_SEARCH_BINDING_NAME, TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME,
TOGGLE_PROJECT_EXPLORER_BINDING_NAME, TOGGLE_WARP_DRIVE_BINDING_NAME,
};
use crate::{
appearance::Appearance,
code::file_tree::FileTreeView,
drive::panel::{MAX_SIDEBAR_WIDTH_RATIO, MIN_SIDEBAR_WIDTH},
pane_group::pane::view::header::{components::HEADER_EDGE_PADDING, PANE_HEADER_HEIGHT},
pane_group::{self},
terminal::resizable_data::{ModalType, ResizableData},
ui_components::{
buttons::{icon_button, icon_button_with_color},
icons,
},
util::bindings::keybinding_name_to_display_string,
workspace::WorkspaceAction,
TelemetryEvent,
};
use crate::workspace::WorkspaceAction;
use crate::TelemetryEvent;
#[derive(Default)]
struct MouseStateHandles {
project_explorer_button: MouseStateHandle,
conversation_list_view_button: MouseStateHandle,
global_search_button: MouseStateHandle,
warp_drive_button: MouseStateHandle,
conversation_list_view_button: MouseStateHandle,
}
#[derive(Clone, Debug)]
@@ -77,13 +80,14 @@ pub enum LeftPanelAction {
ConversationListView,
}
#[allow(clippy::large_enum_variant)]
pub enum LeftPanelEvent {
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
FileTree(pane_group::Event),
WarpDrive(DrivePanelEvent),
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
OpenFileWithTarget {
path: PathBuf,
location: LocalOrRemotePath,
target: FileTarget,
line_col: Option<LineAndColumnArg>,
},
@@ -106,9 +110,10 @@ pub enum ToolPanelView {
/// Encapsulates the active view state to enforce that all mutations go through
/// `active_view_state::set`, which handles necessary side effects.
mod active_view_state {
use super::ToolPanelView;
use galaxyui::ViewContext;
use super::ToolPanelView;
pub struct ActiveViewState(ToolPanelView);
impl ActiveViewState {
@@ -270,27 +275,45 @@ impl LeftPanelView {
}
let has_terminal_session = directories.iter().any(|dir| dir.terminal_id.is_some());
// Update GlobalSearchView root directories based on all working directories
let roots: Vec<PathBuf> = directories.iter().map(|d| d.path.clone()).collect();
// Split directories into local and remote.
let local_paths: Vec<PathBuf> = directories
.iter()
.filter_map(|d| d.path.to_local_path().map(|p| p.to_path_buf()))
.collect();
#[allow(unused_variables)]
let remote_repos: Vec<repo_metadata::RemoteRepositoryIdentifier> = directories
.iter()
.filter_map(|d| match &d.path {
LocalOrRemotePath::Remote(remote_path) => {
Some(repo_metadata::RemoteRepositoryIdentifier::new(
remote_path.host_id.clone(),
remote_path.path.clone(),
))
}
_ => None,
})
.collect();
// Update GlobalSearchView root directories (local + remote).
let all_directories: Vec<LocalOrRemotePath> =
directories.iter().map(|d| d.path.clone()).collect();
let global_search_view =
me.get_or_create_global_search_view_for_pane_group(active_pane_group.id(), ctx);
global_search_view.update(ctx, |view, view_ctx| {
view.set_root_directories(roots, view_ctx);
view.set_root_directories(all_directories, view_ctx);
});
let directories: Vec<PathBuf> =
directories.iter().map(|dir| dir.path.clone()).collect();
// Directories are already in display order (most recent first) from the model
let directories = deduplicate_by_directory_name(directories);
let local_directories = deduplicate_by_directory_name(local_paths);
let file_tree_view =
me.get_or_create_file_tree_view_for_pane_group(active_pane_group.id(), ctx);
let is_visible =
active_pane_group.as_ref(ctx).left_panel_open && me.is_file_tree_active();
file_tree_view.update(ctx, |view, ctx| {
view.set_root_directories(directories, ctx);
view.set_root_directories(local_directories, ctx);
#[cfg(feature = "local_fs")]
view.set_remote_root_directories(&remote_repos, ctx);
view.set_has_terminal_session(has_terminal_session, ctx);
view.set_is_active(is_visible, ctx);
@@ -591,26 +614,44 @@ impl LeftPanelView {
.iter()
.any(|dir| dir.terminal_id.is_some());
// Update GlobalSearchView root directories based on all working directories
let roots: Vec<PathBuf> = active_directories.iter().map(|d| d.path.clone()).collect();
// Split directories into local and remote.
let local_paths: Vec<PathBuf> = active_directories
.iter()
.filter_map(|d| d.path.to_local_path().map(|p| p.to_path_buf()))
.collect();
#[allow(unused_variables)]
let remote_repos: Vec<repo_metadata::RemoteRepositoryIdentifier> = active_directories
.iter()
.filter_map(|d| match &d.path {
LocalOrRemotePath::Remote(remote_path) => {
Some(repo_metadata::RemoteRepositoryIdentifier::new(
remote_path.host_id.clone(),
remote_path.path.clone(),
))
}
_ => None,
})
.collect();
// Update GlobalSearchView root directories (local + remote).
let all_directories: Vec<LocalOrRemotePath> =
active_directories.iter().map(|d| d.path.clone()).collect();
let global_search_view =
self.get_or_create_global_search_view_for_pane_group(pane_group_id, ctx);
global_search_view.update(ctx, |view, view_ctx| {
view.set_root_directories(roots, view_ctx);
view.set_root_directories(all_directories, view_ctx);
});
let directories: Vec<PathBuf> = active_directories
.iter()
.map(|dir| dir.path.clone())
.collect();
let directories = deduplicate_by_directory_name(directories);
let local_directories = deduplicate_by_directory_name(local_paths);
let active_file_model = pane_group.as_ref(ctx).active_file_model().clone();
let file_tree_view = self.get_or_create_file_tree_view_for_pane_group(pane_group_id, ctx);
let left_panel_open = pane_group.as_ref(ctx).left_panel_open;
let is_visible = left_panel_open && self.is_file_tree_active();
file_tree_view.update(ctx, |view, ctx| {
view.set_root_directories(directories, ctx);
view.set_root_directories(local_directories, ctx);
#[cfg(feature = "local_fs")]
view.set_remote_root_directories(&remote_repos, ctx);
view.set_has_terminal_session(has_terminal_session, ctx);
view.set_active_file_model(active_file_model, ctx);
view.set_is_active(is_visible, ctx);
@@ -701,7 +742,7 @@ impl LeftPanelView {
) {
match event {
GlobalSearchViewEvent::OpenMatch {
path,
location,
line_number,
column_num,
} => {
@@ -711,13 +752,27 @@ impl LeftPanelView {
};
let settings = EditorSettings::as_ref(ctx);
let target = resolve_file_target_with_editor_choice(
path,
*settings.open_code_panels_file_editor,
*settings.prefer_markdown_viewer,
*settings.open_file_layout,
None,
);
let target = match location {
LocalOrRemotePath::Local(path) => resolve_file_target_with_editor_choice(
path,
*settings.open_code_panels_file_editor,
*settings.prefer_markdown_viewer,
*settings.open_file_layout,
None,
),
// Local-fs-based target resolution can't inspect remote
// files; mirror the file tree's remote handling (code
// editor, or markdown viewer by extension + preference).
LocalOrRemotePath::Remote(remote) => {
let is_markdown =
is_markdown_file(std::path::Path::new(remote.path.as_str()));
if is_markdown && *settings.prefer_markdown_viewer {
FileTarget::MarkdownViewer(EditorLayout::SplitPane)
} else {
FileTarget::CodeEditor(EditorLayout::SplitPane)
}
}
};
send_telemetry_from_ctx!(
TelemetryEvent::CodePanelsFileOpened {
@@ -728,7 +783,7 @@ impl LeftPanelView {
);
ctx.emit(LeftPanelEvent::OpenFileWithTarget {
path: path.clone(),
location: location.clone(),
target,
line_col: Some(line_col),
});
@@ -761,7 +816,7 @@ impl LeftPanelView {
line_col,
} => {
ctx.emit(LeftPanelEvent::OpenFileWithTarget {
path: path.clone(),
location: path.clone(),
target: target.clone(),
line_col: *line_col,
});
@@ -1083,11 +1138,11 @@ impl View for LeftPanelView {
let mouse_state_handles = vec![
self.mouse_state_handles.project_explorer_button.clone(),
self.mouse_state_handles.global_search_button.clone(),
self.mouse_state_handles.warp_drive_button.clone(),
self.mouse_state_handles
.conversation_list_view_button
.clone(),
self.mouse_state_handles.global_search_button.clone(),
self.mouse_state_handles.warp_drive_button.clone(),
];
// If there is only one button in the toolbelt row,
+27 -8
View File
@@ -1,16 +1,18 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use onboarding::{ProjectOnboardingSettings, SelectedSettings};
use galaxy_core::execution_mode::AppExecutionMode;
use warpui::{SingletonEntity as _, ViewContext};
use crate::pane_group::{NewTerminalOptions, PanesLayout};
use crate::settings::AISettings;
use crate::terminal;
use crate::terminal::view::{
AgentOnboardingVersion, OnboardingIntention, OnboardingVersion, TerminalAction,
};
use crate::workspace::Workspace;
use crate::FeatureFlag;
use galaxyui::{SingletonEntity as _, ViewContext};
use onboarding::{ProjectOnboardingSettings, SelectedSettings};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use crate::{terminal, FeatureFlag};
/// Configuration for starting the agent onboarding tutorial.
#[derive(Debug, Clone)]
@@ -87,6 +89,12 @@ impl Workspace {
tutorial: OnboardingTutorial,
ctx: &mut ViewContext<Self>,
) {
// Onboarding requires a real user to interact with it; skip when running
// in a headless mode like the SDK/CLI.
if !AppExecutionMode::as_ref(ctx).can_show_onboarding() {
return;
}
match tutorial {
OnboardingTutorial::InitProject {
ref path,
@@ -143,6 +151,12 @@ impl Workspace {
intention: OnboardingIntention,
ctx: &mut ViewContext<Self>,
) {
// Onboarding requires a real user to interact with it; skip when running
// in a headless mode like the SDK/CLI.
if !AppExecutionMode::as_ref(ctx).can_show_onboarding() {
return;
}
// With new onboarding, skip the guided tour when AI is not enabled
// (e.g. terminal-intent users or users who disabled AI).
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled()
@@ -237,7 +251,12 @@ impl Workspace {
);
}
pub(crate) fn should_show_agent_onboarding(&self, _ctx: &mut ViewContext<Self>) -> bool {
pub(crate) fn should_show_agent_onboarding(&self, ctx: &mut ViewContext<Self>) -> bool {
// Onboarding requires a real user to interact with it; suppress when
// running in a headless mode like the SDK/CLI.
if !AppExecutionMode::as_ref(ctx).can_show_onboarding() {
return false;
}
FeatureFlag::AgentOnboarding.is_enabled()
}
}
@@ -1,4 +1,10 @@
use galaxy_core::ui::theme::{phenomenon::PhenomenonStyle, Fill};
use markdown_parser::{
FormattedText, FormattedTextFragment, FormattedTextLine, FormattedTextStyles, Hyperlink,
};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::theme::phenomenon::PhenomenonStyle;
use galaxy_core::ui::theme::Fill;
use galaxyui::assets::asset_cache::AssetSource;
use galaxyui::elements::{
Align, CacheOption, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
@@ -14,8 +20,6 @@ use galaxyui::{
use markdown_parser::{
FormattedText, FormattedTextFragment, FormattedTextLine, FormattedTextStyles, Hyperlink,
};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use crate::appearance::Appearance;
use crate::ui_components::icons::Icon;
@@ -190,15 +194,23 @@ impl OpenWarpLaunchModal {
}
fn render_badge(appearance: &Appearance) -> Box<dyn Element> {
Container::new(
Text::new_inline("New".to_string(), appearance.ui_font_family(), 14.)
.with_color(PhenomenonStyle::modal_badge_text())
.finish(),
let text = Text::new_inline("New".to_string(), appearance.ui_font_family(), 14.)
.with_color(PhenomenonStyle::modal_badge_text())
.finish();
ConstrainedBox::new(
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min)
.with_child(text)
.finish(),
)
.with_horizontal_padding(8.)
.with_background(Fill::Solid(PhenomenonStyle::modal_badge_background()))
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.finish(),
)
.with_horizontal_padding(8.)
.with_vertical_padding(2.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_background(Fill::Solid(PhenomenonStyle::modal_badge_background()))
.with_height(24.)
.finish()
}
@@ -0,0 +1,3 @@
mod view;
pub use view::{init, OrchestrationLaunchModal, OrchestrationLaunchModalEvent};
@@ -0,0 +1,445 @@
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::theme::Fill;
use warpui::assets::asset_cache::AssetSource;
use warpui::elements::{
Align, CacheOption, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Expanded, Flex, Image, MainAxisSize, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Radius, Stack, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::keymap::FixedBinding;
use warpui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::appearance::Appearance;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, ActionButtonTheme, ButtonSize};
const MODAL_WIDTH: f32 = 420.;
const HERO_HEIGHT: f32 = 92.;
const HERO_IMAGE_PATH: &str = "async/png/onboarding/orchestration_launch_banner.png";
const LEARN_MORE_URL: &str = "https://www.warp.dev/blog/multi-harness-cloud-agent-orchestration";
fn modal_background(appearance: &Appearance) -> Fill {
appearance.theme().surface_3()
}
fn modal_text_main(appearance: &Appearance) -> ColorU {
appearance
.theme()
.main_text_color(modal_background(appearance))
.into_solid()
}
fn modal_text_sub(appearance: &Appearance) -> ColorU {
appearance
.theme()
.sub_text_color(modal_background(appearance))
.into_solid()
}
fn modal_overlay_1(appearance: &Appearance) -> Fill {
appearance.theme().surface_overlay_1()
}
fn modal_overlay_2(appearance: &Appearance) -> Fill {
appearance.theme().surface_overlay_2()
}
fn modal_terminal_magenta(appearance: &Appearance) -> ColorU {
appearance.theme().terminal_colors().normal.magenta.into()
}
fn modal_terminal_magenta_overlay_1(appearance: &Appearance) -> ColorU {
let magenta = appearance.theme().terminal_colors().normal.magenta;
appearance.theme().ansi_overlay_1(magenta)
}
struct FeatureItem {
icon: Icon,
title: &'static str,
description: &'static str,
badge: Option<&'static str>,
}
const FEATURE_ITEMS: &[FeatureItem] = &[
FeatureItem {
icon: Icon::Cloud,
title: "Run any agent harness in the cloud",
description: "Use Oz to spin up Claude Code or Codex agents in the cloud; Oz will help you track and steer the agents.",
badge: None,
},
FeatureItem {
icon: Icon::Atom,
title: "Multi-agent orchestration",
description: "Warp Agents will now orchestrate swarms of subagents, allowing you to parallelize tasks.",
badge: None,
},
FeatureItem {
icon: Icon::Cognition,
title: "Agent Memory",
description: "Agents will now store and access long-term memories, enabling self-improvement over time.",
badge: Some("Research preview"),
},
];
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_fixed_bindings([FixedBinding::new(
"escape",
OrchestrationLaunchModalAction::Close,
id!(OrchestrationLaunchModal::ui_name()),
)]);
}
#[derive(Clone, Debug)]
pub enum OrchestrationLaunchModalAction {
Close,
LearnMore,
}
#[derive(Clone, Debug)]
pub enum OrchestrationLaunchModalEvent {
Close,
}
struct CloseButtonTheme;
impl ActionButtonTheme for CloseButtonTheme {
fn background(&self, hovered: bool, appearance: &Appearance) -> Option<Fill> {
if hovered {
Some(modal_overlay_1(appearance))
} else {
None
}
}
fn text_color(
&self,
_hovered: bool,
_background: Option<Fill>,
_appearance: &Appearance,
) -> ColorU {
ColorU::white()
}
}
struct LearnMoreButtonTheme;
impl ActionButtonTheme for LearnMoreButtonTheme {
fn background(&self, hovered: bool, appearance: &Appearance) -> Option<Fill> {
if hovered {
Some(modal_overlay_2(appearance))
} else {
None
}
}
fn text_color(
&self,
_hovered: bool,
_background: Option<Fill>,
appearance: &Appearance,
) -> ColorU {
modal_text_main(appearance)
}
fn border(&self, appearance: &Appearance) -> Option<ColorU> {
Some(appearance.theme().outline().into_solid())
}
}
struct CtaButtonTheme;
impl ActionButtonTheme for CtaButtonTheme {
fn background(&self, _hovered: bool, appearance: &Appearance) -> Option<Fill> {
Some(Fill::Solid(appearance.theme().foreground().into_solid()))
}
fn text_color(
&self,
_hovered: bool,
_background: Option<Fill>,
appearance: &Appearance,
) -> ColorU {
appearance.theme().background().into_solid()
}
}
pub struct OrchestrationLaunchModal {
close_button: ViewHandle<ActionButton>,
learn_more_button: ViewHandle<ActionButton>,
go_to_warp_button: ViewHandle<ActionButton>,
}
impl OrchestrationLaunchModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let close_button = ctx.add_view(|_ctx| {
ActionButton::new("", CloseButtonTheme)
.with_icon(Icon::X)
.with_size(ButtonSize::Small)
.on_click(|ctx| ctx.dispatch_typed_action(OrchestrationLaunchModalAction::Close))
});
let learn_more_button = ctx.add_view(|_ctx| {
ActionButton::new("Learn more", LearnMoreButtonTheme)
.with_icon(Icon::LinkExternal)
.with_full_width(true)
.on_click(|ctx| {
ctx.dispatch_typed_action(OrchestrationLaunchModalAction::LearnMore)
})
});
let go_to_warp_button = ctx.add_view(|_ctx| {
ActionButton::new("Close", CtaButtonTheme)
.with_full_width(true)
.on_click(|ctx| ctx.dispatch_typed_action(OrchestrationLaunchModalAction::Close))
});
Self {
close_button,
learn_more_button,
go_to_warp_button,
}
}
fn render_hero(&self) -> Box<dyn Element> {
let hero = Clipped::new(
ConstrainedBox::new(
Image::new(
AssetSource::Bundled {
path: HERO_IMAGE_PATH,
},
CacheOption::Original,
)
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(8.)))
.cover()
.top_aligned()
.finish(),
)
.with_width(MODAL_WIDTH)
.with_height(HERO_HEIGHT)
.finish(),
)
.finish();
let close_el = Container::new(ChildView::new(&self.close_button).finish())
.with_uniform_padding(4.)
.with_padding_right(2.)
.finish();
let mut hero_stack = Stack::new();
hero_stack.add_child(hero);
hero_stack.add_positioned_child(
close_el,
OffsetPositioning::offset_from_parent(
vec2f(-4., 0.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::TopRight,
ChildAnchor::TopRight,
),
);
hero_stack.finish()
}
fn render_badge(appearance: &Appearance) -> Box<dyn Element> {
let text_color = modal_terminal_magenta(appearance);
let background_color = modal_terminal_magenta_overlay_1(appearance);
let text = Text::new_inline("New".to_string(), appearance.ui_font_family(), 14.)
.with_color(text_color)
.finish();
ConstrainedBox::new(
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min)
.with_child(text)
.finish(),
)
.with_horizontal_padding(8.)
.with_background(Fill::Solid(background_color))
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.finish(),
)
.with_height(24.)
.finish()
}
fn render_title(appearance: &Appearance) -> Box<dyn Element> {
Text::new(
"Orchestrate any agent, anywhere",
appearance.ui_font_family(),
20.,
)
.with_color(modal_text_main(appearance))
.with_style(Properties::default().weight(Weight::Semibold))
.finish()
}
fn render_description(appearance: &Appearance) -> Box<dyn Element> {
Text::new(
"We've made major improvements to Warp's cloud agent orchestration platform, Oz.",
appearance.ui_font_family(),
14.,
)
.with_color(modal_text_sub(appearance))
.finish()
}
fn render_feature_badge(label: &'static str, appearance: &Appearance) -> Box<dyn Element> {
let font_family = appearance.ui_font_family();
let color = modal_text_sub(appearance);
Container::new(
Text::new_inline(label.to_string(), font_family, 11.)
.with_color(color)
.finish(),
)
.with_horizontal_padding(6.)
.with_vertical_padding(2.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_background(modal_overlay_1(appearance))
.finish()
}
fn render_feature_row(&self, item: &FeatureItem, appearance: &Appearance) -> Box<dyn Element> {
let icon_el = ConstrainedBox::new(
item.icon
.to_warpui_icon(Fill::Solid(modal_text_sub(appearance)))
.finish(),
)
.with_width(16.)
.with_height(16.)
.finish();
let mut title_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(6.);
title_row.add_child(
Text::new_inline(item.title.to_string(), appearance.ui_font_family(), 14.)
.with_color(modal_text_main(appearance))
.finish(),
);
if let Some(badge_label) = item.badge {
title_row.add_child(Self::render_feature_badge(badge_label, appearance));
}
let text_col = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(2.)
.with_child(title_row.finish())
.with_child(
Text::new(item.description, appearance.ui_font_family(), 14.)
.with_color(modal_text_sub(appearance))
.finish(),
)
.finish();
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(10.)
.with_child(icon_el)
.with_child(Expanded::new(1., text_col).finish())
.finish()
}
fn render_body(&self, appearance: &Appearance) -> Box<dyn Element> {
let mut features_col = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(12.);
for item in FEATURE_ITEMS {
features_col.add_child(self.render_feature_row(item, appearance));
}
let footer = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(8.)
.with_child(
Expanded::new(1., ChildView::new(&self.learn_more_button).finish()).finish(),
)
.with_child(
Expanded::new(1., ChildView::new(&self.go_to_warp_button).finish()).finish(),
)
.finish();
Container::new(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(8.)
.with_child(Self::render_badge(appearance))
.with_child(Self::render_title(appearance))
.with_child(Self::render_description(appearance))
.finish(),
)
.with_child(
Container::new(features_col.finish())
.with_margin_top(16.)
.finish(),
)
.with_child(Container::new(footer).with_margin_top(32.).finish())
.finish(),
)
.with_horizontal_padding(32.)
.with_vertical_padding(32.)
.with_background(modal_background(appearance))
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
.finish()
}
}
impl Entity for OrchestrationLaunchModal {
type Event = OrchestrationLaunchModalEvent;
}
impl View for OrchestrationLaunchModal {
fn ui_name() -> &'static str {
"OrchestrationLaunchModal"
}
fn on_focus(&mut self, _focus_ctx: &warpui::FocusContext, ctx: &mut ViewContext<Self>) {
ctx.focus_self();
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let card = ConstrainedBox::new(
Container::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(self.render_hero())
.with_child(self.render_body(appearance))
.finish(),
)
.with_background(modal_background(appearance))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish(),
)
.with_width(MODAL_WIDTH)
.finish();
Container::new(Align::new(card).finish())
.with_background(Fill::Solid(ColorU::new(97, 97, 97, 255)).with_opacity(50))
.finish()
}
}
impl TypedActionView for OrchestrationLaunchModal {
type Action = OrchestrationLaunchModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
OrchestrationLaunchModalAction::Close => {
ctx.emit(OrchestrationLaunchModalEvent::Close);
}
OrchestrationLaunchModalAction::LearnMore => {
ctx.open_url(LEARN_MORE_URL);
}
}
}
}
+328 -163
View File
@@ -1,64 +1,63 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use dunce::canonicalize;
use itertools::Itertools;
use pathfinder_color::ColorU;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::Icon;
use warp_util::path::LineAndColumnArg;
use warpui::elements::{
resizable_state_handle, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container,
CrossAxisAlignment, DragBarSide, Element, Empty, Flex, MainAxisAlignment, MainAxisSize,
MouseStateHandle, ParentElement, PositionedElementAnchor, Resizable, ResizableStateHandle,
Shrinkable, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::keymap::EditableBinding;
use warpui::platform::Cursor;
use warpui::ui_components::components::UiComponent;
use warpui::{
AppContext, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle, WeakViewHandle,
};
use crate::ai::agent::AgentReviewCommentBatch;
use crate::appearance::{Appearance, AppearanceEvent};
use crate::code::buffer_location::LocalOrRemotePath;
use crate::code_review::code_review_header::HEADER_BUTTON_PADDING;
#[cfg(feature = "local_fs")]
use crate::code_review::code_review_view::CodeReviewAction;
use crate::code_review::code_review_view::{
render_file_navigation_button, CodeReviewView, CONTENT_LEFT_MARGIN, CONTENT_RIGHT_MARGIN,
render_file_navigation_button, CodeReviewCommentDebugState, CodeReviewView,
CodeReviewViewEvent, ReviewActionTargetProvider, CONTENT_LEFT_MARGIN, CONTENT_RIGHT_MARGIN,
};
use crate::code_review::code_review_view::{CodeReviewCommentDebugState, CodeReviewViewEvent};
use crate::code_review::diff_state::DiffStateModel;
use crate::code_review::telemetry_event::CodeReviewContextDestination;
use crate::pane_group::pane::view::header::{components::HEADER_EDGE_PADDING, PANE_HEADER_HEIGHT};
use crate::pane_group::WorkingDirectoriesEvent;
use crate::pane_group::{Event as PaneGroupEvent, PaneGroup, WorkingDirectoriesModel};
use crate::drive::panel::{MAX_SIDEBAR_WIDTH_RATIO, MIN_SIDEBAR_WIDTH};
use crate::pane_group::pane::view::header::components::HEADER_EDGE_PADDING;
use crate::pane_group::pane::view::header::PANE_HEADER_HEIGHT;
use crate::pane_group::{
Event as PaneGroupEvent, PaneGroup, WorkingDirectoriesEvent, WorkingDirectoriesModel,
};
use crate::settings::{AISettings, AISettingsChangedEvent};
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
use crate::terminal::input::MenuPositioning;
use crate::terminal::resizable_data::{ModalType, ResizableData};
use crate::terminal::view::TerminalView;
use crate::terminal::CLIAgent;
use crate::ui_components::{buttons::icon_button_with_color, icons};
use crate::ui_components::buttons::icon_button_with_color;
use crate::ui_components::icons;
use crate::util::bindings::{keybinding_name_to_display_string, CustomAction};
#[cfg(feature = "local_fs")]
use crate::util::openable_file_type::FileTarget;
use crate::util::path::{display_name_with_host, display_path_with_host};
use crate::view_components::action_button::{ActionButton, PaneHeaderTheme};
#[cfg(feature = "local_fs")]
use crate::view_components::action_button::{NakedTheme, TooltipAlignment};
use crate::view_components::{Dropdown, DropdownItem};
use crate::workspace::view::TOGGLE_RIGHT_PANEL_BINDING_NAME;
use crate::workspace::WorkspaceAction;
use crate::{
appearance::Appearance,
drive::panel::{MAX_SIDEBAR_WIDTH_RATIO, MIN_SIDEBAR_WIDTH},
terminal::resizable_data::{ModalType, ResizableData},
};
use crate::{code_review::diff_state::DiffStateModel, terminal::view::TerminalView};
use dunce::canonicalize;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::Icon;
use galaxy_util::path::LineAndColumnArg;
use galaxyui::elements::{ChildAnchor, Empty, PositionedElementAnchor};
use galaxyui::keymap::EditableBinding;
use galaxyui::EntityId;
use galaxyui::{
elements::{
resizable_state_handle, Container, DragBarSide, Element, MainAxisSize, MouseStateHandle,
Resizable, ResizableStateHandle,
},
AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle, WeakViewHandle,
};
use galaxyui::{
elements::{
ChildView, Clipped, ConstrainedBox, CrossAxisAlignment, Flex, MainAxisAlignment,
ParentElement, Shrinkable, Text,
},
fonts::{Properties, Weight},
platform::Cursor,
ui_components::components::UiComponent,
};
use itertools::Itertools;
use std::{
path::{Path, PathBuf},
sync::Arc,
};
/// Describes which agent destination is available for sending review comments.
#[derive(Clone, Debug, PartialEq)]
@@ -107,7 +106,7 @@ impl ReviewTerminalUnavailableReason {
#[derive(Debug)]
struct ReviewTerminalStatus {
active_session_path: Option<PathBuf>,
current_repo_path: Option<PathBuf>,
current_repo_path: Option<LocalOrRemotePath>,
active_cli_agent: Option<String>,
is_executing: bool,
is_input_box_visible: bool,
@@ -119,13 +118,71 @@ impl ReviewTerminalStatus {
}
}
/// `ReviewActionTargetProvider` backed by the right panel's active pane group,
/// so code review actions resolve their target terminal at action time instead
/// of using a handle captured when the review view was created.
struct RightPanelReviewActionTargetProvider {
right_panel: WeakViewHandle<RightPanelView>,
}
impl ReviewActionTargetProvider for RightPanelReviewActionTargetProvider {
fn attach_terminal(
&self,
repo_path: &LocalOrRemotePath,
app: &AppContext,
) -> Option<ViewHandle<TerminalView>> {
let right_panel = self.right_panel.upgrade(app)?;
right_panel.read(app, |panel, app| {
let pane_group = panel.active_pane_group.as_ref()?;
let ai_enabled = AISettings::as_ref(app).is_any_ai_enabled(app);
panel
.find_review_terminal(pane_group, repo_path, ai_enabled, app)
.or_else(|| {
// No terminal is available (e.g. all candidates are
// executing). Fall back to the focused terminal when it is
// inside the repo, so per-action handling for busy
// terminals still targets the focused conversation.
let focused = pane_group
.read(app, |pane_group, app| pane_group.focused_session_view(app))?;
let status = RightPanelView::review_terminal_status(
&focused,
Some(repo_path),
ai_enabled,
app,
);
let in_repo = !status.unavailable_reasons.iter().any(|reason| {
matches!(
reason,
ReviewTerminalUnavailableReason::NoSelectedRepo
| ReviewTerminalUnavailableReason::SessionPathUnavailable
| ReviewTerminalUnavailableReason::SessionOutsideSelectedRepo
)
});
in_repo.then_some(focused)
})
})
}
fn focused_terminal(&self, app: &AppContext) -> Option<ViewHandle<TerminalView>> {
let right_panel = self.right_panel.upgrade(app)?;
right_panel.read(app, |panel, app| {
let pane_group = panel.active_pane_group.as_ref()?;
pane_group.read(app, |pane_group, app| {
pane_group
.focused_session_view(app)
.or_else(|| pane_group.active_session_view(app))
})
})
}
}
struct CodeReviewState {
dropdown: ViewHandle<Dropdown<RightPanelAction>>,
available_repos: Vec<PathBuf>,
available_repos: Vec<LocalOrRemotePath>,
/// The repository path of the focused terminal
focused_repo_path: Option<PathBuf>,
focused_repo_path: Option<LocalOrRemotePath>,
/// The repository path of the repository selected in the dropdown
selected_repo_path: Option<PathBuf>,
selected_repo_path: Option<LocalOrRemotePath>,
/// Avoid showing the jump-to-repo button if the focused repo has not changed
did_focused_repo_change: bool,
}
@@ -136,16 +193,27 @@ struct CodeReviewSessionEnv {
is_wsl: bool,
}
/// Resolve the repo-switcher dropdown's text color from the current theme.
/// Kept as a free function so the construction site and the
/// `AppearanceEvent::ThemeChanged` subscription compute the exact same color.
fn repo_dropdown_font_color(appearance: &Appearance) -> ColorU {
appearance
.theme()
.sub_text_color(appearance.theme().background())
.into_solid()
}
impl CodeReviewState {
pub fn new(ctx: &mut ViewContext<RightPanelView>) -> Self {
CodeReviewState {
dropdown: ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
let font_color = appearance
.theme()
.sub_text_color(appearance.theme().background())
.into_solid();
let ui_font_size = appearance.ui_font_size();
let (font_color, ui_font_size) = {
let appearance = Appearance::as_ref(ctx);
(
repo_dropdown_font_color(appearance),
appearance.ui_font_size(),
)
};
let mut dropdown = Dropdown::new(ctx);
dropdown.set_menu_position(
PositionedElementAnchor::BottomRight,
@@ -158,6 +226,19 @@ impl CodeReviewState {
dropdown.set_vertical_margin(0., ctx);
dropdown.set_top_bar_height(galaxy_core::ui::icons::ICON_DIMENSIONS, ctx);
dropdown.set_padding(HEADER_BUTTON_PADDING, ctx);
// The font color above is derived from the active theme and
// cached inside the dropdown. Without this subscription, the
// cached value goes stale across light/dark switches and the
// header label becomes unreadable on the new background
// (e.g. white-on-white in light mode after starting in dark).
ctx.subscribe_to_model(&Appearance::handle(ctx), |dropdown, _, event, ctx| {
if matches!(event, AppearanceEvent::ThemeChanged) {
let font_color = repo_dropdown_font_color(Appearance::as_ref(ctx));
dropdown.set_font_color(font_color, ctx);
}
});
dropdown
}),
available_repos: vec![],
@@ -170,13 +251,17 @@ impl CodeReviewState {
#[cfg(not(feature = "local_fs"))]
fn set_available_repos(
&mut self,
_repos: Vec<PathBuf>,
_repos: Vec<LocalOrRemotePath>,
_ctx: &mut ViewContext<RightPanelView>,
) {
}
#[cfg(feature = "local_fs")]
fn set_available_repos(&mut self, repos: Vec<PathBuf>, ctx: &mut ViewContext<RightPanelView>) {
fn set_available_repos(
&mut self,
repos: Vec<LocalOrRemotePath>,
ctx: &mut ViewContext<RightPanelView>,
) {
let should_clear = self
.selected_repo_path
.as_ref()
@@ -200,19 +285,23 @@ impl CodeReviewState {
#[cfg(not(feature = "local_fs"))]
pub fn set_selected_repo(
&mut self,
_repo_path: PathBuf,
_repo_path: LocalOrRemotePath,
_ctx: &mut ViewContext<RightPanelView>,
) {
}
#[cfg(feature = "local_fs")]
pub fn set_selected_repo(&mut self, repo_path: PathBuf, ctx: &mut ViewContext<RightPanelView>) {
pub fn set_selected_repo(
&mut self,
repo_path: LocalOrRemotePath,
ctx: &mut ViewContext<RightPanelView>,
) {
self.set_selected_repo_internal(repo_path, true, ctx);
}
pub fn set_focused_repo(
&mut self,
repo_path: Option<PathBuf>,
repo_path: Option<LocalOrRemotePath>,
ctx: &mut ViewContext<RightPanelView>,
) {
self.did_focused_repo_change = true;
@@ -226,10 +315,13 @@ impl CodeReviewState {
#[cfg(feature = "local_fs")]
fn set_selected_repo_internal(
&mut self,
repo_path: PathBuf,
repo_path: LocalOrRemotePath,
update_dropdown: bool,
ctx: &mut ViewContext<RightPanelView>,
) {
if repo_path.is_remote() && !FeatureFlag::RemoteCodeReview.is_enabled() {
return;
}
if self.selected_repo_path.as_ref() == Some(&repo_path) {
return;
}
@@ -246,11 +338,13 @@ impl CodeReviewState {
}
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
fn get_repo_display_name(&self, repo_path: &Path) -> Option<String> {
repo_path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.to_string())
fn get_repo_display_name(
&self,
repo_path: &LocalOrRemotePath,
ctx: &AppContext,
) -> Option<String> {
let name = display_name_with_host(repo_path, ctx);
(!name.is_empty()).then_some(name)
}
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
@@ -262,7 +356,7 @@ impl CodeReviewState {
.iter()
.map(|repo_path| {
let display_name = self
.get_repo_display_name(repo_path)
.get_repo_display_name(repo_path, ctx)
.unwrap_or_else(|| "Unknown".to_string());
DropdownItem::new(
display_name,
@@ -277,7 +371,7 @@ impl CodeReviewState {
let selected_display_name = self
.selected_repo_path
.as_ref()
.and_then(|selected| self.get_repo_display_name(selected));
.and_then(|selected| self.get_repo_display_name(selected, ctx));
(items, selected_display_name)
};
@@ -294,12 +388,12 @@ impl CodeReviewState {
}
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
pub enum RightPanelAction {
ToggleFileSidebar,
SelectRepo {
repo_path: PathBuf,
repo_path: LocalOrRemotePath,
from_dropdown: bool,
},
OpenRepository,
@@ -317,7 +411,7 @@ pub enum RightPanelEvent {
line_col: Option<LineAndColumnArg>,
},
OpenFileInNewTab {
path: PathBuf,
path: LocalOrRemotePath,
line_and_column: Option<LineAndColumnArg>,
},
#[cfg(not(target_family = "wasm"))]
@@ -464,14 +558,18 @@ impl RightPanelView {
ctx.notify();
}
pub fn selected_repo_path(&self) -> Option<&PathBuf> {
pub fn selected_repo_path(&self) -> Option<&LocalOrRemotePath> {
self.code_review_state
.as_ref()
.and_then(|s| s.selected_repo_path.as_ref())
}
#[cfg(feature = "local_fs")]
pub fn update_selected_repo(&mut self, repo_path: PathBuf, ctx: &mut ViewContext<Self>) {
pub fn update_selected_repo(
&mut self,
repo_path: LocalOrRemotePath,
ctx: &mut ViewContext<Self>,
) {
self.handle_action(
&RightPanelAction::SelectRepo {
repo_path,
@@ -503,7 +601,7 @@ impl RightPanelView {
.and_then(|s| s.selected_repo_path.clone());
if let Some(state) = self.code_review_state.as_mut() {
state.set_available_repos(repositories.to_owned(), ctx);
state.set_available_repos(repositories.clone(), ctx);
}
let new_selected = self
@@ -570,12 +668,22 @@ impl RightPanelView {
self.active_pane_group = Some(pane_group);
if let Some(state) = &mut self.code_review_state {
let active_repositories = working_directories_model.read(ctx, |model, _| {
model
.most_recent_repositories_for_pane_group(pane_group_id)
.map(|repos| repos.collect())
.unwrap_or_default()
});
let (active_repositories, saved_selection) =
working_directories_model.read(ctx, |model, _| {
let repos: Vec<LocalOrRemotePath> = model
.most_recent_repositories_for_pane_group(pane_group_id)
.map(|repos| repos.collect())
.unwrap_or_default();
let saved = model.get_selected_review_repo(pane_group_id).cloned();
(repos, saved)
});
// Replace the carried-over selection from a different pane group
// with whatever was saved for this pane group (if anything). This
// ensures `set_available_repos` either keeps the saved selection
// (when it's still in the repo list) or falls back to auto-selecting
// the first repo, instead of preserving the previous tab's repo.
state.selected_repo_path = saved_selection;
state.set_available_repos(active_repositories, ctx);
}
@@ -598,9 +706,8 @@ impl RightPanelView {
/// Will only update repo_path if one is not already set
pub fn open_code_review(
&mut self,
repo_path: Option<PathBuf>,
repo_path: Option<LocalOrRemotePath>,
diff_state_model: ModelHandle<DiffStateModel>,
terminal_view: WeakViewHandle<TerminalView>,
ctx: &mut ViewContext<Self>,
) {
let Some(repo_dropdown_state) = &mut self.code_review_state else {
@@ -610,6 +717,9 @@ impl RightPanelView {
else {
return;
};
if repo_path.is_remote() && !FeatureFlag::RemoteCodeReview.is_enabled() {
return;
}
let pane_group_id = active_pane_group.id();
if repo_dropdown_state.selected_repo_path.is_none() {
@@ -622,19 +732,14 @@ impl RightPanelView {
.get_code_review_view(pane_group_id, repo_path);
if let Some(view) = existing_view {
view.update(ctx, |view, ctx| {
view.set_terminal_view(terminal_view);
view.on_open(Some(repo_path.clone()), ctx);
view.on_open(ctx);
});
self.recompute_terminal_availability(ctx);
} else if let Some(view) = self.create_code_review_view(
repo_path,
diff_state_model.clone(),
pane_group_id,
terminal_view.clone(),
ctx,
) {
} else if let Some(view) =
self.create_code_review_view(repo_path, diff_state_model.clone(), pane_group_id, ctx)
{
view.update(ctx, |view, ctx| {
view.on_open(Some(repo_path.clone()), ctx);
view.on_open(ctx);
});
self.recompute_terminal_availability(ctx);
};
@@ -646,7 +751,7 @@ impl RightPanelView {
fn close_code_review_view(
&self,
pane_group_id: EntityId,
repo_path: &Path,
repo_path: &LocalOrRemotePath,
ctx: &mut ViewContext<Self>,
) {
if let Some(code_review_view) = self
@@ -771,27 +876,34 @@ impl RightPanelView {
.finish();
};
let selected_repo_path = state
.selected_repo_path
.as_ref()
.filter(|repo_path| state.available_repos.contains(repo_path));
let selected_repo_path = state.selected_repo_path.as_ref().filter(|repo_path| {
if repo_path.is_remote() {
FeatureFlag::RemoteCodeReview.is_enabled()
} else {
state.available_repos.contains(repo_path)
}
});
let Some(selected_repo_path) = selected_repo_path else {
let simple_header = self.render_simple_header(close_button);
#[cfg(feature = "local_fs")]
let no_repo_body = {
let button = Some(ChildView::new(&self.open_repository_button).finish());
let open_repo_button =
|| Some(ChildView::new(&self.open_repository_button).finish());
if let Some(env) = &self.code_review_session_env {
if env.is_remote {
CodeReviewView::render_remote_state(appearance, button)
// No "Open repository" CTA when the session is remote — the
// button navigates to a local folder, which is not meaningful
// in a remote session.
CodeReviewView::render_remote_state(appearance, None)
} else if env.is_wsl {
CodeReviewView::render_wsl_state(appearance, button)
CodeReviewView::render_wsl_state(appearance, open_repo_button())
} else {
CodeReviewView::render_not_repo_state(appearance, button)
CodeReviewView::render_not_repo_state(appearance, open_repo_button())
}
} else {
CodeReviewView::render_not_repo_state(appearance, button)
CodeReviewView::render_not_repo_state(appearance, open_repo_button())
}
};
@@ -856,14 +968,11 @@ impl RightPanelView {
let repo_path = crv.repo_path();
let branch_name = crv
.diff_state_model()
.read(app, |model, _| model.get_current_branch_name());
.read(app, |model, ctx| model.get_current_branch_name(ctx));
let diff_stats = crv.loaded_diff_stats();
let repo_path_element = repo_path.map(|repo_path| {
let display_path = dirs::home_dir()
.and_then(|home| repo_path.strip_prefix(&home).ok())
.map(|relative| format!("~/{}", relative.display()))
.unwrap_or_else(|| repo_path.display().to_string());
let display_path = display_path_with_host(repo_path, true, app);
Container::new(
Text::new_inline(
format!("{display_path}:"),
@@ -1097,18 +1206,22 @@ impl RightPanelView {
fn create_code_review_view(
&self,
repo_path: &Path,
repo_path: &LocalOrRemotePath,
diff_state_model: ModelHandle<DiffStateModel>,
pane_group_id: EntityId,
terminal_view: WeakViewHandle<TerminalView>,
ctx: &mut ViewContext<Self>,
) -> Option<ViewHandle<CodeReviewView>> {
// Early check: if pane group has no active repositories, don't create a view
let has_active_repos = self
.working_directories_model
.as_ref(ctx)
.most_recent_repositories_for_pane_group(pane_group_id)
.is_some_and(|repos| repos.count() > 0);
// Early check: if pane group has no active repositories, don't create a view.
// Remote repos require the RemoteCodeReview feature flag; local repos go
// through the active-repos check.
let has_active_repos = if repo_path.is_remote() {
FeatureFlag::RemoteCodeReview.is_enabled()
} else {
self.working_directories_model
.as_ref(ctx)
.most_recent_repositories_for_pane_group(pane_group_id)
.is_some_and(|mut repos| repos.any(|r| &r == repo_path))
};
if !has_active_repos {
return None;
@@ -1120,12 +1233,16 @@ impl RightPanelView {
.update(ctx, |working_directories, ctx| {
working_directories.get_or_create_code_review_comments(repo_path, ctx)
});
let action_target_provider: Box<dyn ReviewActionTargetProvider> =
Box::new(RightPanelReviewActionTargetProvider {
right_panel: ctx.handle(),
});
let code_review_view = ctx.add_typed_action_view(|ctx| {
CodeReviewView::new(
Some(repo_path.to_path_buf()),
Some(repo_path.clone()),
diff_state_model_clone,
code_review_comment_batch,
Some(terminal_view),
Some(action_target_provider),
ctx,
)
});
@@ -1134,7 +1251,7 @@ impl RightPanelView {
self.working_directories_model.update(ctx, |model, _ctx| {
model.store_code_review_view(
pane_group_id,
repo_path.to_path_buf(),
repo_path.clone(),
code_review_view.clone(),
);
});
@@ -1198,7 +1315,7 @@ impl RightPanelView {
&mut self,
code_review_view: &ViewHandle<CodeReviewView>,
comments: AgentReviewCommentBatch,
repo_path: &Path,
repo_path: &LocalOrRemotePath,
ctx: &mut ViewContext<Self>,
) {
let Some(pane_group) = &self.active_pane_group else {
@@ -1226,7 +1343,7 @@ impl RightPanelView {
.filter_map(|c| {
c.target
.absolute_file_path()
.map(|p| p.to_string_lossy().to_string())
.map(LocalOrRemotePath::display_path)
})
.collect::<std::collections::HashSet<_>>()
.len();
@@ -1274,9 +1391,14 @@ impl RightPanelView {
.unwrap_or_else(|| "<none>".to_string())
}
fn format_optional_location(path: Option<&LocalOrRemotePath>) -> String {
path.map(LocalOrRemotePath::display_path)
.unwrap_or_else(|| "<none>".to_string())
}
fn review_terminal_status(
tv: &ViewHandle<TerminalView>,
repo_path: Option<&Path>,
repo_path: Option<&LocalOrRemotePath>,
ai_enabled: bool,
ctx: &AppContext,
) -> ReviewTerminalStatus {
@@ -1290,16 +1412,25 @@ impl RightPanelView {
let mut unavailable_reasons = Vec::new();
match repo_path {
Some(repo_path) => match active_session_path.as_ref() {
// Canonicalize the CWD, note that repo_path has already been canonicalized.
Some(cwd)
if canonicalize(cwd)
.as_deref()
.unwrap_or(cwd)
.starts_with(repo_path) => {}
Some(_) => unavailable_reasons
Some(repo_path) => match (repo_path, t.current_repo_path()) {
(LocalOrRemotePath::Local(repo_path), _) => {
match active_session_path.as_ref() {
Some(cwd)
if canonicalize(cwd)
.as_deref()
.unwrap_or(cwd)
.starts_with(repo_path) => {}
Some(_) => unavailable_reasons
.push(ReviewTerminalUnavailableReason::SessionOutsideSelectedRepo),
None => unavailable_reasons
.push(ReviewTerminalUnavailableReason::SessionPathUnavailable),
}
}
(repo_path @ LocalOrRemotePath::Remote(_), Some(current_repo_path))
if repo_path.strip_repo_prefix(current_repo_path).is_some() => {}
(LocalOrRemotePath::Remote(_), Some(_)) => unavailable_reasons
.push(ReviewTerminalUnavailableReason::SessionOutsideSelectedRepo),
None => unavailable_reasons
(LocalOrRemotePath::Remote(_), None) => unavailable_reasons
.push(ReviewTerminalUnavailableReason::SessionPathUnavailable),
},
None => unavailable_reasons.push(ReviewTerminalUnavailableReason::NoSelectedRepo),
@@ -1331,7 +1462,7 @@ impl RightPanelView {
fn log_code_review_debug_state(debug_state: &CodeReviewCommentDebugState) {
log::info!(
"Active code review view: repo_path={}, has_active_comment_model={}, review_destination={:?}, total_comments={}, sendable_comments={}, is_collapsed={}, is_outdated_section_collapsed={:?}, ai_available={}, ai_enabled={}, send_button_tooltip={}",
Self::format_optional_path(debug_state.repo_path.as_deref()),
Self::format_optional_location(debug_state.repo_path.as_ref()),
debug_state.has_active_comment_model,
debug_state.comment_list.review_destination,
debug_state.comment_list.total_comments,
@@ -1356,7 +1487,7 @@ impl RightPanelView {
let Some(pane_group) = &self.active_pane_group else {
log::info!(
"Review comment send status for active tab: no active pane group, selected_repo_path={}, ai_enabled={}",
Self::format_optional_path(selected_repo_path.as_deref()),
Self::format_optional_location(selected_repo_path.as_ref()),
ai_enabled,
);
if let Some(debug_state) = &code_review_debug_state {
@@ -1381,7 +1512,7 @@ impl RightPanelView {
log::info!(
"Review comment send status for active tab: pane_group_id={pane_group_id}, selected_repo_path={}, ai_enabled={}, focused_pane_id={focused_pane_id}, preferred_terminal_id={preferred_terminal_id:?}, chosen_terminal_id={chosen_terminal_id:?}, visible_pane_count={}",
Self::format_optional_path(selected_repo_path.as_deref()),
Self::format_optional_location(selected_repo_path.as_ref()),
ai_enabled,
visible_pane_ids.len(),
);
@@ -1418,7 +1549,7 @@ impl RightPanelView {
let terminal_id = terminal_view.id();
let terminal_status = Self::review_terminal_status(
&terminal_view,
selected_repo_path.as_deref(),
selected_repo_path.as_ref(),
ai_enabled,
ctx,
);
@@ -1439,7 +1570,7 @@ impl RightPanelView {
chosen_terminal_id == Some(terminal_id),
terminal_status.is_available(),
Self::format_optional_path(terminal_status.active_session_path.as_deref()),
Self::format_optional_path(terminal_status.current_repo_path.as_deref()),
Self::format_optional_location(terminal_status.current_repo_path.as_ref()),
terminal_status
.active_cli_agent
.as_deref()
@@ -1460,7 +1591,7 @@ impl RightPanelView {
/// considered available (non-CLI Warp terminals require AI to be on).
fn is_terminal_available_for_review(
tv: &ViewHandle<TerminalView>,
repo_path: &Path,
repo_path: &LocalOrRemotePath,
ai_enabled: bool,
ctx: &AppContext,
) -> bool {
@@ -1474,7 +1605,7 @@ impl RightPanelView {
terminal_views: &[ViewHandle<TerminalView>],
focused_terminal: Option<&ViewHandle<TerminalView>>,
preferred_terminal_id: Option<EntityId>,
repo_path: &Path,
repo_path: &LocalOrRemotePath,
ai_enabled: bool,
ctx: &AppContext,
) -> Option<ViewHandle<TerminalView>> {
@@ -1508,11 +1639,11 @@ impl RightPanelView {
fn find_review_terminal(
&self,
pane_group: &ViewHandle<PaneGroup>,
repo_path: &Path,
repo_path: &LocalOrRemotePath,
ai_enabled: bool,
ctx: &AppContext,
) -> Option<ViewHandle<TerminalView>> {
let terminal_views = pane_group.read(ctx, |pg, ctx| pg.terminal_views(ctx));
let terminal_views = pane_group.read(ctx, |pg, ctx| pg.visible_terminal_views(ctx));
let focused_terminal = pane_group.read(ctx, |pg, ctx| pg.focused_session_view(ctx));
let pane_group_id = pane_group.id();
let preferred_terminal_id = self
@@ -1570,7 +1701,14 @@ impl RightPanelView {
});
}
fn ensure_code_review_view_exists(&mut self, repo_path: &Path, ctx: &mut ViewContext<Self>) {
fn ensure_code_review_view_exists(
&mut self,
repo_path: &LocalOrRemotePath,
ctx: &mut ViewContext<Self>,
) {
if repo_path.is_remote() && !FeatureFlag::RemoteCodeReview.is_enabled() {
return;
}
let Some(pane_group) = &self.active_pane_group else {
return;
};
@@ -1588,46 +1726,59 @@ impl RightPanelView {
if is_panel_open {
// on_open is idempotent (guards on is_open), so this is safe for
// already-open views and correctly re-opens cached-but-closed ones.
let repo_path = repo_path.to_path_buf();
view.update(ctx, |view, ctx| {
view.on_open(Some(repo_path), ctx);
view.on_open(ctx);
});
}
} else {
// Prefer the pane group's active session so the diff request rides
// the connection actually showing the review; the manager falls
// back to any connected session for the host when unavailable.
let preferred_session = pane_group
.read(ctx, |pg, ctx| pg.active_session_view(ctx))
.and_then(|tv| tv.as_ref(ctx).active_block_session_id());
let diff_state_model = self.working_directories_model.update(ctx, |model, ctx| {
model.get_or_create_diff_state_model(repo_path.to_path_buf(), ctx)
model.get_or_create_diff_state_model(repo_path.clone(), preferred_session, ctx)
});
let Some(diff_state_model) = diff_state_model else {
return;
};
let working_directories_model = self.working_directories_model.as_ref(ctx);
let Some(terminal_view_id) =
working_directories_model.get_terminal_id_for_root_path(pane_group_id, repo_path)
else {
return;
let is_known_repo = self
.working_directories_model
.as_ref(ctx)
.most_recent_repositories_for_pane_group(pane_group_id)
.is_some_and(|mut repos| repos.any(|r| &r == repo_path));
// Only create a view when a terminal exists for the repo. The view
// resolves its target terminal lazily via `ReviewActionTargetProvider`,
// so this is purely a creation gate.
let has_review_terminal = if is_known_repo {
let Some(terminal_view_id) = self
.working_directories_model
.as_ref(ctx)
.get_terminal_id_for_root_path(pane_group_id, repo_path)
else {
return;
};
ctx.view_with_id::<TerminalView>(ctx.window_id(), terminal_view_id)
.is_some()
} else {
// For repos not yet tracked (e.g. remote repos from direct open),
// fall back to the active session.
pane_group
.read(ctx, |pane_group, ctx| pane_group.active_session_view(ctx))
.is_some()
};
if working_directories_model
.most_recent_repositories_for_pane_group(pane_group_id)
.is_some_and(|mut repos| repos.contains(repo_path))
{
if let Some(terminal_view) =
ctx.view_with_id::<TerminalView>(ctx.window_id(), terminal_view_id)
if has_review_terminal {
if let Some(view) =
self.create_code_review_view(repo_path, diff_state_model, pane_group_id, ctx)
{
if let Some(view) = self.create_code_review_view(
repo_path,
diff_state_model,
pane_group_id,
terminal_view.downgrade(),
ctx,
) {
if is_panel_open {
let repo_path = repo_path.to_path_buf();
view.update(ctx, |view, ctx| {
view.on_open(Some(repo_path), ctx);
});
}
if is_panel_open {
view.update(ctx, |view, ctx| {
view.on_open(ctx);
});
}
}
}
@@ -1685,6 +1836,20 @@ impl TypedActionView for RightPanelView {
ctx,
);
self.ensure_code_review_view_exists(repo_path, ctx);
// Persist the user's manual selection so it can be restored when
// they leave this pane group's session and come back. We only
// persist explicit `SelectRepo` actions (i.e. dropdown picks or
// contextual opens) so that the auto-selected default doesn't
// overwrite an earlier manual choice for a different pane group.
if let Some(pane_group) = &self.active_pane_group {
let pane_group_id = pane_group.id();
let repo_path = repo_path.clone();
self.working_directories_model.update(ctx, |model, _| {
model.set_selected_review_repo(pane_group_id, repo_path);
});
}
ctx.notify();
}
}
+4 -3
View File
@@ -1,14 +1,15 @@
//! Logic to determine the working directory for new terminal sessions.
use std::path::PathBuf;
use warpui::{AppContext, SingletonEntity, ViewContext, WindowId};
use super::Workspace;
use crate::terminal::available_shells::AvailableShell;
#[cfg(feature = "local_tty")]
use crate::terminal::available_shells::AvailableShells;
use crate::terminal::session_settings::{NewSessionSource, SessionSettings};
use crate::terminal::ShellLaunchData;
use galaxyui::SingletonEntity;
use galaxyui::{AppContext, ViewContext, WindowId};
use std::path::PathBuf;
impl Workspace {
/// Helper function to compute the initial directory for a new session
+717
View File
@@ -0,0 +1,717 @@
use std::collections::HashSet;
use itertools::{Either, Itertools};
use galaxy_core::features::FeatureFlag;
use warpui::{EntityId, UpdateView, ViewContext};
use super::{group_member_indices, Workspace};
use crate::menu::{MenuItem, MenuItemFields};
use crate::tab::{TabData, MOVE_TO_GROUP_LABEL};
use crate::workspace::action::{TabContextMenuAnchor, WorkspaceAction};
use crate::workspace::tab_group::{TabGroup, TabGroupId};
use crate::workspace::util::PaneViewLocator;
// TODO(johnturcoo) move tab grouping helpers here from workspace/view.rs.
impl Workspace {
/// Clears the multi-selection on every tab.
pub(super) fn clear_tab_multi_selection(&mut self, ctx: &mut ViewContext<Self>) {
for tab in &mut self.tabs {
tab.in_multi_selection = false;
}
ctx.notify();
}
/// Adds the inclusive range between `anchor_index` and `clicked_index` to
/// the multi-selection, expanding any collapsed groups the range crosses.
/// Existing multi-selection outside the range is preserved (additive
/// semantics), so cmd-click selections survive a subsequent shift-click.
fn set_tab_range_selection(
&mut self,
anchor_index: usize,
clicked_index: usize,
ctx: &mut ViewContext<Self>,
) {
// Determine the bounds for our range selection.
let lo_index = anchor_index.min(clicked_index);
let hi_index = anchor_index.max(clicked_index);
// Identify groups in the selection range.
let crossed_group_ids: HashSet<TabGroupId> = self
.tabs
.get(lo_index..=hi_index)
.into_iter()
.flatten()
.filter_map(|tab| tab.group_id)
.collect();
// Expand any groups within the selected range, so user can see what they are selecting.
for group_id in &crossed_group_ids {
self.expand_tab_group(*group_id, ctx);
}
// Add tabs in the selected range to the multi-selection.
self.tabs
.iter_mut()
.enumerate()
.filter(|(index, _)| (lo_index..=hi_index).contains(index))
.for_each(|(_, tab)| tab.in_multi_selection = true);
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
}
/// Shift-click on a vertical tab row: selects every tab between the
/// active tab and `locator` (inclusive).
pub(super) fn shift_select_tab_range(
&mut self,
locator: PaneViewLocator,
ctx: &mut ViewContext<Self>,
) {
if !FeatureFlag::GroupedTabs.is_enabled() {
return;
}
// Identify index of the tab that was shift-clicked.
if let Some(clicked_index) = self
.tabs
.iter()
.position(|tab| tab.pane_group.id() == locator.pane_group_id)
{
self.set_tab_range_selection(self.active_tab_index, clicked_index, ctx);
}
}
/// Cmd-click on a tab: toggles the multi-selection flag
/// for a single tab.
pub(super) fn toggle_tab_multi_selection(
&mut self,
locator: PaneViewLocator,
ctx: &mut ViewContext<Self>,
) {
if !FeatureFlag::GroupedTabs.is_enabled() {
return;
}
if let Some(tab) = self
.tabs
.iter_mut()
.find(|tab| tab.pane_group.id() == locator.pane_group_id)
{
// Toggle multi selection flag for this tab.
tab.in_multi_selection = !tab.in_multi_selection;
ctx.notify();
}
}
/// Returns all tabs that are part of the multi tab selection.
/// The active tab index is always included if any other tab is marked
/// as selected. This is to handle the edge case where we only mark other
/// tabs as selected via command click.
pub(super) fn selected_tab_indices(&self) -> Vec<usize> {
let any_flagged = self.tabs.iter().any(|tab| tab.in_multi_selection);
// If no tab is part of the multi selection, return empty list.
if !any_flagged {
return Vec::new();
}
// Otherwise, the active tab must always be part of the multi tab selection.
self.tabs
.iter()
.enumerate()
.filter(|(index, tab)| tab.in_multi_selection || *index == self.active_tab_index)
.map(|(index, _)| index)
.collect()
}
/// Drives right-click menu dispatch: when a selected tab is right-clicked
/// and the selection covers multiple tabs, show the multi-tab menu;
/// otherwise fall through to the normal single-pane menu.
pub(super) fn is_tab_in_multi_tab_selection(&self, tab_index: usize) -> bool {
if !FeatureFlag::GroupedTabs.is_enabled() {
return false;
}
let indices = self.selected_tab_indices();
indices.len() > 1 && indices.contains(&tab_index)
}
/// Gates the "Remove from group" menu item. All selected tabs
/// must be in the same group in order to display this option.
pub(super) fn selection_shared_group(&self) -> Option<TabGroupId> {
let indices = self.selected_tab_indices();
let mut group_ids = indices
.iter()
.filter_map(|index| self.tabs.get(*index))
.map(|tab| tab.group_id);
let first = group_ids.next()??;
group_ids.all(|gid| gid == Some(first)).then_some(first)
}
/// Re-seats `active_tab_index` so the previously-active pane group stays
/// visually active across a tab reorder. Pass the pane group id captured
/// before the reorder; no-op if it can't be found.
pub(super) fn restore_active_tab_index(&mut self, pane_group_id: Option<EntityId>) {
if let Some(active_id) = pane_group_id {
if let Some(new_index) = self
.tabs
.iter()
.position(|tab| tab.pane_group.id() == active_id)
{
self.active_tab_index = new_index;
}
}
}
/// Context-aware "create group" entry point used by the
/// `workspace:new_tab_group_from_active_or_selected_tabs` keybinding. When
/// the multi-selection covers 2+ tabs, groups the selection; otherwise
/// groups just the active tab. `selected_tab_indices` already folds the
/// active tab into the selection, so a lone flagged active tab (or no
/// selection at all) takes the single-tab path.
pub(super) fn new_tab_group_from_active_or_selected_tabs(
&mut self,
ctx: &mut ViewContext<Self>,
) {
if !FeatureFlag::GroupedTabs.is_enabled() {
return;
}
if self.selected_tab_indices().len() >= 2 {
self.new_tab_group_from_selected_tabs(ctx);
} else {
self.new_tab_group_from_tab(self.active_tab_index, ctx);
}
}
/// Context-aware "remove from group" entry point used by the
/// `workspace:remove_active_or_selected_tabs_from_group` keybinding. With a
/// 2+ multi-selection, removes the whole selection from its group;
/// otherwise removes just the active tab.
pub(super) fn remove_active_or_selected_tabs_from_group(
&mut self,
ctx: &mut ViewContext<Self>,
) {
if !FeatureFlag::GroupedTabs.is_enabled() {
return;
}
if self.selected_tab_indices().len() >= 2 {
self.remove_selected_tabs_from_group(ctx);
} else {
self.remove_tab_from_group(self.active_tab_index, ctx);
}
}
/// "Create group from tabs" menu action. Group membership requires
/// tabs to be contiguous in the bar, so we gather the selected tabs into
/// a single block anchored at the earliest selected tab's position before
/// binding them to the new group. When that earliest tab was itself in a
/// group, the block is placed just past that group's last remaining
/// member so the existing group stays contiguous instead of being split.
pub(super) fn new_tab_group_from_selected_tabs(&mut self, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::GroupedTabs.is_enabled() {
return;
}
let selected_indices = self.selected_tab_indices();
// Should be unreachable: the multi-tab menu only opens when 2+ tabs
// are selected.
if selected_indices.len() < 2 {
log::warn!(
"new_tab_group_from_selected_tabs called with {} selected tab(s); expected at least 2",
selected_indices.len()
);
return;
}
// Remember the groups the selected tabs are leaving so we can prune
// any that become empty after the move.
let previous_group_ids: HashSet<TabGroupId> = selected_indices
.iter()
.filter_map(|index| self.tabs[*index].group_id)
.collect();
let group = TabGroup::new();
let group_id = group.id;
self.tab_groups.insert(group_id, group);
// Store the active tab (pane group).
let active_pane_group_id = self
.tabs
.get(self.active_tab_index)
.map(|tab| tab.pane_group.id());
// Anchor the group block at the earliest selected tab. `selected_indices`
// is ascending, so its first entry is the earliest tab in the list, and
// we remember the group it currently belongs to (if any).
let anchor_index = selected_indices[0];
let anchor_previous_group_id = self.tabs[anchor_index].group_id;
// Assign membership and clear flags for every selected tab. The new
// group is unpinned, so any selected tab in set as unpinned.
for &index in &selected_indices {
let tab = &mut self.tabs[index];
tab.group_id = Some(group_id);
tab.pinned = false;
tab.in_multi_selection = false;
}
// Split tabs into the new group's members and all other tabs.
let (selected_tabs, mut other_tabs): (Vec<_>, Vec<_>) = self
.tabs
.drain(..)
.partition(|tab| tab.group_id == Some(group_id));
// Compute where to splice the new group block into `other_tabs`.
//
// Simple case — anchor tab was NOT in a group:
// Every tab before `anchor_index` in the original list was unselected
// (because `anchor_index` is the smallest selected index), so those
// tabs are still at the front of `other_tabs` in their original order.
// Inserting at `anchor_index` in `other_tabs` places the block exactly
// where the anchor tab used to be.
//
// Edge case — anchor tab WAS in an existing group G:
// Other surviving members of G are still in `other_tabs`. Inserting at
// `anchor_index` could land in the middle of G's run and split it.
// Example: tabs = [A(G), B(G), C(G), D] and we select B and D.
// other_tabs = [A(G), C(G), D]. anchor_index = 1, which points at C —
// inserting there would produce [A(G), B(new), D(new), C(G)], breaking
// G's contiguity. Instead we search other_tabs from the right for the
// last surviving G member (C, at index 1) and insert after it (index 2),
// giving [A(G), C(G), B(new), D(new)].
let insert_at = anchor_previous_group_id
.and_then(|prev_group_id| {
other_tabs
.iter()
.rposition(|tab| tab.group_id == Some(prev_group_id))
.map(|last| last + 1)
})
.unwrap_or(anchor_index);
// Our insertion index for this group should be below any pinned items.
let insert_at = self.clamp_to_unpinned_region(&other_tabs, insert_at);
other_tabs.splice(insert_at..insert_at, selected_tabs);
self.tabs = other_tabs;
self.restore_active_tab_index(active_pane_group_id);
// Prune any groups that are now empty.
for previous_group_id in previous_group_ids {
self.prune_empty_tab_group(previous_group_id, ctx);
}
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
ctx.dispatch_typed_action_deferred(WorkspaceAction::RenameTabGroup(group_id));
}
/// "Move to group" menu action. The destination group's first-member
/// position is preserved so the group doesn't visually jump while the
/// selected tabs are folded in.
pub(super) fn move_selected_tabs_to_group(
&mut self,
group_id: TabGroupId,
ctx: &mut ViewContext<Self>,
) {
if !FeatureFlag::GroupedTabs.is_enabled() || !self.tab_groups.contains_key(&group_id) {
return;
}
let selected_indices = self.selected_tab_indices();
// Should be unreachable: the multi-tab menu only opens when 2+ tabs
// are selected.
if selected_indices.len() < 2 {
log::warn!(
"move_selected_tabs_to_group called with {} selected tab(s); expected at least 2",
selected_indices.len()
);
return;
}
// Store all groups that tabs previously belonged to, excluding the
// group that we are moving tabs to. In order to prune these groups later.
let previous_group_ids: HashSet<TabGroupId> = selected_indices
.iter()
.filter_map(|index| self.tabs[*index].group_id)
.filter(|gid| *gid != group_id)
.collect();
// Anchor the block at the existing first member so the group doesn't jump.
let first_existing_member = self
.tabs
.iter()
.position(|tab| tab.group_id == Some(group_id));
// Store the active tab (pane group).
let active_pane_group_id = self
.tabs
.get(self.active_tab_index)
.map(|tab| tab.pane_group.id());
// Assign membership and clear flags for every selected tab. Entering
// the group removes any per-tab pinned flag — the destination group's
// own `pinned` flag now governs the member's position.
for &index in &selected_indices {
let tab = &mut self.tabs[index];
tab.group_id = Some(group_id);
tab.pinned = false;
tab.in_multi_selection = false;
}
// Anchor the group block at its original first-member position, shifted
// left by the count of newly-added members from before that position.
let insert_at = first_existing_member.map_or(0, |first| {
first - selected_indices.iter().filter(|&&i| i < first).count()
});
// Split tabs into the destination group's members (existing + newly
// added) and the rest.
let (members, mut rest): (Vec<_>, Vec<_>) = self
.tabs
.drain(..)
.partition(|tab| tab.group_id == Some(group_id));
// Drop the group block into rest at the anchored position.
rest.splice(insert_at..insert_at, members);
self.tabs = rest;
self.restore_active_tab_index(active_pane_group_id);
self.expand_tab_group(group_id, ctx);
// Prune any groups that are now empty.
for previous_group_id in previous_group_ids {
self.prune_empty_tab_group(previous_group_id, ctx);
}
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
}
/// "Remove from group" menu action. Removed tabs land just below the
/// group's remaining members so the user can still see where they came
/// from; if the group ends up empty it's pruned and the removed block
/// anchors at the original position instead.
pub(super) fn remove_selected_tabs_from_group(&mut self, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::GroupedTabs.is_enabled() {
return;
}
let Some(group_id) = self.selection_shared_group() else {
// Only a single-group selection has an unambiguous group to leave.
self.clear_tab_multi_selection(ctx);
return;
};
// Capture the group's first index before clearing membership so we can
// anchor the removed block if the group ends up empty.
let group_first_index = self
.tabs
.iter()
.position(|tab| tab.group_id == Some(group_id))
.unwrap_or(0);
// Store the active tab (pane group).
let active_pane_group_id = self
.tabs
.get(self.active_tab_index)
.map(|tab| tab.pane_group.id());
let selected_indices = self.selected_tab_indices();
let selected_set: HashSet<usize> = selected_indices.iter().copied().collect();
// Clear the group that all selected tabs belonged to.
for &index in &selected_indices {
self.tabs[index].group_id = None;
}
// Non-selected tabs originally before the group's first member; if the
// group ends up empty we fall back to inserting at this position.
let kept_before_group = group_first_index
- selected_indices
.iter()
.filter(|&&i| i < group_first_index)
.count();
// Split tabs by index into the removed (selected) block and the rest.
let (removed, mut rest): (Vec<_>, Vec<_>) =
self.tabs
.drain(..)
.enumerate()
.partition_map(|(index, tab)| {
if selected_set.contains(&index) {
Either::Left(tab)
} else {
Either::Right(tab)
}
});
// Anchor the removed block just after the group's remaining members;
// if none remain, fall back to the pre-computed prefix position.
let natural_insert_at = match rest.iter().rposition(|tab| tab.group_id == Some(group_id)) {
Some(last) => last + 1,
None => kept_before_group,
};
// The removed tabs are now unpinned (they left a possibly-pinned
// group); they must land past every effectively pinned tab in
// not just past the source group's remaining members.
let insert_at = self.clamp_to_unpinned_region(&rest, natural_insert_at);
rest.splice(insert_at..insert_at, removed);
self.tabs = rest;
self.clear_tab_multi_selection(ctx);
self.restore_active_tab_index(active_pane_group_id);
self.prune_empty_tab_group(group_id, ctx);
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
}
/// Items shown in the multi-tab right-click menu. Composition depends on
/// the selection: "Create group from tabs" is always there; "Remove from
/// group" only when the selection has an unambiguous group; "Move to
/// group" only when there's a destination group worth offering.
fn tab_selection_menu_items(&self) -> Vec<MenuItem<WorkspaceAction>> {
let shared_group = self.selection_shared_group();
let mut menu_items = vec![MenuItemFields::new("Create group from tabs")
.with_on_select_action(WorkspaceAction::NewTabGroupFromSelectedTabs)
.into_item()];
// Only single-group selections have an unambiguous group to leave.
if shared_group.is_some() {
menu_items.push(
MenuItemFields::new("Remove from group")
.with_on_select_action(WorkspaceAction::RemoveSelectedTabsFromGroup)
.into_item(),
);
}
// Offer "Move to group" only when another group is available.
let has_destination_group = self
.tab_groups
.keys()
.any(|group_id| Some(*group_id) != shared_group);
if has_destination_group {
menu_items.push(MenuItemFields::new_submenu(MOVE_TO_GROUP_LABEL).into_item());
}
menu_items
}
/// Opens (or closes) the multi-tab right-click menu. Reuses the shared
/// `tab_right_click_menu` view — the menu rendering pipeline doesn't need
/// to know which item set is loaded, only which `show_*` flag is set.
pub fn toggle_tab_selection_right_click_menu(
&mut self,
tab_index: usize,
anchor: TabContextMenuAnchor,
ctx: &mut ViewContext<Self>,
) {
if self.show_tab_selection_right_click_menu.is_some() {
self.show_tab_selection_right_click_menu = None;
self.hide_move_to_group_sidecar(ctx);
ctx.notify();
return;
}
let menu_items = self.tab_selection_menu_items();
ctx.update_view(&self.tab_right_click_menu, |context_menu, view_ctx| {
context_menu.set_items(menu_items, view_ctx);
});
self.show_tab_right_click_menu = None;
self.show_tab_group_right_click_menu = None;
self.hide_move_to_group_sidecar(ctx);
self.show_tab_selection_right_click_menu = Some((tab_index, anchor));
ctx.focus(&self.tab_right_click_menu);
ctx.notify();
}
/// True when `tab` is positioned in the pinned region of the tab list —
/// either because its own `pinned` flag is set (ungrouped pinned tab) or
/// because it belongs to a pinned group.
pub(super) fn is_tab_effectively_pinned(&self, tab: &TabData) -> bool {
// Safety net, ensures no behavioral changes if feature flag
// is off and some tabs have a pinned state saved.
if !FeatureFlag::PinnedTabs.is_enabled() {
return false;
}
tab.pinned
|| tab
.group_id
.is_some_and(|gid| self.tab_groups.get(&gid).is_some_and(|g| g.pinned))
}
/// Index where the unpinned region begins within `tabs`: the count of
/// leading tabs that belong to the pinned region.
pub(super) fn pinned_boundary_index(&self, tabs: &[TabData]) -> usize {
tabs.iter()
.take_while(|tab| self.is_tab_effectively_pinned(tab))
.count()
}
/// Pushes `idx` past the leading effectively-pinned tabs in `tabs` if it
/// falls inside that prefix.
pub(super) fn clamp_to_unpinned_region(&self, tabs: &[TabData], idx: usize) -> usize {
idx.max(self.pinned_boundary_index(tabs))
}
/// Returns the slot just past the last member of `group_id`, suitable as
/// an insert/move target that keeps the group contiguous. `None` when the
/// group has no members.
pub(super) fn index_after_group(&self, group_id: TabGroupId) -> Option<usize> {
group_member_indices(&self.tabs, group_id)
.last()
.map(|last| last + 1)
}
/// Pins the tab. Grouped tabs are extracted from their group first
/// regardless of whether that group itself is pinned — tab pinning and
/// group pinning are independent concepts.
pub(super) fn pin_tab(&mut self, tab_index: usize, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::PinnedTabs.is_enabled() {
return;
}
let Some(tab) = self.tabs.get(tab_index) else {
log::debug!("pin_tab: tab_index {tab_index} out of bounds");
return;
};
if tab.pinned {
log::debug!("pin_tab: tab {tab_index} is already pinned");
return;
}
let previous_group_id = tab.group_id;
// Identify where this newly pinned tab should land (after the last pinned item).
let target = self.pinned_boundary_index(&self.tabs);
self.tabs[tab_index].group_id = None;
self.tabs[tab_index].pinned = true;
self.move_tab_to_index(tab_index, target, ctx);
if let Some(prev) = previous_group_id {
self.prune_empty_tab_group(prev, ctx);
}
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
}
/// Unpins a pinned tab and moves it to the start of the unpinned region.
pub(super) fn unpin_tab(&mut self, tab_index: usize, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::PinnedTabs.is_enabled() {
return;
}
let Some(tab) = self.tabs.get(tab_index) else {
log::debug!("unpin_tab: tab_index {tab_index} out of bounds");
return;
};
if !tab.pinned {
log::debug!("unpin_tab: tab {tab_index} is not pinned");
return;
}
// This tab should land right after all pinned items.
let target = self.pinned_boundary_index(&self.tabs);
self.tabs[tab_index].pinned = false;
self.move_tab_to_index(tab_index, target, ctx);
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
}
/// Pins the entire tab group: flips the group's `pinned` flag and moves
/// its contiguous block of members to the end of the pinned region. We
/// don't touch individual member `tab.pinned` flags because the block
/// always travels as a unit, and we want to support pinning a tab even if
/// it already belongs to a (pinned) group.
pub(super) fn pin_tab_group(&mut self, group_id: TabGroupId, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::PinnedTabs.is_enabled() {
return;
}
let Some(group) = self.tab_groups.get(&group_id) else {
log::debug!("pin_tab_group: unknown group {group_id:?}");
return;
};
if group.pinned {
log::debug!("pin_tab_group: group {group_id:?} is already pinned");
return;
}
let target = self.pinned_boundary_index(&self.tabs);
if let Some(group) = self.tab_groups.get_mut(&group_id) {
group.pinned = true;
}
self.move_group_block(group_id, target, ctx);
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
}
/// Unpins the entire tab group: clears the group's `pinned` flag and
/// moves the group's block to the start of the unpinned region.
pub(super) fn unpin_tab_group(&mut self, group_id: TabGroupId, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::PinnedTabs.is_enabled() {
return;
}
let Some(group) = self.tab_groups.get(&group_id) else {
log::debug!("unpin_tab_group: unknown group {group_id:?}");
return;
};
if !group.pinned {
log::debug!("unpin_tab_group: group {group_id:?} is not pinned");
return;
}
let target = self.pinned_boundary_index(&self.tabs);
if let Some(group) = self.tab_groups.get_mut(&group_id) {
group.pinned = false;
}
self.move_group_block(group_id, target, ctx);
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
}
/// Builds the "Move to group" submenu. One builder serves both parent
/// menus: `Some(tab_index)` for the single-tab pane menu, `None` for the
/// multi-tab selection menu. Destination groups exclude the source's own
/// group (no useful move) and follow panel order so the submenu visually
/// matches what the user sees in the tabs sidebar.
pub(super) fn build_move_to_group_sidecar_items(
&self,
tab_index: Option<usize>,
) -> Vec<MenuItem<WorkspaceAction>> {
// Exclude the source's current group (if any) — there's nowhere to
// move it to. For a mixed selection (no shared group) every
// destination stays available.
let excluded_group = match tab_index {
Some(idx) => self.tabs.get(idx).and_then(|tab| tab.group_id),
None => self.selection_shared_group(),
};
let mut groups_with_first_index: Vec<(TabGroupId, usize)> = self
.tab_groups
.keys()
.copied()
.filter(|gid| Some(*gid) != excluded_group)
.filter_map(|gid| {
group_member_indices(&self.tabs, gid)
.next()
.map(|idx| (gid, idx))
})
.collect();
groups_with_first_index.sort_by_key(|(_, idx)| *idx);
groups_with_first_index
.into_iter()
.map(|(group_id, _)| {
let label = self
.tab_groups
.get(&group_id)
.and_then(|g| g.name.clone())
.unwrap_or_else(|| "Untitled group".to_string());
let action = match tab_index {
Some(tab_index) => WorkspaceAction::MoveTabToGroup {
tab_index,
group_id,
},
None => WorkspaceAction::MoveSelectedTabsToGroup { group_id },
};
MenuItemFields::new(label)
.with_on_select_action(action)
.into_item()
})
.collect()
}
}
File diff suppressed because it is too large Load Diff
+198 -35
View File
@@ -1,29 +1,40 @@
use std::path::PathBuf;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use galaxyui::elements::PositionedElementOffsetBounds;
use galaxyui::EntityId;
use super::{
branch_label_display, coalesce_summary_branch_entries, code_detail_kind_label,
compact_branch_subtitle_display, detail_sidecar_width_and_bounds,
detail_target_for_hovered_row, non_terminal_search_text_fragments,
pane_ids_for_display_granularity, pane_search_text_fragments, preferred_agent_tab_titles,
push_normalized_unique_summary_label, search_fragments_contain_query,
select_summary_pane_kind_icons, should_keep_detail_sidecar_visible_for_mouse_position,
sort_summary_primary_labels_status_first, summary_overflow_count,
summary_search_text_fragments, terminal_kind_badge_label, terminal_primary_line_data,
terminal_pull_request_badge_label, terminal_search_text_fragments,
terminal_title_fallback_font, uses_outer_group_container, visible_pane_ids_for_detail_target,
vtab_diff_stats_text, AgentTabTextPreference, SummaryPaneKind, SummaryPaneKindIcons,
TerminalAgentText, TerminalPrimaryLineData, TerminalPrimaryLineFont, VerticalTabsDetailTarget,
VerticalTabsDetailTargetKind, VerticalTabsSummaryBranchEntry, VerticalTabsSummaryData,
VerticalTabsSummaryPrimaryLabel,
};
use crate::ai::agent::conversation::ConversationStatus;
use crate::context_chips::display_chip::GitLineChanges;
use crate::pane_group::pane::IPaneType;
use crate::pane_group::{PaneId, TerminalPaneId};
use crate::safe_triangle::SafeTriangle;
use crate::terminal::CLIAgent;
use crate::workspace::tab_settings::VerticalTabsDisplayGranularity;
use galaxyui::elements::PositionedElementOffsetBounds;
use galaxyui::EntityId;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use std::path::PathBuf;
use super::{
branch_label_display, coalesce_summary_branch_entries, code_detail_kind_label,
compact_branch_subtitle_display, detail_sidecar_width_and_bounds,
detail_target_for_hovered_row, format_summary_primary_labels,
non_terminal_search_text_fragments, pane_ids_for_display_granularity,
pane_search_text_fragments, preferred_agent_tab_titles, search_fragments_contain_query,
select_summary_pane_kind_icons, should_keep_detail_sidecar_visible_for_mouse_position,
summary_overflow_count, summary_search_text_fragments, terminal_kind_badge_label,
terminal_primary_line_data, terminal_pull_request_badge_label, terminal_search_text_fragments,
terminal_title_fallback_font, uses_outer_group_container, visible_pane_ids_for_detail_target,
vtab_diff_stats_text, AgentTabTextPreference, SummaryPaneKind, SummaryPaneKindIcons,
TerminalAgentText, TerminalPrimaryLineData, TerminalPrimaryLineFont, VerticalTabsDetailTarget,
VerticalTabsDetailTargetKind, VerticalTabsSummaryBranchEntry, VerticalTabsSummaryData,
};
fn label(text: &str) -> VerticalTabsSummaryPrimaryLabel {
VerticalTabsSummaryPrimaryLabel {
text: text.to_string(),
status: None,
}
}
fn pane_id() -> PaneId {
TerminalPaneId::dummy_terminal_pane_id().into()
@@ -87,6 +98,7 @@ fn summary_pane_kind_icons_distinguish_agent_terminals_from_plain_terminals() {
EntityId::from_usize(20),
SummaryPaneKind::CLIAgent {
agent: CLIAgent::Claude,
is_ambient: false,
},
),
(
@@ -98,6 +110,41 @@ fn summary_pane_kind_icons_distinguish_agent_terminals_from_plain_terminals() {
primary: SummaryPaneKind::Terminal,
secondary: SummaryPaneKind::CLIAgent {
agent: CLIAgent::Claude,
is_ambient: false,
},
})
);
}
#[test]
fn summary_pane_kind_icons_distinguish_ambient_claude_from_local_claude() {
// A local Claude session and a cloud-mode Claude session should count as distinct kinds
// so they render with different icons (claude.svg vs claude_cloud.svg).
assert_eq!(
select_summary_pane_kind_icons([
(
EntityId::from_usize(10),
SummaryPaneKind::CLIAgent {
agent: CLIAgent::Claude,
is_ambient: false,
},
),
(
EntityId::from_usize(20),
SummaryPaneKind::CLIAgent {
agent: CLIAgent::Claude,
is_ambient: true,
},
),
]),
Some(SummaryPaneKindIcons::Pair {
primary: SummaryPaneKind::CLIAgent {
agent: CLIAgent::Claude,
is_ambient: false,
},
secondary: SummaryPaneKind::CLIAgent {
agent: CLIAgent::Claude,
is_ambient: true,
},
})
);
@@ -869,6 +916,7 @@ fn coalesce_summary_branch_entries_groups_by_repo_and_branch() {
branch_name: "main".to_string(),
diff_stats: None,
pull_request_label: None,
pull_request_url: None,
},
VerticalTabsSummaryBranchEntry {
repo_path: repo_a.clone(),
@@ -879,6 +927,7 @@ fn coalesce_summary_branch_entries_groups_by_repo_and_branch() {
lines_removed: 3,
}),
pull_request_label: Some("#123".to_string()),
pull_request_url: Some("https://github.com/acme/repo-a/pull/123".to_string()),
},
VerticalTabsSummaryBranchEntry {
repo_path: repo_b.clone(),
@@ -889,6 +938,7 @@ fn coalesce_summary_branch_entries_groups_by_repo_and_branch() {
lines_removed: 6,
}),
pull_request_label: Some("#456".to_string()),
pull_request_url: Some("https://github.com/acme/repo-b/pull/456".to_string()),
},
];
@@ -904,6 +954,7 @@ fn coalesce_summary_branch_entries_groups_by_repo_and_branch() {
lines_removed: 3,
}),
pull_request_label: Some("#123".to_string()),
pull_request_url: Some("https://github.com/acme/repo-a/pull/123".to_string()),
},
VerticalTabsSummaryBranchEntry {
repo_path: repo_b,
@@ -914,37 +965,143 @@ fn coalesce_summary_branch_entries_groups_by_repo_and_branch() {
lines_removed: 6,
}),
pull_request_label: Some("#456".to_string()),
pull_request_url: Some("https://github.com/acme/repo-b/pull/456".to_string()),
},
]
);
}
#[test]
fn format_summary_primary_labels_appends_overflow_count() {
let labels = vec![
"Claude".to_string(),
"Oz".to_string(),
"cargo".to_string(),
"code review".to_string(),
"tests".to_string(),
];
fn summary_overflow_count_caps_visible_region() {
assert_eq!(summary_overflow_count(5, 3), 2);
assert_eq!(summary_overflow_count(3, 3), 0);
assert_eq!(summary_overflow_count(2, 3), 0);
}
#[test]
fn primary_labels_dedupe_preserves_first_seen_status() {
let mut values = Vec::new();
let mut seen = std::collections::HashMap::new();
push_normalized_unique_summary_label(&mut values, &mut seen, " cargo test ", None);
push_normalized_unique_summary_label(
&mut values,
&mut seen,
"cargo test",
Some(ConversationStatus::InProgress),
);
assert_eq!(
format_summary_primary_labels(&labels, 4),
Some("Claude • Oz • cargo • code review + 1 more".to_string())
values,
vec![VerticalTabsSummaryPrimaryLabel {
text: "cargo test".to_string(),
status: None,
}]
);
}
#[test]
fn primary_labels_preserve_status_through_aggregation() {
let mut values = Vec::new();
let mut seen = std::collections::HashMap::new();
push_normalized_unique_summary_label(
&mut values,
&mut seen,
"Plan a refactor",
Some(ConversationStatus::InProgress),
);
push_normalized_unique_summary_label(
&mut values,
&mut seen,
"Investigate failure",
Some(ConversationStatus::Success),
);
push_normalized_unique_summary_label(&mut values, &mut seen, "cargo build", None);
assert_eq!(
values,
vec![
VerticalTabsSummaryPrimaryLabel {
text: "Plan a refactor".to_string(),
status: Some(ConversationStatus::InProgress),
},
VerticalTabsSummaryPrimaryLabel {
text: "Investigate failure".to_string(),
status: Some(ConversationStatus::Success),
},
VerticalTabsSummaryPrimaryLabel {
text: "cargo build".to_string(),
status: None,
},
]
);
}
#[test]
fn sort_summary_primary_labels_moves_status_first_and_preserves_order() {
let mut values = vec![
VerticalTabsSummaryPrimaryLabel {
text: "plain terminal".to_string(),
status: None,
},
VerticalTabsSummaryPrimaryLabel {
text: "first conversation".to_string(),
status: Some(ConversationStatus::InProgress),
},
VerticalTabsSummaryPrimaryLabel {
text: "code pane".to_string(),
status: None,
},
VerticalTabsSummaryPrimaryLabel {
text: "second conversation".to_string(),
status: Some(ConversationStatus::Success),
},
VerticalTabsSummaryPrimaryLabel {
text: "last terminal".to_string(),
status: None,
},
];
sort_summary_primary_labels_status_first(&mut values);
assert_eq!(
values,
vec![
VerticalTabsSummaryPrimaryLabel {
text: "first conversation".to_string(),
status: Some(ConversationStatus::InProgress),
},
VerticalTabsSummaryPrimaryLabel {
text: "second conversation".to_string(),
status: Some(ConversationStatus::Success),
},
VerticalTabsSummaryPrimaryLabel {
text: "plain terminal".to_string(),
status: None,
},
VerticalTabsSummaryPrimaryLabel {
text: "code pane".to_string(),
status: None,
},
VerticalTabsSummaryPrimaryLabel {
text: "last terminal".to_string(),
status: None,
},
]
);
assert_eq!(summary_overflow_count(labels.len(), 4), 1);
}
#[test]
fn summary_search_fragments_include_hidden_overflow_values() {
let summary = VerticalTabsSummaryData {
primary_labels: vec![
"Claude".to_string(),
"Oz".to_string(),
"cargo".to_string(),
"code review".to_string(),
"hidden work".to_string(),
VerticalTabsSummaryPrimaryLabel {
text: "Claude".to_string(),
status: Some(ConversationStatus::InProgress),
},
label("Oz"),
label("cargo"),
label("code review"),
label("hidden work"),
],
working_directories: vec!["~/warp-internal".to_string(), "~/warp-server".to_string()],
branch_entries: vec![
@@ -957,31 +1114,37 @@ fn summary_search_fragments_include_hidden_overflow_values() {
lines_removed: 3,
}),
pull_request_label: Some("#123".to_string()),
pull_request_url: Some("https://github.com/acme/repo-a/pull/123".to_string()),
},
VerticalTabsSummaryBranchEntry {
repo_path: PathBuf::from("/tmp/repo-b"),
branch_name: "feature/hidden".to_string(),
diff_stats: None,
pull_request_label: None,
pull_request_url: None,
},
VerticalTabsSummaryBranchEntry {
repo_path: PathBuf::from("/tmp/repo-c"),
branch_name: "cleanup".to_string(),
diff_stats: None,
pull_request_label: None,
pull_request_url: None,
},
VerticalTabsSummaryBranchEntry {
repo_path: PathBuf::from("/tmp/repo-d"),
branch_name: "hidden-branch".to_string(),
diff_stats: None,
pull_request_label: Some("#789".to_string()),
pull_request_url: Some("https://github.com/acme/repo-d/pull/789".to_string()),
},
],
has_unread_activity: false,
};
let fragments = summary_search_text_fragments(&summary, Some("Custom tab"));
assert!(search_fragments_contain_query(&fragments, "custom tab"));
assert!(search_fragments_contain_query(&fragments, "claude"));
assert!(search_fragments_contain_query(&fragments, "hidden work"));
assert!(search_fragments_contain_query(&fragments, "hidden-branch"));
assert!(search_fragments_contain_query(&fragments, "#789"));
+25 -11
View File
@@ -1,20 +1,17 @@
//! WASM-only view functions for the Workspace.
use galaxy_core::channel::ChannelState;
use galaxyui::elements::{ChildView, Element};
use galaxyui::{AppContext, SingletonEntity, ViewContext, ViewHandle};
use galaxy_core::channel::ChannelState;
use crate::uri::browser_url_handler::parse_current_url;
use super::PanelPosition;
use crate::ai::agent_conversations_model::AgentConversationsModel;
use crate::ai::conversation_details_panel::{
ConversationDetailsData, ConversationDetailsPanel, ConversationDetailsPanelEvent,
};
use crate::terminal::TerminalView;
use crate::ui_components::icons;
use crate::uri::browser_url_handler::parse_current_url;
use crate::view_components::action_button::{
ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme,
};
@@ -114,24 +111,28 @@ impl Workspace {
/// Check if we should show the conversation details panel, given the focused terminal view.
/// Returns true for:
/// - Conversation transcript viewers (always)
/// - Shared sessions with an ambient agent task ID, OR an active conversation
/// - Restored ambient cloud tasks
/// - Shared sessions with an active conversation
pub(super) fn should_show_conversation_details_panel(
focused_terminal_view: &ViewHandle<TerminalView>,
ctx: &AppContext,
) -> bool {
let terminal_view_ref = focused_terminal_view.as_ref(ctx);
if terminal_view_ref
.ambient_agent_task_id_for_details_panel(ctx)
.is_some()
{
return true;
}
let model = terminal_view_ref.model.lock();
// Always show for conversation transcript viewers
if model.is_conversation_transcript_viewer() {
return true;
}
// For shared sessions, show if there's an ambient agent task_id or an active conversation
// For shared sessions, show if there's an active conversation.
if model.shared_session_status().is_sharer_or_viewer() {
if model.ambient_agent_task_id().is_some() {
return true;
}
drop(model); // Release lock before accessing BlocklistAIHistoryModel
return BlocklistAIHistoryModel::as_ref(ctx)
.active_conversation(focused_terminal_view.id())
@@ -194,6 +195,19 @@ impl Workspace {
ctx.notify();
return;
}
// Task not yet available - check if the fetch failed and show error state
if let Some(error_message) = conversations_model_handle
.as_ref(ctx)
.task_fetch_error(&task_id)
.cloned()
{
let details =
ConversationDetailsData::from_task_id(task_id, Some(error_message));
panel.set_conversation_details(details, ctx);
ctx.notify();
return;
}
}
// Otherwise, populate from conversation
File diff suppressed because it is too large Load Diff