#![expect(
clippy::struct_excessive_bools,
clippy::if_not_else,
clippy::collapsible_if
)]
pub(crate) mod alarms;
pub(crate) mod board;
pub(crate) mod common;
pub(crate) mod custom_tools;
pub(crate) mod dialog;
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 markdown_breaks;
pub(crate) mod media_markers;
pub(crate) mod menus;
pub(crate) mod running;
pub(crate) mod session_view;
pub(crate) mod sessions;
pub(crate) mod settings;
pub(crate) mod shell;
pub(crate) mod spinner;
pub(crate) mod text_rendering;
pub(crate) mod theme;
pub(crate) mod tool_failures;
pub(crate) mod users;
pub(crate) mod widgets;
pub(crate) mod workspaces;
pub(crate) use self::spinner::spinner;
use crate::logs::LogStore;
use crate::pipeline::board::Ticket;
use iced::keyboard;
use iced::widget::{
Column, Row, Space, button, column, container, image, pick_list, row, rule, text, tooltip,
};
use iced::window;
use iced::{Alignment, Element, Length, Task};
use crate::Role;
use crate::Workspace;
#[cfg(target_os = "macos")]
use crate::audio::voice::VoiceStatus;
use self::menus::ContextMenu;
use iced_fonts::lucide;
pub use theme::JETBRAINS_MONO;
use std::collections::{HashMap, HashSet};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tokio::sync::broadcast;
use futures_util::StreamExt;
const RUNTIME_REFRESH_COALESCE: Duration = Duration::from_millis(250);
const BOOT_EXIT_WAIT: Duration = Duration::from_secs(15);
pub static LOG_BROADCAST: OnceLock<broadcast::Sender<String>> = OnceLock::new();
pub fn init_git_file_change_tx() {
git::init_file_change_tx();
}
pub fn init_git_commit_tx() {
crate::git::commands::init_git_commit_tx();
}
pub fn init_board_change_tx() {
let _ = crate::db::cdc::ticket_sender();
}
pub fn init_workspace_tx() {
crate::db::cdc::init_workspace_tx();
}
pub fn init_users_tx() {
crate::db::cdc::init_users_tx();
}
pub fn init_user_channels_tx() {
crate::db::cdc::init_user_channels_tx();
}
pub fn init_runtime_event_tx() {
crate::runtime_events::init_runtime_event_tx();
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Page {
Home,
Sessions,
Logs,
Shell,
Editor,
Settings,
Alarms,
RunningAgents,
}
impl Page {
const fn sidebar_pages() -> &'static [Page] {
&[Page::Home, Page::Editor, Page::Shell, Page::Alarms]
}
const fn sidebar_bottom_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::Alarms => "Alarms",
Page::RunningAgents => "Running Agents",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ToastKind {
Success,
Warning,
Error,
}
static NEXT_TOAST_ID: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone)]
pub(crate) struct Toast {
pub id: u64,
pub message: String,
pub kind: ToastKind,
}
#[derive(Debug, Clone)]
pub enum ToastMessage {
Saved,
Deleted,
Error(String),
Warning(String),
SuccessMsg(String),
}
impl Toast {
fn new(message: String, kind: ToastKind) -> Self {
Self {
id: NEXT_TOAST_ID.fetch_add(1, Ordering::Relaxed),
message,
kind,
}
}
fn from_toast_msg(msg: &ToastMessage) -> Self {
match msg {
ToastMessage::Saved => Toast::new("Saved".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),
ToastExpired(u64),
RuntimeChanged,
WorkspacesCdcChanged,
UsersCdcChanged,
ActiveWorkspaceSynced {
generation: u64,
name: Option<String>,
},
Shutdown,
DrainStarted,
CloseRequested(window::Id),
QuitRequested,
WindowEvent(window::Id, window::Event),
#[cfg(target_os = "windows")]
WindowOpened(window::Id),
#[cfg(target_os = "windows")]
WindowHandle(u64),
FocusSearch,
EscapePressed,
UpdateBot,
ConfirmUpdate,
CancelUpdate,
UpdateResult(Result<String, String>),
Toggle(ToggleKind),
WorkspacePick(String),
ToggleResult(ToggleKind, Result<(), String>, String, bool),
Nop,
BootWorkspaces {
workspaces: Result<HashMap<String, Workspace>, String>,
restored_name: String,
generation: u64,
},
BootExitWaitExpired,
WorkspacesReloaded {
workspaces: HashMap<String, Workspace>,
generation: u64,
},
WorkspacesReloadFailed {
error: String,
generation: u64,
},
Home(home::HomeMessage),
Logs(logs::LogMessage),
Board(board::BoardMessage),
Sessions(sessions::SessionsMessage),
Alarms(alarms::AlarmsMessage),
RunningAgents(running::RunningMessage),
DiffModal(diff::DiffMessage),
Git(git::GitMessage),
Shell(shell::ShellMessage),
Editor(editor::EditorMessage),
Settings(settings::SettingsMessage),
OpenDiffModal(Option<String>),
CloseDiffModal,
#[cfg(target_os = "macos")]
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::Sessions(sessions::SessionsMessage::Toast(tm))
| Message::Alarms(alarms::AlarmsMessage::Toast(tm))
| Message::Settings(
settings::SettingsMessage::WorkspaceMsg(workspaces::WorkspacesMessage::Toast(tm))
| settings::SettingsMessage::UserMsg(users::UsersMessage::Toast(tm)),
) => Some(tm),
_ => None,
}
}
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();
pub struct Dashboard {
ready: bool,
boot_error: Option<String>,
exit_after_boot: bool,
page: Page,
log_store: Option<LogStore>,
last_size: iced::Size,
last_position: iced::Point,
toasts: Vec<Toast>,
workspaces: HashMap<String, Workspace>,
workspaces_error: Option<String>,
workspaces_reload_gen: u64,
active_workspace_gen: u64,
pushed_workspace: Option<(String, String)>,
selected_workspace_name: Option<String>,
exit_requested_during_update: bool,
draining: bool,
show_update_confirm: bool,
pending_research_cancel: Option<String>,
running_expanded: HashSet<(String, u64)>,
logs_state: logs::LogsState,
board_state: board::BoardState,
sessions_state: sessions::SessionsState,
alarms_state: alarms::AlarmsState,
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,
#[cfg(target_os = "macos")]
tts_download_progress: Option<(String, f32)>,
}
impl Dashboard {
#[must_use]
pub fn loading() -> Self {
Self {
ready: false,
boot_error: None,
exit_after_boot: false,
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(),
workspaces_error: None,
workspaces_reload_gen: 0,
active_workspace_gen: 0,
pushed_workspace: None,
selected_workspace_name: None,
exit_requested_during_update: false,
draining: false,
show_update_confirm: false,
pending_research_cancel: None,
running_expanded: HashSet::new(),
logs_state: logs::LogsState::new(),
board_state: board::BoardState::new(),
sessions_state: sessions::SessionsState::new(),
alarms_state: alarms::AlarmsState::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(),
#[cfg(target_os = "macos")]
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 boot_workspaces = Task::perform(
load_workspace_options(self.workspaces_reload_gen),
std::convert::identity,
);
Task::batch([
refresh_logs.map(Message::Logs),
refresh_board.map(Message::Board),
boot_workspaces,
])
}
Err(e) => {
self.boot_error = Some(e);
Task::none()
}
}
}
fn selected_workspace_info(&self) -> Option<&Workspace> {
self.selected_workspace_name
.as_ref()
.and_then(|name| self.workspaces.get(name))
}
fn resolved_workspace(&self) -> Option<(&str, &str)> {
let name = self.selected_workspace_name.as_deref()?;
if crate::users::is_personal_workspace(name) {
return None;
}
let path = self.workspaces.get(name)?.path.as_str();
(!path.is_empty()).then_some((name, path))
}
fn page_available(&self, page: Page) -> bool {
!matches!(page, Page::Editor | Page::Shell) || self.resolved_workspace().is_some()
}
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);
}
fn request_exit(&mut self, route: Option<&'static str>) -> Task<Message> {
if crate::self_update::update_in_progress() && crate::self_update::update_is_finalizing() {
if !self.exit_requested_during_update {
record_exit_route(route);
}
self.persist_window_state();
self.exit_requested_during_update = true;
Task::none()
} else if !self.ready {
if self.boot_error.is_some() {
record_exit_route(route);
self.save_and_exit()
} else if self.exit_after_boot {
Task::none()
} else {
record_exit_route(route);
self.exit_after_boot = true;
Task::perform(async { tokio::time::sleep(BOOT_EXIT_WAIT).await }, |()| {
Message::BootExitWaitExpired
})
}
} else if crate::shutdown::is_draining() {
if !crate::shutdown::shutdown_token().is_cancelled() {
record_exit_route(route);
}
crate::shutdown::force_cancel();
Task::none()
} else {
record_exit_route(route);
crate::shutdown::drain_begin();
Task::none()
}
}
fn save_and_exit(&self) -> Task<Message> {
crate::channels::chat_draft::flush_global();
self.persist_window_state();
if crate::self_update::update_in_progress() && crate::self_update::update_is_finalizing() {
return Task::none();
}
crate::shutdown::force_cancel();
iced::exit()
}
pub fn title(&self) -> String {
let page_name = self.page.label();
format!("MahBot — {page_name}")
}
fn apply_boot_workspaces(
&mut self,
workspaces: Result<HashMap<String, Workspace>, String>,
restored_name: &str,
read_generation: u64,
) -> Task<Message> {
if read_generation == self.workspaces_reload_gen {
self.apply_boot_workspace_map(workspaces);
}
self.selected_workspace_name = Some(restored_name.to_owned());
self.propagate_workspace_selection(restored_name)
}
fn navigate_to(&mut self, page: Page) -> Task<Message> {
self.page = page;
let visibility = Task::done(Message::Editor(
editor::EditorMessage::PageVisibilityChanged(self.page == Page::Editor),
));
let refresh = match page {
Page::Logs | Page::Shell | Page::Editor | Page::RunningAgents => Task::none(),
Page::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([snap, board_refresh])
}
Page::Sessions => sessions::SessionsState::refresh().map(Message::Sessions),
Page::Alarms => self.alarms_state.refresh().map(Message::Alarms),
Page::Settings => {
self.settings_state.refresh();
self.sync_settings_workspaces_from_map();
Task::batch([
self.refresh_settings_users(),
self.refresh_settings_custom_tools(),
])
}
};
Task::batch([visibility, refresh])
}
fn refresh_settings_users(&self) -> Task<Message> {
self.settings_state
.users_state
.refresh()
.map(|msg| Message::Settings(settings::SettingsMessage::UserMsg(msg)))
}
fn refresh_settings_custom_tools(&mut self) -> Task<Message> {
self.settings_state
.custom_tools_state
.refresh()
.map(|msg| Message::Settings(settings::SettingsMessage::CustomToolsMsg(msg)))
}
fn sync_settings_workspaces_from_map(&mut self) {
let mut list: Vec<Workspace> = self.workspaces.values().cloned().collect();
list.sort_by(|a, b| a.name.cmp(&b.name));
self.settings_state.workspaces_state.workspaces = list;
}
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 {
return self.push_toast(
"No workspace selected — select a workspace first".to_string(),
ToastKind::Warning,
);
};
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()
};
let toast = self.push_toast(format!("{label} for {ws_name}"), ToastKind::Success);
let refresh = self.reload_workspace_map();
Task::batch([toast, refresh])
}
Err(e) => self.push_toast(format!("{}: {e}", kind.label_err()), ToastKind::Error),
}
}
fn push_toast(&mut self, message: String, kind: ToastKind) -> Task<Message> {
self.spawn_toast(Toast::new(message, kind))
}
fn push_toast_msg(&mut self, msg: &ToastMessage) -> Task<Message> {
self.spawn_toast(Toast::from_toast_msg(msg))
}
fn spawn_toast(&mut self, toast: Toast) -> Task<Message> {
let id = toast.id;
let duration = toast.duration();
self.toasts.push(toast);
Task::perform(
async move { tokio::time::sleep(duration).await },
move |()| Message::ToastExpired(id),
)
}
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 | Page::Alarms => 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 process_settings_message(&mut self, msg: settings::SettingsMessage) -> Task<Message> {
let needs_global_reload = matches!(
msg,
settings::SettingsMessage::WorkspaceMsg(
workspaces::WorkspacesMessage::DeleteResult(Ok(()))
| workspaces::WorkspacesMessage::ReanalyzeResult(Ok(()))
| workspaces::WorkspacesMessage::DiagnosticsSaved(_, Ok(()))
| workspaces::WorkspacesMessage::RediscoverDiagnosticsResult(_, Ok(()))
| workspaces::WorkspacesMessage::NotesSaved(_, Ok(()))
) | settings::SettingsMessage::AddWorkspaceResult(Ok(_))
);
let settings_task = self.settings_state.update(msg).map(Message::Settings);
if needs_global_reload {
Task::batch([settings_task, self.reload_workspace_map()])
} else {
settings_task
}
}
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 },
|()| Message::RunningAgents(running::RunningMessage::CancelFinished),
)
}
running::RunningMessage::CancelDismissed => {
self.pending_research_cancel = None;
Task::none()
}
running::RunningMessage::CancelFinished => {
self.push_toast_msg(&ToastMessage::SuccessMsg(
"Research run cancelled — agents stopped; the run's temporary \
traces are being removed."
.to_string(),
))
}
running::RunningMessage::ToggleAgentExpanded {
agent_id,
generation,
} => {
if self
.running_expanded
.contains(&(agent_id.clone(), generation))
{
self.running_expanded.remove(&(agent_id, generation));
} else {
self.running_expanded.insert((agent_id, generation));
}
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() {
return self.push_toast_msg(tm);
}
if let Some(url) = message.as_link_url() {
open_url(url);
return Task::none();
}
}
match message {
Message::Boot(result) => {
if std::mem::take(&mut self.exit_after_boot) {
return self.save_and_exit();
}
self.finish_boot(result)
}
Message::BootExitWaitExpired => {
self.save_and_exit()
}
Message::BootWorkspaces {
workspaces,
restored_name,
generation,
} => self.apply_boot_workspaces(workspaces, &restored_name, generation),
Message::CloseRequested(_) => self.request_exit(None),
Message::QuitRequested => self.request_exit(Some(crate::shutdown::MACOS_QUIT_TRIGGER)),
Message::Shutdown => self.save_and_exit(),
Message::DrainStarted => {
if !self.draining
&& crate::self_update::update_in_progress()
&& crate::self_update::update_is_finalizing()
{
let toast = self.push_toast(
"Update complete — restarting…".to_string(),
ToastKind::Warning,
);
self.draining = true;
return toast;
}
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(),
},
#[cfg(target_os = "windows")]
Message::WindowOpened(id) => window::raw_id::<Message>(id).map(Message::WindowHandle),
#[cfg(target_os = "windows")]
Message::WindowHandle(raw) => {
crate::app_icon::apply_window_icons(raw);
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::WorkspacePick(name) => self.select_workspace(&name),
Message::ToastExpired(id) => {
self.toasts.retain(|t| t.id != id);
Task::none()
}
Message::RuntimeChanged => {
running::prune_expanded(
&mut self.running_expanded,
&crate::agent::registry::AGENT_REGISTRY.list(),
);
if self.page == Page::Sessions {
self.sessions_state
.update(sessions::SessionsMessage::RuntimeChanged)
.map(Message::Sessions)
} else {
Task::none()
}
}
Message::Home(msg) => 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::Alarms(msg) => self.alarms_state.update(msg).map(Message::Alarms),
Message::RunningAgents(msg) => self.process_running_message(msg),
Message::DiffModal(diff::DiffMessage::CloseModal) => {
self.show_diff_modal = false;
Task::none()
}
Message::DiffModal(msg) => {
let commit_succeeded = matches!(&msg, diff::DiffMessage::CommitResult(Ok(_)));
let diff_task = self.diff_state.update(msg).map(Message::DiffModal);
if commit_succeeded {
Task::batch([
diff_task,
self.git_state
.update(git::GitMessage::RefreshAfterCommit)
.map(Message::Git),
])
} else {
diff_task
}
}
Message::Editor(msg) => {
if matches!(msg, editor::EditorMessage::RequestGitStatusRefresh) {
self.git_state
.update(git::GitMessage::FileChanged)
.map(Message::Git)
} else {
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;
}
let snapshot = matches!(msg, git::GitMessage::WorktreeSnapshot(..));
let task = self.git_state.update(msg).map(Message::Git);
if snapshot {
if let Some((workspace, statuses)) = self.git_state.take_status_forward() {
return Task::batch([
task,
Task::done(Message::Editor(editor::EditorMessage::GitStatusUpdated {
workspace,
statuses,
})),
]);
}
}
task
}
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 => {
let availability = crate::self_update::update_availability();
if availability.available && !availability.in_progress {
self.show_update_confirm = true;
}
Task::none()
}
Message::ConfirmUpdate => {
self.show_update_confirm = false;
let availability = crate::self_update::update_availability();
if !availability.available || availability.in_progress {
return Task::none();
}
self.persist_window_state();
let toast = self.push_toast("Update started…".to_string(), ToastKind::Success);
Task::batch([
toast,
Task::perform(
async {
match crate::self_update::execute_update().await {
Ok(()) => Ok("ok".to_string()),
Err(e) => {
let msg = format!("❌ Update failed:\n{e:#}");
let target =
crate::self_update::resolve_admin_telegram_target().await;
crate::self_update::notify_admin(
&crate::self_update::update_failure_notification(&e),
target.as_deref(),
)
.await;
Err(msg)
}
}
},
Message::UpdateResult,
),
])
}
Message::CancelUpdate => {
self.show_update_confirm = false;
Task::none()
}
Message::UpdateResult(result) => {
if let Err(err) = result {
let toast = self.push_toast_msg(&ToastMessage::Error(err));
if self.exit_requested_during_update {
self.exit_requested_during_update = false;
return Task::batch([toast, self.save_and_exit()]);
}
return toast;
}
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::WorkspacesCdcChanged => self.reload_workspace_map(),
Message::WorkspacesReloaded {
workspaces,
generation,
} => {
if generation != self.workspaces_reload_gen {
return Task::none();
}
self.apply_workspace_reload(Ok(workspaces));
let unknown = self.selected_workspace_name.as_deref().is_some_and(|name| {
!self.workspaces.contains_key(name)
&& !crate::users::is_personal_workspace(name)
});
if unknown {
return self.sync_workspace_surfaces();
}
let personal_name =
crate::users::personal_workspace_name(crate::users::ADMIN_USER_NAME);
let resolved = resolve_workspace_selection(
&self.workspaces,
self.selected_workspace_name.as_deref(),
&personal_name,
);
let current = self.selected_workspace_name.as_deref().unwrap_or_default();
if current != resolved.as_str() {
self.selected_workspace_name = Some(resolved.clone());
return self.propagate_workspace_selection(&resolved);
}
self.sync_workspace_surfaces()
}
Message::UsersCdcChanged => {
self.active_workspace_gen += 1;
let generation = self.active_workspace_gen;
Task::batch([
self.refresh_settings_users(),
Task::perform(read_active_workspace(), move |name| {
Message::ActiveWorkspaceSynced { generation, name }
}),
])
}
Message::ActiveWorkspaceSynced {
generation,
name: Some(name),
} => {
if generation != self.active_workspace_gen
|| self.selected_workspace_name.as_deref() == Some(name.as_str())
{
Task::none()
} else {
self.apply_workspace_selection(&name)
}
}
Message::ActiveWorkspaceSynced { name: None, .. } => Task::none(),
Message::WorkspacesReloadFailed { error, generation } => {
if generation != self.workspaces_reload_gen {
return Task::none();
}
self.apply_workspace_reload(Err(error));
Task::none()
}
Message::Nop => Task::none(),
#[cfg(target_os = "macos")]
Message::TtsDownloadEvent(event) => self.handle_tts_download_event(event),
}
}
#[cfg(target_os = "macos")]
#[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> {
if self.resolved_workspace().is_none() {
return Task::none();
}
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 diff_task = match commit_hash {
Some(hash) => Task::done(Message::DiffModal(diff::DiffMessage::NavigateToCommit(
hash,
))),
None => Task::done(Message::DiffModal(diff::DiffMessage::BackToWorkingTree)),
};
Task::batch([close_board, diff_task])
}
fn apply_workspace_selection(&mut self, name: &str) -> Task<Message> {
self.selected_workspace_name = Some(name.to_string());
self.propagate_workspace_selection(name)
}
fn select_workspace(&mut self, name: &str) -> Task<Message> {
let propagate = self.apply_workspace_selection(name);
let known = crate::users::is_personal_workspace(name) || self.workspaces.contains_key(name);
self.active_workspace_gen += 1;
let db_write = known.then(|| {
let ws = name.to_string();
Task::perform(
async move {
if let Err(e) = users::set_selected_workspace_field(
crate::users::ADMIN_USER_NAME.to_string(),
ws,
)
.await
{
tracing::warn!(error = %e, "Failed to persist workspace selection");
}
},
|()| Message::Nop,
)
});
Task::batch(std::iter::once(propagate).chain(db_write))
}
fn propagate_workspace_selection(&mut self, name: &str) -> Task<Message> {
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_error = None;
self.board_state.search_generation += 1;
self.board_state.load_state.clear_error();
self.board_state.board_generation += 1;
self.board_state.replace_on_refresh = true;
self.board_state.delta_removed_ids.clear();
let board_refresh = self.board_state.refresh().map(Message::Board);
let home_task: Task<Message> =
Task::done(home::HomeMessage::WorkspaceChanged(Some(name.to_string())))
.map(Message::Home);
let surfaces = self.sync_workspace_surfaces();
Task::batch([board_refresh, home_task, surfaces])
}
fn sync_workspace_surfaces(&mut self) -> Task<Message> {
let resolved = self
.resolved_workspace()
.map(|(name, path)| (name.to_string(), path.to_string()));
if resolved.is_none() {
self.show_diff_modal = false;
}
if resolved == self.pushed_workspace {
return Task::none();
}
self.pushed_workspace.clone_from(&resolved);
if let Some((name, path)) = resolved {
Task::batch([
Task::done(editor::EditorMessage::WorkspaceSelected(
name.clone(),
path.clone(),
))
.map(Message::Editor),
Task::done(shell::ShellMessage::WorkspaceSelected(
name.clone(),
path.clone(),
))
.map(Message::Shell),
self.git_state
.set_workspace_path(Some(name.clone()), Some(path.clone()))
.map(Message::Git),
Task::done(diff::DiffMessage::WorkspaceSelected(path)).map(Message::DiffModal),
])
} else {
Task::batch([
self.git_state
.set_workspace_path(None, None)
.map(Message::Git),
Task::done(diff::DiffMessage::WorkspaceUnresolved).map(Message::DiffModal),
])
}
}
fn reload_workspace_map(&mut self) -> Task<Message> {
self.workspaces_reload_gen = self.workspaces_reload_gen.wrapping_add(1);
let generation = self.workspaces_reload_gen;
Task::perform(load_workspace_map(generation), std::convert::identity)
}
fn apply_boot_workspace_map(&mut self, result: Result<HashMap<String, Workspace>, String>) {
match result {
Ok(map) => {
self.workspaces = map;
self.workspaces_error = None;
}
Err(e) => {
self.workspaces = HashMap::new();
self.workspaces_error = Some(e);
}
}
self.sync_settings_workspaces_from_map();
}
fn apply_workspace_reload(&mut self, result: Result<HashMap<String, Workspace>, String>) {
match result {
Ok(map) => {
self.workspaces = map;
self.workspaces_error = None;
}
Err(e) => self.workspaces_error = Some(e),
}
self.sync_settings_workspaces_from_map();
}
fn active_workspace_name(&self) -> Option<String> {
match self.selected_workspace_name.as_deref() {
Some(n) if !crate::users::is_personal_workspace(n) => Some(n.to_string()),
_ => None,
}
}
fn has_active_workspace(&self) -> bool {
self.selected_workspace_name
.as_deref()
.is_some_and(|n| !crate::users::is_personal_workspace(n))
}
fn overlay_modal_open(&self) -> bool {
self.show_diff_modal || self.show_update_confirm || self.git_state.is_modal_open()
}
#[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(theme::TEXT_14).color(theme::TEXT_SECONDARY),
]
.spacing(theme::SPACE_12)
.padding(theme::PAGE_PADDING),
)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into();
}
if !self.ready {
return container(
column![
image(crate::app_icon::widget_image()).width(96).height(96),
text("MahBot")
.size(theme::TEXT_24)
.color(theme::TEXT_PRIMARY),
text("Starting…")
.size(theme::TEXT_16)
.color(theme::TEXT_MUTED),
]
.spacing(theme::SPACE_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.draining).map(Message::Home);
let sidebar = ticket_sidebar(&self.board_state);
let home_view: Element<'_, Message> = ContextMenu::new(
home_view,
vec![menus::MenuItem::new(
"Reset session".into(),
Message::Home(home::HomeMessage::ClearChat),
)],
)
.into();
let mut menu_items = vec![menus::MenuItem::new(
"Archive done & cancelled".into(),
Message::Board(board::BoardMessage::ArchiveAllCompleted),
)];
if self.board_state.has_visible_queued_tickets() {
menu_items.push(menus::MenuItem::new(
"Pause all queued tickets".into(),
Message::Board(board::BoardMessage::PauseAllQueued),
));
}
let sidebar: Element<'_, Message> = ContextMenu::new(sidebar, menu_items).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::Alarms => self.alarms_state.view().map(Message::Alarms),
Page::Shell => {
if self.page_available(Page::Shell) {
self.shell_state.view().map(Message::Shell)
} else {
unresolved_workspace_page(Page::Shell)
}
}
Page::Editor => {
if self.page_available(Page::Editor) {
self.editor_state
.view(self.overlay_modal_open())
.map(Message::Editor)
} else {
unresolved_workspace_page(Page::Editor)
}
}
Page::Settings => self
.settings_state
.view(self.workspaces_error.as_deref())
.map(Message::Settings),
Page::RunningAgents => running::view(
&self.workspaces,
self.pending_research_cancel.as_deref(),
&self.running_expanded,
),
};
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() {
widgets::empty_stack_placeholder()
} else {
let mut toast_col = Column::new()
.spacing(theme::SPACE_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(theme::TEXT_12).color(color))
.padding([theme::PAD_6, theme::PAD_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 {
widgets::empty_stack_placeholder()
};
let branch_overlay: Element<'_, Message> = if self.git_state.is_modal_open() {
let dialog = dialog::dialog_shell(self.git_state.view().map(Message::Git), 400.0, 24.0);
widgets::modal_backdrop(dialog, Message::Git(git::GitMessage::CloseModal), 0.5)
} else {
widgets::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)
.height(Length::Fill)
.padding(theme::PAD_16)
.style(theme::dialog_container_style);
let centered = row![
Space::new().width(Length::FillPortion(1)), widgets::dialog_click_guard(dialog.width(Length::FillPortion(8))), Space::new().width(Length::FillPortion(1)), ]
.width(Length::Fill)
.height(Length::Fill);
iced::widget::stack([
widgets::modal_backdrop_layer(on_close, 0.5),
centered.into(),
])
.into()
}
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![
widgets::section_heading(msg),
text(hash).size(theme::TEXT_12).color(theme::TEXT_SECONDARY),
]
.spacing(theme::SPACE_2)
.padding(iced::Padding {
bottom: theme::PAD_12,
..iced::Padding::ZERO
})
.into()
} else {
column![
widgets::section_heading("Uncommitted changes"),
text("Working tree diff \u{2014} press Escape to close")
.size(theme::TEXT_11)
.color(theme::TEXT_SECONDARY),
]
.spacing(theme::SPACE_4)
.padding(iced::Padding {
bottom: theme::PAD_12,
..iced::Padding::ZERO
})
.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.text().is_empty();
let search_input = widgets::single_line_editor(
&board_state.search_query.buffer,
"Search tickets…",
false,
Length::Fill,
Some(iced::widget::Id::new("board_ticket_search")),
|action| Message::Board(board::BoardMessage::SearchInputChanged(action)),
);
let clear_btn = widgets::icon_tooltip_button(
text("×").size(theme::TEXT_14),
"clear search",
Some(Message::Board(board::BoardMessage::SearchCleared)),
theme::PAD_4,
theme::button_text,
tooltip::Position::Top,
);
let mut search_row_children: Vec<Element<'_, Message>> = Vec::with_capacity(2);
search_row_children.push(search_input);
if search_active {
search_row_children.push(clear_btn);
}
let search_row = Row::with_children(search_row_children)
.spacing(theme::SPACE_4)
.padding([0.0, theme::PAD_8])
.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([theme::PAD_8, theme::PAD_4])
.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(theme::TEXT_12)
.color(theme::TEXT_SECONDARY),
]
.spacing(theme::SPACE_4)
.padding(theme::PAD_8)
.into()
}
fn render_normal_ticket_list(board_state: &board::BoardState) -> Element<'_, Message> {
if let Some(err) = board_state.load_state.error() {
return widgets::error_banner(err);
}
let [in_progress, queued, pending, completed] =
board::BoardState::board_sections(&board_state.tickets);
let is_empty =
in_progress.is_empty() && queued.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(theme::SPACE_8);
if !in_progress.is_empty() {
groups = groups.push(group_section("In Progress", &in_progress, board_state));
}
if !queued.is_empty() {
groups = groups.push(group_section("Queued", &queued, 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));
}
widgets::vscroll(groups)
}
}
fn render_search_results(board_state: &board::BoardState) -> Element<'_, Message> {
if let Some(err) = board_state.search_error.as_deref() {
return widgets::error_banner(err);
}
if board_state.search_results.is_empty() {
section_hint("No matching tickets")
} else {
let mut cards = Column::new().spacing(theme::SPACE_2);
for ticket in &board_state.search_results {
cards = cards.push(board_state.render_ticket_card(ticket).map(Message::Board));
}
widgets::vscroll(cards)
}
}
fn group_section<'a>(
label: &'static str,
tickets: &[&'a Ticket],
board_state: &'a board::BoardState,
) -> Element<'a, Message> {
let header = text(label)
.size(theme::TEXT_11)
.color(theme::TEXT_SECONDARY);
let mut cards = Column::new().spacing(theme::SPACE_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 always_on = iced::Subscription::batch([
window::resize_events()
.map(|(id, size)| Message::WindowEvent(id, window::Event::Resized(size))),
window::events().filter_map(|(id, event)| match &event {
window::Event::Moved(_) => Some(Message::WindowEvent(id, event)),
#[cfg(target_os = "windows")]
window::Event::Opened { .. } => Some(Message::WindowOpened(id)),
_ => None,
}),
window::close_requests().map(Message::CloseRequested),
iced::Subscription::run(quit_requests_subscription),
]);
if !self.ready {
return always_on;
}
iced::Subscription::batch([
always_on,
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),
if self.page_available(Page::Editor) {
self.editor_state
.subscription(self.overlay_modal_open())
.map(Message::Editor)
} else {
iced::Subscription::none()
},
self.home_state.subscription().map(Message::Home),
match self.page {
Page::Alarms => iced::time::every(common::POLLED_LIST_REFRESH_INTERVAL)
.map(|_| Message::Alarms(alarms::AlarmsMessage::Tick)),
Page::Settings => {
iced::time::every(common::POLLED_LIST_REFRESH_INTERVAL).map(|_| {
Message::Settings(settings::SettingsMessage::CustomToolsMsg(
custom_tools::CustomToolsMessage::Tick,
))
})
}
_ => iced::Subscription::none(),
},
iced::Subscription::run(shutdown_subscription),
iced::Subscription::run(git_file_changes_subscription),
iced::Subscription::run(git_commit_subscription),
iced::time::every(git::REMOTE_REFRESH_INTERVAL)
.map(|_| Message::Git(git::GitMessage::PeriodicSyncTick)),
iced::Subscription::run(board_change_subscription),
iced::Subscription::run(workspaces_cdc_subscription),
iced::Subscription::run(users_cdc_subscription),
iced::Subscription::run(user_channels_cdc_subscription),
iced::Subscription::run(runtime_change_subscription),
#[cfg(target_os = "macos")]
iced::Subscription::run(tts_download_subscription).map(Message::TtsDownloadEvent),
if self.show_diff_modal {
self.diff_state.subscription().map(Message::DiffModal)
} else {
iced::Subscription::none()
},
])
}
}
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();
tokio::select! {
() = crate::shutdown::drain_wait() => {
let _ = output.try_send(Message::DrainStarted);
}
() = token.cancelled() => {}
}
token.cancelled().await;
let _ = output.try_send(Message::Shutdown);
})
}
fn record_exit_route(route: Option<&'static str>) {
if let Some(route) = route {
crate::shutdown::record_exit_trigger(route);
}
}
fn quit_requests_subscription() -> impl futures_util::Stream<Item = Message> {
use futures_util::SinkExt;
use iced::futures::channel::mpsc;
iced::stream::channel(1, |mut output: mpsc::Sender<Message>| async move {
let Some(mut requests) = crate::shutdown::take_quit_requests() else {
return;
};
while requests.recv().await.is_some() {
if output.send(Message::QuitRequested).await.is_err() {
return;
}
}
})
}
#[cfg(target_os = "macos")]
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 runtime_change_subscription() -> impl futures_util::Stream<Item = Message> {
common::coalesced_broadcast_producer(
128,
&crate::runtime_events::RUNTIME_EVENTS,
RUNTIME_REFRESH_COALESCE,
|| Message::RuntimeChanged,
)
}
fn git_file_changes_subscription() -> impl futures_util::Stream<Item = Message> {
use iced::futures::channel::mpsc;
common::broadcast_stream_producer(
1,
&git::FILE_CHANGE_TX,
|output: &mut mpsc::Sender<Message>, event: Option<()>| {
Box::pin(async move {
if event.is_some() {
let _ = output.try_send(Message::Git(git::GitMessage::FileChanged));
}
})
},
)
}
fn git_commit_subscription() -> impl futures_util::Stream<Item = Message> {
use iced::futures::channel::mpsc;
common::broadcast_stream_producer(
1,
&crate::git::commands::GIT_COMMIT_TX,
|output: &mut mpsc::Sender<Message>, event: Option<std::path::PathBuf>| {
Box::pin(async move {
if let Some(path) = event {
let _ = output.try_send(Message::Git(git::GitMessage::PipelineCommit(path)));
}
})
},
)
}
fn board_change_subscription() -> impl futures_util::Stream<Item = Message> {
use iced::futures::channel::mpsc;
common::broadcast_stream_producer(
128,
crate::db::cdc::ticket_sender_lock(),
|output: &mut mpsc::Sender<Message>, event: Option<crate::db::cdc::ChangeEvent>| {
Box::pin(async move {
let msg = Message::Board(match event {
Some(ev) => board::BoardMessage::TicketChanged(Box::new(ev)),
None => board::BoardMessage::BoardRefreshNeeded,
});
let _ = futures_util::SinkExt::send(output, msg).await;
})
},
)
}
fn workspaces_cdc_subscription() -> impl futures_util::Stream<Item = Message> {
use iced::futures::channel::mpsc;
futures_util::stream::once(async { Message::WorkspacesCdcChanged }).chain(
common::broadcast_stream_producer(
128,
crate::db::cdc::workspace_sender_lock(),
|output: &mut mpsc::Sender<Message>, _event: Option<crate::db::cdc::ChangeEvent>| {
Box::pin(async move {
let _ =
futures_util::SinkExt::send(output, Message::WorkspacesCdcChanged).await;
})
},
),
)
}
fn users_cdc_subscription() -> impl futures_util::Stream<Item = Message> {
use iced::futures::channel::mpsc;
futures_util::stream::once(async { Message::UsersCdcChanged }).chain(
common::broadcast_stream_producer(
128,
crate::db::cdc::users_sender_lock(),
|output: &mut mpsc::Sender<Message>, _event: Option<crate::db::cdc::ChangeEvent>| {
Box::pin(async move {
let _ = futures_util::SinkExt::send(output, Message::UsersCdcChanged).await;
})
},
),
)
}
fn user_channels_cdc_subscription() -> impl futures_util::Stream<Item = Message> {
use iced::futures::channel::mpsc;
common::broadcast_stream_producer(
128,
crate::db::cdc::user_channels_sender_lock(),
|output: &mut mpsc::Sender<Message>, _event: Option<crate::db::cdc::ChangeEvent>| {
Box::pin(async move {
let _ = futures_util::SinkExt::send(output, Message::UsersCdcChanged).await;
})
},
)
}
fn page_icon(page: Page) -> iced::widget::Text<'static, iced::Theme, iced::Renderer> {
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::Alarms => lucide::bell::<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>(),
}
}
fn unresolved_workspace_page(page: Page) -> Element<'static, Message> {
widgets::empty_state_placeholder(
page_icon(page),
"Select a workspace to open this page",
theme::TEXT_MUTED,
)
}
impl Dashboard {
fn sidebar_view(&self) -> Element<'_, Message> {
let col = column![
self.render_sidebar_nav(),
Space::new().height(Length::Fill),
self.render_sidebar_bottom_nav(),
];
container(col.spacing(theme::SPACE_2))
.width(Length::Fixed(56.0))
.height(Length::Fill)
.style(theme::surface_container_style)
.padding(theme::PAD_4)
.into()
}
fn sidebar_nav_column(&self, pages: &[Page]) -> Column<'_, Message> {
let mut col = Column::new().spacing(theme::SPACE_2);
for page in pages {
if !self.page_available(*page) {
continue;
}
let color = if self.page == *page {
theme::ACCENT
} else {
theme::TEXT_MUTED
};
col = col.push(
tooltip(
button(
container(page_icon(*page).size(28).color(color))
.width(Length::Fill)
.center_x(Length::Fill)
.padding([theme::PAD_10, 0.0]),
)
.width(Length::Fill)
.padding(0)
.style(theme::button_text)
.on_press(Message::Navigation(*page)),
text(page.label()).size(theme::TEXT_11),
tooltip::Position::Right,
)
.style(theme::tooltip_style),
);
}
col
}
fn render_sidebar_nav(&self) -> Element<'_, Message> {
self.sidebar_nav_column(Page::sidebar_pages()).into()
}
fn render_sidebar_bottom_nav(&self) -> Element<'_, Message> {
self.sidebar_nav_column(Page::sidebar_bottom_pages()).into()
}
fn render_maintainer_toggle(&self) -> Element<'_, Message> {
let enabled = self.maintenance_enabled();
let icon: Element<'_, Message> = theme::role_icon(&Role::Maintainer)
.size(theme::TEXT_24)
.color(if enabled {
theme::ACCENT
} else {
theme::TEXT_MUTED
})
.into();
widgets::icon_tooltip_button(
icon,
if enabled {
"stop maintenance"
} else {
"start maintenance"
},
Some(Message::Toggle(ToggleKind::Maintenance)),
theme::PAD_3,
theme::button_text,
tooltip::Position::Top,
)
}
fn render_pause_toggle(&self) -> Element<'_, Message> {
let paused = self.paused();
let icon: Element<'_, Message> = if paused {
lucide::play::<iced::Theme, iced::Renderer>()
.size(24)
.color(theme::ACCENT)
.into()
} else {
lucide::pause::<iced::Theme, iced::Renderer>()
.size(24)
.color(theme::TEXT_MUTED)
.into()
};
let tooltip_text = if paused {
"Resume pipeline"
} else {
"Pause pipeline"
};
widgets::icon_tooltip_button(
icon,
tooltip_text,
Some(Message::Toggle(ToggleKind::Pause)),
theme::PAD_3,
theme::button_text,
tooltip::Position::Top,
)
}
fn picker_failure<'a>(&'a self, options: &[widgets::PickOption]) -> Option<&'a str> {
if options.is_empty() {
self.workspaces_error.as_deref()
} else {
None
}
}
fn render_workspace_picker(&self) -> Option<Element<'_, Message>> {
let options = shared_workspace_options(&self.workspaces);
if let Some(err) = self.picker_failure(&options) {
return Some(widgets::tooltip_hint(
container(
text("Workspaces unavailable")
.size(theme::TEXT_14)
.color(theme::STATUS_ERROR),
)
.width(Length::Fixed(140.0))
.padding([theme::PAD_4, theme::PAD_8])
.style(theme::pill_style(theme::STATUS_ERROR.scale_alpha(0.08))),
err,
));
}
if options.is_empty() {
return None;
}
if options.len() == 1 {
let sole = &options[0];
let label = if self.active_workspace_name().as_deref() == Some(sole.value.as_str()) {
sole.label.clone()
} else {
"Select workspace".to_string()
};
return Some(widgets::tooltip_hint(
container(text(label).size(theme::TEXT_14).color(theme::TEXT_PRIMARY))
.width(Length::Fixed(140.0))
.padding([theme::PAD_4, theme::PAD_8])
.style(theme::pill_style(theme::BG_ELEVATED)),
"Active workspace",
));
}
let selected = self
.active_workspace_name()
.and_then(|name| options.iter().find(|o| o.value == name).cloned());
Some(widgets::tooltip_hint(
pick_list(options, selected, |opt| Message::WorkspacePick(opt.value))
.placeholder("Select workspace")
.style(theme::pick_list_style)
.menu_style(theme::pick_list_menu_style)
.padding([theme::PAD_4, theme::PAD_8])
.width(Length::Fixed(140.0)),
"Active workspace",
))
}
fn render_update_confirm(&self) -> Element<'_, Message> {
if !self.show_update_confirm {
return iced::widget::stack([widgets::empty_stack_placeholder()]).into();
}
let mode_desc = match crate::self_update::update_mode() {
crate::self_update::UpdateMode::Downloaded => {
"The ready-made file for this system is downloaded in the background \
while the system keeps working, and put at the standard location: it \
replaces the file this copy runs from when that is already where it \
sits, and for a copy running from anywhere else it is placed there \
while the file it came from is taken away. Nothing is paused during \
it.\n\n\
When it is in place, current in-flight work is drained (up to \
~10 min), databases are checkpointed, and the app restarts from \
the standard location, automatically resuming work afterwards. \
The window closing signals the restart."
}
crate::self_update::UpdateMode::SourceTree => {
"The new version is built from this working tree 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."
}
};
widgets::modal_backdrop(
dialog::confirm_dialog(
dialog::dialog_title("Update MahBot?"),
dialog::dialog_body([mode_desc]),
[
dialog::DialogAction::secondary("Cancel", Message::CancelUpdate),
dialog::DialogAction::secondary("Update", Message::ConfirmUpdate),
],
),
Message::CancelUpdate,
0.5,
)
}
fn render_update_button() -> Option<Element<'static, Message>> {
let availability = crate::self_update::update_availability();
if !availability.available && !availability.in_progress {
return None;
}
let (update_color, tooltip_text, clickable) = if availability.in_progress {
(theme::TEXT_FAINT, "Updating…".to_string(), false)
} else {
let tooltip = match crate::self_update::update_latest() {
Some(v) => format!("Update MahBot to v{v}"),
None => "Update MahBot".to_string(),
};
(theme::ACCENT, tooltip, true)
};
let update_icon = lucide::refresh_cw::<iced::Theme, iced::Renderer>()
.size(theme::TEXT_24)
.color(update_color);
Some(widgets::icon_tooltip_button(
update_icon,
tooltip_text,
if clickable {
Some(Message::UpdateBot)
} else {
None
},
theme::PAD_3,
theme::button_text,
tooltip::Position::Top,
))
}
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(theme::TEXT_24)
.color(theme::ACCENT),
text(truncated).size(theme::TEXT_16).color(theme::ACCENT),
]
.spacing(theme::SPACE_6)
.align_y(Alignment::Center);
Some(widgets::icon_tooltip_button(
branch_content,
"active branch",
Some(Message::Git(git::GitMessage::OpenModal)),
theme::PAD_3,
theme::button_text,
tooltip::Position::Top,
))
}
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(theme::TEXT_16)
.color(theme::TEXT_SECONDARY)
.into(),
);
parts.push(
text(format!("{ahead}"))
.size(theme::TEXT_16)
.color(theme::TEXT_SECONDARY)
.into(),
);
}
if behind > 0 {
if ahead > 0 {
parts.push(Space::new().width(theme::SPACE_8).into());
}
parts.push(
lucide::arrow_down::<iced::Theme, iced::Renderer>()
.size(theme::TEXT_16)
.color(theme::TEXT_SECONDARY)
.into(),
);
parts.push(
text(format!("{behind}"))
.size(theme::TEXT_16)
.color(theme::TEXT_SECONDARY)
.into(),
);
}
Row::with_children(parts)
.spacing(theme::SPACE_2)
.align_y(Alignment::Center)
.into()
};
Some(widgets::icon_tooltip_button(
sync_text_label,
"sync commits pull and push",
if self.git_state.is_syncing() {
None
} else {
Some(Message::Git(git::GitMessage::Sync))
},
theme::PAD_3,
theme::button_text,
tooltip::Position::Top,
))
}
fn render_git_diff_stats(&self) -> Option<Element<'_, Message>> {
let stats = self.git_state.diff_stats()?;
Some(widgets::icon_tooltip_button(
widgets::git_footer_stats::<Message>(
stats.added,
stats.removed,
stats.huge_binary_file_count,
15.0,
)?,
"uncommitted changes",
Some(Message::OpenDiffModal(None)),
theme::PAD_3,
theme::button_text,
tooltip::Position::Top,
))
}
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(theme::SPACE_6)
.align_y(Alignment::Center)
.into(),
)
}
fn render_active_agents() -> Option<Element<'static, Message>> {
let handles = crate::agent::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(theme::TEXT_24).color(color);
let content: Element<'_, Message> = if *count > 1 {
container(
row![
icon,
text(format!("×{count}")).size(theme::TEXT_14).color(color)
]
.spacing(theme::SPACE_3)
.align_y(Alignment::Center),
)
.padding([0.0, theme::PAD_3])
.into()
} else {
container(icon).padding([0.0, theme::PAD_3]).into()
};
icons.push(widgets::icon_tooltip_button(
content,
role.display_label(),
Some(Message::Navigation(Page::RunningAgents)),
0,
theme::button_text,
tooltip::Position::Top,
));
}
Some(
Row::with_children(icons)
.spacing(theme::SPACE_12)
.align_y(Alignment::Center)
.into(),
)
}
fn render_non_agent_calls() -> Option<Element<'static, Message>> {
let handles = crate::agent::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(theme::TEXT_24)
.color(color),
text(format!("×{}", handles.len()))
.size(theme::TEXT_14)
.color(color),
]
.spacing(theme::SPACE_3)
.align_y(Alignment::Center),
)
.padding([0.0, theme::PAD_3]);
let mut labels: Vec<&str> = handles
.iter()
.map(|h| crate::agent::registry::call_kind_label(h.kind))
.collect();
labels.sort_unstable();
labels.dedup();
let tooltip_text = labels.join(", ");
Some(widgets::icon_tooltip_button(
content,
tooltip_text,
Some(Message::Navigation(Page::RunningAgents)),
0,
theme::button_text,
tooltip::Position::Top,
))
}
#[cfg(target_os = "macos")]
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}%");
footer_status_label(label)
}
#[cfg(target_os = "macos")]
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}"),
};
footer_status_label(label)
}
fn footer_view(&self) -> Element<'_, Message> {
let mut left_elements: Vec<Element<'_, Message>> = Vec::with_capacity(5);
if let Some(el) = Self::render_update_button() {
left_elements.push(el);
}
if let Some(el) = self.render_workspace_picker() {
left_elements.push(el);
}
if self.has_active_workspace() {
left_elements.push(self.render_maintainer_toggle());
left_elements.push(self.render_pause_toggle());
}
if let Some(el) = self.render_git_block() {
left_elements.push(el);
}
let left = Row::with_children(left_elements)
.spacing(theme::SPACE_6)
.align_y(Alignment::Center);
#[cfg(target_os = "macos")]
let center = row![
self.render_tts_download_progress(),
Self::render_voice_status(),
]
.spacing(theme::SPACE_8)
.align_y(Alignment::Center);
#[cfg(not(target_os = "macos"))]
let center = Space::new().width(0);
let right = Row::with_children(
[Self::render_active_agents(), Self::render_non_agent_calls()]
.into_iter()
.flatten()
.collect::<Vec<_>>(),
)
.spacing(theme::SPACE_12)
.align_y(Alignment::Center);
let footer_row = row![left, center, Space::new().width(Length::Fill), right]
.align_y(Alignment::Center)
.padding([theme::PAD_3, theme::PAD_18]);
container(footer_row)
.align_y(Alignment::Center)
.height(Length::Fixed(42.0))
.width(Length::Fill)
.style(theme::surface_container_style)
.into()
}
}
#[cfg(target_os = "macos")]
fn footer_status_label(label: String) -> Element<'static, Message> {
container(
text(label)
.size(theme::TEXT_12)
.color(theme::TEXT_SECONDARY),
)
.padding([0.0, theme::PAD_12])
.into()
}
#[derive(serde::Deserialize)]
pub struct WindowState {
pub width: f32,
pub height: f32,
pub x: i32,
pub y: i32,
}
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,
}
}
}
const WINDOW_STATE_FILE: &str = "window-state.json";
#[must_use]
pub fn read_window_state() -> WindowState {
let Ok(dir) = crate::config::default_config_dir() else {
return WindowState::default();
};
std::fs::read_to_string(dir.join(WINDOW_STATE_FILE))
.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) {
let state = serde_json::json!({
"width": size.width,
"height": size.height,
"x": pos.x as i32,
"y": pos.y as i32,
});
if let Ok(dir) = crate::config::default_config_dir() {
let _ = std::fs::create_dir_all(&dir);
let _ = std::fs::write(dir.join(WINDOW_STATE_FILE), state.to_string());
}
}
async fn load_workspace_map(generation: u64) -> Message {
let mut attempt = 0;
let workspaces = loop {
match read_workspace_map().await {
Ok(map) => break map,
Err(e) => {
tracing::warn!(error = %e, attempt = attempt + 1, "Failed to load workspaces");
if attempt == 0 {
attempt += 1;
tokio::time::sleep(Duration::from_secs(1)).await;
} else {
return Message::WorkspacesReloadFailed {
error: e.to_string(),
generation,
};
}
}
}
};
Message::WorkspacesReloaded {
workspaces,
generation,
}
}
async fn read_workspace_map() -> anyhow::Result<HashMap<String, Workspace>> {
let list = crate::workspace::store().list().await?;
Ok(list.into_iter().map(|ws| (ws.name.clone(), ws)).collect())
}
#[must_use]
fn resolve_workspace_selection(
map: &HashMap<String, Workspace>,
prev_selection: Option<&str>,
personal_name: &str,
) -> String {
match prev_selection {
Some(name) if map.contains_key(name) => name.to_string(),
_ => personal_name.to_string(),
}
}
async fn load_workspace_options(generation: u64) -> Message {
let user = crate::users::ADMIN_USER_NAME;
let prev = crate::users::resolve_selected_workspace_name(user).await;
let personal_name = crate::users::personal_workspace_name(user);
let (workspaces, restored) = match read_workspace_map().await {
Ok(map) => {
let restored = resolve_workspace_selection(&map, prev.as_deref(), &personal_name);
(Ok(map), restored)
}
Err(e) => {
tracing::warn!(error = %e, "Failed to load workspaces during boot");
(Err(e.to_string()), personal_name)
}
};
Message::BootWorkspaces {
workspaces,
restored_name: restored,
generation,
}
}
async fn read_active_workspace() -> Option<String> {
crate::users::resolve_selected_workspace_name(crate::users::ADMIN_USER_NAME).await
}
#[must_use]
fn shared_workspace_options(map: &HashMap<String, Workspace>) -> Vec<widgets::PickOption> {
let mut values: Vec<&Workspace> = map
.values()
.filter(|ws| !crate::users::is_personal_workspace(&ws.name))
.collect();
values.sort_by(|a, b| a.name.cmp(&b.name));
values
.into_iter()
.map(|ws| widgets::PickOption {
value: ws.name.clone(),
label: ws.display_name(),
})
.collect()
}
fn open_url(url: &str) {
#[cfg(target_os = "windows")]
shell_open(url);
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open").arg(url).spawn();
#[cfg(target_os = "linux")]
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
let _ = url;
}
#[cfg(target_os = "windows")]
fn shell_open(address: &str) {
if address.trim().is_empty() {
return;
}
let address = address.to_owned();
let mut wide: Vec<u16> = address.encode_utf16().collect();
wide.push(0);
let spawned = std::thread::Builder::new()
.name("shell-open".to_owned())
.spawn(move || {
if let Some(code) = shell_execute_address(&wide) {
tracing::warn!(%address, code, "ShellExecuteExW did not open the address");
}
});
if let Err(e) = spawned {
tracing::warn!(error = %e, "Failed to spawn the thread that opens an address");
}
}
#[cfg(target_os = "windows")]
fn shell_execute_address(address: &[u16]) -> Option<u32> {
use windows_sys::Win32::Foundation::{FALSE, GetLastError};
use windows_sys::Win32::System::Com::{
COINIT_APARTMENTTHREADED, COINIT_DISABLE_OLE1DDE, CoInitializeEx, CoUninitialize,
};
use windows_sys::Win32::UI::Shell::{SEE_MASK_NOASYNC, SHELLEXECUTEINFOW, ShellExecuteExW};
use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
let coinit = u32::try_from(COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE)
.expect("COINIT flags are small positives");
let apartment = unsafe { CoInitializeEx(std::ptr::null(), coinit) };
let mut info: SHELLEXECUTEINFOW = unsafe { std::mem::zeroed() };
info.cbSize = u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>())
.expect("shell execute info fits in u32");
info.fMask = SEE_MASK_NOASYNC;
info.lpVerb = std::ptr::null();
info.lpFile = address.as_ptr();
info.lpParameters = std::ptr::null();
info.nShow = SW_SHOWNORMAL;
let failed = unsafe { ShellExecuteExW(&raw mut info) } == FALSE;
let code = failed.then(|| unsafe { GetLastError() });
if apartment >= 0 {
unsafe { CoUninitialize() };
}
code
}
#[cfg(test)]
mod tests {
use super::*;
fn ws(name: &str) -> Workspace {
Workspace {
name: name.to_string(),
path: format!("/p/{name}"),
..Default::default()
}
}
fn ready_dashboard() -> Dashboard {
let mut dash = Dashboard::loading();
dash.ready = true;
dash
}
fn reloaded(map: HashMap<String, Workspace>) -> Message {
Message::WorkspacesReloaded {
workspaces: map,
generation: 0,
}
}
fn reload_failed(error: &str) -> Message {
Message::WorkspacesReloadFailed {
error: error.to_string(),
generation: 0,
}
}
#[test]
fn workspaces_reloaded_replaces_map_and_defers_an_unknown_selection() {
let mut dash = ready_dashboard();
dash.workspaces = HashMap::from([
("ws1".to_string(), ws("ws1")),
("ws2".to_string(), ws("ws2")),
]);
dash.selected_workspace_name = Some("ws2".to_string());
let _ = dash.apply_workspace_selection("ws2");
assert_eq!(dash.resolved_workspace(), Some(("ws2", "/p/ws2")));
let new_map = HashMap::from([
("ws1".to_string(), ws("ws1")),
("ws3".to_string(), ws("ws3")),
]);
let _ = dash.update(reloaded(new_map));
assert_eq!(dash.selected_workspace_name.as_deref(), Some("ws2"));
assert_eq!(dash.resolved_workspace(), None);
assert_eq!(dash.pushed_workspace, None);
assert!(dash.workspaces.contains_key("ws1"));
assert!(dash.workspaces.contains_key("ws3"));
assert!(!dash.workspaces.contains_key("ws2"));
dash.selected_workspace_name = Some("ws3".to_string());
let new_map2 = HashMap::from([("ws3".to_string(), ws("ws3"))]);
let _ = dash.update(reloaded(new_map2));
assert_eq!(dash.selected_workspace_name.as_deref(), Some("ws3"));
}
#[test]
fn active_workspace_sync_follows_an_external_switch_and_drops_a_superseded_read() {
let mut dash = ready_dashboard();
dash.workspaces = HashMap::from([
("ws1".to_string(), ws("ws1")),
("ws2".to_string(), ws("ws2")),
]);
dash.selected_workspace_name = Some("ws1".to_string());
let _ = dash.update(Message::ActiveWorkspaceSynced {
generation: 0,
name: Some("ws2".to_string()),
});
assert_eq!(dash.selected_workspace_name.as_deref(), Some("ws2"));
let _ = dash.select_workspace("ws1");
assert_eq!(dash.active_workspace_gen, 1);
let _ = dash.update(Message::ActiveWorkspaceSynced {
generation: 0,
name: Some("ws2".to_string()),
});
assert_eq!(
dash.selected_workspace_name.as_deref(),
Some("ws1"),
"a superseded read must not revert the live selection"
);
let _ = dash.update(Message::ActiveWorkspaceSynced {
generation: 1,
name: None,
});
let board_gen = dash.board_state.board_generation;
let _ = dash.update(Message::ActiveWorkspaceSynced {
generation: 1,
name: Some("ws1".to_string()),
});
assert_eq!(dash.selected_workspace_name.as_deref(), Some("ws1"));
assert_eq!(
dash.board_state.board_generation, board_gen,
"an unchanged value must not re-propagate the selection"
);
let stale = dash.active_workspace_gen;
dash.active_workspace_gen += 1; let _ = dash.update(Message::ActiveWorkspaceSynced {
generation: stale,
name: Some("ws2".to_string()),
});
assert_eq!(dash.selected_workspace_name.as_deref(), Some("ws1"));
assert!(!dash.workspaces.contains_key("late"));
let _ = dash.update(Message::ActiveWorkspaceSynced {
generation: dash.active_workspace_gen,
name: Some("late".to_string()),
});
assert_eq!(dash.selected_workspace_name.as_deref(), Some("late"));
let _ = dash.update(reloaded(HashMap::from([("ws1".to_string(), ws("ws1"))])));
assert_eq!(dash.selected_workspace_name.as_deref(), Some("late"));
}
#[test]
fn workspaces_reloaded_preserves_boot_restored_selection() {
let mut dash = ready_dashboard();
dash.selected_workspace_name = Some("mahbot".to_string());
let map = HashMap::from([("mahbot".to_string(), ws("mahbot"))]);
let _ = dash.update(reloaded(map));
assert_eq!(dash.selected_workspace_name.as_deref(), Some("mahbot"));
}
#[test]
fn boot_without_the_workspace_map_pushes_nothing_then_recovers() {
let mut dash = ready_dashboard();
let _ = dash.apply_boot_workspaces(Ok(HashMap::new()), "ws1", 0);
assert_eq!(dash.selected_workspace_name.as_deref(), Some("ws1"));
assert_eq!(dash.resolved_workspace(), None);
assert_eq!(
dash.pushed_workspace, None,
"an unresolvable selection must not be substituted with a workspace"
);
let _ = dash.update(reloaded(HashMap::from([("ws1".to_string(), ws("ws1"))])));
assert_eq!(dash.selected_workspace_name.as_deref(), Some("ws1"));
assert_eq!(dash.resolved_workspace(), Some(("ws1", "/p/ws1")));
assert_eq!(
dash.pushed_workspace
.as_ref()
.map(|(name, path)| (name.as_str(), path.as_str())),
Some(("ws1", "/p/ws1")),
"the reload that made the unchanged selection resolvable must re-push it"
);
}
#[test]
fn only_a_registered_workspace_with_a_path_resolves() {
let mut dash = ready_dashboard();
dash.workspaces = HashMap::from([
("ws1".to_string(), ws("ws1")),
(
"pathless".to_string(),
Workspace {
name: "pathless".to_string(),
..Default::default()
},
),
]);
dash.selected_workspace_name = Some("personal:admin".to_string());
assert_eq!(dash.resolved_workspace(), None);
dash.selected_workspace_name = Some("gone".to_string());
assert_eq!(dash.resolved_workspace(), None);
dash.selected_workspace_name = Some("pathless".to_string());
assert_eq!(dash.resolved_workspace(), None);
dash.selected_workspace_name = Some(String::new());
assert_eq!(dash.resolved_workspace(), None);
dash.selected_workspace_name = None;
assert_eq!(dash.resolved_workspace(), None);
dash.selected_workspace_name = Some("ws1".to_string());
assert_eq!(dash.resolved_workspace(), Some(("ws1", "/p/ws1")));
}
#[test]
fn unresolved_selection_hides_the_workspace_scoped_surfaces() {
let mut dash = ready_dashboard();
dash.workspaces = HashMap::from([("ws1".to_string(), ws("ws1"))]);
let _ = dash.apply_workspace_selection("ws1");
assert!(dash.render_git_block().is_some());
let _ = dash.apply_workspace_selection("personal:admin");
assert!(!dash.page_available(Page::Editor));
assert!(!dash.page_available(Page::Shell));
assert!(
dash.page_available(Page::Home),
"other pages stay available"
);
assert!(dash.render_git_block().is_none());
let _ = dash.open_diff_modal(None);
assert!(!dash.show_diff_modal, "the diff view must not open");
let _ = dash.apply_workspace_selection("gone");
assert!(!dash.page_available(Page::Editor));
assert!(dash.render_git_block().is_none());
let _ = dash.apply_workspace_selection("ws1");
assert!(dash.page_available(Page::Editor));
assert!(dash.page_available(Page::Shell));
assert!(dash.render_git_block().is_some());
let _ = dash.open_diff_modal(None);
assert!(dash.show_diff_modal);
}
#[test]
fn losing_the_resolution_closes_the_diff_view() {
let mut dash = ready_dashboard();
dash.workspaces = HashMap::from([("ws1".to_string(), ws("ws1"))]);
let _ = dash.apply_workspace_selection("ws1");
let _ = dash.open_diff_modal(None);
assert!(dash.show_diff_modal);
let _ = dash.apply_workspace_selection("personal:admin");
assert!(!dash.show_diff_modal);
assert_eq!(dash.resolved_workspace(), None);
assert_eq!(dash.pushed_workspace, None);
}
#[test]
fn workspace_switch_clears_the_previous_board_failure() {
let mut dash = ready_dashboard();
dash.workspaces = HashMap::from([("ws1".to_string(), ws("ws1"))]);
dash.board_state.load_state.fail("db down".to_string());
let _ = dash.propagate_workspace_selection("ws1");
assert!(dash.board_state.load_state.error().is_none());
}
#[test]
fn stale_workspace_reload_outcomes_are_dropped() {
let mut dash = ready_dashboard();
dash.workspaces = HashMap::from([("ws1".to_string(), ws("ws1"))]);
dash.workspaces_reload_gen = 2;
let _ = dash.update(Message::WorkspacesReloadFailed {
error: "db down".to_string(),
generation: 1,
});
assert!(
dash.workspaces_error.is_none(),
"a stale failure must not re-set the error"
);
let _ = dash.update(Message::WorkspacesReloaded {
workspaces: HashMap::from([("ws2".to_string(), ws("ws2"))]),
generation: 1,
});
assert!(
dash.workspaces.contains_key("ws1"),
"a stale map must not replace the newer one"
);
}
#[test]
fn resolve_workspace_selection_keeps_valid_falls_back_personal() {
let map = HashMap::from([("ws1".to_string(), ws("ws1"))]);
let personal = "personal:alice";
assert_eq!(
resolve_workspace_selection(&map, Some("ws1"), personal),
"ws1"
);
assert_eq!(
resolve_workspace_selection(&map, Some("gone"), personal),
personal
);
assert_eq!(resolve_workspace_selection(&map, None, personal), personal);
}
#[test]
fn shared_workspace_options_never_offer_a_personal_workspace() {
let map = HashMap::from([
("personal:admin".to_string(), ws("personal:admin")),
("ws2".to_string(), ws("ws2")),
("ws1".to_string(), ws("ws1")),
]);
let options = shared_workspace_options(&map);
assert_eq!(
options.iter().map(|o| o.value.as_str()).collect::<Vec<_>>(),
vec!["ws1", "ws2"],
"shared workspaces only, ordered by name"
);
}
#[test]
fn workspaces_reloaded_syncs_settings_lists() {
let mut dash = ready_dashboard();
let map = HashMap::from([
("beta".to_string(), ws("beta")),
("alpha".to_string(), ws("alpha")),
]);
let _ = dash.update(reloaded(map));
let ws_names: Vec<&str> = dash
.settings_state
.workspaces_state
.workspaces
.iter()
.map(|w| w.name.as_str())
.collect();
assert_eq!(ws_names, vec!["alpha", "beta"]);
}
#[test]
fn workspaces_reload_failure_sets_and_clears_error() {
let mut dash = ready_dashboard();
dash.workspaces = HashMap::from([("ws1".to_string(), ws("ws1"))]);
let _ = dash.update(reload_failed("db down"));
assert_eq!(dash.workspaces_error.as_deref(), Some("db down"));
assert!(
dash.workspaces.contains_key("ws1"),
"a failed reload must preserve the current map"
);
assert!(
dash.picker_failure(&shared_workspace_options(&dash.workspaces))
.is_none(),
"a preserved map keeps the picker working — the pill is for a read that left nothing to pick"
);
let new_map = HashMap::from([("ws2".to_string(), ws("ws2"))]);
let _ = dash.update(reloaded(new_map));
assert!(dash.workspaces_error.is_none());
}
#[test]
fn boot_workspace_read_failure_completes_with_error_state() {
let mut dash = Dashboard::loading();
let _ =
dash.apply_boot_workspaces(Err("boot read failed".to_string()), "personal:admin", 0);
assert_eq!(dash.workspaces_error.as_deref(), Some("boot read failed"));
assert!(dash.workspaces.is_empty());
assert_eq!(
dash.picker_failure(&shared_workspace_options(&dash.workspaces)),
Some("boot read failed"),
"a read that left nothing to pick must show the failure in the picker"
);
}
#[test]
fn late_boot_read_does_not_replace_a_newer_reload() {
let mut dash = Dashboard::loading();
let read_generation = dash.workspaces_reload_gen;
dash.workspaces = HashMap::from([("ws1".to_string(), ws("ws1"))]);
dash.workspaces_reload_gen = read_generation + 1;
let _ = dash.apply_boot_workspaces(
Err("boot read failed".to_string()),
"personal:admin",
read_generation,
);
assert!(
dash.workspaces_error.is_none(),
"the superseded boot read must not set the failure over the newer read"
);
assert!(
dash.workspaces.contains_key("ws1"),
"the superseded boot read must not clear the newer map"
);
}
#[tokio::test]
#[serial_test::serial(gui_admin_workspace)] async fn boot_workspace_options_resolve_from_db() {
crate::util::test::init_test_stores().await;
crate::workspace::store()
.conn
.execute(
"INSERT OR IGNORE INTO workspaces (name, path, created_at, updated_at) \
VALUES ('boot_ws_gui', '/p/boot_ws_gui', '2026-01-01T00:00:00+00:00', \
'2026-01-01T00:00:00+00:00')",
crate::db::params![],
)
.await
.expect("seed workspace");
let store = crate::users::store();
crate::users::test_util::with_admin_workspace_restored(async {
store
.set_selected_workspace(crate::users::ADMIN_USER_NAME, Some("boot_ws_gui"))
.await
.expect("update admin selected_workspace");
let Message::BootWorkspaces {
workspaces: Ok(map),
restored_name,
..
} = load_workspace_options(0).await
else {
panic!("expected BootWorkspaces with a loaded map");
};
assert!(map.contains_key("boot_ws_gui"));
assert_eq!(restored_name, "boot_ws_gui");
store
.set_selected_workspace(crate::users::ADMIN_USER_NAME, Some("gone_ws_gui"))
.await
.expect("update admin selected_workspace");
let Message::BootWorkspaces { restored_name, .. } = load_workspace_options(0).await
else {
panic!("expected BootWorkspaces");
};
assert_eq!(restored_name, "personal:admin");
})
.await;
}
}