Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,238 @@
use crate::ai::agent::conversation::{AIConversation, AIConversationId};
use crate::ai::conversation_navigation::ConversationNavigationData;
use crate::search::command_palette::conversations::search::{
ConversationMatchResult, ConversationSearcher, FuzzyConversationSearcher, MatchedConversation,
};
use crate::search::command_palette::conversations::search_item::{
ConversationAction, ConversationSearchItem,
};
use crate::search::command_palette::mixer::CommandPaletteItemAction;
use crate::search::command_palette::separator_search_item::SeparatorSearchItem;
use crate::search::data_source::{DataSourceSearchError, Query, QueryResult};
use crate::search::mixer::DataSourceRunErrorWrapper;
use crate::search::SyncDataSource;
use crate::workspace::Workspace;
use itertools::Itertools;
use std::collections::HashMap;
use warpui::{AppContext, Entity};
/// Sections for grouping conversations in the command palette.
#[derive(Debug, PartialEq, Eq, Hash)]
enum ConversationSection {
ActivePane,
OtherActive,
Past,
}
impl ConversationSection {
fn title(&self) -> &'static str {
match self {
ConversationSection::ActivePane => "Active pane conversations",
ConversationSection::OtherActive => "Other active conversations",
ConversationSection::Past => "Past conversations",
}
}
/// Returns the ordering of the sections for display in the command palette
/// (the command palette renders items in reverse order).
fn reverse_order() -> [ConversationSection; 3] {
[
ConversationSection::Past,
ConversationSection::OtherActive,
ConversationSection::ActivePane,
]
}
fn for_conversation(conversation: &ConversationNavigationData) -> Self {
if conversation.is_historical() {
ConversationSection::Past
} else if conversation.is_in_active_pane {
ConversationSection::ActivePane
} else {
ConversationSection::OtherActive
}
}
}
/// Data source that produces conversations for a user to navigate to.
pub struct DataSource {
searcher: FuzzyConversationSearcher,
/// Whether to include extra conversation actions (i.e. new conversation & fork conversation)
add_conversation_actions: bool,
}
impl Default for DataSource {
fn default() -> Self {
Self::new()
}
}
impl DataSource {
pub fn new() -> Self {
Self {
searcher: FuzzyConversationSearcher::new(),
add_conversation_actions: true,
}
}
pub fn historical() -> Self {
Self {
searcher: FuzzyConversationSearcher::historical(),
add_conversation_actions: false,
}
}
/// Returns a [`QueryResult`] for a conversation identified by `conversation_id`. `None` if no result was
/// found with the given ID.
pub fn query_result(
conversation_id: &AIConversationId,
app: &AppContext,
) -> Option<QueryResult<CommandPaletteItemAction>> {
let all_conversations = ConversationNavigationData::all_conversations(app);
all_conversations
.into_iter()
.find(|conversation| &conversation.id == conversation_id)
.map(|conversation| {
let search_item = ConversationSearchItem::new(ConversationAction::Resume(
Box::new(MatchedConversation {
conversation,
match_result: ConversationMatchResult::no_match(),
}),
));
QueryResult::from(search_item)
})
}
pub fn top_n(
&self,
limit: usize,
app: &AppContext,
) -> impl Iterator<Item = QueryResult<<Self as SyncDataSource>::Action>> {
self.searcher
.searchable_conversations(app)
.into_iter()
.k_largest_by_key(limit, |conversation| conversation.last_updated)
.map(|conversation| {
QueryResult::from(ConversationSearchItem::new(ConversationAction::Resume(
Box::new(MatchedConversation {
conversation,
match_result: ConversationMatchResult::no_match(),
}),
)))
})
}
}
/// Get the selected conversation in the focused pane.
fn selected_conversation_in_focused_pane(app: &AppContext) -> Option<&AIConversation> {
app.windows().active_window().and_then(|window_id| {
app.views_of_type::<Workspace>(window_id)
.and_then(|views| views.first().cloned())
.and_then(|workspace| {
workspace.read(app, |workspace, workspace_ctx| {
workspace.active_tab_pane_group().read(
workspace_ctx,
|pane_group, pane_group_ctx| {
pane_group.focused_session_view(pane_group_ctx).and_then(
|terminal_view| {
terminal_view
.as_ref(pane_group_ctx)
.ai_context_model()
.as_ref(pane_group_ctx)
.selected_conversation(app)
},
)
},
)
})
})
})
}
impl SyncDataSource for DataSource {
type Action = CommandPaletteItemAction;
fn run_query(
&self,
query: &Query,
app: &AppContext,
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
// When the query is empty, we want to insert special separator items between historical conversations,
// open conversations, conversations in the active pane, and the conversation action items (i.e. new conversation & fork conversation).
let result = if query.text.trim().is_empty() {
let conversations = self.searcher.searchable_conversations(app);
let mut results = Vec::new();
// Group conversations by section.
let mut grouped: HashMap<ConversationSection, Vec<ConversationNavigationData>> =
HashMap::new();
for conversation in conversations {
let section = ConversationSection::for_conversation(&conversation);
grouped.entry(section).or_default().push(conversation);
}
grouped.values_mut().for_each(|group| group.sort());
// The command palette renders items in reverse order, so we need to add the sections in reverse order
// and add each separator item after all of the items in the section.
for section in ConversationSection::reverse_order() {
if let Some(conversations) = grouped.get(&section) {
if !conversations.is_empty() {
for conversation in conversations {
let matched_conversation = MatchedConversation {
conversation: conversation.clone(),
match_result: ConversationMatchResult::no_match(),
};
results.push(
ConversationSearchItem::new(ConversationAction::Resume(Box::new(
matched_conversation,
)))
.into(),
);
}
results.push(SeparatorSearchItem::new(section.title().to_string()).into());
}
}
}
Ok(results)
} else {
self.searcher
.search(&query.text.trim().to_lowercase(), app)
.map_err(|err| {
let search_error = DataSourceSearchError {
message: err.to_string(),
};
Box::new(search_error) as DataSourceRunErrorWrapper
})
};
// When the query is empty, we want to add the "new conversation" and "fork conversation" items.
if self.add_conversation_actions && query.text.trim().is_empty() {
result.map(|mut results| {
if !cfg!(target_family = "wasm") {
if let Some(conversation) = selected_conversation_in_focused_pane(app) {
// Only surface the fork option if the selected conversation is done.
if conversation.status().is_done() {
results.push(
ConversationSearchItem::new(ConversationAction::Fork {
conversation_id: conversation.id(),
title: conversation.title().unwrap_or_default().to_string(),
})
.into(),
);
}
}
}
results.push(ConversationSearchItem::new(ConversationAction::New).into());
results
})
} else {
result
}
}
}
impl Entity for DataSource {
type Event = ();
}
@@ -0,0 +1,9 @@
mod data_source;
mod search;
mod search_item;
#[cfg(test)]
mod search_test;
pub use crate::ai::conversation_navigation::ConversationNavigationData;
pub use data_source::DataSource;
@@ -0,0 +1,230 @@
use crate::ai::conversation_navigation::ConversationNavigationData;
use crate::search::command_palette::conversations::search_item::ConversationAction;
use crate::search::command_palette::conversations::search_item::ConversationSearchItem;
use crate::search::command_palette::conversations::DataSource;
use crate::search::data_source::QueryResult;
use crate::search::SyncDataSource;
use fuzzy_match::match_indices_case_insensitive;
use warpui::AppContext;
/// A conversation that was fuzzy matched against a search term.
#[derive(Debug)]
pub struct MatchedConversation {
pub conversation: ConversationNavigationData,
pub match_result: ConversationMatchResult,
}
impl MatchedConversation {
/// Returns the score for the [`MatchedConversation`]. If there was no match result, a score of `0`
/// is returned.
pub fn score(&self) -> i64 {
self.match_result.score
}
/// Returns the [`ConversationHighlightIndices`] belonging to the matched conversation.
pub fn highlight_indices(&self) -> &ConversationHighlightIndices {
&self.match_result.highlight_indices
}
}
/// Result from matching a conversation.
#[derive(Debug)]
pub struct ConversationMatchResult {
score: i64,
highlight_indices: ConversationHighlightIndices,
}
impl ConversationMatchResult {
/// Returns a dummy match result when there is no match.
pub fn no_match() -> Self {
ConversationMatchResult {
score: 0,
highlight_indices: ConversationHighlightIndices {
title_indices: vec![],
initial_query_indices: vec![],
working_directory_indices: vec![],
},
}
}
pub fn score(&self) -> i64 {
self.score
}
}
/// Matching indices for a matched conversation.
#[derive(Debug)]
pub struct ConversationHighlightIndices {
pub(super) title_indices: Vec<usize>,
pub(super) initial_query_indices: Vec<usize>,
pub(super) working_directory_indices: Vec<usize>,
}
impl ConversationHighlightIndices {
fn new(
title_indices: Vec<usize>,
initial_query_indices: Vec<usize>,
working_directory_indices: Vec<usize>,
) -> ConversationHighlightIndices {
ConversationHighlightIndices {
title_indices,
initial_query_indices,
working_directory_indices,
}
}
/// Returns the highlight indices for the conversation title.
pub fn title_indices(&self) -> &Vec<usize> {
&self.title_indices
}
/// Returns the highlight indices for the initial query.
pub fn initial_query_indices(&self) -> &Vec<usize> {
&self.initial_query_indices
}
/// Returns the highlight indices for the working directory.
pub fn working_directory_indices(&self) -> &Vec<usize> {
&self.working_directory_indices
}
}
/// Returns an iterator of conversations that match `search_term`.
pub fn filter_conversations<'a, 'b, I>(
conversations_iter: I,
search_term: &'b str,
) -> impl Iterator<Item = MatchedConversation> + use<'a, 'b, I>
where
I: IntoIterator<Item = &'a ConversationNavigationData>,
{
conversations_iter
.into_iter()
.filter_map(move |conversation| {
if search_term.is_empty() {
Some((ConversationMatchResult::no_match(), conversation.clone()))
} else {
// Match against title, initial_query, and initial_working_directory
let title_match = match_indices_case_insensitive(&conversation.title, search_term);
let initial_query_match =
conversation
.initial_query
.as_deref()
.and_then(|initial_query| {
match_indices_case_insensitive(initial_query, search_term)
});
let working_directory_match = conversation
.initial_working_directory
.as_deref()
.and_then(|initial_working_directory| {
match_indices_case_insensitive(initial_working_directory, search_term)
});
// If none of the fields match, filter this conversation out
if title_match.is_none()
&& initial_query_match.is_none()
&& working_directory_match.is_none()
{
return None;
}
// Determine the best score among all matches
let best_score = [
title_match.as_ref(),
initial_query_match.as_ref(),
working_directory_match.as_ref(),
]
.into_iter()
.flatten()
.map(|r| r.score)
.max()
.unwrap_or(0);
let title_indices = title_match.map(|r| r.matched_indices).unwrap_or_default();
let initial_query_indices = initial_query_match
.map(|r| r.matched_indices)
.unwrap_or_default();
let working_directory_indices = working_directory_match
.map(|r| r.matched_indices)
.unwrap_or_default();
let highlight_indices = ConversationHighlightIndices::new(
title_indices,
initial_query_indices,
working_directory_indices,
);
Some((
ConversationMatchResult {
score: best_score,
highlight_indices,
},
conversation.clone(),
))
}
})
.map(|(match_result, conversation)| MatchedConversation {
conversation,
match_result,
})
}
type SearcherAction = <DataSource as SyncDataSource>::Action;
pub trait ConversationSearcher {
fn search(
&self,
_search_term: &str,
_app: &AppContext,
) -> anyhow::Result<Vec<QueryResult<SearcherAction>>>;
}
#[derive(PartialEq)]
pub enum ConversationType {
All,
Historical,
}
pub struct FuzzyConversationSearcher {
filter: ConversationType,
}
impl FuzzyConversationSearcher {
pub fn new() -> Self {
Self {
filter: ConversationType::All,
}
}
pub fn historical() -> Self {
Self {
filter: ConversationType::Historical,
}
}
pub fn searchable_conversations(&self, app: &AppContext) -> Vec<ConversationNavigationData> {
match self.filter {
ConversationType::Historical => {
ConversationNavigationData::historical_conversations(app)
}
ConversationType::All => ConversationNavigationData::all_conversations(app),
}
}
}
impl ConversationSearcher for FuzzyConversationSearcher {
fn search(
&self,
search_term: &str,
app: &AppContext,
) -> anyhow::Result<Vec<QueryResult<SearcherAction>>> {
let conversations = self.searchable_conversations(app);
Ok(filter_conversations(conversations.as_slice(), search_term)
.map(|matched_conversation| {
ConversationSearchItem::new(ConversationAction::Resume(Box::new(
matched_conversation,
)))
.into()
})
.collect())
}
}
@@ -0,0 +1,440 @@
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
use crate::appearance::Appearance;
use crate::search::command_palette::conversations::search::MatchedConversation;
use crate::search::command_palette::mixer::CommandPaletteItemAction;
use crate::search::command_palette::render_util::render_search_item_icon;
use crate::search::command_palette::view::Action;
use crate::search::item::IconLocation;
use crate::search::result_renderer::ItemHighlightState;
use crate::search::SearchItem;
use crate::ui_components::buttons::icon_button;
use crate::util::time_format::format_approx_duration_from_now;
use ordered_float::OrderedFloat;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use warp_core::ui::color::{blend::Blend, coloru_with_opacity};
use warp_core::ui::icons::Icon;
use warp_core::ui::theme::color::internal_colors;
use warpui::elements::{
AnchorPair, Container, CrossAxisAlignment, Expanded, Fill, Flex, Highlight, MainAxisSize,
MouseStateHandle, OffsetPositioning, OffsetType, ParentElement, ParentOffsetBounds,
PositioningAxis, Stack, Text, XAxisAnchor, YAxisAnchor,
};
use warpui::fonts::{Properties, Weight};
use warpui::ui_components::button::ButtonTooltipPosition;
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::{AppContext, Element, Gradient, SingletonEntity};
/// Information about which action to take once the conversation item is accepted.
#[derive(Debug)]
pub enum ConversationAction {
/// Start a new conversation in the current view.
New,
/// Fork the current active conversation into a new view.
Fork {
conversation_id: AIConversationId,
title: String,
},
/// Resume the matched conversation in its associated view.
Resume(Box<MatchedConversation>),
}
/// Search item to render a conversation within the command palette.
/// When matched_conversation is None, we render this as a new conversation item.
#[derive(Debug)]
pub struct ConversationSearchItem {
action_info: ConversationAction,
action_button_mouse_state: MouseStateHandle,
}
impl ConversationSearchItem {
pub fn new(action_info: ConversationAction) -> Self {
Self {
action_info,
action_button_mouse_state: MouseStateHandle::default(),
}
}
/// Renders the new conversation item for the command palette.
pub fn render_new_conversation_action_item(
&self,
highlight_state: ItemHighlightState,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
Flex::row()
.with_child(
Text::new_inline(
"New conversation",
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
.with_style(Properties::default().weight(Weight::Bold))
.finish(),
)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.finish()
}
pub fn render_fork_conversation_action_item(
&self,
highlight_state: ItemHighlightState,
conversation_title: &str,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let action_title = Text::new_inline(
"Fork current conversation",
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
.with_style(Properties::default().weight(Weight::Bold));
let conversation_title = Text::new_inline(
conversation_title.to_owned(),
appearance.ui_font_family(),
appearance.monospace_font_size() - 2.,
)
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
Flex::column()
.with_child(action_title.finish())
.with_child(conversation_title.finish())
.with_spacing(4.)
.finish()
}
fn render_matched_conversation_item(
&self,
matched_conversation: &MatchedConversation,
highlight_state: ItemHighlightState,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let conversation = matched_conversation.conversation.clone();
let sub_text_font_size = appearance.monospace_font_size() - 2.;
let mut conversation_title_element = Text::new_inline(
conversation.title().to_string(),
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
.with_style(Properties::default().weight(Weight::Bold));
let mut working_directory_element = Text::new_inline(
conversation
.initial_working_directory
.clone()
.unwrap_or_default(),
appearance.ui_font_family(),
sub_text_font_size,
)
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
// When the search query is empty, we only show the conversation's title and working directory.
// Otherwise, we show the conversation's title, initial user query, and working directory.
// We also highlight the indices in those elements that match the search query.
let mut left_container = Flex::column().with_spacing(4.);
if !self.query_is_empty() {
// The first user query that was submitted for this conversation.
let mut initial_query_element = Text::new_inline(
conversation.initial_query.clone().unwrap_or_default(),
appearance.ui_font_family(),
sub_text_font_size,
)
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
// Apply highlights for the search query's matching indices.
let highlight = Highlight::new()
.with_properties(Properties::default().weight(Weight::Bold))
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid());
let highlight_indices = matched_conversation.highlight_indices();
if !highlight_indices.title_indices().is_empty() {
conversation_title_element = conversation_title_element
.with_single_highlight(highlight, highlight_indices.title_indices().clone());
}
if !highlight_indices.initial_query_indices().is_empty() {
initial_query_element = initial_query_element.with_single_highlight(
highlight,
highlight_indices.initial_query_indices().clone(),
);
}
if !highlight_indices.working_directory_indices().is_empty() {
working_directory_element = working_directory_element.with_single_highlight(
highlight,
highlight_indices.working_directory_indices().clone(),
);
}
// Add the conversation title and initial user query to the left container.
left_container = left_container
.with_child(conversation_title_element.finish())
.with_child(initial_query_element.finish());
} else {
// When the search query is empty, we only show the conversation's title and working directory.
left_container = left_container.with_child(conversation_title_element.finish());
}
// In all cases, we show the conversation's working directory last.
left_container = left_container.with_child(working_directory_element.finish());
let last_updated = format_approx_duration_from_now(conversation.last_updated());
let last_updated_element = Container::new(
Text::new_inline(
last_updated,
appearance.ui_font_family(),
sub_text_font_size,
)
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
.finish(),
)
.with_padding_left(8.)
.finish();
let search_item_content = Flex::row()
.with_child(Expanded::new(1.0, left_container.finish()).finish())
.with_child(last_updated_element)
.with_main_axis_size(MainAxisSize::Max)
.finish();
// We only want to show the fork button if the conversation is completed
// (i.e. the agent has finished responding and there are no blocked commands).
let conversation_is_done = BlocklistAIHistoryModel::as_ref(app)
.conversation(&conversation.id())
.map(|c| c.status().is_done())
.unwrap_or(true);
if highlight_state.is_hovered() && conversation_is_done && !cfg!(target_family = "wasm") {
// Base row content (unchanged layout for existing children)
let base_row = Flex::row()
.with_child(Expanded::new(1.0, search_item_content).finish())
.with_main_axis_size(MainAxisSize::Max)
.finish();
// Overlay fork button on the right, positioned absolutely so it doesn't affect the layout.
let fork_button_positioning = OffsetPositioning::from_axes(
PositioningAxis::relative_to_parent(
ParentOffsetBounds::ParentByPosition,
OffsetType::Pixel(0.),
AnchorPair::new(XAxisAnchor::Right, XAxisAnchor::Right),
),
PositioningAxis::relative_to_parent(
ParentOffsetBounds::ParentByPosition,
OffsetType::Pixel(0.),
AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Top),
),
);
// We create a gradient background that is semi-transparent on the left and the item background color on the right.
// The end color is the highlight_bg_color over surface_2 at the given highlight state's opacity.
// The start color is fully transparent.
let base_bg = appearance.theme().surface_2().into_solid();
let end_color = base_bg.blend(&coloru_with_opacity(
Fill::from(appearance.theme().accent()).start_color(),
highlight_state.container_background_opacity(),
));
let start_color = ColorU::new(end_color.r, end_color.g, end_color.b, 0);
let fork_button_tool_tip = appearance
.ui_builder()
.tool_tip("Fork conversation".to_string())
.build();
let fork_button_inner = icon_button(
appearance,
Icon::ArrowSplit,
false,
self.action_button_mouse_state.clone(),
)
.with_hovered_styles(
UiComponentStyles::default()
.set_background(internal_colors::fg_overlay_3(appearance.theme()).into()),
)
.with_clicked_styles(
UiComponentStyles::default()
.set_background(internal_colors::fg_overlay_5(appearance.theme()).into()),
)
.with_tooltip(|| fork_button_tool_tip.finish())
.with_tooltip_position(ButtonTooltipPosition::AboveRight)
.build()
.on_click(move |ctx, _app, _pos| {
ctx.dispatch_typed_action(Action::ResultClicked {
action: CommandPaletteItemAction::ForkConversation {
conversation_id: conversation.id(),
},
});
})
.finish();
// When the fork button itself is hovered, we use a solid background equal to the
// gradient's end color. Otherwise, we use the original gradient.
let is_hovered = self
.action_button_mouse_state
.lock()
.map(|s| s.is_hovered())
.unwrap_or(false);
let fork_button = if is_hovered {
Container::new(fork_button_inner)
.with_background_color(end_color)
.finish()
} else {
Container::new(fork_button_inner)
.with_background_gradient(
vec2f(0.0, 0.0),
vec2f(0.2, 0.0),
Gradient {
start: start_color,
end: end_color,
},
)
.finish()
};
let mut stack = Stack::new().with_child(base_row);
stack.add_positioned_child(fork_button, fork_button_positioning);
stack.finish()
} else {
search_item_content
}
}
fn query_is_empty(&self) -> bool {
match &self.action_info {
ConversationAction::Resume(matched_conversation) => {
// If the score is empty, the query must be empty (otherwise, we would not be showing this item)
matched_conversation.as_ref().match_result.score() == 0
}
ConversationAction::Fork { .. } | ConversationAction::New => {
// We only show these items when the search query is empty.
true
}
}
}
}
impl SearchItem for ConversationSearchItem {
type Action = CommandPaletteItemAction;
fn is_multiline(&self) -> bool {
true
}
fn render_icon(
&self,
highlight_state: ItemHighlightState,
appearance: &Appearance,
) -> Box<dyn Element> {
let (color, icon) = match &self.action_info {
ConversationAction::Resume(..) => (
appearance.theme().foreground().into_solid(),
Icon::Conversation,
),
ConversationAction::New => (appearance.theme().foreground().into_solid(), Icon::Plus),
ConversationAction::Fork { .. } => (
appearance.theme().foreground().into_solid(),
Icon::ArrowSplit,
),
};
render_search_item_icon(appearance, icon, color, highlight_state)
}
fn icon_location(&self, appearance: &Appearance) -> IconLocation {
if matches!(self.action_info, ConversationAction::New) {
IconLocation::Centered
} else {
// The icon has the size of the monospace font, whereas the text has a height of
// `line_height_ratio * font_size`. Offset the icon by this difference so it is rendered
// centered with the text.
let margin_top = (appearance.line_height_ratio() * appearance.monospace_font_size())
- appearance.monospace_font_size();
IconLocation::Top { margin_top }
}
}
fn render_item(
&self,
highlight_state: ItemHighlightState,
app: &AppContext,
) -> Box<dyn Element> {
match &self.action_info {
ConversationAction::Resume(matched_conversation) => self
.render_matched_conversation_item(
matched_conversation.as_ref(),
highlight_state,
app,
),
ConversationAction::New => {
self.render_new_conversation_action_item(highlight_state, app)
}
ConversationAction::Fork { title, .. } => {
self.render_fork_conversation_action_item(highlight_state, title, app)
}
}
}
fn score(&self) -> OrderedFloat<f64> {
let score = match &self.action_info {
ConversationAction::Resume(matched_conversation) => matched_conversation.score() as f64,
ConversationAction::Fork { .. } => f64::NAN,
ConversationAction::New => f64::NAN,
};
OrderedFloat::from(score)
}
fn accept_result(&self) -> Self::Action {
match &self.action_info {
ConversationAction::Resume(matched_conversation) => {
let conversation = &matched_conversation.as_ref().conversation;
CommandPaletteItemAction::NavigateToConversation {
pane_view_locator: conversation.pane_view_locator(),
window_id: conversation.window_id(),
conversation_id: conversation.id(),
terminal_view_id: conversation.terminal_view_id,
}
}
ConversationAction::Fork {
conversation_id, ..
} => CommandPaletteItemAction::ForkConversation {
conversation_id: *conversation_id,
},
ConversationAction::New => CommandPaletteItemAction::NewConversation,
}
}
fn execute_result(&self) -> Self::Action {
self.accept_result()
}
fn accessibility_label(&self) -> String {
match &self.action_info {
ConversationAction::Resume(matched_conversation) => {
format!(
"Conversation: {}",
matched_conversation.as_ref().conversation.title()
)
}
ConversationAction::Fork { title, .. } => {
format!("Fork current conversation ({title})")
}
ConversationAction::New => "New conversation".to_string(),
}
}
fn accessibility_help_message(&self) -> Option<String> {
match &self.action_info {
ConversationAction::Resume(matched_conversation) => Some(format!(
"Press enter to navigate to conversation \"{}\".",
matched_conversation.as_ref().conversation.title()
)),
ConversationAction::Fork { .. } => {
Some("Press enter to fork the current conversation into a new conversation.".into())
}
ConversationAction::New => Some("Press enter to create a new conversation.".into()),
}
}
}
@@ -0,0 +1,127 @@
use crate::ai::{
agent::conversation::AIConversationId, conversation_navigation::ConversationNavigationData,
};
use warpui::{EntityId, WindowId};
#[test]
fn test_conversation_navigation_data_ordering() {
// Create test data with different active states and timestamps
let now = chrono::Local::now();
let one_hour_ago = now - chrono::Duration::hours(1);
let two_hours_ago = now - chrono::Duration::hours(2);
let active_recent = ConversationNavigationData {
id: AIConversationId::new(),
title: "Active Recent".to_string(),
initial_query: None,
last_updated: now,
terminal_view_id: Some(EntityId::new()),
window_id: Some(WindowId::new()),
pane_view_locator: None,
initial_working_directory: None,
latest_working_directory: None,
is_selected: true,
is_closed: false,
server_conversation_token: None,
is_in_active_pane: true,
};
let active_old = ConversationNavigationData {
id: AIConversationId::new(),
title: "Active Old".to_string(),
initial_query: None,
last_updated: two_hours_ago,
terminal_view_id: Some(EntityId::new()),
window_id: Some(WindowId::new()),
pane_view_locator: None,
initial_working_directory: None,
latest_working_directory: None,
is_selected: true,
is_closed: false,
server_conversation_token: None,
is_in_active_pane: true,
};
let inactive_recent = ConversationNavigationData {
id: AIConversationId::new(),
title: "Inactive Recent".to_string(),
initial_query: None,
last_updated: now,
terminal_view_id: Some(EntityId::new()),
window_id: Some(WindowId::new()),
pane_view_locator: None,
initial_working_directory: None,
latest_working_directory: None,
is_selected: false,
is_closed: false,
server_conversation_token: None,
is_in_active_pane: false,
};
let inactive_old = ConversationNavigationData {
id: AIConversationId::new(),
title: "Inactive Old".to_string(),
initial_query: None,
last_updated: one_hour_ago,
terminal_view_id: Some(EntityId::new()),
window_id: Some(WindowId::new()),
pane_view_locator: None,
initial_working_directory: None,
latest_working_directory: None,
is_selected: false,
is_closed: false,
server_conversation_token: None,
is_in_active_pane: false,
};
let historical_recent = ConversationNavigationData {
id: AIConversationId::new(),
title: "Historical Recent".to_string(),
initial_query: None,
last_updated: now,
terminal_view_id: None,
window_id: None,
pane_view_locator: None,
initial_working_directory: None,
latest_working_directory: None,
is_selected: false,
is_closed: false,
server_conversation_token: None,
is_in_active_pane: false,
};
let historical_old = ConversationNavigationData {
id: AIConversationId::new(),
title: "Historical Old".to_string(),
initial_query: None,
last_updated: one_hour_ago,
terminal_view_id: None,
window_id: None,
pane_view_locator: None,
initial_working_directory: None,
latest_working_directory: None,
is_selected: false,
is_closed: false,
server_conversation_token: None,
is_in_active_pane: false,
};
// Test sorting a vector
let mut conversations = [
inactive_old.clone(),
active_old.clone(),
inactive_recent.clone(),
active_recent.clone(),
historical_old.clone(),
historical_recent.clone(),
];
conversations.sort();
assert_eq!(conversations[0].title, "Historical Old");
assert_eq!(conversations[1].title, "Historical Recent");
assert_eq!(conversations[2].title, "Inactive Old");
assert_eq!(conversations[3].title, "Inactive Recent");
assert_eq!(conversations[4].title, "Active Old");
assert_eq!(conversations[5].title, "Active Recent");
}