#![expect(
clippy::struct_excessive_bools,
clippy::if_not_else,
clippy::collapsible_if
)]
pub(crate) mod board;
pub(crate) mod common;
pub(crate) mod context_menu;
pub(crate) mod diff;
pub(crate) mod diff_widget;
pub(crate) mod editor;
pub(crate) mod editor_widget;
pub(crate) mod git;
pub(crate) mod highlight;
pub(crate) mod home;
pub(crate) mod logs;
pub(crate) mod media_markers;
pub(crate) mod role_menu;
pub(crate) mod running;
pub(crate) mod session_preview;
pub(crate) mod sessions;
pub(crate) mod settings;
pub(crate) mod shell;
pub(crate) mod text_rendering;
pub(crate) mod theme;
pub(crate) mod tool_failures;
pub(crate) mod users;
pub(crate) mod widget_helpers;
pub(crate) mod widgets;
pub(crate) mod workspaces;
use crate::board::Ticket;
use crate::logs::LogStore;
use iced::keyboard;
use iced::widget::{
Column, Row, Space, button, column, container, row, rule, scrollable, text, text_input, tooltip,
};
use iced::window;
use iced::{Alignment, Color, Element, Length, Task};
use crate::Role;
use crate::audio::voice::VoiceStatus;
use crate::self_update::UpdateMode;
use self::context_menu::ContextMenu;
use iced_fonts::lucide;
pub const JETBRAINS_MONO: iced::Font = iced::Font {
family: iced::font::Family::Name("JetBrains Mono"),
weight: iced::font::Weight::Normal,
stretch: iced::font::Stretch::Normal,
style: iced::font::Style::Normal,
};
use std::collections::HashMap;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use tokio::sync::broadcast;
pub static LOG_BROADCAST: OnceLock<broadcast::Sender<String>> = OnceLock::new();
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Page {
Home,
Sessions,
Logs,
Shell,
Editor,
Settings,
RunningAgents,
}
impl Page {
const fn sidebar_pages() -> &'static [Page] {
&[Page::Home, Page::Editor, Page::Shell]
}
const fn footer_pages() -> &'static [Page] {
&[Page::Sessions, Page::Logs, Page::Settings]
}
const fn label(self) -> &'static str {
match self {
Page::Home => "Home",
Page::Sessions => "Sessions",
Page::Logs => "Logs",
Page::Shell => "Shell",
Page::Editor => "Editor",
Page::Settings => "Settings",
Page::RunningAgents => "Running Agents",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToastKind {
Success,
Warning,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UpdateStatus {
Unavailable,
Available,
InProgress,
}
#[derive(Debug, Clone)]
pub struct Toast {
pub message: String,
pub kind: ToastKind,
pub created_at: Instant,
}
#[derive(Debug, Clone)]
pub enum ToastMessage {
Saved,
Created,
Deleted,
Error(String),
Warning(String),
SuccessMsg(String),
}
impl Toast {
fn new(message: String, kind: ToastKind) -> Self {
Self {
message,
kind,
created_at: Instant::now(),
}
}
fn from_toast_msg(msg: &ToastMessage) -> Self {
match msg {
ToastMessage::Saved => Toast::new("Saved".to_string(), ToastKind::Success),
ToastMessage::Created => Toast::new("Created".to_string(), ToastKind::Success),
ToastMessage::Deleted => Toast::new("Deleted".to_string(), ToastKind::Success),
ToastMessage::Error(s) => Toast::new(format!("Failed: {s}"), ToastKind::Error),
ToastMessage::Warning(s) => Toast::new(s.clone(), ToastKind::Warning),
ToastMessage::SuccessMsg(s) => Toast::new(s.clone(), ToastKind::Success),
}
}
const fn duration(&self) -> Duration {
match self.kind {
ToastKind::Success => Duration::from_secs(2),
ToastKind::Warning | ToastKind::Error => Duration::from_secs(4),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ToggleKind {
Pause,
Maintenance,
}
impl ToggleKind {
fn label_on(self) -> &'static str {
match self {
Self::Pause => "Pipeline paused",
Self::Maintenance => "Maintainer enabled",
}
}
fn label_off(self) -> &'static str {
match self {
Self::Pause => "Pipeline resumed",
Self::Maintenance => "Maintainer disabled",
}
}
fn label_err(self) -> &'static str {
match self {
Self::Pause => "Failed to toggle pipeline pause",
Self::Maintenance => "Failed to toggle maintainer",
}
}
async fn persist_to_store(self, name: String, state: bool) -> Result<(), String> {
let store = crate::workspace::store();
match self {
Self::Pause => store
.set_paused(&name, state)
.await
.map_err(|e| e.to_string()),
Self::Maintenance => store
.set_maintenance_enabled(&name, state)
.await
.map_err(|e| e.to_string()),
}
}
}
#[derive(Debug, Clone)]
#[expect(private_interfaces)]
pub enum Message {
Boot(Result<(), String>),
Navigation(Page),
Tick,
Shutdown,
DrainStarted,
CloseRequested(window::Id),
WindowEvent(window::Id, window::Event),
FocusSearch,
EscapePressed,
UpdateBot,
ConfirmUpdate,
CancelUpdate,
UpdateResult(Result<String, String>),
RegistryUpdateCheck,
RegistryUpdateResult(Result<Option<semver::Version>, String>),
Toggle(ToggleKind),
ToggleResult(ToggleKind, Result<(), String>, String, bool),
WorkspaceStatesRefreshed(Vec<(String, bool, bool)>),
Nop,
BootWorkspaces(HashMap<String, WorkspaceInfo>, Option<String>),
Home(home::HomeMessage),
Logs(logs::LogMessage),
Board(board::BoardMessage),
Sessions(sessions::SessionsMessage),
RunningAgents(running::RunningMessage),
DiffModal(diff::DiffMessage),
Git(git::GitMessage),
Shell(shell::ShellMessage),
Editor(editor::EditorMessage),
Settings(settings::SettingsMessage),
OpenDiffModal(Option<String>),
CloseDiffModal,
RoleAndPoolLoaded {
role: Option<Role>,
pool: Vec<Role>,
},
RoleSwitchFailed(String),
TtsDownloadEvent(crate::audio::tts::TtsDownloadEvent),
}
impl Message {
pub(crate) fn as_toast(&self) -> Option<&ToastMessage> {
match self {
Message::Home(home::HomeMessage::Toast(tm))
| Message::Board(board::BoardMessage::Toast(tm))
| Message::DiffModal(diff::DiffMessage::Toast(tm))
| Message::Git(git::GitMessage::Toast(tm))
| Message::Editor(editor::EditorMessage::Toast(tm))
| Message::Settings(
settings::SettingsMessage::Toast(tm)
| settings::SettingsMessage::WorkspaceMsg(workspaces::WorkspacesMessage::Toast(tm))
| settings::SettingsMessage::UserMsg(users::UsersMessage::Toast(tm)),
) => Some(tm),
_ => None,
}
}
pub(crate) fn as_link_url(&self) -> Option<&str> {
match self {
Message::Board(board::BoardMessage::LinkClicked(url))
| Message::Sessions(sessions::SessionsMessage::LinkClicked(url))
| Message::Settings(settings::SettingsMessage::WorkspaceMsg(
workspaces::WorkspacesMessage::LinkClicked(url),
)) => Some(url.as_str()),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct KeyboardMods {
pub is_cmd: bool,
pub is_platform_mod: bool,
pub ctrl_held: bool,
pub is_emacs_ctrl: bool,
pub altgr_active: bool,
}
impl KeyboardMods {
#[must_use]
pub(crate) fn is_nav_platform_mod(self) -> bool {
if cfg!(target_os = "macos") {
self.is_cmd
} else {
self.is_platform_mod
}
}
#[must_use]
pub(crate) fn is_text_platform_mod(self) -> bool {
if cfg!(target_os = "macos") {
self.is_cmd && !self.ctrl_held
} else {
self.is_platform_mod && !self.altgr_active
}
}
#[must_use]
pub(crate) fn is_shortcut_platform_mod(self) -> bool {
self.is_platform_mod && !self.is_emacs_ctrl && !self.altgr_active
}
}
pub(crate) fn detect_keyboard_mods(modifiers: keyboard::Modifiers) -> KeyboardMods {
let is_cmd = modifiers.command();
let is_platform_mod = modifiers.command() || modifiers.control();
let ctrl_held = modifiers.control();
let is_emacs_ctrl = cfg!(target_os = "macos") && modifiers.control() && !modifiers.command();
let altgr_active = !cfg!(target_os = "macos") && modifiers.alt() && modifiers.control();
KeyboardMods {
is_cmd,
is_platform_mod,
ctrl_held,
is_emacs_ctrl,
altgr_active,
}
}
#[must_use]
pub(crate) fn parse_key_press(
event: keyboard::Event,
) -> Option<(keyboard::Key, keyboard::Modifiers, keyboard::key::Physical)> {
let keyboard::Event::KeyPressed {
key,
modifiers,
physical_key,
..
} = event
else {
return None;
};
Some((key, modifiers, physical_key))
}
pub static BOOT_LOG_STORE: OnceLock<LogStore> = OnceLock::new();
#[derive(Debug, Clone)]
pub(crate) struct WorkspaceInfo {
path: String,
paused: bool,
maintenance_enabled: bool,
}
#[cfg(test)]
impl WorkspaceInfo {
pub(crate) fn test_new(path: String, paused: bool, maintenance_enabled: bool) -> Self {
Self {
path,
paused,
maintenance_enabled,
}
}
}
pub struct Dashboard {
ready: bool,
boot_error: Option<String>,
page: Page,
log_store: Option<LogStore>,
last_size: iced::Size,
last_position: iced::Point,
toasts: Vec<Toast>,
workspaces: HashMap<String, WorkspaceInfo>,
selected_workspace_name: Option<String>,
selected_user_name: Option<String>,
selected_user_role: Option<Role>,
selected_user_roles: Vec<Role>,
update_status: UpdateStatus,
update_mode: UpdateMode,
registry_latest: Option<semver::Version>,
exit_requested_during_update: bool,
draining: bool,
show_update_confirm: bool,
pending_research_cancel: Option<String>,
logs_state: logs::LogsState,
board_state: board::BoardState,
sessions_state: sessions::SessionsState,
diff_state: diff::DiffState,
home_state: home::HomeState,
shell_state: shell::ShellState,
editor_state: editor::EditorState,
settings_state: settings::SettingsState,
show_diff_modal: bool,
git_state: git::GitState,
tts_download_progress: Option<(String, f32)>,
}
impl Dashboard {
#[must_use]
pub fn loading(update_mode: UpdateMode) -> Self {
Self {
ready: false,
boot_error: None,
page: Page::Home,
log_store: None,
last_size: iced::Size::new(1500.0, 800.0),
last_position: iced::Point::new(-1.0, -1.0),
toasts: Vec::new(),
workspaces: HashMap::new(),
selected_workspace_name: None,
selected_user_name: None,
selected_user_role: None,
selected_user_roles: Vec::new(),
update_status: if update_mode == UpdateMode::LocalCheckout
&& crate::self_update::is_update_available()
{
UpdateStatus::Available
} else {
UpdateStatus::Unavailable
},
update_mode,
registry_latest: None,
exit_requested_during_update: false,
draining: false,
show_update_confirm: false,
pending_research_cancel: None,
logs_state: logs::LogsState::new(),
board_state: board::BoardState::new(),
sessions_state: sessions::SessionsState::new(),
diff_state: diff::DiffState::new(),
home_state: home::HomeState::new(),
shell_state: shell::ShellState::new(),
editor_state: editor::EditorState::new(),
settings_state: settings::SettingsState::new(),
show_diff_modal: false,
git_state: git::GitState::new(),
tts_download_progress: None,
}
}
fn finish_boot(&mut self, result: Result<(), String>) -> Task<Message> {
match result {
Ok(()) => {
let log_store = BOOT_LOG_STORE
.get()
.cloned()
.expect("BOOT_LOG_STORE set before Boot(Ok)");
self.ready = true;
self.boot_error = None;
let refresh_logs = self.logs_state.refresh(&log_store);
let refresh_board = self.board_state.refresh();
self.log_store = Some(log_store);
let prev = read_window_state();
self.selected_user_name = prev.selected_user;
self.board_state.current_user_name = self.selected_user_name.clone();
let boot_workspaces = Task::perform(
load_workspace_options(prev.selected_workspace),
std::convert::identity,
);
let settings_start = if !crate::config::provider_configured() {
self.navigate_to(Page::Settings)
} else {
Task::none()
};
Task::batch([
refresh_logs.map(Message::Logs),
refresh_board.map(Message::Board),
boot_workspaces,
settings_start,
])
}
Err(e) => {
self.boot_error = Some(e);
Task::none()
}
}
}
fn selected_workspace_info(&self) -> Option<&WorkspaceInfo> {
self.selected_workspace_name
.as_ref()
.and_then(|name| self.workspaces.get(name))
}
fn paused(&self) -> bool {
self.selected_workspace_info().is_some_and(|w| w.paused)
}
fn maintenance_enabled(&self) -> bool {
self.selected_workspace_info()
.is_some_and(|w| w.maintenance_enabled)
}
pub const fn theme(&self) -> iced::Theme {
iced::Theme::Dark
}
fn persist_window_state(&self) {
save_window_state(
self.last_position,
self.last_size,
self.selected_workspace_name.as_deref(),
self.selected_user_name.as_deref(),
);
}
fn save_and_exit(&self) -> Task<Message> {
self.persist_window_state();
if self.update_status == UpdateStatus::InProgress
&& crate::self_update::update_is_finalizing()
{
return Task::none();
}
iced::exit()
}
pub fn title(&self) -> String {
let page_name = self.page.label();
format!("MahBot — {page_name}")
}
fn apply_boot_workspaces(
&mut self,
workspaces: HashMap<String, WorkspaceInfo>,
restored_name: &str,
) -> Task<Message> {
self.workspaces = workspaces;
if let Some(ref user_name) = self.selected_user_name {
self.home_state.selected_user = Some(user_name.clone());
crate::audio::voice::set_active_user_name(user_name);
}
let role_cache = self.refresh_selected_user_role_cache();
let load_users = self.home_state.load_users().map(Message::Home);
let ws_name = if restored_name.is_empty() {
self.selected_workspace_name = None;
String::new()
} else {
self.selected_workspace_name = Some(restored_name.to_owned());
restored_name.to_owned()
};
Task::batch([
role_cache,
self.propagate_workspace_selection(&ws_name),
load_users,
])
}
fn navigate_to(&mut self, page: Page) -> Task<Message> {
self.page = page;
self.sessions_state.set_page_active(page == Page::Sessions);
match page {
Page::Logs | Page::Shell | Page::Editor | Page::RunningAgents => Task::none(),
Page::Home => {
let load_users = self.home_state.load_users().map(Message::Home);
let snap = iced::widget::operation::snap_to_end::<Message>(home::CHAT_SCROLL_ID);
let board_refresh = self.board_state.refresh().map(Message::Board);
Task::batch([load_users, snap, board_refresh])
}
Page::Sessions => sessions::SessionsState::refresh().map(Message::Sessions),
Page::Settings => {
self.settings_state.refresh();
self.refresh_settings_lists(false)
}
}
}
fn refresh_settings_lists(&mut self, gate_loading: bool) -> Task<Message> {
let ws = if gate_loading && self.settings_state.workspaces_state.load_state.loading() {
Task::none()
} else {
if gate_loading {
self.settings_state
.workspaces_state
.load_state
.start_loading();
}
self.settings_state
.workspaces_state
.refresh()
.map(|msg| Message::Settings(settings::SettingsMessage::WorkspaceMsg(msg)))
};
let us = if gate_loading && self.settings_state.users_state.load_state.loading() {
Task::none()
} else {
if gate_loading {
self.settings_state.users_state.load_state.start_loading();
}
self.settings_state
.users_state
.refresh()
.map(|msg| Message::Settings(settings::SettingsMessage::UserMsg(msg)))
};
Task::batch([ws, us])
}
fn toggle_workspace_state(&mut self, kind: ToggleKind) -> Task<Message> {
let current_state = match kind {
ToggleKind::Pause => self.paused(),
ToggleKind::Maintenance => self.maintenance_enabled(),
};
let Some(ws_name) = self.active_workspace_name() else {
self.toasts.push(Toast::new(
"No workspace selected — select a workspace first".to_string(),
ToastKind::Warning,
));
return Task::none();
};
let new_state = !current_state;
let ws_name_clone = ws_name.clone();
Task::perform(
kind.persist_to_store(ws_name_clone, new_state),
move |result| Message::ToggleResult(kind, result, ws_name, new_state),
)
}
fn finish_toggle(
&mut self,
kind: ToggleKind,
result: Result<(), String>,
ws_name: &str,
intended_state: bool,
) -> Task<Message> {
match result {
Ok(()) => {
let label = if intended_state {
kind.label_on()
} else {
kind.label_off()
};
self.toasts.push(Toast::new(
format!("{label} for {ws_name}"),
ToastKind::Success,
));
refresh_workspace_states_task()
}
Err(e) => {
self.toasts.push(Toast::new(
format!("{}: {e}", kind.label_err()),
ToastKind::Error,
));
Task::none()
}
}
}
fn process_tick(&mut self) -> Task<Message> {
let now = Instant::now();
self.toasts
.retain(|t| now.duration_since(t.created_at) < t.duration());
let ws_refresh = if self.has_active_workspace() {
refresh_workspace_states_task()
} else {
Task::none()
};
let page_task = match self.page {
Page::Home => {
let list_refresh = if !self.board_state.load_state.loading()
&& self.board_state.search_query.is_empty()
{
self.board_state.load_state.start_loading();
self.board_state.refresh().map(Message::Board)
} else {
Task::none()
};
let detail_refresh = self
.board_state
.refresh_selected_ticket()
.map(Message::Board);
Task::batch([list_refresh, detail_refresh])
}
Page::Sessions if !self.sessions_state.load_state.loading() => {
self.sessions_state.load_state.start_loading();
sessions::SessionsState::refresh().map(Message::Sessions)
}
Page::Settings => self.refresh_settings_lists(true),
_ => Task::none(),
};
let git_tasks = self.git_state.update_tick().map(Message::Git);
Task::batch([ws_refresh, page_task, git_tasks])
}
fn process_escape(&mut self) -> Task<Message> {
if self.show_update_confirm {
self.show_update_confirm = false;
Task::none()
} else if self.show_diff_modal {
self.show_diff_modal = false;
Task::done(Message::DiffModal(diff::DiffMessage::ClearCommitState))
} else if self.git_state.is_modal_open() {
self.git_state
.update(git::GitMessage::CloseModal)
.map(Message::Git)
} else {
match self.page {
Page::Home => self
.board_state
.update(board::BoardMessage::Escape)
.map(Message::Board),
Page::RunningAgents => {
self.pending_research_cancel = None;
Task::none()
}
Page::Shell => Task::none(),
Page::Logs => self
.logs_state
.update(
logs::LogMessage::Escape,
self.log_store.as_ref().expect("ready"),
)
.map(Message::Logs),
Page::Sessions => self
.sessions_state
.update(sessions::SessionsMessage::Escape)
.map(Message::Sessions),
Page::Editor => self
.editor_state
.update(editor::EditorMessage::Escape)
.map(Message::Editor),
Page::Settings => self
.settings_state
.update(settings::SettingsMessage::Escape)
.map(Message::Settings),
}
}
}
fn refresh_selected_user_role_cache(&self) -> Task<Message> {
let Some(ref user) = self.selected_user_name else {
return Task::none();
};
let user = user.clone();
Task::perform(
async move {
let pool = crate::users::role_pool(&user).await;
let role = crate::users::resolve_active_role_from_pool(&user, &pool).await;
Message::RoleAndPoolLoaded { role, pool }
},
std::convert::identity,
)
}
fn process_settings_message(&mut self, msg: settings::SettingsMessage) -> Task<Message> {
let mut intercept_task: Option<Task<Message>> = None;
if let settings::SettingsMessage::UserMsg(ref inner) = msg {
match inner {
users::UsersMessage::SwitchUser(user) => {
intercept_task = Some(
Task::done(home::HomeMessage::UserSelected(user.clone()))
.map(Message::Home),
);
}
users::UsersMessage::DeleteResult(Ok(()), deleted_user) => {
if self.selected_user_name.as_deref() == Some(deleted_user.as_str()) {
self.selected_user_name = Some("admin".to_string());
self.persist_window_state();
intercept_task = Some(
Task::done(home::HomeMessage::UserSelected("admin".to_string()))
.map(Message::Home),
);
}
}
users::UsersMessage::UpdateWorkspace(sender, ws)
if self.selected_user_name.as_deref() == Some(sender.as_str()) =>
{
intercept_task = Some(self.select_workspace(ws));
}
users::UsersMessage::PoolEditResult(Ok(()))
| users::UsersMessage::RoleUpdateResult(Ok(())) => {
intercept_task = Some(self.refresh_selected_user_role_cache());
}
_ => {}
}
}
let needs_global_reload = matches!(
msg,
settings::SettingsMessage::WorkspaceMsg(workspaces::WorkspacesMessage::DeleteResult(
Ok(())
)) | settings::SettingsMessage::AddWorkspaceResult(Ok(_))
);
let settings_task = self.settings_state.update(msg).map(Message::Settings);
let tasks = [
intercept_task,
Some(settings_task),
needs_global_reload.then(|| self.reload_workspace_options()),
];
Task::batch(tasks.into_iter().flatten())
}
fn process_running_message(&mut self, msg: running::RunningMessage) -> Task<Message> {
match msg {
running::RunningMessage::CancelRequest(run_key) => {
self.pending_research_cancel = Some(run_key);
Task::none()
}
running::RunningMessage::CancelConfirmed => {
let Some(run_key) = self.pending_research_cancel.take() else {
return Task::none();
};
Task::perform(
async move { crate::research_cancel::cancel_research_run(&run_key).await },
|result| {
Message::RunningAgents(running::RunningMessage::CancelFinished(result))
},
)
}
running::RunningMessage::CancelDismissed => {
self.pending_research_cancel = None;
Task::none()
}
running::RunningMessage::CancelFinished(result) => {
let toast = match result {
Ok(()) => ToastMessage::SuccessMsg(
"Research run cancelled — agents stopped, run removed permanently."
.to_string(),
),
Err(e) => ToastMessage::Error(format!("research cancel failed: {e}")),
};
self.toasts.push(Toast::from_toast_msg(&toast));
Task::none()
}
}
}
#[expect(clippy::too_many_lines)]
pub fn update(&mut self, message: Message) -> Task<Message> {
if self.ready {
if let Some(tm) = message.as_toast() {
self.toasts.push(Toast::from_toast_msg(tm));
return Task::none();
}
if let Some(url) = message.as_link_url() {
open_url(url);
return Task::none();
}
}
match message {
Message::Boot(result) => self.finish_boot(result),
Message::BootWorkspaces(workspaces, restored_name) => {
self.apply_boot_workspaces(workspaces, restored_name.as_deref().unwrap_or(""))
}
Message::CloseRequested(_) => {
if self.update_status == UpdateStatus::InProgress
&& crate::self_update::update_is_finalizing()
{
self.persist_window_state();
self.exit_requested_during_update = true;
Task::none()
} else if crate::shutdown::is_draining() {
crate::shutdown::force_cancel();
Task::none()
} else {
crate::shutdown::drain_begin();
Task::none()
}
}
Message::Shutdown => self.save_and_exit(),
Message::DrainStarted => {
if !self.draining
&& self.update_status == UpdateStatus::InProgress
&& crate::self_update::update_is_finalizing()
{
self.toasts.push(Toast::new(
"Update complete — restarting…".to_string(),
ToastKind::Warning,
));
}
self.draining = true;
Task::none()
}
Message::WindowEvent(_, event) => match event {
window::Event::Resized(new_size) => {
self.last_size = new_size;
Task::none()
}
window::Event::Moved(new_pos) => {
self.last_position = new_pos;
Task::none()
}
_ => Task::none(),
},
Message::CloseDiffModal => {
self.show_diff_modal = false;
Task::done(Message::DiffModal(diff::DiffMessage::ClearCommitState))
}
Message::Navigation(_) if !self.ready => Task::none(),
Message::Navigation(page) => self.navigate_to(page),
_ if !self.ready => Task::none(),
Message::Tick => self.process_tick(),
Message::Home(msg) => {
if let home::HomeMessage::RequestWorkspaceChange(ref name) = msg {
return self.select_workspace(name);
}
if let home::HomeMessage::UserSelected(ref user) = msg {
self.selected_user_name = Some(user.clone());
self.board_state.current_user_name = Some(user.clone());
self.selected_user_role = None; self.selected_user_roles = Vec::new();
crate::audio::voice::set_active_user_name(user);
self.persist_window_state();
let role_cache = self.refresh_selected_user_role_cache();
return Task::batch([
role_cache,
self.home_state.update(msg.clone()).map(Message::Home),
]);
}
if let home::HomeMessage::SwitchRole(ref user_name, ref role) = msg {
if !self.selected_user_roles.contains(role) {
return Task::done(Message::Home(home::HomeMessage::Toast(
ToastMessage::Error(format!(
"Role '{}' is not in {}'s allowed roles.",
role.as_str(),
user_name
)),
)));
}
self.selected_user_role = Some(*role);
let user = user_name.clone();
let role = *role;
return Task::batch([
Task::done(Message::Home(home::HomeMessage::RoleMenuClosed)),
Task::perform(
async move {
crate::users::switch_active_role(&user, role)
.await
.map_err(|e| e.to_string())?;
Ok(role)
},
|res| {
match res {
Ok(role) => Message::Home(home::HomeMessage::Toast(
ToastMessage::SuccessMsg(format!(
"Switched to {} role",
crate::role::role_info(&role).display_label
)),
)),
Err(e) => Message::RoleSwitchFailed(e),
}
},
),
]);
}
self.home_state.update(msg).map(Message::Home)
}
Message::Shell(msg) => self.shell_state.update(msg).map(Message::Shell),
Message::Logs(msg) => self
.logs_state
.update(msg, self.log_store.as_ref().expect("ready"))
.map(Message::Logs),
Message::Board(msg) => {
if let board::BoardMessage::ViewCommitDiff {
ref commit_hash, ..
} = msg
{
return self.open_diff_modal(Some(commit_hash.clone()));
}
self.board_state.update(msg).map(Message::Board)
}
Message::Sessions(msg) => self.sessions_state.update(msg).map(Message::Sessions),
Message::RunningAgents(msg) => self.process_running_message(msg),
Message::DiffModal(diff::DiffMessage::CloseModal) => {
self.show_diff_modal = false;
Task::none()
}
Message::DiffModal(msg) => self.diff_state.update(msg).map(Message::DiffModal),
Message::Editor(msg) => self.editor_state.update(msg).map(Message::Editor),
Message::Settings(msg) => self.process_settings_message(msg),
Message::OpenDiffModal(commit_hash) => self.open_diff_modal(commit_hash),
Message::Git(msg) => {
if matches!(msg, git::GitMessage::OpenModal) {
self.show_diff_modal = false;
}
self.git_state.update(msg).map(Message::Git)
}
Message::FocusSearch => match self.page {
Page::Logs => self
.logs_state
.update(
logs::LogMessage::FocusSearch,
self.log_store.as_ref().expect("ready"),
)
.map(Message::Logs),
_ => Task::none(),
},
Message::EscapePressed => self.process_escape(),
Message::UpdateBot => {
if self.update_status == UpdateStatus::Available {
self.show_update_confirm = true;
}
Task::none()
}
Message::ConfirmUpdate => {
self.show_update_confirm = false;
if self.update_status != UpdateStatus::Available {
return Task::none();
}
self.update_status = UpdateStatus::InProgress;
self.persist_window_state();
self.toasts.push(Toast::new(
"Update started — building/installing…".to_string(),
ToastKind::Success,
));
Task::perform(
async {
crate::self_update::execute_update()
.await
.map_err(|e| format!("{e:#}"))
.map(|()| "ok".to_string())
},
Message::UpdateResult,
)
}
Message::CancelUpdate => {
self.show_update_confirm = false;
Task::none()
}
Message::UpdateResult(result) => {
if let Err(err) = result {
self.update_status = UpdateStatus::Available;
self.toasts
.push(Toast::from_toast_msg(&ToastMessage::Error(err)));
if self.exit_requested_during_update {
self.exit_requested_during_update = false;
return self.save_and_exit();
}
}
Task::none()
}
Message::RegistryUpdateCheck => {
if self.update_status == UpdateStatus::InProgress {
return Task::none();
}
Task::perform(
async {
crate::self_update::check_registry_update()
.await
.map_err(|e| format!("{e:#}"))
},
Message::RegistryUpdateResult,
)
}
Message::RegistryUpdateResult(result) => {
if self.update_status == UpdateStatus::InProgress {
return Task::none();
}
match result {
Ok(Some(version)) => {
self.registry_latest = Some(version);
self.update_status = UpdateStatus::Available;
}
Ok(None) => {
self.registry_latest = None;
self.update_status = UpdateStatus::Unavailable;
}
Err(_) => {
}
}
Task::none()
}
Message::Toggle(kind) => self.toggle_workspace_state(kind),
Message::ToggleResult(kind, result, ws_name, intended_state) => {
self.finish_toggle(kind, result, &ws_name, intended_state)
}
Message::WorkspaceStatesRefreshed(states) => {
for (name, paused, maintenance_enabled) in states {
if let Some(info) = self.workspaces.get_mut(&name) {
info.paused = paused;
info.maintenance_enabled = maintenance_enabled;
} else {
self.workspaces.insert(
name,
WorkspaceInfo {
path: String::new(),
paused,
maintenance_enabled,
},
);
}
}
Task::none()
}
Message::Nop => Task::none(),
Message::RoleAndPoolLoaded { role, pool } => {
self.selected_user_role = role;
self.selected_user_roles = pool;
Task::none()
}
Message::RoleSwitchFailed(e) => {
Task::batch([
Task::done(Message::Home(home::HomeMessage::Toast(
ToastMessage::Error(e),
))),
self.refresh_selected_user_role_cache(),
])
}
Message::TtsDownloadEvent(event) => self.handle_tts_download_event(event),
}
}
#[expect(clippy::cast_precision_loss)]
fn handle_tts_download_event(
&mut self,
event: crate::audio::tts::TtsDownloadEvent,
) -> Task<Message> {
match event {
crate::audio::tts::TtsDownloadEvent::FileStarted { name, .. } => {
self.tts_download_progress = Some((name, 0.0));
Task::none()
}
crate::audio::tts::TtsDownloadEvent::FileProgress {
name,
bytes_downloaded,
total_bytes,
} => {
let progress = if total_bytes > 0 {
(bytes_downloaded as f32 / total_bytes as f32).clamp(0.0, 1.0)
} else {
0.0
};
self.tts_download_progress = Some((name, progress));
Task::none()
}
crate::audio::tts::TtsDownloadEvent::FileCompleted { name } => {
self.tts_download_progress = Some((name, 1.0));
Task::none()
}
crate::audio::tts::TtsDownloadEvent::Complete
| crate::audio::tts::TtsDownloadEvent::Failed { .. } => {
self.tts_download_progress = None;
Task::none()
}
}
}
fn open_diff_modal(&mut self, commit_hash: Option<String>) -> Task<Message> {
let close_board = self
.board_state
.update(board::BoardMessage::CloseModal)
.map(Message::Board);
self.show_diff_modal = true;
let _ = self.git_state.update(git::GitMessage::CloseModal);
let ws = self.selected_workspace_name.clone().unwrap_or_default();
let diff_task = match commit_hash {
Some(hash) => Task::done(Message::DiffModal(diff::DiffMessage::NavigateToCommit(
ws, hash,
))),
None => Task::done(Message::DiffModal(diff::DiffMessage::BackToWorkingTree)),
};
Task::batch([close_board, diff_task])
}
fn select_workspace(&mut self, name: &str) -> Task<Message> {
self.selected_workspace_name = if name.is_empty() {
None
} else {
Some(name.to_string())
};
self.persist_window_state();
self.propagate_workspace_selection(name)
}
fn propagate_workspace_selection(&mut self, name: &str) -> Task<Message> {
let ws_path = self.workspaces.get(name).map(|w| w.path.clone());
self.board_state.workspace_name = Some(name.to_string());
self.board_state.search_query.clear();
self.board_state.search_results.clear();
self.board_state.search_generation += 1;
let board_refresh = self.board_state.refresh().map(Message::Board);
let personal_path = if name.is_empty() {
self.selected_user_name.as_ref().map(|u| {
crate::users::personal_workspace_path(u)
.to_string_lossy()
.to_string()
})
} else {
None
};
let (page_name, page_path) =
resolve_dashboard_workspace_path(name, ws_path.as_deref(), personal_path.as_deref());
let editor_task: Task<Message> = Task::done(editor::EditorMessage::WorkspaceSelected(
page_name.clone(),
page_path.clone(),
))
.map(Message::Editor);
let diff_name = name.to_string();
let diff_path = personal_path.clone();
let resolved_path = ws_path.or_else(|| personal_path.clone());
let git_task: Task<Message> = self
.git_state
.set_workspace_path(resolved_path)
.map(Message::Git);
let diff_task: Task<Message> =
Task::done(diff::DiffMessage::WorkspaceSelected(diff_name, diff_path))
.map(Message::DiffModal);
let shell_task: Task<Message> =
Task::done(shell::ShellMessage::WorkspaceSelected(page_name, page_path))
.map(Message::Shell);
let home_name = name.to_string();
let home_task: Task<Message> =
Task::done(home::HomeMessage::WorkspaceChanged(Some(home_name))).map(Message::Home);
Task::batch([
board_refresh,
editor_task,
diff_task,
shell_task,
home_task,
git_task,
])
}
fn reload_workspace_options(&self) -> Task<Message> {
let prev_selection = self.selected_workspace_name.clone();
Task::perform(
load_workspace_options(prev_selection),
std::convert::identity,
)
}
fn active_workspace_name(&self) -> Option<String> {
match self.selected_workspace_name.as_deref() {
Some(n) if !n.is_empty() => Some(n.to_string()),
_ => None,
}
}
fn has_active_workspace(&self) -> bool {
self.selected_workspace_name
.as_deref()
.is_some_and(|n| !n.is_empty())
}
#[expect(clippy::too_many_lines)]
pub fn view(&self) -> Element<'_, Message> {
if let Some(err) = &self.boot_error {
return container(
column![
text("MahBot failed to start")
.size(20)
.color(theme::STATUS_ERROR),
text(err).size(14).color(theme::TEXT_SECONDARY),
]
.spacing(12)
.padding(24),
)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into();
}
if !self.ready {
return container(
column![
text("MahBot").size(24).color(theme::ACCENT),
text("Starting…").size(16).color(theme::TEXT_MUTED),
]
.spacing(16)
.align_x(Alignment::Center),
)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into();
}
let sidebar = self.sidebar_view();
let footer = self.footer_view();
let content = match self.page {
Page::Home => {
let home_view = self
.home_state
.view(
self.selected_user_role,
&self.selected_user_roles,
self.draining,
)
.map(Message::Home);
let sidebar = ticket_sidebar(&self.board_state);
let home_view: Element<'_, Message> = ContextMenu::new(
home_view,
vec![context_menu::MenuItem::new(
"Reset session".into(),
Message::Home(home::HomeMessage::ClearChat),
)],
)
.into();
let sidebar: Element<'_, Message> = ContextMenu::new(
sidebar,
vec![context_menu::MenuItem::new(
"Archive done & cancelled".into(),
Message::Board(board::BoardMessage::ArchiveAllCompleted),
)],
)
.into();
let base = row![
container(home_view).width(Length::FillPortion(7)),
container(sidebar).width(Length::FillPortion(3))
];
let modal = self.board_state.render_modal_overlay().map(Message::Board);
iced::widget::stack([base.into(), modal]).into()
}
Page::Logs => self.logs_state.view().map(Message::Logs),
Page::Sessions => self.sessions_state.view().map(Message::Sessions),
Page::Shell => self.shell_state.view().map(Message::Shell),
Page::Editor => self.editor_state.view().map(Message::Editor),
Page::Settings => self
.settings_state
.view(self.selected_user_name.as_deref())
.map(Message::Settings),
Page::RunningAgents => {
running::view(&self.workspaces, self.pending_research_cancel.as_deref())
}
};
let body = column![
row![sidebar, content]
.width(Length::Fill)
.height(Length::Fill),
footer,
]
.width(Length::Fill)
.height(Length::Fill);
let overlay: Element<'_, Message> = if self.toasts.is_empty() {
widget_helpers::empty_stack_placeholder()
} else {
let mut toast_col = Column::new().spacing(6).align_x(Alignment::Center);
for toast in &self.toasts {
let (color, _bg) = match toast.kind {
ToastKind::Success => (theme::STATUS_SUCCESS, theme::BG_ELEVATED),
ToastKind::Warning => (theme::STATUS_WARNING, theme::BG_ELEVATED),
ToastKind::Error => (theme::STATUS_ERROR, theme::BG_ELEVATED),
};
let pill = container(text(&toast.message).size(12).color(color))
.padding([6, 14])
.style(move |_theme: &iced::Theme| container::Style {
background: Some(iced::Background::Color(theme::BG_ELEVATED)),
border: iced::Border {
radius: 20.0.into(),
width: 1.0,
color: theme::BORDER,
},
..container::Style::default()
});
toast_col = toast_col.push(pill);
}
container(toast_col)
.width(Length::Fill)
.align_x(Alignment::Center)
.padding(iced::Padding {
bottom: 44.0,
..Default::default()
})
.align_bottom(Length::Fill)
.into()
};
let diff_overlay: Element<'_, Message> = if self.show_diff_modal {
render_diff_modal(&self.diff_state)
} else {
widget_helpers::empty_stack_placeholder()
};
let branch_overlay: Element<'_, Message> = if self.git_state.is_modal_open() {
let inner = self.git_state.view().map(Message::Git);
modal_overlay(inner, Message::Git(git::GitMessage::CloseModal))
} else {
widget_helpers::empty_stack_placeholder()
};
let update_overlay: Element<'_, Message> = self.render_update_confirm();
iced::widget::stack![body, diff_overlay, branch_overlay, update_overlay, overlay].into()
}
}
fn modal_overlay<'a>(
inner: impl Into<Element<'a, Message>>,
on_close: Message,
) -> Element<'a, Message> {
let dialog = container(inner)
.width(Length::Fill)
.height(Length::Fill)
.padding(16)
.style(theme::dialog_container_style);
let centered = row![
Space::new().width(Length::FillPortion(1)), dialog.width(Length::FillPortion(8)), Space::new().width(Length::FillPortion(1)), ]
.width(Length::Fill)
.height(Length::Fill);
widget_helpers::modal_backdrop(centered, on_close, 0.5)
}
fn render_diff_modal(diff_state: &diff::DiffState) -> Element<'_, Message> {
let viewing_commit = diff_state.is_viewing_commit();
let header: Element<'_, Message> = if viewing_commit {
let msg = diff_state
.commit_message()
.unwrap_or("(no commit message)")
.to_string();
let hash = diff_state.commit_short_hash().unwrap_or("????????");
column![
text(msg).size(18).color(theme::TEXT_PRIMARY),
text(hash).size(12).color(theme::TEXT_MUTED),
]
.spacing(2)
.padding(iced::Padding {
top: 0.0,
right: 0.0,
bottom: 12.0,
left: 0.0,
})
.into()
} else {
column![
text("Uncommitted changes")
.size(18)
.color(theme::TEXT_PRIMARY),
text("Working tree diff \u{2014} press Escape to close")
.size(11)
.color(theme::TEXT_MUTED),
]
.spacing(4)
.padding(iced::Padding {
top: 0.0,
right: 0.0,
bottom: 12.0,
left: 0.0,
})
.into()
};
let diff_content: Element<'_, diff::DiffMessage> = diff_state.view();
let inner = column![header, diff_content.map(Message::DiffModal)].spacing(0);
modal_overlay(inner, Message::CloseDiffModal)
}
fn ticket_sidebar(board_state: &board::BoardState) -> Element<'_, Message> {
let search_active = !board_state.search_query.is_empty();
let search_input = text_input("Search tickets…", &board_state.search_query)
.on_input(|q| Message::Board(board::BoardMessage::SearchInputChanged(q)))
.style(widgets::text_input_style)
.size(13)
.padding([4, 8]);
let clear_btn = tooltip(
button(text("×").size(14))
.on_press(Message::Board(board::BoardMessage::SearchCleared))
.style(theme::button_text)
.padding(4),
text("clear search").size(11),
tooltip::Position::Top,
)
.style(theme::tooltip_style);
let search_row = row![search_input, clear_btn]
.spacing(4)
.align_y(Alignment::Center);
let body: Element<'_, Message> = if search_active {
render_search_results(board_state)
} else {
render_normal_ticket_list(board_state)
};
let content = column![
Space::new().height(8),
search_row,
Space::new().height(8),
body,
]
.spacing(0);
container(content)
.padding([8, 12])
.width(Length::Fill)
.height(Length::Fill)
.style(theme::surface_container_style)
.into()
}
fn section_hint(label: &str) -> Element<'_, Message> {
column![
Space::new().height(8),
text(label).size(12).color(theme::TEXT_MUTED),
]
.spacing(4)
.padding([8, 0])
.into()
}
fn render_normal_ticket_list(board_state: &board::BoardState) -> Element<'_, Message> {
let [in_progress, ready, pending, completed] =
board::BoardState::board_sections(&board_state.tickets);
let is_empty =
in_progress.is_empty() && ready.is_empty() && pending.is_empty() && completed.is_empty();
if !board_state.load_state.has_loaded() {
section_hint("Loading…")
} else if is_empty {
section_hint("No tickets")
} else {
let mut groups = Column::new().spacing(8);
if !in_progress.is_empty() {
groups = groups.push(group_section("In Progress", &in_progress, board_state));
}
if !ready.is_empty() {
groups = groups.push(group_section("Ready", &ready, board_state));
}
if !pending.is_empty() {
groups = groups.push(group_section("Pending", &pending, board_state));
}
if !completed.is_empty() {
groups = groups.push(group_section("Completed", &completed, board_state));
}
scrollable(groups)
.height(Length::Fill)
.direction(theme::vertical_scrollbar())
.style(theme::scrollbar_style)
.into()
}
}
fn render_search_results(board_state: &board::BoardState) -> Element<'_, Message> {
if board_state.search_results.is_empty() {
section_hint("No matching tickets")
} else {
let mut cards = Column::new().spacing(2);
for ticket in &board_state.search_results {
cards = cards.push(board_state.render_ticket_card(ticket).map(Message::Board));
}
scrollable(cards)
.height(Length::Fill)
.direction(theme::vertical_scrollbar())
.style(theme::scrollbar_style)
.into()
}
}
fn group_section<'a>(
label: &'static str,
tickets: &[&'a Ticket],
board_state: &'a board::BoardState,
) -> Element<'a, Message> {
let header = text(label).size(11).color(theme::TEXT_SECONDARY);
let mut cards = Column::new().spacing(2);
for ticket in tickets {
cards = cards.push(board_state.render_ticket_card(ticket).map(Message::Board));
}
column![header, Space::new().height(4), cards]
.spacing(0)
.into()
}
impl Dashboard {
pub fn subscription(&self) -> iced::Subscription<Message> {
let window_events = iced::Subscription::batch([
window::resize_events()
.map(|(id, size)| Message::WindowEvent(id, window::Event::Resized(size))),
window::events().filter_map(|(id, event)| {
matches!(&event, window::Event::Moved(_)).then_some(Message::WindowEvent(id, event))
}),
]);
if !self.ready {
return window_events;
}
iced::Subscription::batch([
window_events,
iced::time::every(Duration::from_secs(1)).map(|_| Message::Tick),
window::close_requests().map(Message::CloseRequested),
keyboard::listen().filter_map(|event| {
use keyboard::Key;
let (key, modifiers, physical_key) = parse_key_press(event)?;
let km = detect_keyboard_mods(modifiers);
let latin = key.to_latin(physical_key);
if !km.altgr_active && km.is_cmd && !modifiers.shift() && latin == Some('f') {
return Some(Message::FocusSearch);
}
if let Key::Named(iced::keyboard::key::Named::Escape) = key {
Some(Message::EscapePressed)
} else if km.is_cmd && !km.altgr_active {
if let Some(digit) = latin.and_then(|c| c.to_digit(10)) {
let idx = digit as usize;
if idx >= 1 {
let pages = Page::sidebar_pages();
if let Some(page) = pages.get(idx - 1).copied() {
return Some(Message::Navigation(page));
}
}
}
None
} else {
None
}
}),
self.shell_state.subscription().map(Message::Shell),
self.logs_state.subscription().map(Message::Logs),
self.board_state.subscription().map(Message::Board),
self.sessions_state.subscription().map(Message::Sessions),
self.editor_state.subscription().map(Message::Editor),
self.home_state.subscription().map(Message::Home),
iced::Subscription::run(shutdown_subscription),
iced::Subscription::run(tts_download_subscription).map(Message::TtsDownloadEvent),
if self.update_mode == UpdateMode::Registry && !cfg!(windows) {
iced::Subscription::run(registry_update_subscription)
} else {
iced::Subscription::none()
},
if self.show_diff_modal {
self.diff_state.subscription().map(Message::DiffModal)
} else {
iced::Subscription::none()
},
])
}
}
fn registry_update_subscription() -> impl futures_util::Stream<Item = Message> {
use iced::futures::channel::mpsc;
iced::stream::channel(1, |mut output: mpsc::Sender<Message>| async move {
loop {
let _ = output.try_send(Message::RegistryUpdateCheck);
tokio::time::sleep(Duration::from_mins(10)).await;
}
})
}
fn shutdown_subscription() -> impl futures_util::Stream<Item = Message> {
use iced::futures::channel::mpsc;
iced::stream::channel(1, |mut output: mpsc::Sender<Message>| async move {
let token = crate::shutdown::shutdown_token();
loop {
if crate::shutdown::is_draining() {
let _ = output.try_send(Message::DrainStarted);
break;
}
if token.is_cancelled() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
token.cancelled().await;
let _ = output.try_send(Message::Shutdown);
})
}
fn tts_download_subscription()
-> impl futures_util::Stream<Item = crate::audio::tts::TtsDownloadEvent> {
use iced::futures::channel::mpsc;
common::broadcast_stream_producer(
1,
&crate::audio::tts::DOWNLOAD_EVENTS,
|output: &mut mpsc::Sender<crate::audio::tts::TtsDownloadEvent>,
event: Option<crate::audio::tts::TtsDownloadEvent>| {
Box::pin(async move {
if let Some(event) = event {
let _ = output.try_send(event);
}
})
},
)
}
fn page_icon(page: Page, size: u32, color: Color) -> Element<'static, Message> {
let text = match page {
Page::Home => lucide::layout_dashboard::<iced::Theme, iced::Renderer>(),
Page::Editor => lucide::pencil_line::<iced::Theme, iced::Renderer>(),
Page::Shell => lucide::terminal::<iced::Theme, iced::Renderer>(),
Page::Sessions => lucide::scroll_text::<iced::Theme, iced::Renderer>(),
Page::Logs => lucide::activity::<iced::Theme, iced::Renderer>(),
Page::Settings => lucide::settings::<iced::Theme, iced::Renderer>(),
Page::RunningAgents => lucide::radar::<iced::Theme, iced::Renderer>(),
};
text.size(size).color(color).into()
}
fn render_sidebar_toggle<'a>(
icon: Element<'a, Message>,
tooltip_text: impl text::IntoFragment<'a>,
action: Option<Message>,
position: tooltip::Position,
) -> Element<'a, Message> {
tooltip(
button(
container(icon)
.width(Length::Fill)
.center_x(Length::Fill)
.padding([4, 0]),
)
.width(Length::Fill)
.padding(0)
.style(theme::button_text)
.on_press_maybe(action),
text(tooltip_text).size(11),
position,
)
.style(theme::tooltip_style)
.into()
}
impl Dashboard {
fn sidebar_view(&self) -> Element<'_, Message> {
container(
column![
self.render_sidebar_nav(),
Space::new().height(Length::Fill),
self.render_maintainer_toggle(),
self.render_pause_toggle(),
]
.spacing(2),
)
.width(Length::Fixed(56.0))
.height(Length::Fill)
.style(theme::surface_container_style)
.padding(12)
.into()
}
fn render_sidebar_nav(&self) -> Element<'_, Message> {
let mut col = Column::new().spacing(2);
for page in Page::sidebar_pages() {
let is_active = self.page == *page;
let has_any_workspace =
self.selected_workspace_name.is_some() || self.selected_user_name.is_some();
let requires_workspace = matches!(*page, Page::Editor | Page::Shell);
let disabled = requires_workspace && !has_any_workspace;
let color = if is_active {
theme::ACCENT
} else if disabled {
theme::TEXT_FAINT
} else {
theme::TEXT_MUTED
};
let tooltip_text = if disabled {
format!("Select a workspace to access {}", page.label())
} else {
page.label().to_string()
};
let nav_btn = render_sidebar_toggle(
page_icon(*page, 28, color),
tooltip_text,
if disabled {
None
} else {
Some(Message::Navigation(*page))
},
tooltip::Position::Right,
);
col = col.push(nav_btn);
}
col.into()
}
fn render_maintainer_toggle(&self) -> Element<'_, Message> {
let has_ws = self.has_active_workspace();
let maint_icon = widgets::maint_badge(self.maintenance_enabled());
let tooltip_text = if !has_ws {
"Select a workspace to toggle maintainer"
} else if self.maintenance_enabled() {
"stop maintenance"
} else {
"start maintenance"
};
render_sidebar_toggle(
maint_icon.into(),
tooltip_text,
if has_ws {
Some(Message::Toggle(ToggleKind::Maintenance))
} else {
None
},
tooltip::Position::Top,
)
}
fn render_pause_toggle(&self) -> Element<'_, Message> {
let has_ws = self.has_active_workspace();
let pause_icon = if !has_ws {
lucide::pause::<iced::Theme, iced::Renderer>()
.size(28)
.color(theme::TEXT_FAINT)
} else if self.paused() {
lucide::play::<iced::Theme, iced::Renderer>()
.size(28)
.color(theme::ACCENT)
} else {
lucide::pause::<iced::Theme, iced::Renderer>()
.size(28)
.color(theme::TEXT_MUTED)
};
let tooltip_text = if !has_ws {
"Select a workspace to pause"
} else if self.paused() {
"Resume pipeline"
} else {
"Pause pipeline"
};
render_sidebar_toggle(
pause_icon.into(),
tooltip_text,
if has_ws {
Some(Message::Toggle(ToggleKind::Pause))
} else {
None
},
tooltip::Position::Top,
)
}
fn render_update_confirm(&self) -> Element<'_, Message> {
if !self.show_update_confirm {
return iced::widget::stack([widget_helpers::empty_stack_placeholder()]).into();
}
let dialog = container(
column![
text("Update MahBot?")
.size(16)
.color(theme::TEXT_PRIMARY)
.font(theme::FONT_BOLD),
Space::new().height(12),
text(
"The new version is built and installed in the background \
while the system keeps working — the build can take \
10–60 minutes and nothing is paused during it.\n\n\
When the build finishes, current in-flight work is \
drained (up to ~10 min), databases are checkpointed, and \
the app restarts, automatically resuming work afterwards. \
The window closing signals the restart.",
)
.size(13)
.color(theme::TEXT_SECONDARY),
Space::new().height(16),
row![
Space::new().width(Length::Fill),
button(text("Cancel").size(13))
.style(theme::button_secondary)
.on_press(Message::CancelUpdate),
Space::new().width(8),
button(text("Update").size(13))
.style(theme::button_primary)
.on_press(Message::ConfirmUpdate),
]
.align_y(Alignment::Center),
]
.width(Length::Fill),
)
.width(Length::Fixed(480.0))
.padding(24)
.style(theme::dialog_container_style);
widget_helpers::modal_backdrop(dialog, Message::CancelUpdate, 0.5)
}
fn render_update_button(&self) -> Option<Element<'_, Message>> {
let (update_color, tooltip_text, clickable) = match self.update_status {
UpdateStatus::Unavailable => return None,
UpdateStatus::Available => {
let tooltip = if self.update_mode == UpdateMode::Registry {
match &self.registry_latest {
Some(v) => format!("Update MahBot to v{v}"),
None => "Update MahBot".to_string(),
}
} else {
"Update MahBot".to_string()
};
(theme::ACCENT, tooltip, true)
}
UpdateStatus::InProgress => (theme::TEXT_FAINT, "Updating…".to_string(), false),
};
let update_icon = lucide::refresh_cw::<iced::Theme, iced::Renderer>()
.size(24)
.color(update_color);
let update_btn = button(update_icon)
.style(theme::button_text)
.padding(3)
.on_press_maybe(if clickable {
Some(Message::UpdateBot)
} else {
None
});
let update_tooltip = tooltip_text;
Some(
tooltip(
update_btn,
text(update_tooltip).size(11),
tooltip::Position::Top,
)
.style(theme::tooltip_style)
.into(),
)
}
fn render_nav_icons(&self) -> Element<'_, Message> {
let mut icons: Vec<Element<'_, Message>> = Vec::with_capacity(3);
for page in Page::footer_pages() {
let is_active = self.page == *page;
let color = if is_active {
theme::ACCENT
} else {
theme::TEXT_MUTED
};
let icon: Element<'_, Message> = page_icon(*page, 24, color);
let btn = button(icon)
.style(theme::button_text)
.padding(3)
.on_press(Message::Navigation(*page));
icons.push(
tooltip(btn, text(page.label()).size(11), tooltip::Position::Top)
.style(theme::tooltip_style)
.into(),
);
}
Row::with_children(icons)
.spacing(6)
.align_y(Alignment::Center)
.into()
}
fn render_git_divider() -> Element<'static, Message> {
rule::vertical(1)
.style(|_: &iced::Theme| rule::Style {
color: theme::TEXT_MUTED,
radius: 0.0.into(),
fill_mode: rule::FillMode::Padded(8),
snap: true,
})
.into()
}
fn render_git_branch(&self) -> Option<Element<'_, Message>> {
let b = self.git_state.current_branch()?;
let truncated = if b.len() > 20 {
format!("{}…", crate::util::truncate_bytes(b, 19))
} else {
b.to_string()
};
let branch_content = row![
lucide::git_branch::<iced::Theme, iced::Renderer>()
.size(24)
.color(theme::ACCENT),
text(truncated).size(16).color(theme::ACCENT),
]
.spacing(6)
.align_y(Alignment::Center);
Some(
tooltip(
button(branch_content)
.style(theme::button_text)
.padding(3)
.on_press(Message::Git(git::GitMessage::OpenModal)),
text("active branch").size(11),
tooltip::Position::Top,
)
.style(theme::tooltip_style)
.into(),
)
}
fn render_git_sync(&self) -> Option<Element<'_, Message>> {
let (behind, ahead) = self.git_state.behind_ahead()?;
if behind == 0 && ahead == 0 {
return None;
}
let sync_text_label: Element<'_, Message> = {
let mut parts: Vec<Element<'_, Message>> = Vec::new();
if ahead > 0 {
parts.push(
lucide::arrow_up::<iced::Theme, iced::Renderer>()
.size(16)
.color(theme::TEXT_MUTED)
.into(),
);
parts.push(
text(format!("{ahead}"))
.size(16)
.color(theme::TEXT_MUTED)
.into(),
);
}
if behind > 0 {
if ahead > 0 {
parts.push(Space::new().width(8).into());
}
parts.push(
lucide::arrow_down::<iced::Theme, iced::Renderer>()
.size(16)
.color(theme::TEXT_MUTED)
.into(),
);
parts.push(
text(format!("{behind}"))
.size(16)
.color(theme::TEXT_MUTED)
.into(),
);
}
Row::with_children(parts)
.spacing(2)
.align_y(Alignment::Center)
.into()
};
let sync_icon_color = if self.git_state.is_syncing() {
theme::TEXT_MUTED
} else {
theme::ACCENT
};
let sync_content = row![
lucide::refresh_cw::<iced::Theme, iced::Renderer>()
.size(24)
.color(sync_icon_color),
sync_text_label,
]
.spacing(6)
.align_y(Alignment::Center);
Some(
tooltip(
button(sync_content)
.style(theme::button_text)
.padding(3)
.on_press_maybe(if self.git_state.is_syncing() {
None
} else {
Some(Message::Git(git::GitMessage::Sync))
}),
text("sync commits pull and push").size(11),
tooltip::Position::Top,
)
.style(theme::tooltip_style)
.into(),
)
}
fn render_git_diff_stats(&self) -> Option<Element<'_, Message>> {
let (added, removed) = self.git_state.diff_stats()?;
if added == 0 && removed == 0 {
return None;
}
let stats_row = widgets::diff_stats_row::<Message>(added, removed, 15.0);
Some(
tooltip(
button(stats_row)
.style(theme::button_text)
.padding(3)
.on_press(Message::OpenDiffModal(None)),
text("uncommitted changes").size(11),
tooltip::Position::Top,
)
.style(theme::tooltip_style)
.into(),
)
}
fn render_git_block(&self) -> Option<Element<'_, Message>> {
if !self.git_state.has_filesystem_path() {
return None;
}
let mut elements: Vec<Element<'_, Message>> = Vec::with_capacity(4);
elements.push(Self::render_git_divider());
if let Some(el) = self.render_git_branch() {
elements.push(el);
}
if let Some(el) = self.render_git_sync() {
elements.push(el);
}
if let Some(el) = self.render_git_diff_stats() {
elements.push(el);
}
Some(
Row::with_children(elements)
.spacing(6)
.align_y(Alignment::Center)
.into(),
)
}
fn render_active_agents() -> Option<Element<'static, Message>> {
let handles = crate::registry::AGENT_REGISTRY.list();
let mut role_counts: std::collections::BTreeMap<&str, usize> =
std::collections::BTreeMap::new();
for h in &handles {
*role_counts.entry(h.role.as_str()).or_insert(0) += 1;
}
if role_counts.is_empty() {
return None;
}
let mut icons: Vec<Element<'_, Message>> = Vec::new();
for (role_str, count) in &role_counts {
let role: crate::Role = role_str.parse().unwrap_or(crate::Role::Engineer);
let (color, _bg) = theme::role_badge_color_for(&role);
let icon = theme::role_icon(&role).size(24).color(color);
let content: Element<'_, Message> = if *count > 1 {
container(
row![icon, text(format!("×{count}")).size(15).color(color)]
.spacing(3)
.align_y(Alignment::Center),
)
.padding([0, 3])
.into()
} else {
container(icon).padding([0, 3]).into()
};
let btn = button(content)
.style(theme::button_text)
.padding(0)
.on_press(Message::Navigation(Page::RunningAgents));
icons.push(
tooltip(
btn,
text(role.display_label()).size(11),
tooltip::Position::Top,
)
.style(theme::tooltip_style)
.into(),
);
}
Some(
Row::with_children(icons)
.spacing(12)
.align_y(Alignment::Center)
.into(),
)
}
fn render_non_agent_calls() -> Option<Element<'static, Message>> {
let handles = crate::call_registry::NON_AGENT_CALLS.list();
if handles.is_empty() {
return None;
}
let color = theme::ACCENT;
let content = container(
row![
lucide::zap::<iced::Theme, iced::Renderer>()
.size(24)
.color(color),
text(format!("×{}", handles.len())).size(15).color(color),
]
.spacing(3)
.align_y(Alignment::Center),
)
.padding([0, 3]);
let mut labels: Vec<&str> = handles
.iter()
.map(|h| crate::call_registry::call_kind_label(h.kind))
.collect();
labels.sort_unstable();
labels.dedup();
let tooltip_text = labels.join(", ");
let btn = button(content)
.style(theme::button_text)
.padding(0)
.on_press(Message::Navigation(Page::RunningAgents));
Some(
tooltip(btn, text(tooltip_text).size(11), tooltip::Position::Top)
.style(theme::tooltip_style)
.into(),
)
}
fn render_tts_download_progress(&self) -> Element<'_, Message> {
let Some((file_name, progress)) = &self.tts_download_progress else {
return Space::new().width(0).into();
};
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let pct = (progress * 100.0).round() as u32;
let label = format!("TTS: {file_name} {pct}%");
container(text(label).size(12).color(theme::TEXT_MUTED))
.padding([0, 12])
.into()
}
fn render_voice_status() -> Element<'static, Message> {
let label: String = match crate::audio::voice::get_status() {
VoiceStatus::Disabled => return Space::new().width(0).into(),
VoiceStatus::LoadingModels => "🔊 Loading…".into(),
VoiceStatus::ModelError => "🔊 ⚠ Model error".into(),
VoiceStatus::Listening => "🔊 Listening".into(),
VoiceStatus::Recording | VoiceStatus::RecordingManual => "🔊 Recording…".into(),
VoiceStatus::Transcribing => "🔊 Transcribing…".into(),
VoiceStatus::MicPermissionDenied => "🔊 No mic access".into(),
VoiceStatus::MicDisconnected => "🔊 Mic disconnected".into(),
VoiceStatus::Enrolling { sample, total, .. } => {
format!("🔊 Enrolling {sample}/{total}")
}
VoiceStatus::ListeningDuringEnrollment { sample, total } => {
format!("🔊 Listen… {sample}/{total}")
}
VoiceStatus::WaitingForSilenceDuringEnrollment { sample, total } => {
format!("🔊 Wait… {sample}/{total}")
}
VoiceStatus::EnrollingNegatives {
accumulated_secs,
target_secs,
..
} => {
format!("🔊 Collecting negatives {accumulated_secs}s/{target_secs}s")
}
VoiceStatus::Enrolled => "🔊 ✅ Enrolled".into(),
VoiceStatus::Error(msg) => format!("🔊 Error: {msg}"),
};
container(text(label).size(12).color(theme::TEXT_MUTED))
.padding([0, 12])
.into()
}
fn footer_view(&self) -> Element<'_, Message> {
let mut left_elements: Vec<Element<'_, Message>> = Vec::with_capacity(3);
if let Some(el) = self.render_update_button() {
left_elements.push(el);
}
left_elements.push(self.render_nav_icons());
if let Some(el) = self.render_git_block() {
left_elements.push(el);
}
let left = Row::with_children(left_elements)
.spacing(6)
.align_y(Alignment::Center);
let center = row![
self.render_tts_download_progress(),
Self::render_voice_status(),
]
.spacing(8)
.align_y(Alignment::Center);
let right = Row::with_children(
[Self::render_active_agents(), Self::render_non_agent_calls()]
.into_iter()
.flatten()
.collect::<Vec<_>>(),
)
.spacing(12)
.align_y(Alignment::Center);
let footer_row = row![left, center, Space::new().width(Length::Fill), right]
.align_y(Alignment::Center)
.padding([3, 18]);
container(footer_row)
.align_y(Alignment::Center)
.height(Length::Fixed(42.0))
.width(Length::Fill)
.style(theme::surface_container_style)
.into()
}
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct WindowState {
pub width: f32,
pub height: f32,
pub x: i32,
pub y: i32,
#[serde(default)]
pub selected_workspace: Option<String>,
#[serde(default)]
pub selected_user: Option<String>,
}
impl WindowState {
#[expect(clippy::cast_precision_loss)]
#[must_use]
pub const fn position(&self) -> iced::window::Position {
iced::window::Position::Specific(iced::Point::new(self.x as f32, self.y as f32))
}
}
impl Default for WindowState {
fn default() -> Self {
Self {
width: 1500.0,
height: 800.0,
x: -1,
y: -1,
selected_workspace: None,
selected_user: None,
}
}
}
#[must_use]
pub fn read_window_state() -> WindowState {
let dir = std::env::var("HOME")
.map(|h| std::path::PathBuf::from(h).join(".mahbot"))
.ok();
let path = dir.map(|d| d.join("window-state.json"));
path.as_ref()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|json| serde_json::from_str(&json).ok())
.unwrap_or_default()
}
#[expect(clippy::cast_possible_truncation)]
fn save_window_state(
pos: iced::Point,
size: iced::Size,
selected_workspace: Option<&str>,
selected_user: Option<&str>,
) {
let mut state = serde_json::json!({
"width": size.width,
"height": size.height,
"x": pos.x as i32,
"y": pos.y as i32,
});
if let Some(ws) = selected_workspace {
state["selected_workspace"] = serde_json::Value::String(ws.to_string());
}
if let Some(user) = selected_user {
state["selected_user"] = serde_json::Value::String(user.to_string());
}
if let Ok(dir) = std::env::var("HOME") {
let path = std::path::PathBuf::from(dir)
.join(".mahbot")
.join("window-state.json");
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, state.to_string());
}
}
fn refresh_workspace_states_task() -> Task<Message> {
Task::perform(
async {
let store = crate::workspace::store();
match store.list_states().await {
Ok(states) => Message::WorkspaceStatesRefreshed(states),
Err(e) => {
tracing::warn!("Failed to refresh workspace states: {e}");
Message::Nop
}
}
},
std::convert::identity,
)
}
async fn load_workspace_options(prev_selection: Option<String>) -> Message {
let store = crate::workspace::store();
let mut workspaces = HashMap::new();
let mut restored_name = None;
if let Ok(ws_list) = store.list().await {
for ws in &ws_list {
workspaces.insert(
ws.name.clone(),
WorkspaceInfo {
path: ws.path.clone(),
paused: ws.paused,
maintenance_enabled: ws.maintenance_enabled,
},
);
}
}
if let Some(ref name) = prev_selection {
if name.is_empty() || workspaces.contains_key(name.as_str()) {
restored_name = Some(name.clone());
}
}
if restored_name.is_none() {
restored_name = Some(String::new());
}
Message::BootWorkspaces(workspaces, restored_name)
}
fn open_url(url: &str) {
let _ = if cfg!(target_os = "macos") {
std::process::Command::new("open").arg(url).spawn()
} else if cfg!(target_os = "linux") {
std::process::Command::new("xdg-open").arg(url).spawn()
} else if cfg!(target_os = "windows") {
std::process::Command::new("cmd")
.args(["/c", "start", url])
.spawn()
} else {
return;
};
}
fn resolve_dashboard_workspace_path(
name: &str,
ws_path: Option<&str>,
personal_path: Option<&str>,
) -> (String, Option<String>) {
if let Some(p) = ws_path {
(name.to_string(), Some(p.to_string()))
} else if let Some(p) = personal_path {
(name.to_string(), Some(p.to_string()))
} else if name.is_empty() {
(String::new(), None)
} else {
tracing::warn!(
workspace = name,
"Workspace path not found in map — sending empty selection"
);
(String::new(), None)
}
}