use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use crate::ChatMessage;
use crate::session::SessionMetadata;
use iced::widget::{
Column, Id, Space, button, column, container, markdown, responsive, row, scrollable, text,
};
use iced::{Alignment, Element, Length, Task};
use iced_fonts::lucide;
use super::ToastMessage;
use super::media_markers;
use super::menus::{ContextMenu, MenuItem};
use super::session_view::preview::{
MAX_PREVIEW_LINES, MessageMeasure, measure_message, re_measure, width_bucket,
};
use super::session_view::{self, SessionEntry, ToolCallEntry};
use super::theme;
use super::widgets;
use super::widgets::selectable_text;
const BUBBLE_BODY_RATIO: f32 = 0.75;
const COLLAPSE_COMMIT_DELAY: Duration = Duration::from_millis(350);
const CARD_TITLE_MAX_CHARS: usize = 32;
struct TranscriptCtx<'a> {
entries: &'a [SessionEntry],
entry_md: &'a [Option<Vec<markdown::Item>>],
expanded: &'a HashSet<(usize, usize)>,
measure_cache: &'a RefCell<HashMap<(usize, usize), MessageMeasure>>,
text_width: f32,
}
#[derive(Debug, Clone)]
pub(crate) enum SessionsMessage {
Refreshed(Vec<SessionMetadata>),
RefreshError(String),
SelectSession(String),
SessionMessages(String, Vec<ChatMessage>),
SessionError(String),
ToggleExpand(usize, usize),
ToggleCancel(usize, usize),
ToggleCommit(usize, usize),
ScrollChanged(scrollable::Viewport),
Escape,
LinkClicked(String),
Toast(ToastMessage),
DeleteSession(String),
SessionDeleted(String, bool),
}
#[derive(Debug, Clone)]
struct CachedSessionItem {
agent_id: String,
role: Option<crate::Role>,
title: String,
msg_count_label: String,
token_label: Option<String>,
timestamp_label: String,
}
pub(crate) struct SessionsState {
sessions: Vec<SessionMetadata>,
pub(crate) load_state: super::common::AsyncLoadState,
selected_session: Option<String>,
entries: Vec<SessionEntry>,
entry_md: Vec<Option<Vec<markdown::Item>>>,
selected_loading: bool,
expanded: HashSet<(usize, usize)>,
pending_collapses: HashSet<(usize, usize)>,
measure_cache: RefCell<HashMap<(usize, usize), MessageMeasure>>,
cached_session_items: Option<Vec<CachedSessionItem>>,
scrollable_id: Id,
auto_scroll_enabled: bool,
}
impl SessionsState {
pub(crate) fn new() -> Self {
Self {
sessions: Vec::new(),
load_state: super::common::AsyncLoadState::new(),
selected_session: None,
entries: Vec::new(),
entry_md: Vec::new(),
selected_loading: false,
expanded: HashSet::new(),
pending_collapses: HashSet::new(),
measure_cache: RefCell::new(HashMap::new()),
cached_session_items: None,
scrollable_id: Id::new("session_transcript_scroll"),
auto_scroll_enabled: false,
}
}
pub(crate) fn refresh() -> Task<SessionsMessage> {
Task::perform(
async {
let store = crate::session::store();
let list = store.list_sessions_with_metadata().await;
Ok::<_, String>(list)
},
|res| match res {
Ok(sessions) => SessionsMessage::Refreshed(sessions),
Err(e) => SessionsMessage::RefreshError(e),
},
)
}
pub(crate) fn update(&mut self, msg: SessionsMessage) -> Task<SessionsMessage> {
match msg {
SessionsMessage::Refreshed(sessions) => {
self.sessions = sessions;
self.rebuild_session_cache();
self.load_state.finish_loading();
Task::none()
}
SessionsMessage::RefreshError(e) => {
self.load_state.fail(e);
Task::none()
}
SessionsMessage::SelectSession(key) => {
self.selected_session = Some(key.clone());
self.selected_loading = true;
self.expanded.clear();
self.pending_collapses.clear();
Task::perform(
async move {
let store = crate::session::store();
let messages = store.load(&key).await;
Ok::<_, String>((key, messages))
},
|res| match res {
Ok((key, messages)) => SessionsMessage::SessionMessages(key, messages),
Err(e) => SessionsMessage::SessionError(e),
},
)
}
SessionsMessage::SessionMessages(key, messages) => {
if self.selected_session.as_deref() == Some(&key) {
self.entries = session_view::build_ledger(&messages);
self.entry_md = session_view::parse_entry_bodies(&self.entries);
self.measure_cache.borrow_mut().clear();
self.pending_collapses.clear();
self.selected_loading = false;
return iced::widget::operation::snap_to_end(self.scrollable_id.clone());
}
Task::none()
}
SessionsMessage::SessionError(e) => {
self.load_state.fail(e);
self.selected_loading = false;
Task::none()
}
SessionsMessage::ScrollChanged(viewport) => {
let bounds = viewport.bounds();
let content = viewport.content_bounds();
let at_bottom = if content.height > bounds.height {
viewport.relative_offset().y >= 0.99
} else {
content.height <= bounds.height
};
self.auto_scroll_enabled = at_bottom;
Task::none()
}
SessionsMessage::ToggleExpand(i, j) => self.handle_toggle_expand(i, j),
SessionsMessage::ToggleCancel(i, j) => {
self.pending_collapses.remove(&(i, j));
Task::none()
}
SessionsMessage::ToggleCommit(i, j) => {
if self.pending_collapses.remove(&(i, j)) {
self.expanded.remove(&(i, j));
}
Task::none()
}
SessionsMessage::DeleteSession(key) => Task::perform(
async move {
crate::jobs::abandon_session_jobs(&key)
.await
.map_err(|e| e.to_string())?;
let store = crate::session::store();
let deleted = store.delete(&key).await.map_err(|e| e.to_string())?;
Ok::<_, String>((key, deleted))
},
|res| match res {
Ok((key, deleted)) => SessionsMessage::SessionDeleted(key, deleted),
Err(e) => SessionsMessage::Toast(ToastMessage::Error(e)),
},
),
SessionsMessage::SessionDeleted(key, deleted) => {
self.sessions.retain(|s| s.agent_id != key);
self.rebuild_session_cache();
if self.selected_session.as_deref() == Some(&key) {
self.clear_selection();
}
if deleted {
Task::done(SessionsMessage::Toast(ToastMessage::Deleted))
} else {
Task::none()
}
}
SessionsMessage::Toast(_) | SessionsMessage::LinkClicked(_) => Task::none(),
SessionsMessage::Escape => {
self.clear_selection();
Task::none()
}
}
}
fn handle_toggle_expand(&mut self, i: usize, j: usize) -> Task<SessionsMessage> {
let key = (i, j);
if self.expanded.contains(&key) {
self.pending_collapses.insert(key);
Task::perform(
async { tokio::time::sleep(COLLAPSE_COMMIT_DELAY).await },
move |()| SessionsMessage::ToggleCommit(i, j),
)
} else {
self.expanded.insert(key);
Task::none()
}
}
fn clear_selection(&mut self) {
self.selected_session = None;
self.entries.clear();
self.entry_md.clear();
self.selected_loading = false;
self.expanded.clear();
self.pending_collapses.clear();
self.auto_scroll_enabled = false;
self.measure_cache.borrow_mut().clear();
}
fn rebuild_session_cache(&mut self) {
let now = chrono::Local::now();
let items: Vec<CachedSessionItem> = self
.sessions
.iter()
.map(|s| {
let role = s
.role
.as_deref()
.and_then(|r| r.parse::<crate::Role>().ok());
let name = s
.user_name
.as_deref()
.filter(|n| !n.is_empty())
.or_else(|| s.workspace_name.as_deref().filter(|n| !n.is_empty()))
.unwrap_or("");
CachedSessionItem {
agent_id: s.agent_id.clone(),
role,
title: crate::util::truncate(name, CARD_TITLE_MAX_CHARS),
msg_count_label: format!("{} msgs", s.message_count),
token_label: s.token_length.map(theme::format_compact_tokens),
timestamp_label: theme::format_relative_time(
&s.last_activity.to_rfc3339(),
now,
),
}
})
.collect();
self.cached_session_items = if items.is_empty() { None } else { Some(items) };
}
#[expect(clippy::too_many_lines)]
pub(crate) fn view(&self) -> Element<'_, SessionsMessage> {
let mut content = column![];
content = widgets::push_error_banner_inset(content, self.load_state.error());
if self.load_state.loading() && !self.load_state.has_loaded() {
content = content.push(widgets::scroll_h_inset(widgets::loading_text()));
} else if self.sessions.is_empty() {
content = content.push(widgets::empty_state_placeholder(
lucide::layout_dashboard::<iced::Theme, iced::Renderer>(),
"No sessions",
theme::TEXT_MUTED,
));
} else {
let mut session_list = Column::new().spacing(theme::SPACE_4);
if let Some(ref cached) = self.cached_session_items {
for item in cached {
let is_selected = self.selected_session.as_deref() == Some(&item.agent_id);
let mut title_row = iced::widget::Row::new()
.spacing(theme::SPACE_6)
.align_y(Alignment::Center);
if let Some(role) = item.role {
title_row = title_row.push(
theme::role_icon(&role)
.size(theme::TEXT_14)
.color(theme::role_badge_color_for(&role).0),
);
}
let title_color = if is_selected {
theme::ACCENT
} else {
theme::TEXT_PRIMARY
};
title_row = title_row.push(
text(item.title.clone())
.size(theme::TEXT_13)
.color(title_color),
);
let sess_row: Element<'_, SessionsMessage> = ContextMenu::new(
container(
column![
row![
button(
container(
column![
title_row,
{
let mut meta_row = iced::widget::Row::new()
.width(Length::Fill)
.push(
text(&item.msg_count_label)
.size(theme::TEXT_11)
.color(theme::TEXT_SECONDARY),
)
.push(Space::new().width(Length::Fill));
if let Some(token) = &item.token_label {
meta_row = meta_row.push(
text(token)
.size(theme::TEXT_11)
.color(theme::TEXT_SECONDARY),
);
}
meta_row
.push(Space::new().width(Length::Fill))
.push(
text(&item.timestamp_label)
.size(theme::TEXT_11)
.color(theme::TEXT_SECONDARY),
)
},
text(&item.agent_id)
.size(theme::TEXT_11)
.color(theme::TEXT_MUTED),
]
.width(Length::Fill)
.spacing(theme::SPACE_2),
)
.padding(theme::PAD_6)
.width(Length::Fill),
)
.width(Length::Fill)
.style(theme::button_text)
.on_press(
SessionsMessage::SelectSession(item.agent_id.clone())
),
]
.width(Length::Fill)
.align_y(Alignment::Center),
]
.spacing(theme::SPACE_2),
)
.style(theme::surface_card_style),
vec![MenuItem::with_icon(
iced_fonts::lucide::advanced_text::trash,
"Delete".into(),
SessionsMessage::DeleteSession(item.agent_id.clone()),
)],
)
.into();
session_list = session_list.push(sess_row);
}
}
let session_scroll =
widgets::vscroll_sized(session_list, Length::Fixed(350.0), Length::Shrink);
let transcript: iced::Element<'_, SessionsMessage> = if self.selected_loading {
iced::widget::container(widgets::loading_text())
.width(Length::Fill)
.height(Length::Fill)
.padding(theme::PAD_16)
.into()
} else if let Some(ref _key) = self.selected_session {
let entries = &self.entries;
let entry_md = &self.entry_md;
let expanded = &self.expanded;
let measure_cache = &self.measure_cache;
let scrollable_id = self.scrollable_id.clone();
responsive(move |size| {
let text_width = ((size.width - 16.0) * BUBBLE_BODY_RATIO - 24.0).max(0.0);
let ctx = TranscriptCtx {
entries,
entry_md,
expanded,
measure_cache,
text_width,
};
container(render_transcript(&ctx, &scrollable_id))
.width(Length::Fill)
.height(Length::Fill)
.padding([theme::PAD_8, 0.0])
.into()
})
.into()
} else {
container(
text("Select a session to view transcript.")
.size(theme::TEXT_13)
.color(theme::TEXT_MUTED),
)
.width(Length::Fill)
.height(Length::Fill)
.padding(theme::PAD_16)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
};
content = content.push(
row![
session_scroll,
Space::new().width(theme::SPACE_12),
transcript
]
.height(Length::Fill),
);
}
container(content)
.width(Length::Fill)
.height(Length::Fill)
.padding(theme::PAGE_PADDING)
.style(theme::base_container_style)
.into()
}
}
fn align_bubble<'a>(
bubble: impl Into<Element<'a, SessionsMessage>>,
assistant: bool,
) -> Element<'a, SessionsMessage> {
let bubble = bubble.into();
if assistant {
row![Space::new().width(Length::FillPortion(1)), bubble].into()
} else {
row![bubble, Space::new().width(Length::FillPortion(1))].into()
}
}
fn author_label<'a>(role: crate::ChatRole, assistant: bool) -> Element<'a, SessionsMessage> {
let name = match role {
crate::ChatRole::System => "System",
crate::ChatRole::User => "User",
crate::ChatRole::Assistant => "Assistant",
crate::ChatRole::Tool => "Tool",
};
let label = text(name).size(theme::TEXT_11).color(theme::TEXT_SECONDARY);
if assistant {
row![Space::new().width(Length::FillPortion(1)), label].into()
} else {
row![label, Space::new().width(Length::FillPortion(1))].into()
}
}
fn bubble_row(
role: crate::ChatRole,
assistant: bool,
body: Column<'_, SessionsMessage>,
) -> Element<'_, SessionsMessage> {
let bubble = container(body)
.padding(theme::PAD_10)
.style(theme::bubble_style(
if assistant {
theme::BG_ELEVATED
} else {
theme::BG_SURFACE
},
Some(theme::TEXT_PRIMARY),
))
.width(Length::FillPortion(3));
column![
author_label(role, assistant),
align_bubble(bubble, assistant)
]
.spacing(theme::SPACE_2)
.into()
}
#[expect(
clippy::float_cmp,
reason = "font sizes are theme constants passed verbatim; exact identity is intended"
)]
fn element_measurement(
measure_cache: &RefCell<HashMap<(usize, usize), MessageMeasure>>,
key: (usize, usize),
text_width: f32,
need_preview: bool,
content: &str,
font_size: f32,
) -> (u32, Option<String>) {
let bucket = width_bucket(text_width);
let content_len = content.len();
let mut cache = measure_cache.borrow_mut();
let cache_hit = cache.get(&key);
let stale = cache_hit.is_none_or(|m| {
m.width_bucket != bucket || m.content_len != content_len || m.font_size != font_size
});
if stale {
let m = match cache_hit {
Some(m)
if m.width_bucket != bucket
&& m.content_len == content_len
&& m.font_size == font_size =>
{
re_measure(m, text_width)
}
_ => measure_message(content, text_width, content_len, font_size),
};
cache.insert(key, m);
}
let m = cache.get(&key).expect("measurement resolved above");
(
m.wrapped_lines,
if need_preview {
m.preview.clone()
} else {
None
},
)
}
fn collapsible_text_block(
collapses: bool,
is_expanded: bool,
preview: Option<String>,
color: iced::Color,
size: f32,
font: iced::Font,
expanded: Element<'_, SessionsMessage>,
) -> Element<'_, SessionsMessage> {
if collapses && !is_expanded {
let preview = preview.expect("a collapsing element always has a preview");
row![
selectable_text(preview, color).size(size).font(font),
lucide::ellipsis::<iced::Theme, iced::Renderer>()
.size(theme::TEXT_11)
.color(theme::TEXT_MUTED),
]
.spacing(theme::SPACE_4)
.align_y(Alignment::Start)
.into()
} else {
expanded
}
}
fn click_toggle(
key: (usize, usize),
active: bool,
el: Element<'_, SessionsMessage>,
) -> Element<'_, SessionsMessage> {
if active {
widgets::click_to_toggle(
el,
SessionsMessage::ToggleExpand(key.0, key.1),
SessionsMessage::ToggleCancel(key.0, key.1),
)
} else {
el
}
}
fn plain_collapsible<'a>(
ctx: &TranscriptCtx<'a>,
key: (usize, usize),
content: &'a str,
color: iced::Color,
) -> Element<'a, SessionsMessage> {
let is_expanded = ctx.expanded.contains(&key);
let (wrapped_lines, preview) = element_measurement(
ctx.measure_cache,
key,
ctx.text_width,
!is_expanded,
content,
theme::MARKDOWN_TEXT_SIZE,
);
let collapses = wrapped_lines > MAX_PREVIEW_LINES;
let el = collapsible_text_block(
collapses,
is_expanded,
preview,
color,
11.0,
theme::FONT_REGULAR,
selectable_text(content, color).size(theme::TEXT_11).into(),
);
click_toggle(key, collapses, el)
}
fn thinking_block<'a>(
ctx: &TranscriptCtx<'a>,
key: (usize, usize),
content: &'a str,
) -> Element<'a, SessionsMessage> {
let is_expanded = ctx.expanded.contains(&key);
let (wrapped_lines, preview) = element_measurement(
ctx.measure_cache,
key,
ctx.text_width,
!is_expanded,
content,
theme::MARKDOWN_TEXT_SIZE,
);
let collapses = wrapped_lines > MAX_PREVIEW_LINES;
let header = row![
lucide::brain::<iced::Theme, iced::Renderer>()
.size(theme::TEXT_11)
.color(theme::TEXT_MUTED),
text("Thinking")
.size(theme::TEXT_11)
.color(theme::TEXT_MUTED),
]
.spacing(theme::SPACE_4)
.align_y(Alignment::Center);
let body = collapsible_text_block(
collapses,
is_expanded,
preview,
theme::TEXT_MUTED,
11.0,
theme::FONT_REGULAR,
selectable_text(content, theme::TEXT_MUTED)
.size(theme::TEXT_11)
.into(),
);
let whole: Element<'a, SessionsMessage> = column![header, body].spacing(theme::SPACE_4).into();
click_toggle(key, collapses, whole)
}
fn body_block<'a>(
ctx: &TranscriptCtx<'a>,
key: (usize, usize),
content: &'a str,
md: Option<&'a [markdown::Item]>,
is_narration: bool,
) -> Element<'a, SessionsMessage> {
let is_expanded = ctx.expanded.contains(&key);
let size = if is_narration {
theme::NARRATION_TEXT_SIZE
} else {
theme::MARKDOWN_TEXT_SIZE
};
let (wrapped_lines, preview) = element_measurement(
ctx.measure_cache,
key,
ctx.text_width,
!is_expanded,
content,
size,
);
let collapses = wrapped_lines > MAX_PREVIEW_LINES;
let font = if is_narration {
theme::FONT_ITALIC
} else {
theme::FONT_REGULAR
};
let expanded: Element<'a, SessionsMessage> = if let Some(md) = md {
let settings = if is_narration {
theme::narration_markdown_settings()
} else {
theme::markdown_settings()
};
media_markers::selectable_markdown_view(md, settings).map(SessionsMessage::LinkClicked)
} else {
selectable_text(content, theme::TEXT_PRIMARY)
.size(size)
.font(font)
.into()
};
let el = collapsible_text_block(
collapses,
is_expanded,
preview,
theme::TEXT_PRIMARY,
size,
font,
expanded,
);
click_toggle(key, collapses, el)
}
fn result_block<'a>(
ctx: &TranscriptCtx<'a>,
key: (usize, usize),
result: &'a str,
) -> Element<'a, SessionsMessage> {
plain_collapsible(ctx, key, result, theme::TEXT_SECONDARY)
}
fn render_tool_round<'a>(
ctx: &TranscriptCtx<'a>,
i: usize,
narration: Option<&'a str>,
reasoning: Option<&'a str>,
calls: &'a [ToolCallEntry],
) -> Element<'a, SessionsMessage> {
let md = ctx.entry_md.get(i).and_then(|m| m.as_deref());
let mut bubble_col = Column::new().spacing(theme::SPACE_4);
if let Some(reasoning) = session_view::promoted_reasoning(narration, reasoning) {
bubble_col = bubble_col.push(body_block(ctx, (i, 0), reasoning, None, true));
} else {
if let Some(reasoning) = reasoning {
bubble_col = bubble_col.push(thinking_block(ctx, (i, 1), reasoning));
}
if let Some(narration) = narration {
bubble_col = bubble_col.push(body_block(ctx, (i, 0), narration, md, true));
}
}
for (j, call) in calls.iter().enumerate() {
let key = (i, 2 + j);
let collapses = call.result.as_deref().is_some_and(|result| {
let (wrapped_lines, _) = element_measurement(
ctx.measure_cache,
key,
ctx.text_width,
false,
result,
theme::MARKDOWN_TEXT_SIZE,
);
wrapped_lines > MAX_PREVIEW_LINES
});
let tool_block = container(session_view::tool_block(
&call.tool,
session_view::ToolBlockView::Full,
))
.width(Length::Fill)
.padding([theme::PAD_2, theme::PAD_4]);
if collapses {
bubble_col = bubble_col.push(click_toggle(key, true, tool_block.into()));
} else {
bubble_col = bubble_col.push(tool_block);
}
match call.result.as_deref() {
Some(result) if !result.is_empty() => {
bubble_col = bubble_col.push(result_block(ctx, key, result));
}
_ => {
bubble_col = bubble_col.push(
row![
lucide::arrow_down_to_line::<iced::Theme, iced::Renderer>()
.size(theme::TEXT_11)
.color(theme::TEXT_MUTED),
text("(no result)")
.size(theme::TEXT_10)
.color(theme::TEXT_SECONDARY),
]
.spacing(theme::SPACE_4)
.align_y(Alignment::Center),
);
}
}
}
bubble_row(crate::ChatRole::Assistant, true, bubble_col)
}
fn render_entry<'a>(ctx: &TranscriptCtx<'a>, i: usize) -> Element<'a, SessionsMessage> {
let md = ctx.entry_md.get(i).and_then(|m| m.as_deref());
let entry = &ctx.entries[i];
match entry {
SessionEntry::Message {
role,
content,
thinking,
} => {
let assistant = matches!(role, crate::ChatRole::Assistant);
let mut bubble_col = Column::new().spacing(theme::SPACE_4);
if let Some(thinking) = thinking {
bubble_col = bubble_col.push(thinking_block(ctx, (i, 1), thinking));
}
if let Some(content) = content {
bubble_col = bubble_col.push(body_block(ctx, (i, 0), content, md, false));
}
if thinking.is_some() || content.is_some() {
bubble_row(*role, assistant, bubble_col)
} else {
author_label(*role, assistant)
}
}
SessionEntry::ToolRound {
narration,
reasoning,
calls,
} => render_tool_round(ctx, i, narration.as_deref(), reasoning.as_deref(), calls),
}
}
fn render_transcript<'a>(
ctx: &TranscriptCtx<'a>,
scrollable_id: &Id,
) -> Element<'a, SessionsMessage> {
if ctx.entries.is_empty() {
return text("No messages in this session.")
.size(theme::TEXT_13)
.color(theme::TEXT_MUTED)
.into();
}
let mut items = Column::new().spacing(widgets::CHAT_VERTICAL_RHYTHM);
for i in 0..ctx.entries.len() {
items = items.push(render_entry(ctx, i));
}
widgets::vscroll_tracked(
items,
Length::Fill,
Length::Fill,
scrollable_id.clone(),
SessionsMessage::ScrollChanged,
)
}