From 94119de09dca84ac303727c1406a27d5d797809f Mon Sep 17 00:00:00 2001 From: Eliot Hedeman Date: Thu, 23 Apr 2026 14:52:39 -0400 Subject: [PATCH] feat(toolpath-desktop): add menu-bar Quick View Adds a macOS tray icon alongside the existing Dock-icon app. A background thread polls every 30s across toolpath-claude, -gemini, -codex, -opencode, and -pi, classifies sessions as active (<2min) or recent (<24h), and updates the tray title with a live activity count. Left-clicking the tray opens a small popover window (new Vite entry: popover.html) listing recent sessions across all five providers. Clicking a claude or pi session derives the trace via the existing derive_* IPC commands and surfaces it directly in the main window's preview route via a trace:opened event. Rows for gemini/codex/opencode show up for activity tracking but are disabled until their desktop-side derive commands land. The popover also has an "Open Toolpath" button that simply shows + focuses the main window. --- CLAUDE.md | 6 +- Cargo.lock | 19 + crates/toolpath-desktop/Cargo.toml | 6 +- crates/toolpath-desktop/README.md | 8 + .../capabilities/default.json | 11 +- crates/toolpath-desktop/frontend/popover.html | 12 + .../toolpath-desktop/frontend/src/app.svelte | 26 +- .../toolpath-desktop/frontend/src/popover.ts | 7 + .../frontend/src/routes/Popover.svelte | 376 +++++++++++ .../toolpath-desktop/frontend/vite.config.ts | 9 + crates/toolpath-desktop/src/main.rs | 9 + crates/toolpath-desktop/src/tray.rs | 592 ++++++++++++++++++ crates/toolpath-desktop/tauri.conf.json | 14 + 13 files changed, 1090 insertions(+), 5 deletions(-) create mode 100644 crates/toolpath-desktop/frontend/popover.html create mode 100644 crates/toolpath-desktop/frontend/src/popover.ts create mode 100644 crates/toolpath-desktop/frontend/src/routes/Popover.svelte create mode 100644 crates/toolpath-desktop/src/tray.rs diff --git a/CLAUDE.md b/CLAUDE.md index 64e1af4..41f77c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,7 +112,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `toolpath-cli` ha - `toolpath-pi`: ~88 unit tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) - `toolpath-cli`: 126 unit + 24 integration tests (all commands, track sessions, merge, validate, roundtrip, render-md snapshots) -- `toolpath-desktop`: 13 unit tests (IPC command modules — source listing, derive validation, export round-trip, upload stub, keychain input checks) +- `toolpath-desktop`: 17 unit tests (IPC command modules — source listing, derive validation, export round-trip, upload stub, keychain input checks; tray activity-window bucketing, stats-snapshot smoke, session-id/basename helpers) Validate example documents: `for f in examples/*.json; do cargo run -p toolpath-cli -- validate --input "$f"; done` @@ -138,6 +138,10 @@ Layout: Tauri dev loop: `cargo tauri dev` spawns `bun --cwd frontend run dev` (Vite on `http://localhost:1420`), then runs the Rust binary against that URL. Frontend edits hot-reload via Vite HMR without restarting Rust; Rust edits trigger `cargo run` to restart. Production: `cargo tauri build` runs `bun --cwd frontend run build` first, bundling to `frontend/dist/`. +Menu-bar mode: the app runs as a normal GUI app (Dock icon + app-switcher entry) *and* installs a tray icon — the tray is an accessory, not a replacement for the main window. Accessory activation policy was tried and reverted because macOS tiling window managers (yabai, Amethyst) stop managing accessory windows. A tray icon is installed in `src/tray.rs`; a background thread polls every 30s across `toolpath-claude`, `-gemini`, `-codex`, `-opencode`, and `-pi`, classifies sessions as *active* (last activity in the last 2 min) or *recent* (last 24h), updates the tray title (`● N`), and emits a `tray:stats` event. The popover is a second Tauri window (`label = "popover"`, undecorated, hidden by default) with its own Vite entry (`frontend/popover.html` → `src/popover.ts` → `routes/Popover.svelte`); left-clicking the tray toggles it via `tauri-plugin-positioner`. For an on-demand snapshot (no waiting for the next poll) the popover invokes the `tray_stats_now` IPC command. + +Opening a trace from the popover: clicking a recent-session row invokes `tray_open_trace { provider, project, session_id }`. The Rust side calls back into the existing `derive_claude` / `derive_pi` commands, shows the main window, and emits a `trace:opened` event to the main window with the derived `{ doc, source, filename }`. `app.svelte` listens for it and dispatches `DeriveSucceeded`, which routes to the preview. Only `claude` and `pi` have derive commands today — rows for `gemini`, `codex`, `opencode` still appear in the list (so users can see activity) but are rendered disabled. + Streaming pattern (Claude project/session lists): Rust command spawns a thread that emits `claude:project`, `claude:session`, `claude:projects-done`, `claude:sessions-done` events. The Svelte component subscribes with `$effect(() => { listen(...) ... return unlisten; })` — Svelte tears down listeners automatically when the effect's deps change or the component unmounts. Package manager for the frontend is `bun` (installed at `~/.bun/bin/bun`). `bun install` to set up, `bun run check` for `svelte-check`, `bun run build` for a production Vite build. Never commit `node_modules/` or `dist/` — both are ignored. diff --git a/Cargo.lock b/Cargo.lock index 5b2e009..b67ce01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4662,6 +4662,21 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-positioner" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02dcd56dd4797bd4d6c4c658daed40ce563176f92df90fbd2c904ce145de17ef" +dependencies = [ + "log", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-runtime" version = "2.10.1" @@ -5142,12 +5157,16 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-opener", + "tauri-plugin-positioner", "tempfile", "thiserror 1.0.69", "toolpath", "toolpath-claude", + "toolpath-codex", + "toolpath-gemini", "toolpath-git", "toolpath-github", + "toolpath-opencode", "toolpath-pi", "uuid", ] diff --git a/crates/toolpath-desktop/Cargo.toml b/crates/toolpath-desktop/Cargo.toml index e022d1c..1ef284f 100644 --- a/crates/toolpath-desktop/Cargo.toml +++ b/crates/toolpath-desktop/Cargo.toml @@ -18,13 +18,17 @@ tauri-build = { version = "2", features = [] } [dependencies] toolpath = { workspace = true } toolpath-claude = { workspace = true, features = ["watcher"] } +toolpath-codex = { workspace = true } +toolpath-gemini = { workspace = true } toolpath-git = { workspace = true } toolpath-github = { workspace = true } +toolpath-opencode = { workspace = true } toolpath-pi = { workspace = true } -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-dialog = "2" tauri-plugin-opener = "2" +tauri-plugin-positioner = { version = "2", features = ["tray-icon"] } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/toolpath-desktop/README.md b/crates/toolpath-desktop/README.md index 26239ae..8004ec7 100644 --- a/crates/toolpath-desktop/README.md +++ b/crates/toolpath-desktop/README.md @@ -18,6 +18,14 @@ intended for people who won't open a terminal to run the `path` CLI. 3. **Export** — save the document as a local `.path.json` file, **or** upload it to Pathbase. The Pathbase upload is stubbed in v0.1 and logs a mock response; the real API will be wired up in a follow-up. +4. **Quick View (menu bar)** — a tray icon that sits alongside the regular + Dock-icon app. A background thread polls every 30s across all five agent + providers (`toolpath-claude`, `-gemini`, `-codex`, `-opencode`, `-pi`) + and updates the tray title with an activity count. Left-click opens a + small popover listing recent sessions; the menu has an "Open Toolpath" + item that brings up the main window, and clicking a recent session in + the popover opens its trace directly in the preview. See `src/tray.rs` + and `frontend/src/routes/Popover.svelte`. ## Architecture diff --git a/crates/toolpath-desktop/capabilities/default.json b/crates/toolpath-desktop/capabilities/default.json index f3f3d8f..081edb6 100644 --- a/crates/toolpath-desktop/capabilities/default.json +++ b/crates/toolpath-desktop/capabilities/default.json @@ -2,10 +2,17 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capabilities required by the Toolpath desktop app.", - "windows": ["main"], + "windows": ["main", "popover"], "permissions": [ "core:default", "dialog:default", - "opener:default" + "opener:default", + "positioner:default", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-set-focus", + "core:window:allow-close", + "core:window:allow-is-visible", + "core:window:allow-start-dragging" ] } diff --git a/crates/toolpath-desktop/frontend/popover.html b/crates/toolpath-desktop/frontend/popover.html new file mode 100644 index 0000000..9dd8b7f --- /dev/null +++ b/crates/toolpath-desktop/frontend/popover.html @@ -0,0 +1,12 @@ + + + + + + Toolpath Quick View + + +
+ + + diff --git a/crates/toolpath-desktop/frontend/src/app.svelte b/crates/toolpath-desktop/frontend/src/app.svelte index 4c5996d..3e0285c 100644 --- a/crates/toolpath-desktop/frontend/src/app.svelte +++ b/crates/toolpath-desktop/frontend/src/app.svelte @@ -1,5 +1,7 @@
diff --git a/crates/toolpath-desktop/frontend/src/popover.ts b/crates/toolpath-desktop/frontend/src/popover.ts new file mode 100644 index 0000000..3fbee35 --- /dev/null +++ b/crates/toolpath-desktop/frontend/src/popover.ts @@ -0,0 +1,7 @@ +import { mount } from "svelte"; +import Popover from "./routes/Popover.svelte"; +import "./styles.css"; + +const target = document.getElementById("popover"); +if (!target) throw new Error("#popover not found"); +mount(Popover, { target }); diff --git a/crates/toolpath-desktop/frontend/src/routes/Popover.svelte b/crates/toolpath-desktop/frontend/src/routes/Popover.svelte new file mode 100644 index 0000000..228f62e --- /dev/null +++ b/crates/toolpath-desktop/frontend/src/routes/Popover.svelte @@ -0,0 +1,376 @@ + + +
+
+ Quick View + {#if stats} + {stats.total_active} active · {stats.total_recent} recent + {:else} + loading… + {/if} +
+ + {#if stats} + + + + + + {#if openError} + + {/if} + {/if} + +
+ + + + {#if stats} + updated {ago(stats.polled_at)} + {/if} +
+
+ + diff --git a/crates/toolpath-desktop/frontend/vite.config.ts b/crates/toolpath-desktop/frontend/vite.config.ts index 4066dcf..47b98b4 100644 --- a/crates/toolpath-desktop/frontend/vite.config.ts +++ b/crates/toolpath-desktop/frontend/vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from "vite"; import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { resolve } from "node:path"; // Tauri expects a fixed port; strictPort = fail if taken rather than pick // another one behind its back. @@ -25,5 +26,13 @@ export default defineConfig({ // panel readable and avoids weird code-splitting behaviour in the // webview. chunkSizeWarningLimit: 1500, + rollupOptions: { + // Two HTML entries: the main window (index.html) and the tray popover + // (popover.html). Both are bundled into frontend/dist by Tauri. + input: { + main: resolve(__dirname, "index.html"), + popover: resolve(__dirname, "popover.html"), + }, + }, }, }); diff --git a/crates/toolpath-desktop/src/main.rs b/crates/toolpath-desktop/src/main.rs index 7d91f7c..9bba9fb 100644 --- a/crates/toolpath-desktop/src/main.rs +++ b/crates/toolpath-desktop/src/main.rs @@ -5,6 +5,7 @@ mod commands; mod error; +mod tray; use commands::{derive, export, keychain, sources, upload}; @@ -12,6 +13,11 @@ fn main() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_positioner::init()) + .setup(|app| { + tray::install(app)?; + Ok(()) + }) .invoke_handler(tauri::generate_handler![ sources::list_agents, sources::list_claude_projects, @@ -31,6 +37,9 @@ fn main() { keychain::github_set_token, keychain::github_has_token, keychain::github_clear_token, + tray::tray_stats_now, + tray::tray_open_main, + tray::tray_open_trace, ]) .run(tauri::generate_context!()) .expect("error while running toolpath-desktop"); diff --git a/crates/toolpath-desktop/src/tray.rs b/crates/toolpath-desktop/src/tray.rs new file mode 100644 index 0000000..aa9b08d --- /dev/null +++ b/crates/toolpath-desktop/src/tray.rs @@ -0,0 +1,592 @@ +//! System tray / menu-bar mode. +//! +//! Sets up a macOS menu-bar icon with a small popover window and a 30-second +//! background poller that walks every agent-conversation provider +//! (`toolpath-claude`, `-gemini`, `-codex`, `-opencode`, `-pi`) and reports +//! how many sessions have been active recently. +//! +//! The popover is a second Tauri window (`label = "popover"`) configured as +//! undecorated and hidden by default in `tauri.conf.json`. Left-clicking the +//! tray icon toggles it; clicking the menu's "Open Toolpath" brings up the +//! main window. +//! +//! The poller emits a `tray:stats` event with a [`TrayStats`] payload. The +//! popover frontend subscribes to that event and also invokes +//! `tray_stats_now` for an immediate value on open. + +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use serde::Serialize; +use tauri::menu::{Menu, MenuItem}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; +use tauri::{AppHandle, Emitter, Manager}; +use tauri_plugin_positioner::{Position, WindowExt}; + +/// How often the background poller re-scans providers. +const POLL_INTERVAL: Duration = Duration::from_secs(30); + +/// Sessions touched within this window count as "active now". +const ACTIVE_WINDOW_SECS: i64 = 120; + +/// Sessions touched within this window count as "recent" (shown in list). +const RECENT_WINDOW_SECS: i64 = 24 * 60 * 60; + +/// Maximum number of recent sessions to include in the popover payload. +const MAX_RECENT_SESSIONS: usize = 20; + +/// Per-provider counts, emitted in [`TrayStats`]. +#[derive(Debug, Clone, Default, Serialize)] +pub struct ProviderCounts { + pub provider: &'static str, + /// Sessions with `last_activity` within [`ACTIVE_WINDOW_SECS`]. + pub active: usize, + /// Sessions with `last_activity` within [`RECENT_WINDOW_SECS`]. + pub recent: usize, +} + +/// One entry in the popover's "recent sessions" list. +#[derive(Debug, Clone, Serialize)] +pub struct RecentSession { + pub provider: &'static str, + /// Project key (empty string for codex/opencode which are project-less). + pub project: String, + pub session_id: String, + pub last_activity: String, +} + +/// Payload for the `tray:stats` event (and `tray_stats_now` response). +#[derive(Debug, Clone, Default, Serialize)] +pub struct TrayStats { + pub counts: Vec, + pub recent: Vec, + pub total_active: usize, + pub total_recent: usize, + /// RFC3339 timestamp of the poll that produced this snapshot. + pub polled_at: String, +} + +/// Compute a fresh stats snapshot by walking every provider. +/// +/// Errors from individual providers are swallowed — a broken pi install +/// shouldn't hide claude activity. This is the same defensive posture the +/// existing `list_claude_projects` command takes. +pub fn collect_stats() -> TrayStats { + let now = Utc::now(); + let mut counts: Vec = Vec::new(); + let mut recent: Vec = Vec::new(); + + collect_claude(&now, &mut counts, &mut recent); + collect_gemini(&now, &mut counts, &mut recent); + collect_codex(&now, &mut counts, &mut recent); + collect_opencode(&now, &mut counts, &mut recent); + collect_pi(&now, &mut counts, &mut recent); + + recent.sort_by(|a, b| b.last_activity.cmp(&a.last_activity)); + recent.truncate(MAX_RECENT_SESSIONS); + + let total_active = counts.iter().map(|c| c.active).sum(); + let total_recent = counts.iter().map(|c| c.recent).sum(); + + TrayStats { + counts, + recent, + total_active, + total_recent, + polled_at: now.to_rfc3339(), + } +} + +fn bucket( + now: &DateTime, + last: Option>, +) -> (bool /* active */, bool /* recent */) { + let Some(ts) = last else { + return (false, false); + }; + let delta = now.signed_duration_since(ts).num_seconds(); + if delta < 0 { + // Future timestamps (clock skew) — treat as active. + return (true, true); + } + (delta <= ACTIVE_WINDOW_SECS, delta <= RECENT_WINDOW_SECS) +} + +fn collect_claude( + now: &DateTime, + counts: &mut Vec, + recent: &mut Vec, +) { + let mgr = toolpath_claude::ClaudeConvo::new(); + let mut c = ProviderCounts { + provider: "claude", + ..Default::default() + }; + if !mgr.exists() { + counts.push(c); + return; + } + let projects = mgr.list_projects().unwrap_or_default(); + for project in projects { + let metas = match mgr.list_conversation_metadata(&project) { + Ok(m) => m, + Err(_) => continue, + }; + for meta in metas { + let (active, is_recent) = bucket(now, meta.last_activity); + if active { + c.active += 1; + } + if is_recent { + c.recent += 1; + if let Some(ts) = meta.last_activity { + recent.push(RecentSession { + provider: "claude", + project: project.clone(), + session_id: meta.session_id, + last_activity: ts.to_rfc3339(), + }); + } + } + } + } + counts.push(c); +} + +fn collect_gemini( + now: &DateTime, + counts: &mut Vec, + recent: &mut Vec, +) { + let mgr = toolpath_gemini::GeminiConvo::new(); + let mut c = ProviderCounts { + provider: "gemini", + ..Default::default() + }; + if !mgr.exists() { + counts.push(c); + return; + } + let projects = mgr.list_projects().unwrap_or_default(); + for project in projects { + let metas = match mgr.list_conversation_metadata(&project) { + Ok(m) => m, + Err(_) => continue, + }; + for meta in metas { + let (active, is_recent) = bucket(now, meta.last_activity); + if active { + c.active += 1; + } + if is_recent { + c.recent += 1; + if let Some(ts) = meta.last_activity { + recent.push(RecentSession { + provider: "gemini", + project: project.clone(), + session_id: meta.session_uuid.clone(), + last_activity: ts.to_rfc3339(), + }); + } + } + } + } + counts.push(c); +} + +fn collect_codex( + now: &DateTime, + counts: &mut Vec, + recent: &mut Vec, +) { + let mgr = toolpath_codex::CodexConvo::new(); + let mut c = ProviderCounts { + provider: "codex", + ..Default::default() + }; + let sessions = mgr.list_sessions().unwrap_or_default(); + for s in sessions { + let (active, is_recent) = bucket(now, s.last_activity); + if active { + c.active += 1; + } + if is_recent { + c.recent += 1; + if let Some(ts) = s.last_activity { + recent.push(RecentSession { + provider: "codex", + project: s + .cwd + .as_ref() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(), + session_id: s.id, + last_activity: ts.to_rfc3339(), + }); + } + } + } + counts.push(c); +} + +fn collect_opencode( + now: &DateTime, + counts: &mut Vec, + recent: &mut Vec, +) { + let mgr = toolpath_opencode::OpencodeConvo::new(); + let mut c = ProviderCounts { + provider: "opencode", + ..Default::default() + }; + let sessions = mgr.list_sessions().unwrap_or_default(); + for s in sessions { + let (active, is_recent) = bucket(now, s.last_activity); + if active { + c.active += 1; + } + if is_recent { + c.recent += 1; + if let Some(ts) = s.last_activity { + recent.push(RecentSession { + provider: "opencode", + project: s.project_id.clone(), + session_id: s.id, + last_activity: ts.to_rfc3339(), + }); + } + } + } + counts.push(c); +} + +fn collect_pi( + now: &DateTime, + counts: &mut Vec, + recent: &mut Vec, +) { + let mgr = toolpath_pi::PiConvo::new(); + let mut c = ProviderCounts { + provider: "pi", + ..Default::default() + }; + if !mgr.exists() { + counts.push(c); + return; + } + let projects = mgr.list_projects().unwrap_or_default(); + for project in projects { + let sessions = match mgr.list_sessions(&project) { + Ok(s) => s, + Err(_) => continue, + }; + for s in sessions { + let ts = DateTime::parse_from_rfc3339(&s.timestamp) + .ok() + .map(|t| t.with_timezone(&Utc)); + let (active, is_recent) = bucket(now, ts); + if active { + c.active += 1; + } + if is_recent { + c.recent += 1; + if let Some(ts) = ts { + recent.push(RecentSession { + provider: "pi", + project: project.clone(), + session_id: s.id, + last_activity: ts.to_rfc3339(), + }); + } + } + } + } + counts.push(c); +} + +/// IPC command — returns the current stats without waiting for the next poll. +/// +/// Called by the popover on open so it doesn't display stale data for up to +/// 30 seconds after being shown. +#[tauri::command] +pub fn tray_stats_now() -> TrayStats { + collect_stats() +} + +/// IPC command — show + focus the main window. +/// +/// Called by the popover's "Open Toolpath" button. Mirrors the tray menu's +/// `tray:open` action so the popover doesn't need a separate permission for +/// addressing another window by label from JS. +#[tauri::command] +pub fn tray_open_main(app: AppHandle) { + show_main(&app); + hide_popover(&app); +} + +/// Payload pushed to the main window after a trace is derived on the tray +/// side. The main window's reducer consumes this as a `DeriveSucceeded` msg. +#[derive(Debug, Clone, Serialize)] +pub struct TraceOpenedPayload { + pub doc: serde_json::Value, + pub source: String, + pub filename: String, +} + +/// IPC command — derive the trace for a single session and surface it in the +/// main window's preview. +/// +/// Callable from the popover when the user clicks a recent session. Only the +/// providers with a desktop-side derive command are supported (claude, pi). +/// For gemini/codex/opencode the popover should disable the row instead of +/// calling this. +#[tauri::command] +pub fn tray_open_trace( + app: AppHandle, + provider: String, + project: String, + session_id: String, +) -> Result<(), String> { + let (doc, source, filename) = match provider.as_str() { + "claude" => { + let value = crate::commands::derive::derive_claude( + project.clone(), + vec![session_id.clone()], + /* include_thinking */ false, + ) + .map_err(|e| e.to_string())?; + let filename = format!( + "claude-{}-{}.path.json", + basename_slug(&project), + short(&session_id) + ); + ( + value, + format!("Claude: {}", basename(&project)), + filename, + ) + } + "pi" => { + let value = crate::commands::derive::derive_pi( + project.clone(), + vec![session_id.clone()], + /* include_thinking */ false, + ) + .map_err(|e| e.to_string())?; + let filename = format!( + "pi-{}-{}.path.json", + basename_slug(&project), + short(&session_id) + ); + ( + value, + format!("pi.dev: {}", basename(&project)), + filename, + ) + } + // Not wired up in the desktop backend yet. The popover disables + // rows for these, but we still reject politely if one slips through. + "gemini" | "codex" | "opencode" => { + return Err(format!( + "Opening {provider} traces from Quick View isn't wired up yet." + )); + } + other => return Err(format!("unknown provider: {other}")), + }; + + let payload = TraceOpenedPayload { + doc, + source, + filename, + }; + show_main(&app); + hide_popover(&app); + app.emit_to("main", "trace:opened", payload) + .map_err(|e| e.to_string())?; + Ok(()) +} + +fn basename(path: &str) -> String { + if path.is_empty() { + return String::new(); + } + std::path::Path::new(path) + .file_name() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| path.to_string()) +} + +fn basename_slug(path: &str) -> String { + basename(path) + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect() +} + +fn short(id: &str) -> &str { + id.get(..8).unwrap_or(id) +} + +/// Install the tray icon, menu, event handlers, and the background poller. +/// +/// Called from `setup` in `main.rs`. Safe to call once per app lifetime. +pub fn install(app: &tauri::App) -> tauri::Result<()> { + let handle = app.handle().clone(); + + let menu_open = MenuItem::with_id(app, "tray:open", "Open Toolpath", true, None::<&str>)?; + let menu_refresh = MenuItem::with_id(app, "tray:refresh", "Refresh now", true, None::<&str>)?; + let menu_quit = MenuItem::with_id(app, "tray:quit", "Quit", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&menu_open, &menu_refresh, &menu_quit])?; + + let _tray = TrayIconBuilder::with_id("main") + .icon( + app.default_window_icon() + .cloned() + .expect("bundle provides a default window icon"), + ) + .icon_as_template(true) + .title("·") + .tooltip("Toolpath — no activity") + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(move |app, event| match event.id.as_ref() { + "tray:quit" => app.exit(0), + "tray:open" => show_main(app), + "tray:refresh" => publish_stats(app), + _ => {} + }) + .on_tray_icon_event(|tray, event| { + let app = tray.app_handle(); + tauri_plugin_positioner::on_tray_event(app, &event); + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + toggle_popover(app); + } + }) + .build(app)?; + + // Kick off the poller. One initial publish so the tray title reflects + // reality without a 30s wait. + let poll_handle = Arc::new(handle.clone()); + thread::spawn(move || { + publish_stats(&poll_handle); + loop { + thread::sleep(POLL_INTERVAL); + publish_stats(&poll_handle); + } + }); + + Ok(()) +} + +fn show_main(app: &AppHandle) { + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.unminimize(); + let _ = w.set_focus(); + } +} + +fn toggle_popover(app: &AppHandle) { + let Some(w) = app.get_webview_window("popover") else { + return; + }; + let visible = w.is_visible().unwrap_or(false); + if visible { + let _ = w.hide(); + return; + } + let _ = w.move_window(Position::TrayBottomCenter); + let _ = w.show(); + let _ = w.set_focus(); +} + +fn hide_popover(app: &AppHandle) { + if let Some(w) = app.get_webview_window("popover") { + let _ = w.hide(); + } +} + +fn publish_stats(app: &AppHandle) { + let stats = collect_stats(); + + // Update the tray title with a compact activity indicator. + if let Some(tray) = app.tray_by_id("main") { + let title = match stats.total_active { + 0 => "·".to_string(), + n => format!("● {n}"), + }; + let tooltip = format!( + "Toolpath — {} active, {} recent", + stats.total_active, stats.total_recent + ); + let _ = tray.set_title(Some(&title)); + let _ = tray.set_tooltip(Some(&tooltip)); + } + + let _ = app.emit("tray:stats", stats); +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + + #[test] + fn bucket_classifies_activity_windows() { + let now = Utc::now(); + + // None → neither active nor recent. + assert_eq!(bucket(&now, None), (false, false)); + + // 10s ago → both active and recent. + assert_eq!( + bucket(&now, Some(now - Duration::seconds(10))), + (true, true) + ); + + // 5 minutes ago → recent but not active. + assert_eq!( + bucket(&now, Some(now - Duration::minutes(5))), + (false, true) + ); + + // 2 days ago → neither. + assert_eq!(bucket(&now, Some(now - Duration::days(2))), (false, false)); + + // Future timestamp (clock skew) → both (optimistic). + assert_eq!( + bucket(&now, Some(now + Duration::seconds(30))), + (true, true) + ); + } + + #[test] + fn collect_stats_runs_without_panic() { + // With no provider data on this machine the call should still + // produce a well-formed snapshot with all five provider slots. + let s = collect_stats(); + let providers: Vec<_> = s.counts.iter().map(|c| c.provider).collect(); + assert_eq!(providers, vec!["claude", "gemini", "codex", "opencode", "pi"]); + } + + #[test] + fn basename_slug_handles_paths_and_empty() { + assert_eq!(basename_slug("/Users/alex/proj"), "proj"); + assert_eq!(basename_slug("my project!"), "my-project-"); + assert_eq!(basename_slug(""), ""); + } + + #[test] + fn short_truncates_session_ids() { + assert_eq!(short("0123456789abcdef"), "01234567"); + assert_eq!(short("abc"), "abc"); + } +} diff --git a/crates/toolpath-desktop/tauri.conf.json b/crates/toolpath-desktop/tauri.conf.json index 74a0df9..db5fa24 100644 --- a/crates/toolpath-desktop/tauri.conf.json +++ b/crates/toolpath-desktop/tauri.conf.json @@ -27,6 +27,20 @@ "minHeight": 640, "resizable": true, "fullscreen": false + }, + { + "label": "popover", + "url": "popover.html", + "title": "Toolpath Quick View", + "width": 340, + "height": 420, + "resizable": false, + "decorations": false, + "transparent": false, + "alwaysOnTop": true, + "skipTaskbar": true, + "visible": false, + "focus": false } ], "security": {