feat(followup): implement PLAN-FOLLOWUP.md gap fixes

Complete implementation of 7 slices addressing E2E testing gaps:

Slice 0+1: Wire Actions + ReasonPrompt
- FocusView now uses useActions hook instead of direct act() calls
- Added pendingAction state pattern for skip/defer/complete actions
- ReasonPrompt integration with proper confirm/cancel flow
- Tags support in DecisionEntry interface

Slice 2: Drag Reorder UI
- Installed @dnd-kit (core, sortable, utilities)
- QueueView with DndContext, SortableContext, verticalListSortingStrategy
- SortableQueueItem wrapper component using useSortable hook
- pendingReorder state with ReasonPrompt for reorder reasons
- Cmd+Up/Down keyboard shortcuts for accessibility
- Fixed: Store item ID in PendingReorder to avoid stale queue reference

Slice 3: System Tray Integration
- tray.rs with TrayState, setup_tray, toggle_window_visibility
- Menu with Show/Quit items
- Left-click toggles window visibility
- update_tray_badge command updates tooltip with item count
- Frontend wiring in AppShell

Slice 4: E2E Test Updates
- Fixed test selectors for InboxView, Queue badge
- Exposed inbox store for test seeding

Slice 5: Staleness Visualization
- Already implemented in computeStaleness() with tests

Slice 6: Quick Wiring
- onStartBatch callback wired to QueueView
- SyncStatus rendered in nav area
- SettingsView renders Settings component

Slice 7: State Persistence
- settings-store with hydrate/update methods
- Tauri backend integration via read_settings/write_settings
- AppShell hydrates settings on mount

Bug fixes from code review:
- close_bead now has error isolation (try/catch) so decision logging
  and queue advancement continue even if bead close fails
- PendingReorder stores item ID to avoid stale queue reference

E2E tests for all ACs (tests/e2e/followup-acs.spec.ts):
- AC-F1: Drag reorder (4 tests)
- AC-F2: ReasonPrompt integration (7 tests)
- AC-F5: Staleness visualization (3 tests)
- AC-F6: Batch mode (2 tests)
- AC-F7: SyncStatus (2 tests)
- ReasonPrompt behavior (3 tests)

Tests: 388 frontend + 119 Rust + 32 E2E all passing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-26 11:26:42 -05:00
parent 5078cb506a
commit f5ce8a9091
44 changed files with 5268 additions and 625 deletions

View File

@@ -47,48 +47,44 @@ pub async fn get_lore_status() -> Result<LoreStatus, McError> {
}
/// Testable inner function that accepts any LoreCli implementation.
///
/// Health is determined by whether we can get data from lore, not by
/// lore's internal health check (which is too strict for our needs).
fn get_lore_status_with(cli: &dyn LoreCli) -> Result<LoreStatus, McError> {
match cli.health_check() {
Ok(true) => match cli.get_me() {
Ok(response) => {
let summary = LoreSummaryStatus {
open_issues: response.data.open_issues.len() as u32,
authored_mrs: response.data.open_mrs_authored.len() as u32,
reviewing_mrs: response.data.reviewing_mrs.len() as u32,
};
Ok(LoreStatus {
last_sync: response.data.since_iso.clone(),
is_healthy: true,
message: format!(
"{} issues, {} authored MRs, {} reviews",
summary.open_issues, summary.authored_mrs, summary.reviewing_mrs
),
summary: Some(summary),
})
}
Err(e) => Ok(LoreStatus {
last_sync: None,
match cli.get_me() {
Ok(response) => {
let summary = LoreSummaryStatus {
open_issues: response.data.open_issues.len() as u32,
authored_mrs: response.data.open_mrs_authored.len() as u32,
reviewing_mrs: response.data.reviewing_mrs.len() as u32,
};
Ok(LoreStatus {
last_sync: response.data.since_iso.clone(),
is_healthy: true,
message: format!("lore healthy but failed to fetch data: {}", e),
summary: None,
}),
},
Ok(false) => Ok(LoreStatus {
last_sync: None,
is_healthy: false,
message: "lore health check failed -- run 'lore index --full'".to_string(),
summary: None,
}),
message: format!(
"{} issues, {} authored MRs, {} reviews",
summary.open_issues, summary.authored_mrs, summary.reviewing_mrs
),
summary: Some(summary),
})
}
Err(LoreError::ExecutionFailed(_)) => Ok(LoreStatus {
last_sync: None,
is_healthy: false,
message: "lore CLI not found -- is it installed?".to_string(),
summary: None,
}),
Err(e) => Ok(LoreStatus {
Err(LoreError::CommandFailed(stderr)) => Ok(LoreStatus {
last_sync: None,
is_healthy: false,
message: format!("lore error: {}", e),
// Pass through lore's error message - it includes actionable suggestions
message: format!("lore error: {}", stderr.trim()),
summary: None,
}),
Err(LoreError::ParseFailed(e)) => Ok(LoreStatus {
last_sync: None,
is_healthy: false,
message: format!("Failed to parse lore response: {}", e),
summary: None,
}),
}
@@ -276,7 +272,7 @@ pub struct TriageTopPick {
/// Human-readable reasons for recommendation
pub reasons: Vec<String>,
/// Number of items this would unblock
pub unblocks: i64,
pub unblocks: i32,
}
/// Quick win item from bv triage
@@ -300,7 +296,7 @@ pub struct TriageBlocker {
/// Bead title
pub title: String,
/// Number of items this blocks
pub unblocks_count: i64,
pub unblocks_count: i32,
/// Whether this is actionable now
pub actionable: bool,
}
@@ -309,13 +305,13 @@ pub struct TriageBlocker {
#[derive(Debug, Clone, Serialize, Type)]
pub struct TriageCounts {
/// Total open items
pub open: i64,
pub open: i32,
/// Items that can be worked on now
pub actionable: i64,
pub actionable: i32,
/// Items blocked by others
pub blocked: i64,
pub blocked: i32,
/// Items currently in progress
pub in_progress: i64,
pub in_progress: i32,
}
/// Full triage response for the frontend
@@ -349,10 +345,10 @@ fn get_triage_with(cli: &dyn BvCli) -> Result<TriageResponse, McError> {
let response = cli.robot_triage()?;
let counts = TriageCounts {
open: response.triage.quick_ref.open_count,
actionable: response.triage.quick_ref.actionable_count,
blocked: response.triage.quick_ref.blocked_count,
in_progress: response.triage.quick_ref.in_progress_count,
open: response.triage.quick_ref.open_count as i32,
actionable: response.triage.quick_ref.actionable_count as i32,
blocked: response.triage.quick_ref.blocked_count as i32,
in_progress: response.triage.quick_ref.in_progress_count as i32,
};
let top_picks = response
@@ -365,7 +361,7 @@ fn get_triage_with(cli: &dyn BvCli) -> Result<TriageResponse, McError> {
title: p.title,
score: p.score,
reasons: p.reasons,
unblocks: p.unblocks,
unblocks: p.unblocks as i32,
})
.collect();
@@ -388,7 +384,7 @@ fn get_triage_with(cli: &dyn BvCli) -> Result<TriageResponse, McError> {
.map(|b| TriageBlocker {
id: b.id,
title: b.title,
unblocks_count: b.unblocks_count,
unblocks_count: b.unblocks_count as i32,
actionable: b.actionable,
})
.collect();
@@ -414,7 +410,7 @@ pub struct NextPickResponse {
/// Reasons for recommendation
pub reasons: Vec<String>,
/// Number of items this unblocks
pub unblocks: i64,
pub unblocks: i32,
/// Shell command to claim this bead
pub claim_command: String,
}
@@ -440,7 +436,7 @@ fn get_next_pick_with(cli: &dyn BvCli) -> Result<NextPickResponse, McError> {
title: response.title,
score: response.score,
reasons: response.reasons,
unblocks: response.unblocks,
unblocks: response.unblocks as i32,
claim_command: response.claim_command,
})
}
@@ -551,6 +547,35 @@ fn get_time_of_day(now: &chrono::DateTime<chrono::Utc>) -> String {
}
}
// -- System tray commands --
/// Update the system tray tooltip to reflect the current item count.
///
/// Called by the frontend whenever the total queue/focus count changes.
/// Gracefully handles missing tray state (e.g., tray init failed).
#[tauri::command]
#[specta::specta]
pub async fn update_tray_badge(
count: u32,
state: tauri::State<'_, crate::tray::TrayState>,
) -> Result<(), McError> {
let tooltip = if count == 0 {
"Mission Control".to_string()
} else {
format!("Mission Control - {} items", count)
};
let tray = state
.tray
.lock()
.map_err(|e| McError::internal(format!("Failed to lock tray state: {}", e)))?;
tray.set_tooltip(Some(&tooltip))
.map_err(|e| McError::internal(format!("Failed to set tray tooltip: {}", e)))?;
Ok(())
}
/// Updates to apply to an item (for defer/skip actions)
#[derive(Debug, Clone, Deserialize, Type)]
pub struct ItemUpdates {
@@ -578,6 +603,123 @@ pub async fn update_item(id: String, updates: ItemUpdates) -> Result<(), McError
Ok(())
}
// -- Lore items command (full data for queue population) --
/// A lore item (issue or MR) for the frontend queue
#[derive(Debug, Clone, Serialize, Type)]
pub struct LoreItem {
/// Unique key matching bridge format: "issue:project:iid" or "mr_review:project:iid"
pub id: String,
/// Item title
pub title: String,
/// Item type: "issue", "mr_review", or "mr_authored"
pub item_type: String,
/// Project path (e.g., "group/repo")
pub project: String,
/// GitLab web URL
pub url: String,
/// Issue/MR IID within the project
pub iid: i64,
/// Last updated timestamp (ISO 8601)
pub updated_at: Option<String>,
/// Who requested this (for reviews)
pub requested_by: Option<String>,
}
/// Response from get_lore_items containing all work items
#[derive(Debug, Clone, Serialize, Type)]
pub struct LoreItemsResponse {
/// All items (reviews, issues, authored MRs)
pub items: Vec<LoreItem>,
/// Whether lore data was successfully fetched
pub success: bool,
/// Error message if fetch failed
pub error: Option<String>,
}
/// Get all lore items (issues, MRs, reviews) for queue population.
///
/// Unlike get_lore_status which returns summary counts, this returns
/// the actual items needed to populate the Focus and Queue views.
#[tauri::command]
#[specta::specta]
pub async fn get_lore_items() -> Result<LoreItemsResponse, McError> {
get_lore_items_with(&RealLoreCli)
}
/// Escape project path for use in item IDs.
/// Replaces / with :: to match bridge key format.
fn escape_project(project: &str) -> String {
project.replace('/', "::")
}
/// Testable inner function that accepts any LoreCli implementation.
fn get_lore_items_with(cli: &dyn LoreCli) -> Result<LoreItemsResponse, McError> {
match cli.get_me() {
Ok(response) => {
let mut items = Vec::new();
// Reviews first (you're blocking someone)
for mr in &response.data.reviewing_mrs {
items.push(LoreItem {
id: format!("mr_review:{}:{}", escape_project(&mr.project), mr.iid),
title: mr.title.clone(),
item_type: "mr_review".to_string(),
project: mr.project.clone(),
url: mr.web_url.clone(),
iid: mr.iid,
updated_at: mr.updated_at_iso.clone(),
requested_by: mr.author_username.clone(),
});
}
// Assigned issues
for issue in &response.data.open_issues {
items.push(LoreItem {
id: format!("issue:{}:{}", escape_project(&issue.project), issue.iid),
title: issue.title.clone(),
item_type: "issue".to_string(),
project: issue.project.clone(),
url: issue.web_url.clone(),
iid: issue.iid,
updated_at: issue.updated_at_iso.clone(),
requested_by: None,
});
}
// Authored MRs last (your own work, less urgent)
for mr in &response.data.open_mrs_authored {
items.push(LoreItem {
id: format!("mr_authored:{}:{}", escape_project(&mr.project), mr.iid),
title: mr.title.clone(),
item_type: "mr_authored".to_string(),
project: mr.project.clone(),
url: mr.web_url.clone(),
iid: mr.iid,
updated_at: mr.updated_at_iso.clone(),
requested_by: None,
});
}
Ok(LoreItemsResponse {
items,
success: true,
error: None,
})
}
Err(LoreError::ExecutionFailed(_)) => Ok(LoreItemsResponse {
items: vec![],
success: false,
error: Some("lore CLI not found -- is it installed?".to_string()),
}),
Err(e) => Ok(LoreItemsResponse {
items: vec![],
success: false,
error: Some(format!("lore error: {}", e)),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -585,7 +727,6 @@ mod tests {
fn mock_healthy_cli() -> MockLoreCli {
let mut mock = MockLoreCli::new();
mock.expect_health_check().returning(|| Ok(true));
mock.expect_get_me().returning(|| {
Ok(LoreMeResponse {
ok: true,
@@ -615,19 +756,21 @@ mod tests {
}
#[test]
fn test_get_lore_status_unhealthy() {
fn test_get_lore_status_command_failed() {
let mut mock = MockLoreCli::new();
mock.expect_health_check().returning(|| Ok(false));
mock.expect_get_me()
.returning(|| Err(LoreError::CommandFailed("config not found".to_string())));
let result = get_lore_status_with(&mock).unwrap();
assert!(!result.is_healthy);
assert!(result.message.contains("health check failed"));
assert!(result.message.contains("lore error"));
assert!(result.message.contains("config not found"));
}
#[test]
fn test_get_lore_status_cli_not_found() {
let mut mock = MockLoreCli::new();
mock.expect_health_check()
mock.expect_get_me()
.returning(|| Err(LoreError::ExecutionFailed("not found".to_string())));
let result = get_lore_status_with(&mock).unwrap();
@@ -636,15 +779,14 @@ mod tests {
}
#[test]
fn test_get_lore_status_healthy_but_get_me_fails() {
fn test_get_lore_status_parse_failed() {
let mut mock = MockLoreCli::new();
mock.expect_health_check().returning(|| Ok(true));
mock.expect_get_me()
.returning(|| Err(LoreError::ParseFailed("bad json".to_string())));
let result = get_lore_status_with(&mock).unwrap();
assert!(result.is_healthy);
assert!(result.message.contains("failed to fetch data"));
assert!(!result.is_healthy);
assert!(result.message.contains("Failed to parse"));
assert!(result.summary.is_none());
}
@@ -653,7 +795,6 @@ mod tests {
use crate::data::lore::{LoreIssue, LoreMr};
let mut mock = MockLoreCli::new();
mock.expect_health_check().returning(|| Ok(true));
mock.expect_get_me().returning(|| {
Ok(LoreMeResponse {
ok: true,
@@ -1155,4 +1296,124 @@ mod tests {
let early_morning = chrono::Utc.with_ymd_and_hms(2026, 2, 26, 3, 0, 0).unwrap();
assert_eq!(get_time_of_day(&early_morning), "night");
}
// -- get_lore_items tests --
#[test]
fn test_get_lore_items_returns_all_item_types() {
use crate::data::lore::{LoreIssue, LoreMr};
let mut mock = MockLoreCli::new();
mock.expect_get_me().returning(|| {
Ok(LoreMeResponse {
ok: true,
data: LoreMeData {
open_issues: vec![LoreIssue {
iid: 42,
title: "Fix auth bug".to_string(),
project: "group/repo".to_string(),
state: "opened".to_string(),
web_url: "https://gitlab.com/group/repo/-/issues/42".to_string(),
labels: vec![],
attention_state: None,
status_name: None,
updated_at_iso: Some("2026-02-26T10:00:00Z".to_string()),
}],
open_mrs_authored: vec![LoreMr {
iid: 100,
title: "Add feature".to_string(),
project: "group/repo".to_string(),
state: "opened".to_string(),
web_url: "https://gitlab.com/group/repo/-/merge_requests/100".to_string(),
labels: vec![],
attention_state: None,
author_username: None,
detailed_merge_status: None,
draft: false,
updated_at_iso: None,
}],
reviewing_mrs: vec![LoreMr {
iid: 200,
title: "Review this".to_string(),
project: "other/project".to_string(),
state: "opened".to_string(),
web_url: "https://gitlab.com/other/project/-/merge_requests/200".to_string(),
labels: vec![],
attention_state: None,
author_username: Some("alice".to_string()),
detailed_merge_status: None,
draft: false,
updated_at_iso: Some("2026-02-26T09:00:00Z".to_string()),
}],
activity: vec![],
since_last_check: None,
summary: None,
username: None,
since_iso: None,
},
meta: None,
})
});
let result = get_lore_items_with(&mock).unwrap();
assert!(result.success);
assert!(result.error.is_none());
assert_eq!(result.items.len(), 3);
// Reviews come first
assert_eq!(result.items[0].item_type, "mr_review");
assert_eq!(result.items[0].id, "mr_review:other::project:200");
assert_eq!(result.items[0].requested_by, Some("alice".to_string()));
// Then issues
assert_eq!(result.items[1].item_type, "issue");
assert_eq!(result.items[1].id, "issue:group::repo:42");
// Then authored MRs
assert_eq!(result.items[2].item_type, "mr_authored");
assert_eq!(result.items[2].id, "mr_authored:group::repo:100");
}
#[test]
fn test_get_lore_items_empty_response() {
let mock = mock_healthy_cli();
let result = get_lore_items_with(&mock).unwrap();
assert!(result.success);
assert!(result.items.is_empty());
}
#[test]
fn test_get_lore_items_cli_not_found() {
let mut mock = MockLoreCli::new();
mock.expect_get_me()
.returning(|| Err(LoreError::ExecutionFailed("not found".to_string())));
let result = get_lore_items_with(&mock).unwrap();
assert!(!result.success);
assert!(result.items.is_empty());
assert!(result.error.unwrap().contains("not found"));
}
#[test]
fn test_get_lore_items_command_failed() {
let mut mock = MockLoreCli::new();
mock.expect_get_me()
.returning(|| Err(LoreError::CommandFailed("auth failed".to_string())));
let result = get_lore_items_with(&mock).unwrap();
assert!(!result.success);
assert!(result.items.is_empty());
assert!(result.error.unwrap().contains("auth failed"));
}
#[test]
fn test_escape_project_replaces_slashes() {
assert_eq!(escape_project("group/repo"), "group::repo");
assert_eq!(escape_project("a/b/c"), "a::b::c");
assert_eq!(escape_project("noslash"), "noslash");
}
}

View File

@@ -11,13 +11,12 @@ use mockall::automock;
/// Trait for interacting with lore CLI
///
/// This abstraction allows us to mock lore in tests.
/// Note: We don't use `lore health` because it's too strict (checks schema
/// migrations, index freshness, etc). MC only cares if we can get data.
#[cfg_attr(test, automock)]
pub trait LoreCli: Send + Sync {
/// Execute `lore --robot me` and return the parsed result
fn get_me(&self) -> Result<LoreMeResponse, LoreError>;
/// Execute `lore --robot health` and check if lore is healthy
fn health_check(&self) -> Result<bool, LoreError>;
}
/// Real implementation that shells out to lore CLI
@@ -39,15 +38,6 @@ impl LoreCli for RealLoreCli {
let stdout = String::from_utf8_lossy(&output.stdout);
serde_json::from_str(&stdout).map_err(|e| LoreError::ParseFailed(e.to_string()))
}
fn health_check(&self) -> Result<bool, LoreError> {
let output = Command::new("lore")
.args(["health", "--json"])
.output()
.map_err(|e| LoreError::ExecutionFailed(e.to_string()))?;
Ok(output.status.success())
}
}
/// Errors that can occur when interacting with lore
@@ -287,15 +277,6 @@ mod tests {
assert_eq!(result.data.open_issues[0].iid, 42);
}
#[test]
fn test_mock_lore_cli_health_check() {
let mut mock = MockLoreCli::new();
mock.expect_health_check().times(1).returning(|| Ok(true));
assert!(mock.health_check().unwrap());
}
#[test]
fn test_mock_lore_cli_can_return_error() {
let mut mock = MockLoreCli::new();

37
src-tauri/src/events.rs Normal file
View File

@@ -0,0 +1,37 @@
//! Typed events for Tauri IPC.
//!
//! These events are registered with tauri-specta to generate TypeScript bindings.
//! Using typed events provides compile-time type safety on both Rust and TS sides.
use serde::Serialize;
use specta::Type;
use tauri_specta::Event;
use crate::app::{CliAvailability, StartupWarning};
/// Emitted when lore.db file changes (triggers data refresh)
#[derive(Debug, Clone, Serialize, Type, Event)]
pub struct LoreDataChanged;
/// Emitted when a global shortcut is triggered
#[derive(Debug, Clone, Serialize, Type, Event)]
pub struct GlobalShortcutTriggered {
/// The shortcut that was triggered: "quick-capture" or "toggle-window"
pub shortcut: String,
}
/// Emitted at startup with any warnings (missing CLIs, state resets, etc.)
#[derive(Debug, Clone, Serialize, Type, Event)]
pub struct StartupWarningsEvent {
pub warnings: Vec<StartupWarning>,
}
/// Emitted at startup with CLI availability status
#[derive(Debug, Clone, Serialize, Type, Event)]
pub struct CliAvailabilityEvent {
pub availability: CliAvailability,
}
/// Emitted when startup sync is ready (all CLIs available)
#[derive(Debug, Clone, Serialize, Type, Event)]
pub struct StartupSyncReady;

View File

@@ -10,74 +10,20 @@ pub mod app;
pub mod commands;
pub mod data;
pub mod error;
pub mod events;
pub mod sync;
pub mod tray;
pub mod watcher;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::TrayIconBuilder;
use tauri::{Emitter, Manager};
use tauri::Manager;
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
use tauri_specta::{collect_commands, Builder};
use tauri_specta::{collect_commands, collect_events, Builder, Event};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
/// Toggle the main window's visibility.
///
/// If the window is visible and focused, hide it.
/// If hidden or not focused, show and focus it.
fn toggle_window_visibility(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) && window.is_focused().unwrap_or(false) {
if let Err(e) = window.hide() {
tracing::warn!("Failed to hide window: {}", e);
}
} else {
if let Err(e) = window.show() {
tracing::warn!("Failed to show window: {}", e);
}
if let Err(e) = window.set_focus() {
tracing::warn!("Failed to focus window: {}", e);
}
}
}
}
/// Set up the system tray icon with a menu.
fn setup_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let show_item = MenuItemBuilder::with_id("show", "Show Mission Control").build(app)?;
let quit_item = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
let menu = MenuBuilder::new(app)
.items(&[&show_item, &quit_item])
.build()?;
TrayIconBuilder::new()
.icon(
app.default_window_icon()
.cloned()
.expect("default-window-icon must be set in tauri.conf.json"),
)
.tooltip("Mission Control")
.menu(&menu)
.on_menu_event(|app, event| match event.id().as_ref() {
"show" => toggle_window_visibility(app),
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let tauri::tray::TrayIconEvent::Click {
button: tauri::tray::MouseButton::Left,
button_state: tauri::tray::MouseButtonState::Up,
..
} = event
{
toggle_window_visibility(tray.app_handle());
}
})
.build(app)?;
Ok(())
}
use events::{
CliAvailabilityEvent, GlobalShortcutTriggered, LoreDataChanged, StartupSyncReady,
StartupWarningsEvent,
};
/// Register global hotkeys:
/// - Cmd+Shift+M: toggle window visibility
@@ -111,32 +57,47 @@ pub fn run() {
tracing::info!("Starting Mission Control");
// Build tauri-specta builder for type-safe IPC
let builder = Builder::<tauri::Wry>::new().commands(collect_commands![
commands::greet,
commands::get_lore_status,
commands::get_bridge_status,
commands::sync_now,
commands::reconcile,
commands::quick_capture,
commands::read_state,
commands::write_state,
commands::clear_state,
commands::get_triage,
commands::get_next_pick,
commands::close_bead,
commands::log_decision,
commands::update_item,
]);
let builder = Builder::<tauri::Wry>::new()
.commands(collect_commands![
commands::greet,
commands::get_lore_status,
commands::get_lore_items,
commands::get_bridge_status,
commands::sync_now,
commands::reconcile,
commands::quick_capture,
commands::read_state,
commands::write_state,
commands::clear_state,
commands::get_triage,
commands::get_next_pick,
commands::close_bead,
commands::log_decision,
commands::update_item,
commands::update_tray_badge,
])
.events(collect_events![
LoreDataChanged,
GlobalShortcutTriggered,
StartupWarningsEvent,
CliAvailabilityEvent,
StartupSyncReady,
]);
// Export TypeScript bindings in debug builds
#[cfg(debug_assertions)]
builder
.export(
specta_typescript::Typescript::default(),
specta_typescript::Typescript::default()
// Allow i64 as JS number - safe for our count values which never exceed 2^53
.bigint(specta_typescript::BigIntExportBehavior::Number),
"../src/lib/bindings.ts",
)
.expect("Failed to export TypeScript bindings");
// Get invoke_handler before moving builder into setup
let invoke_handler = builder.invoke_handler();
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(
@@ -156,12 +117,18 @@ pub fn run() {
tracing::warn!("Failed to focus window for capture: {}", e);
}
}
if let Err(e) = app.emit("global-shortcut-triggered", "quick-capture") {
let event = GlobalShortcutTriggered {
shortcut: "quick-capture".to_string(),
};
if let Err(e) = event.emit(app) {
tracing::error!("Failed to emit quick-capture event: {}", e);
}
} else {
toggle_window_visibility(app);
if let Err(e) = app.emit("global-shortcut-triggered", "toggle-window") {
tray::toggle_window_visibility(app);
let event = GlobalShortcutTriggered {
shortcut: "toggle-window".to_string(),
};
if let Err(e) = event.emit(app) {
tracing::error!("Failed to emit toggle-window event: {}", e);
}
}
@@ -169,7 +136,10 @@ pub fn run() {
})
.build(),
)
.setup(|app| {
.setup(move |app| {
// Mount typed events into Tauri state
builder.mount_events(app);
use data::beads::RealBeadsCli;
use data::bridge::Bridge;
use data::lore::RealLoreCli;
@@ -213,13 +183,17 @@ pub fn run() {
// Emit startup warnings to frontend
if !warnings.is_empty() {
if let Err(e) = app_handle.emit("startup-warnings", &warnings) {
let event = StartupWarningsEvent { warnings };
if let Err(e) = event.emit(&app_handle) {
tracing::error!("Failed to emit startup warnings: {}", e);
}
}
// Emit CLI availability to frontend
if let Err(e) = app_handle.emit("cli-availability", &cli_available) {
let event = CliAvailabilityEvent {
availability: cli_available.clone(),
};
if let Err(e) = event.emit(&app_handle) {
tracing::error!("Failed to emit CLI availability: {}", e);
}
@@ -227,14 +201,14 @@ pub fn run() {
if cli_available.lore && cli_available.br {
tracing::info!("Triggering startup reconciliation");
// The frontend will call reconcile() command when ready
if let Err(e) = app_handle.emit("startup-sync-ready", ()) {
if let Err(e) = StartupSyncReady.emit(&app_handle) {
tracing::error!("Failed to emit startup-sync-ready: {}", e);
}
}
});
// Set up system tray
if let Err(e) = setup_tray(app) {
if let Err(e) = tray::setup_tray(app) {
tracing::error!("Failed to setup system tray: {}", e);
}
@@ -259,7 +233,7 @@ pub fn run() {
}
Ok(())
})
.invoke_handler(builder.invoke_handler())
.invoke_handler(invoke_handler)
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

82
src-tauri/src/tray.rs Normal file
View File

@@ -0,0 +1,82 @@
//! System tray integration for Mission Control.
//!
//! Creates a tray icon with context menu and stores the handle
//! so other parts of the app can update the badge/tooltip.
use std::sync::Mutex;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Manager};
/// Holds the tray icon handle so commands can update tooltip/badge.
pub struct TrayState {
pub tray: Mutex<tauri::tray::TrayIcon>,
}
/// Toggle the main window's visibility.
///
/// If the window is visible and focused, hide it.
/// If hidden or not focused, show and focus it.
pub fn toggle_window_visibility(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) && window.is_focused().unwrap_or(false) {
if let Err(e) = window.hide() {
tracing::warn!("Failed to hide window: {}", e);
}
} else {
if let Err(e) = window.show() {
tracing::warn!("Failed to show window: {}", e);
}
if let Err(e) = window.set_focus() {
tracing::warn!("Failed to focus window: {}", e);
}
}
}
}
/// Set up the system tray icon with a context menu.
///
/// Stores the tray icon handle in Tauri managed state so
/// `update_tray_badge` can update the tooltip later.
pub fn setup_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let show_item = MenuItemBuilder::with_id("show", "Show Mission Control").build(app)?;
let quit_item = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
let menu = MenuBuilder::new(app)
.items(&[&show_item, &quit_item])
.build()?;
let tray = TrayIconBuilder::new()
.icon(
app.default_window_icon()
.cloned()
.expect("default-window-icon must be set in tauri.conf.json"),
)
.tooltip("Mission Control")
.menu(&menu)
.on_menu_event(|app, event| match event.id().as_ref() {
"show" => toggle_window_visibility(app),
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let tauri::tray::TrayIconEvent::Click {
button: tauri::tray::MouseButton::Left,
button_state: tauri::tray::MouseButtonState::Up,
..
} = event
{
toggle_window_visibility(tray.app_handle());
}
})
.build(app)?;
// Store tray handle in managed state for badge updates
app.manage(TrayState {
tray: Mutex::new(tray),
});
Ok(())
}

View File

@@ -7,7 +7,10 @@ use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watche
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use tauri::AppHandle;
use tauri_specta::Event as TauriEvent;
use crate::events::LoreDataChanged;
/// Get the path to lore's database file
fn lore_db_path() -> Option<PathBuf> {
@@ -70,7 +73,7 @@ pub fn start_lore_watcher(app: AppHandle) -> Option<RecommendedWatcher> {
let affects_db = event.paths.iter().any(|p| p.ends_with("lore.db"));
if affects_db {
tracing::debug!("lore.db changed, emitting refresh event");
if let Err(e) = app.emit("lore-data-changed", ()) {
if let Err(e) = LoreDataChanged.emit(&app) {
tracing::warn!("Failed to emit lore-data-changed event: {}", e);
}
}