use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use crate::ChatMessage;
use crate::Workspace;
use crate::session::SessionMetadata;
use iced::widget::{
Column, Id, Space, button, column, container, responsive, row, scrollable, text,
};
use iced::{Alignment, Element, Length, Task};
use iced_fonts::lucide;
use std::path::{Path, PathBuf};
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, ParsedBody, SessionEntry, ToolCallEntry};
use super::theme;
use super::widgets::{self, BubbleSide, align_bubble, 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;
const CARD_TITLE_BRANCH_MAX_CHARS: usize = CARD_TITLE_MAX_CHARS / 2;
struct TranscriptCtx<'a> {
entries: &'a [SessionEntry],
entry_md: &'a [Option<ParsedBody>],
workspace_root: Option<PathBuf>,
expanded: &'a HashSet<(usize, usize)>,
measure_cache: &'a RefCell<HashMap<(usize, usize), MessageMeasure>>,
text_width: f32,
truncatable: bool,
}
#[derive(Debug, Clone)]
pub(crate) enum SessionsMessage {
Refreshed(Vec<SessionMetadata>),
RefreshError(String),
SelectSession(String),
SessionMessages(String, Vec<ChatMessage>),
SessionError {
key: String,
error: String,
},
ToggleExpand(usize, usize),
ToggleCancel(usize, usize),
ToggleCommit(usize, usize),
ScrollChanged(scrollable::Viewport),
Escape,
LinkClicked(String),
RevealFile {
target: String,
workspace: Option<PathBuf>,
},
FileRevealed(Result<(), String>),
Toast(ToastMessage),
DeleteSession(String),
SessionDeleted(String, bool),
TruncateSession(usize),
SessionTruncated {
key: String,
entry: usize,
deleted: usize,
},
RuntimeChanged,
LiveSessionsRead(Result<Vec<SessionMetadata>, String>),
}
#[derive(Debug, Clone)]
struct CachedSessionItem {
agent_id: String,
role: Option<crate::Role>,
title: String,
workspace_name: Option<String>,
custom_role: Option<String>,
custom_identifier: Option<String>,
msg_count_label: String,
token_label: Option<String>,
timestamp_label: String,
}
impl CachedSessionItem {
fn display_title(&self, workspaces: &HashMap<String, Workspace>) -> String {
match (self.role, self.custom_role.as_deref()) {
(Some(crate::Role::Custom), Some(name)) => {
custom_role_title(name, self.custom_identifier.as_deref())
}
(Some(crate::Role::Worker), None) => match self.custom_identifier.as_deref() {
Some(branch) => format!("Worker{}", branch_suffix(branch)),
None => self.title.clone(),
},
(Some(role @ (crate::Role::Manager | crate::Role::Maintainer)), _) => self
.workspace_name
.as_deref()
.and_then(|name| workspaces.get(name))
.map_or_else(
|| role.display_label().to_string(),
|ws| crate::util::truncate(&ws.name, CARD_TITLE_MAX_CHARS),
),
_ => self.title.clone(),
}
}
}
fn branch_suffix(identifier: &str) -> String {
format!(
" · {}",
crate::util::truncate(identifier, CARD_TITLE_BRANCH_MAX_CHARS)
)
}
fn custom_role_title(name: &str, identifier: Option<&str>) -> String {
let Some(identifier) = identifier else {
return crate::util::truncate(name, CARD_TITLE_MAX_CHARS);
};
let suffix = branch_suffix(identifier);
let room = CARD_TITLE_MAX_CHARS - suffix.chars().count();
format!("{}{suffix}", crate::util::truncate(name, room))
}
fn workspace_root_for(name: &str, workspaces: &HashMap<String, Workspace>) -> Option<PathBuf> {
if let Some(user) = crate::users::personal_user_name(name) {
return Some(crate::users::personal_workspace_path(user));
}
workspaces
.get(name)
.map(|ws| PathBuf::from(&ws.path))
.or_else(|| Path::new(name).is_absolute().then(|| PathBuf::from(name)))
}
pub(crate) struct SessionsState {
sessions: Vec<SessionMetadata>,
load_state: super::common::AsyncLoadState,
selected_session: Option<String>,
entries: Vec<SessionEntry>,
entry_md: Vec<Option<ParsedBody>>,
workspace_root: Option<PathBuf>,
entry_rows: Option<Vec<usize>>,
selected_loading: bool,
selected_error: Option<String>,
expanded: HashSet<(usize, usize)>,
pending_collapses: HashSet<(usize, usize)>,
measure_cache: RefCell<HashMap<(usize, usize), MessageMeasure>>,
cached_session_items: Option<Vec<CachedSessionItem>>,
membership_load_in_flight: bool,
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(),
workspace_root: None,
entry_rows: None,
selected_loading: false,
selected_error: None,
expanded: HashSet::new(),
pending_collapses: HashSet::new(),
measure_cache: RefCell::new(HashMap::new()),
cached_session_items: None,
membership_load_in_flight: false,
scrollable_id: Id::new("session_transcript_scroll"),
auto_scroll_enabled: false,
}
}
pub(crate) fn refresh() -> Task<SessionsMessage> {
Task::perform(
async move {
let store = crate::session::store();
store
.list_sessions_with_metadata_checked()
.await
.map_err(|e| e.to_string())
},
|res| match res {
Ok(sessions) => SessionsMessage::Refreshed(sessions),
Err(e) => SessionsMessage::RefreshError(e),
},
)
}
#[expect(
clippy::too_many_lines,
reason = "one arm per transcript interaction; the per-session selection bookkeeping is only readable inline"
)]
pub(crate) fn update(
&mut self,
msg: SessionsMessage,
workspaces: &HashMap<String, Workspace>,
) -> Task<SessionsMessage> {
match msg {
SessionsMessage::Refreshed(sessions) => {
self.sessions = sessions;
self.membership_load_in_flight = false;
if self.adopt_workspace_root(workspaces) {
self.entry_md = session_view::parse_entry_bodies(
&self.entries,
self.workspace_root.as_deref(),
);
}
self.rebuild_session_cache();
self.load_state.clear_error();
self.load_state.finish_loading();
self.refresh_live_membership()
}
SessionsMessage::RefreshError(e) => {
self.load_state.fail(e);
Task::none()
}
SessionsMessage::SelectSession(key) => {
self.selected_session = Some(key.clone());
self.selected_loading = true;
self.selected_error = None;
self.expanded.clear();
self.pending_collapses.clear();
Task::perform(
async move {
let store = crate::session::store();
match store.load_checked(&key).await {
Ok(messages) => Ok::<_, (String, String)>((key, messages)),
Err(e) => Err((key, e.to_string())),
}
},
|res| match res {
Ok((key, messages)) => SessionsMessage::SessionMessages(key, messages),
Err((key, error)) => SessionsMessage::SessionError { key, error },
},
)
}
SessionsMessage::SessionMessages(key, messages) => {
if self.selected_session.as_deref() == Some(&key) {
let (entries, rows) = session_view::build_ledger_with_sources(&messages);
self.entries = entries;
self.entry_rows = Some(rows);
self.adopt_workspace_root(workspaces);
self.entry_md = session_view::parse_entry_bodies(
&self.entries,
self.workspace_root.as_deref(),
);
self.measure_cache.borrow_mut().clear();
self.pending_collapses.clear();
self.selected_loading = false;
self.selected_error = None;
return iced::widget::operation::snap_to_end(self.scrollable_id.clone());
}
Task::none()
}
SessionsMessage::SessionError { key, error } => {
if self.selected_session.as_deref() == Some(&key) {
self.selected_error = Some(error);
self.selected_loading = false;
}
Task::none()
}
SessionsMessage::ScrollChanged(viewport) => {
self.auto_scroll_enabled = viewport.content_bounds().height
<= viewport.bounds().height
|| viewport.relative_offset().y >= 0.99;
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::TruncateSession(entry) => {
let Some(key) = self.selected_session.clone() else {
return Task::none();
};
let Some(row) = self
.entry_rows
.as_ref()
.and_then(|rows| rows.get(entry).copied())
else {
return Task::none();
};
Task::perform(
async move {
let deleted = crate::session::store()
.truncate_from(&key, row)
.await
.map_err(|e| e.to_string())?;
Ok::<_, String>((key, entry, deleted))
},
|res| match res {
Ok((key, entry, deleted)) => SessionsMessage::SessionTruncated {
key,
entry,
deleted,
},
Err(e) => SessionsMessage::Toast(ToastMessage::Error(e)),
},
)
}
SessionsMessage::SessionTruncated {
key,
entry,
deleted,
} => {
if deleted > 0 && self.selected_session.as_deref() == Some(key.as_str()) {
self.entries.truncate(entry);
self.entry_md.truncate(entry);
if let Some(rows) = self.entry_rows.as_mut() {
rows.truncate(entry);
}
self.expanded.retain(|(i, _)| *i < entry);
self.pending_collapses.retain(|(i, _)| *i < entry);
self.measure_cache
.borrow_mut()
.retain(|(i, _), _| *i < entry);
if let Some(session) = self.sessions.iter_mut().find(|s| s.agent_id == key) {
session.message_count = session.message_count.saturating_sub(deleted);
}
self.rebuild_session_cache();
}
Task::none()
}
SessionsMessage::RuntimeChanged => self.on_runtime_changed(workspaces),
SessionsMessage::LiveSessionsRead(result) => {
self.membership_load_in_flight = false;
match result {
Ok(sessions) => self.absorb_live_sessions(sessions),
Err(error) => {
tracing::debug!(error = %error, "live session membership read failed");
}
}
Task::none()
}
SessionsMessage::Toast(_) | SessionsMessage::LinkClicked(_) => Task::none(),
SessionsMessage::RevealFile { target, workspace } => Task::perform(
async move { media_markers::reveal_file_marker(&target, workspace.as_deref()) },
SessionsMessage::FileRevealed,
),
SessionsMessage::FileRevealed(result) => match result {
Ok(()) => Task::none(),
Err(error) => Task::done(SessionsMessage::Toast(ToastMessage::Error(error))),
},
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.entry_rows = None;
self.workspace_root = None;
self.selected_loading = false;
self.selected_error = None;
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),
workspace_name: s.workspace_name.clone().filter(|n| !n.is_empty()),
custom_role: s.custom_role.clone(),
custom_identifier: s.custom_identifier.clone(),
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) };
}
fn on_runtime_changed(
&mut self,
workspaces: &HashMap<String, Workspace>,
) -> Task<SessionsMessage> {
self.rebuild_session_cache();
Task::batch([
self.merge_selected_transcript(workspaces),
self.refresh_live_membership(),
])
}
fn refresh_live_membership(&mut self) -> Task<SessionsMessage> {
if self.membership_load_in_flight || !self.load_state.has_loaded() {
return Task::none();
}
let listed: HashSet<&str> = self.sessions.iter().map(|s| s.agent_id.as_str()).collect();
let missing: Vec<String> = crate::agent::registry::AGENT_REGISTRY
.list()
.into_iter()
.map(|handle| handle.agent_id)
.filter(|id| !listed.contains(id.as_str()))
.collect();
if missing.is_empty() {
return Task::none();
}
self.membership_load_in_flight = true;
Task::perform(
async move {
crate::session::store()
.list_sessions_with_metadata_for_ids(&missing)
.await
.map_err(|e| e.to_string())
},
SessionsMessage::LiveSessionsRead,
)
}
fn absorb_live_sessions(&mut self, sessions: Vec<SessionMetadata>) {
let listed: HashSet<&str> = self.sessions.iter().map(|s| s.agent_id.as_str()).collect();
let added: Vec<SessionMetadata> = sessions
.into_iter()
.filter(|s| !listed.contains(s.agent_id.as_str()))
.collect();
if added.is_empty() {
return;
}
self.sessions.extend(added);
self.rebuild_session_cache();
}
fn selected_workspace_root(&self, workspaces: &HashMap<String, Workspace>) -> Option<PathBuf> {
self.selected_session
.as_deref()
.and_then(|key| self.sessions.iter().find(|session| session.agent_id == key))
.and_then(|session| session.workspace_name.as_deref())
.and_then(|name| workspace_root_for(name, workspaces))
}
fn adopt_workspace_root(&mut self, workspaces: &HashMap<String, Workspace>) -> bool {
let root = self.selected_workspace_root(workspaces);
if root == self.workspace_root {
return false;
}
self.workspace_root = root;
self.measure_cache.borrow_mut().clear();
true
}
fn merge_selected_transcript(
&mut self,
workspaces: &HashMap<String, Workspace>,
) -> Task<SessionsMessage> {
let Some(ref key) = self.selected_session else {
return Task::none();
};
if self.selected_loading {
return Task::none();
}
let Some(snapshot) = crate::session::TRANSCRIPT_REGISTRY.snapshot(key) else {
return Task::none();
};
let live = session_view::build_ledger(&snapshot.history);
let root_changed = self.adopt_workspace_root(workspaces);
let common = if root_changed {
0
} else {
common_entry_prefix(&self.entries, &live)
};
if common == self.entries.len() && common == live.len() {
return Task::none();
}
self.measure_cache
.borrow_mut()
.retain(|(i, _), _| *i < common);
self.entries = live;
self.entry_rows = None;
self.entry_md.truncate(common);
self.entry_md.extend(session_view::parse_entry_bodies(
&self.entries[common..],
self.workspace_root.as_deref(),
));
if self.auto_scroll_enabled {
iced::widget::operation::snap_to_end(self.scrollable_id.clone())
} else {
Task::none()
}
}
#[expect(clippy::too_many_lines)]
pub(crate) fn view(
&self,
workspaces: &HashMap<String, Workspace>,
) -> 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() {
if self.load_state.error().is_none() {
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()
.width(Length::Fill)
.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.display_title(workspaces))
.size(theme::TEXT_13)
.color(title_color),
);
if crate::agent::registry::AGENT_REGISTRY.contains(item.agent_id.as_str()) {
title_row = title_row
.push(Space::new().width(Length::Fill))
.push(super::spinner(theme::TEXT_14));
}
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 workspace_root = self.workspace_root.clone();
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(err) = self.selected_error.as_deref() {
container(widgets::error_banner_fill(err))
.width(Length::Fill)
.height(Length::Fill)
.padding(theme::PAD_16)
.into()
} else if self.selected_session.is_some() {
let truncatable = self.entry_rows.is_some()
&& self
.selected_session
.as_deref()
.is_some_and(|key| !crate::agent::registry::AGENT_REGISTRY.contains(key));
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,
workspace_root: workspace_root.clone(),
expanded,
measure_cache,
text_width,
truncatable,
};
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),
);
}
widgets::page(content)
}
}
fn common_entry_prefix(old: &[SessionEntry], new: &[SessionEntry]) -> usize {
old.iter().zip(new).take_while(|(a, b)| a == b).count()
}
fn author_label<'a>(role: crate::ChatRole) -> Option<Element<'a, SessionsMessage>> {
let name = match role {
crate::ChatRole::System => "System",
crate::ChatRole::User => "User",
crate::ChatRole::Tool => "Tool",
crate::ChatRole::Assistant => return None,
};
let label = text(name).size(theme::TEXT_11).color(theme::TEXT_SECONDARY);
Some(align_bubble(label, BubbleSide::Left))
}
fn bubble_row(
role: crate::ChatRole,
body: Column<'_, SessionsMessage>,
menu: Option<MenuItem<SessionsMessage>>,
) -> Element<'_, SessionsMessage> {
let assistant = matches!(role, crate::ChatRole::Assistant);
let side = if assistant {
BubbleSide::Right
} else {
BubbleSide::Left
};
let bubble: Element<'_, SessionsMessage> = 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))
.into();
let bubble: Element<'_, SessionsMessage> = match menu {
Some(item) => ContextMenu::new(bubble, vec![item]).into(),
None => bubble,
};
let mut row = Column::new().spacing(theme::SPACE_2);
if let Some(label) = author_label(role) {
row = row.push(label);
}
row.push(align_bubble(bubble, side)).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,
markers: &media_markers::MarkerContext<'_>,
) -> (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, markers, 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 marker_context<'a>(
ctx: &'a TranscriptCtx<'_>,
refusals: bool,
) -> media_markers::MarkerContext<'a> {
media_markers::MarkerContext {
workspace: ctx.workspace_root.as_deref(),
refusals,
}
}
fn collapse_state(
ctx: &TranscriptCtx<'_>,
key: (usize, usize),
content: &str,
font_size: f32,
refusals: bool,
) -> (bool, bool, Option<String>) {
let is_expanded = ctx.expanded.contains(&key);
let (wrapped_lines, preview) = element_measurement(
ctx.measure_cache,
key,
ctx.text_width,
!is_expanded,
content,
font_size,
&marker_context(ctx, refusals),
);
(is_expanded, wrapped_lines > MAX_PREVIEW_LINES, preview)
}
fn plain_collapsible<'a>(
ctx: &TranscriptCtx<'a>,
key: (usize, usize),
content: &'a str,
color: iced::Color,
) -> Element<'a, SessionsMessage> {
let (is_expanded, collapses, preview) =
collapse_state(ctx, key, content, theme::MARKDOWN_TEXT_SIZE, false);
let el = collapsible_text_block(
collapses,
is_expanded,
preview,
color,
theme::TEXT_11,
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, collapses, preview) =
collapse_state(ctx, key, content, theme::MARKDOWN_TEXT_SIZE, false);
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,
theme::TEXT_11,
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 ParsedBody>,
is_narration: bool,
) -> Element<'a, SessionsMessage> {
let size = if is_narration {
theme::NARRATION_TEXT_SIZE
} else {
theme::MARKDOWN_TEXT_SIZE
};
let refusals = md.is_some_and(|parsed| parsed.refusals);
let (is_expanded, collapses, preview) = collapse_state(ctx, key, content, size, refusals);
let font = if is_narration {
theme::FONT_ITALIC
} else {
theme::FONT_REGULAR
};
let expanded: Element<'a, SessionsMessage> = if let Some(parsed) = md {
let settings = if is_narration {
theme::narration_markdown_settings()
} else {
theme::markdown_settings()
};
let workspace = ctx.workspace_root.clone();
media_markers::selectable_markdown_view(&parsed.items, settings, &parsed.files).map(
move |url| match media_markers::file_link_target(&url) {
Some(target) => SessionsMessage::RevealFile {
target: target.to_string(),
workspace: workspace.clone(),
},
None => SessionsMessage::LinkClicked(url),
},
)
} 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_ref());
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,
&marker_context(ctx, false),
);
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, bubble_col, None)
}
fn render_entry<'a>(ctx: &TranscriptCtx<'a>, i: usize) -> Option<Element<'a, SessionsMessage>> {
let entry = &ctx.entries[i];
match entry {
SessionEntry::Message {
role,
content,
thinking,
} => {
if thinking.is_none() && content.is_none() {
return author_label(*role);
}
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 {
let md = ctx.entry_md.get(i).and_then(Option::as_ref);
bubble_col = bubble_col.push(body_block(ctx, (i, 0), content, md, false));
}
let menu = (ctx.truncatable && matches!(role, crate::ChatRole::User))
.then(|| MenuItem::new("Truncate".into(), SessionsMessage::TruncateSession(i)));
Some(bubble_row(*role, bubble_col, menu))
}
SessionEntry::ToolRound {
narration,
reasoning,
calls,
} => Some(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() {
if let Some(entry) = render_entry(ctx, i) {
items = items.push(entry);
}
}
widgets::vscroll_tracked(
items,
Length::Fill,
Length::Fill,
scrollable_id.clone(),
SessionsMessage::ScrollChanged,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ChatRole;
use crate::agent::registry::RunningTool;
fn msg(role: ChatRole, content: Option<&str>) -> SessionEntry {
SessionEntry::Message {
role,
content: content.map(str::to_string),
thinking: None,
}
}
fn tool_round() -> SessionEntry {
SessionEntry::ToolRound {
narration: Some("checking the file".to_string()),
reasoning: None,
calls: vec![ToolCallEntry {
tool_call_id: "call_0".to_string(),
tool: RunningTool {
name: "read".to_string(),
args: vec![("path".to_string(), "a.rs".to_string())],
},
result: Some("ok".to_string()),
}],
}
}
fn metadata(agent_id: &str) -> SessionMetadata {
SessionMetadata {
agent_id: agent_id.to_string(),
last_activity: chrono::Utc::now(),
message_count: 1,
token_length: None,
role: Some("engineer".to_string()),
user_name: None,
workspace_name: Some("ws".to_string()),
custom_role: None,
custom_identifier: None,
launch_stopped: false,
}
}
#[test]
fn a_bodyless_assistant_row_is_skipped_but_labelled_roles_keep_it() {
let expanded = HashSet::new();
let cache = RefCell::new(HashMap::new());
let entries = [
msg(ChatRole::User, None),
msg(ChatRole::System, None),
msg(ChatRole::Tool, None),
msg(ChatRole::Assistant, None),
msg(ChatRole::Assistant, Some("done")),
];
let ctx = TranscriptCtx {
entries: &entries,
entry_md: &[],
workspace_root: None,
expanded: &expanded,
measure_cache: &cache,
text_width: 300.0,
truncatable: false,
};
let rendered: Vec<bool> = (0..entries.len())
.map(|i| render_entry(&ctx, i).is_some())
.collect();
assert_eq!(rendered, [true, true, true, false, true]);
}
#[test]
fn live_sessions_are_appended_without_reordering_or_duplicating() {
let mut state = SessionsState::new();
state.sessions = vec![metadata("old_a"), metadata("old_b")];
state.absorb_live_sessions(vec![metadata("new"), metadata("old_a")]);
let ids: Vec<&str> = state.sessions.iter().map(|s| s.agent_id.as_str()).collect();
assert_eq!(ids, ["old_a", "old_b", "new"]);
let cached: Vec<&str> = state
.cached_session_items
.as_ref()
.expect("list cache rebuilt")
.iter()
.map(|i| i.agent_id.as_str())
.collect();
assert_eq!(cached, ["old_a", "old_b", "new"]);
}
#[test]
fn a_workspace_named_after_the_load_reparses_the_ledger() {
let tmp = tempfile::tempdir().unwrap();
let photo = tmp.path().join("generated/poster.png");
std::fs::create_dir_all(photo.parent().unwrap()).unwrap();
std::fs::write(&photo, b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR").unwrap();
let mut state = SessionsState::new();
state.selected_session = Some("s1".to_string());
state.entries = vec![msg(
ChatRole::Assistant,
Some("[IMAGE:generated/poster.png]"),
)];
state.entry_md = session_view::parse_entry_bodies(&state.entries, None);
assert_eq!(state.workspace_root, None);
assert!(
state.entry_md[0]
.as_ref()
.unwrap()
.items
.iter()
.all(|item| !matches!(item, iced::widget::markdown::Item::Image { .. }))
);
let workspaces = HashMap::from([(
"ws".to_string(),
Workspace {
name: "ws".to_string(),
path: tmp.path().to_string_lossy().into_owned(),
..Workspace::default()
},
)]);
state.sessions = vec![metadata("s1")];
let sessions = state.sessions.clone();
let _ = state.update(SessionsMessage::Refreshed(sessions), &workspaces);
assert_eq!(state.workspace_root.as_deref(), Some(tmp.path()));
let parsed = state.entry_md[0].as_ref().unwrap();
assert!(
parsed.refusals,
"an assistant body is the product's own reply"
);
let expected = photo.to_string_lossy().into_owned();
assert!(
parsed.items.iter().any(|item| matches!(
item,
iced::widget::markdown::Item::Image { url, .. } if url.as_str() == expected
)),
"the reassembled body must draw the picture: {:?}",
parsed.items
);
}
#[test]
fn common_entry_prefix_empty_vs_empty() {
assert_eq!(common_entry_prefix(&[], &[]), 0);
}
#[test]
fn common_entry_prefix_identical() {
let old = vec![msg(ChatRole::User, Some("hi")), tool_round()];
let new = old.clone();
assert_eq!(common_entry_prefix(&old, &new), old.len());
}
#[test]
fn common_entry_prefix_pure_append() {
let old = vec![msg(ChatRole::User, Some("hi"))];
let mut new = old.clone();
new.push(msg(ChatRole::Assistant, Some("hello")));
new.push(tool_round());
assert_eq!(common_entry_prefix(&old, &new), old.len());
}
#[test]
fn common_entry_prefix_in_place_edit() {
let old = vec![
msg(ChatRole::User, Some("a")),
msg(ChatRole::Assistant, Some("b")),
msg(ChatRole::User, Some("c")),
msg(ChatRole::Assistant, Some("d")),
];
let mut new = old.clone();
new[2] = msg(ChatRole::User, Some("edited"));
assert_eq!(common_entry_prefix(&old, &new), 2);
}
#[test]
fn common_entry_prefix_differing_lengths() {
let old = vec![
msg(ChatRole::User, Some("a")),
msg(ChatRole::Assistant, Some("b")),
];
let new = vec![msg(ChatRole::User, Some("a"))];
assert_eq!(common_entry_prefix(&old, &new), 1);
}
#[test]
fn transcript_error_stays_out_of_the_list_banner() {
let mut state = SessionsState::new();
state.selected_session = Some("s1".to_string());
state.selected_loading = true;
let _ = state.update(
SessionsMessage::SessionError {
key: "s1".to_string(),
error: "read failed".to_string(),
},
&HashMap::new(),
);
assert_eq!(state.selected_error.as_deref(), Some("read failed"));
assert!(!state.selected_loading);
assert!(
state.load_state.error().is_none(),
"a transcript failure must not surface in the session-list banner"
);
state.selected_error = None;
state.selected_loading = true;
let _ = state.update(
SessionsMessage::SessionError {
key: "s2".to_string(),
error: "stale failure".to_string(),
},
&HashMap::new(),
);
assert!(state.selected_error.is_none());
assert!(state.selected_loading);
}
#[test]
fn successful_transcript_load_clears_the_error() {
let mut state = SessionsState::new();
state.selected_session = Some("s1".to_string());
state.selected_error = Some("old failure".to_string());
state.selected_loading = true;
let _ = state.update(
SessionsMessage::SessionMessages("s1".to_string(), vec![]),
&HashMap::new(),
);
assert!(state.selected_error.is_none());
assert!(!state.selected_loading);
}
fn cached_item(
role: crate::Role,
user: Option<&str>,
workspace: Option<&str>,
) -> CachedSessionItem {
CachedSessionItem {
agent_id: "agent".to_string(),
role: Some(role),
title: user.or(workspace).unwrap_or_default().to_string(),
workspace_name: workspace.map(str::to_string),
custom_role: None,
custom_identifier: None,
msg_count_label: String::new(),
token_label: None,
timestamp_label: String::new(),
}
}
fn workspace_map(names: &[&str]) -> HashMap<String, Workspace> {
names
.iter()
.map(|name| {
(
(*name).to_string(),
Workspace {
name: (*name).to_string(),
..Workspace::default()
},
)
})
.collect()
}
#[test]
fn workspace_root_for_resolves_every_stored_name_shape() {
let admin = crate::users::ADMIN_USER_NAME;
assert_eq!(
workspace_root_for(&format!("personal:{admin}"), &HashMap::new()),
Some(crate::users::personal_workspace_path(admin)),
"a personal workspace is synthetic, so it resolves by name"
);
let mut map = workspace_map(&["mahbot_rlx"]);
map.get_mut("mahbot_rlx").unwrap().path = "/tmp/ws/mahbot_rlx".to_string();
assert_eq!(
workspace_root_for("mahbot_rlx", &map),
Some(PathBuf::from("/tmp/ws/mahbot_rlx")),
"a managed workspace resolves to its path"
);
assert_eq!(
workspace_root_for("/tmp/ws/stored", &map),
Some(PathBuf::from("/tmp/ws/stored")),
"an absolute stored name is its own root"
);
assert_eq!(
workspace_root_for("gone", &map),
None,
"an unknown relative name has no root"
);
}
#[test]
fn workspace_scoped_cards_are_titled_by_their_managed_workspace() {
let map = workspace_map(&["mahbot_rlx"]);
for role in [crate::Role::Manager, crate::Role::Maintainer] {
let scoped = cached_item(role, Some("admin"), Some("mahbot_rlx"));
assert_eq!(scoped.display_title(&map), "mahbot_rlx");
let no_user = cached_item(role, None, Some("mahbot_rlx"));
assert_eq!(no_user.display_title(&map), "mahbot_rlx");
let orphaned = cached_item(role, Some("admin"), Some("deleted_ws"));
assert_eq!(orphaned.display_title(&map), role.display_label());
let no_workspace = cached_item(role, Some("admin"), None);
assert_eq!(no_workspace.display_title(&map), role.display_label());
}
let assistant = cached_item(crate::Role::Assistant, Some("admin"), Some("mahbot_rlx"));
assert_eq!(assistant.display_title(&map), "admin");
let engineer = cached_item(crate::Role::Engineer, Some("alice"), Some("mahbot_rlx"));
assert_eq!(engineer.display_title(&map), "alice");
}
}