Files
galaxy/app/src/ai_assistant/transcript.rs
T

971 lines
36 KiB
Rust

use markdown_parser::markdown_parser::RUNNABLE_BLOCK_MARKDOWN_LANG;
use markdown_parser::CodeBlockText;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::builder::AnimatedButtonOptions;
use galaxyui::clipboard::ClipboardContent;
use galaxyui::elements::{
Align, Border, ChildAnchor, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox,
Container, CornerRadius, CrossAxisAlignment, DispatchEventResult, EventHandler, Fill, Flex,
FormattedTextElement, HyperlinkUrl, Icon, MainAxisAlignment, MainAxisSize, MouseStateHandle,
ParentAnchor, ParentElement, Radius, SavePosition, ScrollbarWidth, Shrinkable, Stack, Text,
Wrap,
};
use galaxyui::keymap::Keystroke;
use galaxyui::platform::Cursor;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::units::{IntoPixels, Pixels};
use galaxyui::{
AppContext, BlurContext, Element, Entity, FocusContext, ModelHandle, SingletonEntity,
TypedActionView, View, ViewContext, WeakViewHandle,
};
use super::panel::{HEADER_HEIGHT, HEXAGON_ALERT_SVG_PATH};
use super::requests::{RequestStatus, Requests};
use super::utils::{
code_block_position_id, markdown_segments_from_text, render_prepared_response_button,
render_request_limit_info, save_as_workflow_position_id, AssistantTranscriptPart,
CodeBlockIndex, FormattedTranscriptMessage, MarkdownSegment, TranscriptPartSubType,
};
use super::AI_ASSISTANT_SVG_PATH;
use crate::appearance::Appearance;
use crate::send_telemetry_from_ctx;
use crate::server::telemetry::{SaveAsWorkflowModalSource, TelemetryEvent, WarpAIActionType};
use crate::ui_components::blended_colors;
use crate::workspaces::user_workspaces::UserWorkspaces;
const TRANSCRIPT_POSITION_ID: &str = "ai_assistant::transcript";
const TERMINAL_INPUT_SVG_PATH: &str = "bundled/svg/terminal-input.svg";
const USER_ICON_SVG_PATH: &str = "bundled/svg/user.svg";
const SAVE_WORKFLOW_ICON_PATH: &str = "bundled/svg/workflow.svg";
const BODY_FONT_SIZE: f32 = 13.;
const CODE_FONT_SIZE: f32 = 12.;
const WARNING_MESSAGE_FONT_SIZE: f32 = 10.;
const PANEL_LEFT_MARGIN: f32 = 15.;
const DETAILS_BOTTOM_MARGIN: f32 = 12.;
const COPY_BUTTON_SIZE: f32 = 14.;
const TERMINAL_INPUT_BUTTON_SIZE: f32 = 20.;
const SAVE_AS_WORKFLOW_BUTTON_SIZE: f32 = 20.;
const HOW_DO_I_FIX_PROMPT: &str = "How do I fix this?";
const SHOW_EXAMPLES_PROMPT: &str = "Show examples.";
const WHAT_TO_DO_NEXT_PROMPT: &str = "What should I do next?";
const IN_FLIGHT_REQUEST_TEXT: &str = "Generating answer...";
const ACCURACY_NOTICE_TEXT: &str = "AI responses can be inaccurate.";
const MISSING_CONTEXT_NOTICE_TEXT: &str =
"Warp AI might forget earlier answers as conversations get long.";
lazy_static::lazy_static! {
static ref SCROLL_BUFFER_OFFSET_PX: Pixels = (10.).into_pixels();
}
#[derive(Debug, Clone, Default)]
pub struct CodeBlockMouseStateHandles {
pub play_button: MouseStateHandle,
pub play_button_tooltip: MouseStateHandle,
pub copy_button: MouseStateHandle,
pub copy_button_tooltip: MouseStateHandle,
pub save_as_workflow_button: MouseStateHandle,
pub save_as_workflow_button_tooltip: MouseStateHandle,
}
#[derive(Default)]
struct MouseStateHandles {
show_examples_button: MouseStateHandle,
what_to_do_next_button: MouseStateHandle,
how_do_i_fix_button: MouseStateHandle,
}
/// A view to render a Q/A style transcript.
pub struct Transcript {
view_handle: WeakViewHandle<Transcript>,
requests_model: ModelHandle<Requests>,
selected_code_block: Option<CodeBlockIndex>,
clipped_scroll_state: ClippedScrollStateHandle,
mouse_state_handles: MouseStateHandles,
}
#[derive(Debug, Clone)]
pub enum TranscriptAction {
CopyAnswerToClipboard {
transcript_part_index: usize,
},
CopyCodeToClipboard {
code_block_index: CodeBlockIndex,
},
PasteInTerminalInput {
code_block_index: CodeBlockIndex,
},
OpenWorkflowModal(CodeBlockIndex),
ClickedCodeBlock {
code_block_index: CodeBlockIndex,
},
ClickedUrl(HyperlinkUrl),
Keydown(Keystroke),
/// A mouse down event outside of the other clickable elements (e.g. buttons, code blocks, etc.)
MouseDown,
}
pub enum TranscriptEvent {
PasteInTerminalInput { code_block_index: CodeBlockIndex },
FocusEditor,
FocusTranscript,
ClickedCodeBlock,
OpenWorkflowModalWithCommand(String),
}
impl Entity for Transcript {
type Event = TranscriptEvent;
}
impl TypedActionView for Transcript {
type Action = TranscriptAction;
fn handle_action(&mut self, action: &TranscriptAction, ctx: &mut ViewContext<Self>) {
use TranscriptAction::*;
match action {
CopyAnswerToClipboard {
transcript_part_index,
} => {
let answer = self
.requests_model
.as_ref(ctx)
.transcript()
.get(*transcript_part_index)
.map(|p| p.assistant.formatted_message.raw.clone());
if let Some(answer) = answer {
ctx.clipboard().write(ClipboardContent::plain_text(answer));
}
send_telemetry_from_ctx!(
TelemetryEvent::WarpAIAction {
action_type: WarpAIActionType::CopyAnswer
},
ctx
);
}
CopyCodeToClipboard { code_block_index } => {
self.copy_code_to_clipboard(*code_block_index, ctx);
}
PasteInTerminalInput { code_block_index } => {
self.paste_in_terminal_input(*code_block_index, ctx);
}
OpenWorkflowModal(code_block_index) => self.open_workflow_modal(*code_block_index, ctx),
ClickedUrl(url) => {
ctx.open_url(&url.url);
}
ClickedCodeBlock { code_block_index } => {
self.selected_code_block = Some(*code_block_index);
ctx.emit(TranscriptEvent::ClickedCodeBlock);
ctx.notify();
}
Keydown(keystroke) => self.handle_keydown(keystroke, ctx),
MouseDown => {
if self.selected_code_block.is_none() {
ctx.emit(TranscriptEvent::FocusEditor);
} else {
ctx.emit(TranscriptEvent::FocusTranscript);
}
ctx.notify();
}
}
}
}
impl Transcript {
pub fn new(requests_model: &ModelHandle<Requests>, ctx: &mut ViewContext<Self>) -> Self {
ctx.observe(requests_model, |_, _, ctx| ctx.notify());
Self {
view_handle: ctx.handle(),
requests_model: requests_model.to_owned(),
selected_code_block: None,
clipped_scroll_state: Default::default(),
mouse_state_handles: Default::default(),
}
}
fn copy_code_to_clipboard(
&mut self,
code_block_index: CodeBlockIndex,
ctx: &mut ViewContext<Self>,
) {
if let Some(code) = self.code_for_index(code_block_index, ctx) {
ctx.clipboard().write(ClipboardContent::plain_text(code));
}
send_telemetry_from_ctx!(
TelemetryEvent::WarpAIAction {
action_type: WarpAIActionType::CopyCode
},
ctx
);
}
fn paste_in_terminal_input(
&mut self,
code_block_index: CodeBlockIndex,
ctx: &mut ViewContext<Self>,
) {
ctx.emit(TranscriptEvent::PasteInTerminalInput { code_block_index });
send_telemetry_from_ctx!(
TelemetryEvent::WarpAIAction {
action_type: WarpAIActionType::InsertIntoInput
},
ctx
);
}
fn open_workflow_modal(
&mut self,
code_block_index: CodeBlockIndex,
ctx: &mut ViewContext<Self>,
) {
if let Some(code) = self.code_for_index(code_block_index, ctx) {
ctx.emit(TranscriptEvent::OpenWorkflowModalWithCommand(code));
}
send_telemetry_from_ctx!(
TelemetryEvent::SaveAsWorkflowModal {
source: SaveAsWorkflowModalSource::WarpAIPanel
},
ctx
);
}
fn handle_keydown(&mut self, keystroke: &Keystroke, ctx: &mut ViewContext<Self>) {
let Some(selected_block_index) = self.selected_code_block else {
return;
};
if keystroke.key == "down" {
let new_index = self.next_code_block_index(ctx);
if new_index.is_some() {
self.selected_code_block = new_index;
} else if keystroke.cmd {
self.selected_code_block = None;
self.scroll_to_bottom_of_transcript(ctx);
ctx.emit(TranscriptEvent::FocusEditor);
}
ctx.notify();
} else if keystroke.key == "up" {
let new_index = self.previous_code_block_index(ctx);
if new_index.is_some() {
self.selected_code_block = new_index;
ctx.notify();
}
} else if keystroke.cmd && keystroke.key == "c" {
self.copy_code_to_clipboard(selected_block_index, ctx);
} else if keystroke.cmd && keystroke.key == "enter" {
self.paste_in_terminal_input(selected_block_index, ctx);
} else if keystroke.cmd && keystroke.key == "s" {
self.open_workflow_modal(selected_block_index, ctx);
} else if keystroke.key == "escape" {
self.selected_code_block = None;
ctx.emit(TranscriptEvent::FocusEditor);
ctx.notify();
}
// If we took an action on a code block or changed code blocks, let's scroll to it
// so the user knows what's going on.
if let Some(selected_code_block) = self.selected_code_block {
self.scroll_to_code_block(selected_code_block, ctx);
}
}
/// Only scrolls to the code block if it isn't already in the viewport.
fn scroll_to_code_block(
&mut self,
code_block_index: CodeBlockIndex,
ctx: &mut ViewContext<Self>,
) {
let Some(transcript_pos) = ctx.element_position_by_id(TRANSCRIPT_POSITION_ID) else {
return;
};
let Some(code_block_pos) =
ctx.element_position_by_id(code_block_position_id(code_block_index))
else {
return;
};
let current_scroll_top_px = self.clipped_scroll_state.scroll_start();
let viewable_transcript_height_px = transcript_pos.height().into_pixels();
let code_block_start_y_px =
code_block_pos.origin_y().into_pixels() - transcript_pos.origin_y().into_pixels();
let code_block_end_y_px =
code_block_pos.origin_y().into_pixels() + code_block_pos.height().into_pixels();
// We only need to scroll if either the start of the code block is cut off or the end is cut off.
if code_block_start_y_px < Pixels::zero()
|| code_block_end_y_px > viewable_transcript_height_px
{
// In the case that the new scroll top exceeds the max scroll top, the after_layout
// of clipped scrollable will re-adjust accordingly, so this is safe.
self.clipped_scroll_state.scroll_to(
current_scroll_top_px + code_block_start_y_px - *SCROLL_BUFFER_OFFSET_PX,
);
ctx.notify();
}
}
pub fn scroll_to_bottom_of_transcript(&mut self, ctx: &mut ViewContext<Self>) {
// This relies on the fact that the clipped scrollable will recompute
// the scroll_top in after_layout if it exceeds the true max.
self.clipped_scroll_state.scroll_to(f32::MAX.into_pixels());
ctx.notify();
}
fn previous_code_block_index(&self, ctx: &mut ViewContext<Self>) -> Option<CodeBlockIndex> {
let transcript = self.requests_model.as_ref(ctx).transcript();
let selected_code_block_index = self.selected_code_block?;
let transcript_index = selected_code_block_index.transcript_index();
// Try to find the prev code block in the current part.
let found = transcript
.get(transcript_index)
.and_then(|p| p.prev_code_block_index(selected_code_block_index));
// If it's not in the current part, then take the last code block from the closest
// transcript part to this one (in reverse).
found.or_else(|| {
transcript
.get(..transcript_index)?
.iter()
.rev()
.find_map(|part| part.last_code_block_index())
})
}
fn next_code_block_index(&self, ctx: &mut ViewContext<Self>) -> Option<CodeBlockIndex> {
let transcript = self.requests_model.as_ref(ctx).transcript();
let selected_code_block_index = self.selected_code_block?;
let transcript_index = selected_code_block_index.transcript_index();
// Try to find the next code block in the current part.
let found = transcript
.get(transcript_index)
.and_then(|p| p.next_code_block_index(selected_code_block_index));
// If it's not in the current part, then take the first code block from the closest
// transcript part to this one (in sequence).
found.or_else(|| {
transcript
.get(transcript_index + 1..)?
.iter()
.find_map(|part| part.first_code_block_index())
})
}
pub fn select_last_code_block(&mut self, ctx: &mut ViewContext<Self>) {
let transcript = self.requests_model.as_ref(ctx).transcript();
// The last code block will be the last code block in the first transcript part starting from the end.
let code_block_index = transcript
.iter()
.rev()
.find_map(|p| p.last_code_block_index());
self.selected_code_block = code_block_index;
if let Some(new_code_block) = self.selected_code_block {
self.scroll_to_code_block(new_code_block, ctx);
ctx.emit(TranscriptEvent::ClickedCodeBlock);
}
ctx.notify();
}
pub fn clear_selected_block(&mut self, ctx: &mut ViewContext<Self>) {
self.selected_code_block = None;
ctx.notify();
}
pub fn code_for_index(
&self,
code_block_index: CodeBlockIndex,
app: &AppContext,
) -> Option<String> {
let transcript = self.requests_model.as_ref(app).transcript();
transcript
.get(code_block_index.transcript_index())
.and_then(|p| p.code_for_block(code_block_index).map(ToOwned::to_owned))
}
pub fn reset(&mut self, ctx: &mut ViewContext<Self>) {
self.selected_code_block = None;
ctx.notify();
}
}
/// Rendering-related implementation.
impl Transcript {
fn render_code_block_actions(
&self,
code_block_index: CodeBlockIndex,
appearance: &Appearance,
code_block_info: &CodeBlockText,
mouse_state_handles: &CodeBlockMouseStateHandles,
) -> Box<dyn Element> {
let mut buttons = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::End)
.with_main_axis_size(MainAxisSize::Max);
let copy_button = appearance
.ui_builder()
.copy_button(COPY_BUTTON_SIZE, mouse_state_handles.copy_button.clone())
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(TranscriptAction::CopyCodeToClipboard {
code_block_index,
});
})
.with_cursor(Cursor::PointingHand)
.finish();
buttons.add_child(appearance.ui_builder().tool_tip_on_element(
"Copy code to clipboard [Cmd + C]".to_string(),
mouse_state_handles.copy_button_tooltip.clone(),
copy_button,
ParentAnchor::TopRight,
ChildAnchor::BottomRight,
vec2f(0., -5.),
));
if code_block_info.lang.ends_with("sh")
|| code_block_info.lang == RUNNABLE_BLOCK_MARKDOWN_LANG
{
let insert_button = appearance
.ui_builder()
.animated_button(
mouse_state_handles.play_button.clone(),
TERMINAL_INPUT_SVG_PATH,
AnimatedButtonOptions {
size: TERMINAL_INPUT_BUTTON_SIZE,
padding: Some(4.),
color: None,
with_accent_animations: true,
circular: true,
},
)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(TranscriptAction::PasteInTerminalInput {
code_block_index,
});
})
.with_cursor(Cursor::PointingHand)
.finish();
buttons.add_child(
Container::new(appearance.ui_builder().tool_tip_on_element(
"Insert code into terminal input [Cmd + Enter]".to_string(),
mouse_state_handles.play_button_tooltip.clone(),
insert_button,
ParentAnchor::TopRight,
ChildAnchor::BottomRight,
vec2f(0., -5.),
))
.with_margin_left(10.)
.with_margin_bottom(-4.)
.finish(),
);
let save_as_workflow_button = appearance
.ui_builder()
.animated_button(
mouse_state_handles.save_as_workflow_button.clone(),
SAVE_WORKFLOW_ICON_PATH,
AnimatedButtonOptions {
size: SAVE_AS_WORKFLOW_BUTTON_SIZE,
padding: Some(4.),
color: None,
with_accent_animations: true,
circular: true,
},
)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(TranscriptAction::OpenWorkflowModal(code_block_index))
})
.with_cursor(Cursor::PointingHand)
.finish();
buttons.add_child(
SavePosition::new(
Container::new(appearance.ui_builder().tool_tip_on_element(
"Save as workflow [Cmd + S]".to_string(),
mouse_state_handles.save_as_workflow_button_tooltip.clone(),
save_as_workflow_button,
ParentAnchor::TopRight,
ChildAnchor::BottomRight,
vec2f(0., -5.),
))
.with_margin_left(2.)
.with_margin_bottom(-4.)
.finish(),
&save_as_workflow_position_id(code_block_index),
)
.finish(),
);
}
buttons.finish()
}
fn render_assistant_answer(
&self,
transcript_part_index: usize,
part: &AssistantTranscriptPart,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let background_color = theme.surface_2().into_solid();
let icon = if part.is_error {
ConstrainedBox::new(
Icon::new(HEXAGON_ALERT_SVG_PATH, appearance.theme().ui_error_color()).finish(),
)
.with_height(18.)
.with_width(18.)
.finish()
} else {
ConstrainedBox::new(
Icon::new(
AI_ASSISTANT_SVG_PATH,
theme.main_text_color(background_color.into()),
)
.finish(),
)
.with_height(16.)
.with_width(16.)
.finish()
};
let bottom_right_element = part.copy_all_tooltip_and_button_mouse_handles.clone().map(
|(tooltip_handle, button_handle)| {
let copy_button = appearance
.ui_builder()
.copy_button(16., button_handle)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(TranscriptAction::CopyAnswerToClipboard {
transcript_part_index,
})
})
.with_cursor(Cursor::PointingHand)
.finish();
appearance.ui_builder().tool_tip_on_element(
"Copy answer to clipboard".to_string(),
tooltip_handle,
copy_button,
ParentAnchor::TopRight,
ChildAnchor::BottomRight,
vec2f(0., -5.),
)
},
);
self.render_message(
&part.formatted_message,
background_color,
icon,
bottom_right_element,
appearance,
)
}
fn render_user_prompt(
&self,
dialogue: &FormattedTranscriptMessage,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let background_color = theme.surface_1().into_solid();
let icon = ConstrainedBox::new(
Icon::new(
USER_ICON_SVG_PATH,
theme.main_text_color(background_color.into()),
)
.finish(),
)
.with_height(16.)
.with_width(16.)
.finish();
self.render_message(dialogue, background_color, icon, None, appearance)
}
/// Renders a single message (whether that be a user's prompt or assistant's answer).
fn render_message(
&self,
dialogue: &FormattedTranscriptMessage,
background_color: ColorU,
icon: Box<dyn Element>,
bottom_right_element: Option<Box<dyn Element>>,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let inline_code_bg_color = appearance.theme().surface_3().into_solid();
let body = if let Some(parts) = &dialogue.markdown {
let mut column = Flex::column();
for part in parts {
let column_part = match part {
MarkdownSegment::Other {
formatted_text,
highlighted_hyperlink,
} => FormattedTextElement::new(
formatted_text.to_owned(),
BODY_FONT_SIZE,
appearance.ui_font_family(),
appearance.monospace_font_family(),
theme.main_text_color(theme.surface_2()).into_solid(),
highlighted_hyperlink.clone(),
)
.with_inline_code_properties(
Some(theme.nonactive_ui_text_color().into()),
Some(inline_code_bg_color),
)
.register_default_click_handlers(move |url, ctx, _| {
ctx.dispatch_typed_action(TranscriptAction::ClickedUrl(url));
})
.finish(),
MarkdownSegment::CodeBlock {
index,
code,
mouse_state_handles,
} => {
let actions = self.render_code_block_actions(
*index,
appearance,
code,
mouse_state_handles,
);
let code = code.code.clone();
let (border_fill, border_width, padding) =
if self.selected_code_block == Some(*index) {
(appearance.theme().accent(), 1.5, 11.5)
} else {
(appearance.theme().outline(), 1., 12.)
};
let code_block_index = *index;
EventHandler::new(
Container::new(
SavePosition::new(
Container::new(
Flex::column()
.with_child(
appearance
.ui_builder()
.wrappable_text(code, true)
.with_style(UiComponentStyles {
font_family_id: Some(
appearance.monospace_font_family(),
),
font_size: Some(CODE_FONT_SIZE),
..Default::default()
})
.build()
.with_margin_bottom(10.)
.finish(),
)
.with_child(actions)
.finish(),
)
.with_uniform_padding(padding)
.with_border(
Border::all(border_width).with_border_fill(border_fill),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
.finish(),
&code_block_position_id(code_block_index),
)
.finish(),
)
.with_margin_top(10.)
.with_margin_bottom(10.)
.finish(),
)
.on_left_mouse_down(move |ctx, _, _| {
ctx.dispatch_typed_action(TranscriptAction::ClickedCodeBlock {
code_block_index,
});
DispatchEventResult::StopPropagation
})
.finish()
}
};
column.add_child(column_part);
}
column.finish()
} else {
// If we don't have the markdown representation, just render it as basic text.
appearance
.ui_builder()
.wrappable_text(dialogue.raw.to_owned(), true)
.with_style(UiComponentStyles {
font_size: Some(BODY_FONT_SIZE),
..Default::default()
})
.build()
.finish()
};
let mut final_col = Flex::column().with_child(body);
if let Some(bottom_right_element) = bottom_right_element {
final_col.add_child(
Align::new(
Container::new(bottom_right_element)
.with_margin_top(16.)
.finish(),
)
.right()
.finish(),
);
}
let row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_child(
Container::new(icon)
.with_margin_right(12.)
.with_margin_top(3.)
.finish(),
)
.with_child(Shrinkable::new(1., Container::new(final_col.finish()).finish()).finish());
Container::new(row.finish())
.with_background_color(background_color)
.with_padding_left(PANEL_LEFT_MARGIN)
.with_padding_top(16.)
.with_padding_bottom(16.)
.with_padding_right(20.)
.finish()
}
fn render_prepared_responses(&self, appearance: &Appearance) -> Box<dyn Element> {
Wrap::row()
.with_run_spacing(10.)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_child(render_prepared_response_button(
appearance,
self.mouse_state_handles.what_to_do_next_button.clone(),
None,
Some(8.),
WHAT_TO_DO_NEXT_PROMPT,
))
.with_child(
Container::new(render_prepared_response_button(
appearance,
self.mouse_state_handles.show_examples_button.clone(),
None,
Some(8.),
SHOW_EXAMPLES_PROMPT,
))
.with_margin_left(10.)
.with_margin_right(10.)
.finish(),
)
.with_child(render_prepared_response_button(
appearance,
self.mouse_state_handles.how_do_i_fix_button.clone(),
None,
Some(8.),
HOW_DO_I_FIX_PROMPT,
))
.finish()
}
fn render_warning_message(&self, message: String, appearance: &Appearance) -> Box<dyn Element> {
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_child(
Text::new_inline(
message,
appearance.ui_font_family(),
WARNING_MESSAGE_FONT_SIZE,
)
.with_color(blended_colors::text_sub(
appearance.theme(),
appearance.theme().background(),
))
.finish(),
)
.finish()
}
}
impl View for Transcript {
fn ui_name() -> &'static str {
"AIAssistantTranscript"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let transcript = self.requests_model.as_ref(app).transcript();
let request_status = self.requests_model.as_ref(app).request_status();
let num_remaining_reqs = self.requests_model.as_ref(app).num_remaining_reqs();
let mut blocks = Flex::column();
for (index, part) in transcript.iter().enumerate() {
blocks.add_child(self.render_user_prompt(&part.user, appearance));
blocks.add_child(self.render_assistant_answer(index, &part.assistant, appearance));
}
if let RequestStatus::InFlight { request, .. } = request_status {
blocks.add_child(self.render_user_prompt(request, appearance));
let transcript_part_index = transcript.len();
let in_flight_request_markdown = markdown_segments_from_text(
transcript_part_index,
TranscriptPartSubType::Answer,
IN_FLIGHT_REQUEST_TEXT,
);
blocks.add_child(self.render_assistant_answer(
transcript_part_index,
&AssistantTranscriptPart {
is_error: false,
copy_all_tooltip_and_button_mouse_handles: None,
formatted_message: FormattedTranscriptMessage {
markdown: in_flight_request_markdown,
raw: IN_FLIGHT_REQUEST_TEXT.to_owned(),
},
},
appearance,
));
}
if !transcript.is_empty() && matches!(request_status, RequestStatus::NotInFlight) {
// Only show the prepared responses if the last response wasn't an error
// and the user still has remaining requests.
if !transcript.last().is_none_or(|p| p.assistant.is_error) && num_remaining_reqs > 0 {
blocks.add_child(
Container::new(self.render_prepared_responses(appearance))
.with_margin_top(15.)
.finish(),
);
}
let is_custom_llm_enabled: bool = UserWorkspaces::as_ref(app)
.current_team()
.is_some_and(|team| team.is_custom_llm_enabled());
if !is_custom_llm_enabled {
blocks.add_child(
Container::new(render_request_limit_info(
&self.requests_model,
app,
appearance,
))
.with_margin_top(15.)
.finish(),
);
}
let current_transcript_summarized = self
.requests_model
.as_ref(app)
.current_transcript_summarized();
blocks.add_child(
Container::new(
self.render_warning_message(ACCURACY_NOTICE_TEXT.to_string(), appearance),
)
.with_margin_top(DETAILS_BOTTOM_MARGIN)
.with_margin_bottom(if current_transcript_summarized {
DETAILS_BOTTOM_MARGIN / 2.
} else {
DETAILS_BOTTOM_MARGIN
})
.finish(),
);
if current_transcript_summarized {
blocks.add_child(
Container::new(self.render_warning_message(
MISSING_CONTEXT_NOTICE_TEXT.to_string(),
appearance,
))
.with_margin_bottom(DETAILS_BOTTOM_MARGIN)
.finish(),
);
}
}
// Note: we don't render a scrollbar because the gutter makes the segmented transcript
// look "broken".
let transcript = SavePosition::new(
ClippedScrollable::vertical(
self.clipped_scroll_state.clone(),
blocks.finish(),
ScrollbarWidth::None,
theme.disabled_text_color(theme.background()).into(),
theme.main_text_color(theme.background()).into(),
Fill::None,
)
.with_padding_end(0.)
.with_padding_start(0.)
.finish(),
TRANSCRIPT_POSITION_ID,
)
.finish();
let mut navigatable_transcript =
EventHandler::new(transcript).on_left_mouse_down(|ctx, _, _| {
ctx.dispatch_typed_action(TranscriptAction::MouseDown);
DispatchEventResult::StopPropagation
});
// Only handle keydown events when a code block is selected and the transcript is focused.
let is_focused = self
.view_handle
.upgrade(app)
.is_some_and(|v| v.is_focused(app));
if self.selected_code_block.is_some() && is_focused {
navigatable_transcript = navigatable_transcript.on_keydown(|ctx, _, keystroke| {
ctx.dispatch_typed_action(TranscriptAction::Keydown(keystroke.to_owned()));
DispatchEventResult::StopPropagation
});
}
let mut stack = Stack::new();
stack.add_child(
Container::new(navigatable_transcript.finish())
.with_padding_top(HEADER_HEIGHT)
.finish(),
);
stack.finish()
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
// Force a re-render to reflect the fact that this view is now focused.
ctx.notify();
}
}
fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext<Self>) {
if blur_ctx.is_self_blurred() {
// Force a re-render to reflect the fact that this view is now blurred.
ctx.notify();
}
}
}
#[cfg(test)]
#[path = "transcript_tests.rs"]
mod transcript_tests;