Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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(§ion) {
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user