From 61c3177ac28b8329d8d0ea42e37f141bcb77be3b Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Sun, 27 Sep 2026 08:23:34 +0900 Subject: [PATCH] feat(desktop): native menu bar, title-bar colour, window title and Dock badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page has no IPC into the shell by design, so every link uses a public surface: menu commands go down as a `gajae:menu` DOM event with a constant id, and the shell reads the page's title and theme-color on the way up. - Menu bar: App (About, Settings… ⌘,), File (New Conversation ⌘N, Add Workspace… ⇧⌘N, Close Window), Edit, View (Search… ⌘K, Show or Hide Sidebar ⌃⌘S, Show or Hide Agent Panel ⌥⌘0, Full Screen), Window, Help (issue, Discord, GitHub, opened by the page's own external-link path). DesktopMenuBridge maps the ids onto palette ops; toggleSidebar, toggleAgentPanel and createWorkspace join the ops registry. A Rust test keeps the two id lists in step. - Title bar: transparent and title-less. The band behind the traffic lights follows WKWebView.themeColor (KVO), which ThemeContext now sets from the --background token, so it matches the page in both themes without forcing the window appearance (that would override prefers-color-scheme). - Window title and Dock badge: the page title is "(n) — Gajae Code App", n = conversations waiting on the user. The shell mirrors the clean title onto the window, shows n (+1 for a [Done] notice) on the Dock icon and bounces once when it rises in the background. - All windows are built in setup (app_window.rs) so the main one can carry its document-title observer; QA profiles keep their isolated WebKit store. --- AGENTS.md | 8 + DESIGN.md | 6 + index.html | 1 - src-tauri/Cargo.toml | 4 +- src-tauri/src/app_menu.rs | 166 ++++++++++++++++++ src-tauri/src/app_window.rs | 60 +++++++ src-tauri/src/main.rs | 29 ++- src-tauri/src/qa_profile.rs | 18 +- src-tauri/src/window_chrome.rs | 98 +++++++++++ src-tauri/src/window_title.rs | 166 ++++++++++++++++++ src-tauri/tauri.conf.json | 4 +- src/App.tsx | 2 + .../app/DesktopMenuBridge.dom.bun.test.tsx | 44 +++++ src/components/app/DesktopMenuBridge.tsx | 46 +++++ .../main-content/view/MainContent.tsx | 1 + .../view/ThemePicker.dom.bun.test.tsx | 13 ++ src/components/sidebar/view/Sidebar.tsx | 28 ++- src/components/sidebar/view/SidebarFooter.tsx | 7 +- .../sidebar/view/SidebarProjectList.tsx | 10 -- src/constants/branding.ts | 2 + src/contexts/ThemeContext.tsx | 13 +- src/stores/usePaletteOpsStore.ts | 20 ++- src/utils/pageTitleNotification.ts | 11 ++ src/utils/windowTitle.test.ts | 15 ++ src/utils/windowTitle.ts | 13 ++ 25 files changed, 739 insertions(+), 46 deletions(-) create mode 100644 src-tauri/src/app_menu.rs create mode 100644 src-tauri/src/app_window.rs create mode 100644 src-tauri/src/window_chrome.rs create mode 100644 src-tauri/src/window_title.rs create mode 100644 src/components/app/DesktopMenuBridge.dom.bun.test.tsx create mode 100644 src/components/app/DesktopMenuBridge.tsx create mode 100644 src/utils/windowTitle.test.ts create mode 100644 src/utils/windowTitle.ts diff --git a/AGENTS.md b/AGENTS.md index a9a1d014..dbb829ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -205,6 +205,14 @@ is `.ts`/`.tsx`. Routing is react-router-dom 7. unknown dependencies. Do not add cross-module imports that violate them. - **Product identity is checked**: `npm run check:identity` verifies names/URLs/scheme against `shared/productIdentity.js`. Change identity constants there, nowhere else. +- **Shell ↔ page without IPC**: the app page has no Tauri IPC, so the macOS + shell talks to it through public surfaces only. Down: menu commands are a + `gajae:menu` window event with a constant id (`src-tauri/src/app_menu.rs` → + `src/components/app/DesktopMenuBridge.tsx`; a Rust test keeps the id lists in + step). Up: the shell reads `document.title` (`(n) …` → Dock badge, + `window_title.rs`) and `` (title-bar colour, + `window_chrome.rs`). The shell never opens URLs for the page. All windows are + built in setup (`app_window.rs`) so the main one carries its title observer. - **Desktop updates are click-driven**: `automatic` means discovery checks only. Download/restart require the native `targetId`; cached bytes alone cannot authorize startup installation. Preserve one-shot manual intent consumption diff --git a/DESIGN.md b/DESIGN.md index 6b2dad02..beeb8753 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -227,6 +227,12 @@ The system uses Tailwind's 4px spacing scale. Existing values like `p-2`, `gap-2 - **Behavior**: the divider supports pointer and keyboard resizing. Expand preserves the hidden conversation viewport and draft; restore returns to its previous width. Closing always restores the conversation. Titles remain plain text, and loading/error feedback stays within the chrome. - **Accessibility**: labelled native buttons, pressed state for expand/restore, a labelled address field, visible focus rings, reduced-motion support, and the app's selected UI language. +### Desktop Window (macOS) + +- **Title bar**: transparent and title-less (`titleBarStyle: Transparent`, `hiddenTitle`). The band holding the traffic lights takes the page's `theme-color`, which `ThemeContext` sets from `--background`; the shell observes `WKWebView.themeColor` (`src-tauri/src/window_chrome.rs`). The window's appearance is never forced, because it drives the page's `prefers-color-scheme`. +- **Title and Dock badge**: the page title is `(n) — Gajae Code App`, where `n` counts conversations that need the user (waiting, failed, finished unread). The shell mirrors the clean title onto the window, shows `n` (+1 for a `[Done] ` notice) on the Dock icon, and bounces the Dock once when it rises while the window is in the background (`src-tauri/src/window_title.rs`). +- **Menu bar**: App (About, Settings… ⌘,), File (New Conversation ⌘N, Add Workspace… ⇧⌘N, Close Window), Edit, View (Search… ⌘K, Show or Hide Sidebar ⌃⌘S, Show or Hide Agent Panel ⌥⌘0), Window, Help. Page commands reach the app as a `gajae:menu` window event handled by `DesktopMenuBridge`; the shell sends only constant ids and opens nothing itself. + ### Desktop Update Notice - **Placement**: a compact `bg-card`, `border-border`, `rounded-lg` card sits immediately above Settings in the fixed sidebar footer. The collapsed rail keeps an accessible update-details icon immediately above its bottom Settings control; opening details only expands the sidebar, without a modal or automatic focus change. diff --git a/index.html b/index.html index 3cb666a5..daa27be7 100644 --- a/index.html +++ b/index.html @@ -10,7 +10,6 @@ name="viewport" > - diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a9dd8542..f339d673 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,8 +36,8 @@ tauri-runtime-wry = "=2.11.4" hmac = "=0.12.1" block2 = "=0.6.2" objc2 = { version = "=0.6.4", default-features = false, features = ["std"] } -objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSView", "NSWindow"] } -objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSError", "NSString"] } +objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSColor", "NSResponder", "NSView", "NSWindow"] } +objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSError", "NSKeyValueObserving", "NSString"] } objc2-web-kit = { version = "=0.3.2", default-features = false, features = ["std", "block2", "objc2-app-kit", "WKContentWorld", "WKFrameInfo", "WKNavigation", "WKWebView"] } rustls-webpki = { version = "=0.103.15", default-features = false, features = ["std"] } rustls-pki-types = "=1.15.1" diff --git a/src-tauri/src/app_menu.rs b/src-tauri/src/app_menu.rs new file mode 100644 index 00000000..cdfb3fd8 --- /dev/null +++ b/src-tauri/src/app_menu.rs @@ -0,0 +1,166 @@ +//! The macOS menu bar. +//! +//! App commands reach the page as a DOM event, because the page has no IPC +//! into the shell: the shell evaluates +//! `window.dispatchEvent(new CustomEvent('gajae:menu', { detail: '' }))` +//! on the main webview and `src/components/app/DesktopMenuBridge.tsx` runs the +//! matching command. Only the constant ids below are ever sent. + +use tauri::menu::{ + AboutMetadata, Menu, MenuBuilder, MenuEvent, MenuItemBuilder, PredefinedMenuItem, + SubmenuBuilder, +}; +use tauri::{AppHandle, Wry}; + +/// Menu ids forwarded to the page, each with its label and accelerator. +const PAGE_COMMANDS: &[(&str, &str, Option<&str>)] = &[ + ("settings", "Settings…", Some("CmdOrCtrl+,")), + ("new-conversation", "New Conversation", Some("CmdOrCtrl+N")), + ("new-workspace", "Add Workspace…", Some("CmdOrCtrl+Shift+N")), + ("command-palette", "Search…", Some("CmdOrCtrl+K")), + ( + "toggle-sidebar", + "Show or Hide Sidebar", + Some("Ctrl+CmdOrCtrl+S"), + ), + ( + "toggle-agent-panel", + "Show or Hide Agent Panel", + Some("Alt+CmdOrCtrl+0"), + ), +]; + +/// Help items; the page opens their links through its external-link path, +/// so the shell holds no URLs and opens nothing itself. +const HELP_COMMANDS: &[(&str, &str)] = &[ + ("report-issue", "Report an Issue…"), + ("community", "Community on Discord"), + ("repository", "Gajae Code App on GitHub"), +]; + +fn page_item(app: &AppHandle, id: &str) -> tauri::Result> { + let (_, label, accelerator) = PAGE_COMMANDS + .iter() + .find(|(command, _, _)| *command == id) + .expect("page command is declared"); + let builder = MenuItemBuilder::with_id(id, *label); + match accelerator { + Some(accelerator) => builder.accelerator(*accelerator).build(app), + None => builder.build(app), + } +} + +pub fn build(app: &AppHandle) -> tauri::Result> { + let name = app.package_info().name.clone(); + let about = AboutMetadata { + name: Some(name.clone()), + version: Some(app.package_info().version.to_string()), + ..Default::default() + }; + let app_menu = SubmenuBuilder::new(app, &name) + .item(&PredefinedMenuItem::about(app, None, Some(about))?) + .separator() + .item(&page_item(app, "settings")?) + .separator() + .item(&PredefinedMenuItem::services(app, None)?) + .separator() + .item(&PredefinedMenuItem::hide(app, None)?) + .item(&PredefinedMenuItem::hide_others(app, None)?) + .item(&PredefinedMenuItem::show_all(app, None)?) + .separator() + .item(&PredefinedMenuItem::quit(app, None)?) + .build()?; + let file = SubmenuBuilder::new(app, "File") + .item(&page_item(app, "new-conversation")?) + .item(&page_item(app, "new-workspace")?) + .separator() + .item(&PredefinedMenuItem::close_window(app, None)?) + .build()?; + let edit = SubmenuBuilder::new(app, "Edit") + .item(&PredefinedMenuItem::undo(app, None)?) + .item(&PredefinedMenuItem::redo(app, None)?) + .separator() + .item(&PredefinedMenuItem::cut(app, None)?) + .item(&PredefinedMenuItem::copy(app, None)?) + .item(&PredefinedMenuItem::paste(app, None)?) + .item(&PredefinedMenuItem::select_all(app, None)?) + .build()?; + let view = SubmenuBuilder::new(app, "View") + .item(&page_item(app, "command-palette")?) + .separator() + .item(&page_item(app, "toggle-sidebar")?) + .item(&page_item(app, "toggle-agent-panel")?) + .separator() + .item(&PredefinedMenuItem::fullscreen(app, None)?) + .build()?; + let window = SubmenuBuilder::new(app, "Window") + .item(&PredefinedMenuItem::minimize(app, None)?) + .item(&PredefinedMenuItem::maximize(app, None)?) + .build()?; + let mut help = SubmenuBuilder::new(app, "Help"); + for (id, label) in HELP_COMMANDS { + help = help.item(&MenuItemBuilder::with_id(*id, *label).build(app)?); + } + MenuBuilder::new(app) + .items(&[&app_menu, &file, &edit, &view, &window, &help.build()?]) + .build() +} + +/// The script that hands a page command to `DesktopMenuBridge`. +fn dispatch_script(command: &str) -> String { + format!("window.dispatchEvent(new CustomEvent('gajae:menu',{{detail:'{command}'}}));") +} + +pub fn handle(app: &AppHandle, event: MenuEvent) { + let id = event.id().as_ref(); + let Some(command) = page_commands().find(|command| *command == id) else { + return; + }; + let Some(window) = crate::main_webview_window(app) else { + return; + }; + // A command for a hidden window (closed to the Dock) brings it back. + let _ = window.show(); + let _ = window.set_focus(); + let _ = window.eval(dispatch_script(command)); +} + +/// Every id the page is asked to run. +fn page_commands() -> impl Iterator { + PAGE_COMMANDS + .iter() + .map(|(command, _, _)| *command) + .chain(HELP_COMMANDS.iter().map(|(command, _)| *command)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn page_commands_match_the_bridge() { + // DesktopMenuBridge.tsx ignores anything it does not declare; keep the + // two lists the same. + let bridge = include_str!("../../src/components/app/DesktopMenuBridge.tsx"); + for command in page_commands() { + let quoted = [format!("'{command}':"), format!("{command}:")]; + assert!( + quoted.iter().any(|needle| bridge.contains(needle.as_str())), + "the page does not handle menu command {command}" + ); + } + } + + #[test] + fn the_dispatch_script_carries_only_the_constant_id() { + assert_eq!( + dispatch_script("new-conversation"), + "window.dispatchEvent(new CustomEvent('gajae:menu',{detail:'new-conversation'}));" + ); + for command in page_commands() { + assert!(command + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte == b'-')); + } + } +} diff --git a/src-tauri/src/app_window.rs b/src-tauri/src/app_window.rs new file mode 100644 index 00000000..85f8acad --- /dev/null +++ b/src-tauri/src/app_window.rs @@ -0,0 +1,60 @@ +//! Creates the configured windows from setup instead of letting Tauri create +//! them before it, so the main window can carry the hooks a config entry +//! cannot express (its document-title observer). + +use tauri::utils::config::WindowConfig; + +/// Takes the configured windows out of Tauri's automatic creation and returns +/// the ones to build in setup. +pub fn defer(config: &mut tauri::Config) -> Vec { + let mut windows = Vec::new(); + for window in &mut config.app.windows { + if window.create { + windows.push(window.clone()); + } + window.create = false; + } + windows +} + +pub fn create(app: &tauri::App, windows: &[WindowConfig]) -> tauri::Result<()> { + for window in windows { + let mut builder = tauri::WebviewWindowBuilder::from_config(app, window)?; + // The runtime drops the UUID in WindowConfig -> WebviewAttributes, so + // an isolated QA store must be set explicitly. + if let Some(store) = window.data_store_identifier { + builder = builder.data_store_identifier(store); + } + if window.label == "main" { + builder = builder.on_document_title_changed(|window, title| { + crate::window_title::apply(&window, &title); + }); + } + builder.build()?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deferred_windows_are_returned_once_and_no_longer_auto_created() { + let mut config: tauri::Config = + serde_json::from_str(include_str!("../tauri.conf.json")).unwrap(); + let windows = defer(&mut config); + assert_eq!( + windows + .iter() + .map(|window| window.label.as_str()) + .collect::>(), + ["main"] + ); + assert!(config.app.windows.iter().all(|window| !window.create)); + assert!( + defer(&mut config).is_empty(), + "a second pass finds nothing left to create" + ); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 6e239b03..eb60ea14 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -7,6 +7,10 @@ use std::fs::OpenOptions; use fs2::FileExt; use tauri::Manager; +#[cfg(target_os = "macos")] +mod app_menu; +#[cfg(target_os = "macos")] +mod app_window; mod build_info; #[cfg(target_os = "macos")] mod builtin_browser; @@ -60,6 +64,10 @@ mod updater_signature; mod updater_store; #[cfg(target_os = "macos")] mod updater_transport; +#[cfg(target_os = "macos")] +mod window_chrome; +#[cfg(target_os = "macos")] +mod window_title; #[cfg(target_os = "windows")] struct SingleInstanceLock { @@ -449,7 +457,7 @@ fn main() { } let context = tauri::generate_context!(); #[cfg(target_os = "macos")] - let (context, qa_windows) = { + let (context, windows) = { let mut context = context; if cfg!(target_arch = "aarch64") { updater_binding::Binding::compiled().configure_plugin( @@ -458,10 +466,12 @@ fn main() { !cfg!(debug_assertions), ); } - let windows = qa_profile - .as_ref() - .map(|profile| profile.configure(context.config_mut())) - .unwrap_or_default(); + // Every window is built in setup, where the main one gets its title + // observer; a QA profile also gives each its isolated store. + let windows = match qa_profile.as_ref() { + Some(profile) => profile.configure(context.config_mut()), + None => app_window::defer(context.config_mut()), + }; (context, windows) }; @@ -557,12 +567,17 @@ fn main() { #[cfg(target_os = "macos")] app.manage(updater_restart::Restarts::default()); #[cfg(target_os = "macos")] - if let Some(profile) = app.try_state::() { - profile.create_windows(app, &qa_windows)?; + { + app.manage(window_title::Attention::default()); + app_window::create(app, &windows)?; + app.set_menu(app_menu::build(app.handle())?)?; + app.on_menu_event(app_menu::handle); } let main = app .get_webview_window("main") .ok_or("main webview is unavailable")?; + #[cfg(target_os = "macos")] + window_chrome::follow_page_color(&main); app.manage(MainWebviewWindow(main)); #[cfg(target_os = "linux")] { diff --git a/src-tauri/src/qa_profile.rs b/src-tauri/src/qa_profile.rs index 4b9f09ed..df7f30de 100644 --- a/src-tauri/src/qa_profile.rs +++ b/src-tauri/src/qa_profile.rs @@ -245,27 +245,13 @@ impl QaProfile { if window.create { windows.push(window.clone()); } - // The runtime drops the UUID in WindowConfig -> WebviewAttributes. - // Build QA windows with the explicit setter. + // The runtime drops the UUID in WindowConfig -> WebviewAttributes; + // app_window::create builds these with the explicit setter. window.create = false; } windows } - #[cfg(target_os = "macos")] - pub(crate) fn create_windows( - &self, - app: &tauri::App, - windows: &[WindowConfig], - ) -> tauri::Result<()> { - for window in windows { - tauri::WebviewWindowBuilder::from_config(app, window)? - .data_store_identifier(self.webkit_store) - .build()?; - } - Ok(()) - } - pub(crate) fn environment(&self) -> BTreeMap { let mut result = BTreeMap::from([ ("PATH".into(), "/usr/bin:/bin:/usr/sbin:/sbin".into()), diff --git a/src-tauri/src/window_chrome.rs b/src-tauri/src/window_chrome.rs new file mode 100644 index 00000000..e58f5a2e --- /dev/null +++ b/src-tauri/src/window_chrome.rs @@ -0,0 +1,98 @@ +//! Paints the main window's transparent title bar in the page's own colour. +//! +//! The window uses a transparent, title-less title bar (`tauri.conf.json`), so +//! the band that holds the traffic lights shows the window's background. The +//! page states its chrome colour in ``, which WebKit +//! exposes as `WKWebView.themeColor`; observing that property keeps the band +//! the same colour as the page through theme changes, with no channel from the +//! page to the shell. The window's appearance is left alone on purpose: it +//! drives the page's `prefers-color-scheme`, which must keep following macOS. + +use std::cell::RefCell; +use std::ffi::c_void; +use std::ptr; + +use objc2::rc::Retained; +use objc2::runtime::{AnyObject, NSObject}; +use objc2::{define_class, msg_send, MainThreadMarker, MainThreadOnly}; +use objc2_foundation::{ + ns_string, NSKeyValueObservingOptions, NSObjectNSKeyValueObserverRegistration, NSString, +}; +use objc2_web_kit::WKWebView; + +thread_local! { + // The observer lives as long as the main webview, which is the life of + // the app; it is main-thread only, like the view it watches. + static OBSERVER: RefCell>> = const { RefCell::new(None) }; +} + +define_class!( + // SAFETY: NSObject has no subclassing requirements, and this type does + // not implement Drop. + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "GajaeThemeColorObserver"] + struct ThemeColorObserver; + + impl ThemeColorObserver { + // SAFETY: the signature matches NSKeyValueObserving's callback. + #[unsafe(method(observeValueForKeyPath:ofObject:change:context:))] + fn observe_value( + &self, + _key_path: Option<&NSString>, + object: Option<&AnyObject>, + _change: Option<&AnyObject>, + _context: *mut c_void, + ) { + if let Some(webview) = object.and_then(|object| object.downcast_ref::()) { + paint_title_bar(webview); + } + } + } +); + +impl ThemeColorObserver { + fn new(mtm: MainThreadMarker) -> Retained { + let this = Self::alloc(mtm).set_ivars(()); + // SAFETY: NSObject's init has this signature. + unsafe { msg_send![super(this), init] } + } +} + +fn paint_title_bar(webview: &WKWebView) { + // SAFETY: themeColor is a plain property read on the main thread. + let Some(color) = (unsafe { webview.themeColor() }) else { + return; + }; + if let Some(window) = webview.window() { + window.setBackgroundColor(Some(&color)); + } +} + +/// Starts following the main webview's theme colour. Call once, after the +/// main window exists. +pub fn follow_page_color(window: &tauri::WebviewWindow) { + let _ = window.with_webview(|platform| { + let raw = platform.inner(); + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + if raw.is_null() || OBSERVER.with_borrow(Option::is_some) { + return; + } + // SAFETY: Wry hands back its live WKWebView on the main thread. + let webview = unsafe { &*raw.cast::() }; + let observer = ThemeColorObserver::new(mtm); + // SAFETY: the observer is an NSObject retained for the webview's + // lifetime, the key path is a constant, and no context is used. + unsafe { + webview.addObserver_forKeyPath_options_context( + &observer, + ns_string!("themeColor"), + NSKeyValueObservingOptions::Initial | NSKeyValueObservingOptions::New, + ptr::null_mut(), + ); + } + OBSERVER.set(Some(observer)); + }); +} diff --git a/src-tauri/src/window_title.rs b/src-tauri/src/window_title.rs new file mode 100644 index 00000000..54f55508 --- /dev/null +++ b/src-tauri/src/window_title.rs @@ -0,0 +1,166 @@ +//! The main window's title, Dock badge and attention request, driven by the +//! page's `document.title`. +//! +//! The app page has no IPC into the shell, but its title is already public: +//! the page composes it as `[Done] (n) — Gajae Code App` +//! (`src/utils/windowTitle.ts`, `src/utils/pageTitleNotification.ts`), where +//! `(n)` counts the conversations waiting on the user and `[Done] ` marks the +//! open conversation finishing while the window was elsewhere. The shell +//! mirrors the clean title onto the window, shows the count on the Dock icon, +//! and bounces the Dock once when something new starts waiting while the +//! window is in the background. + +use std::sync::Mutex; + +const DONE_PREFIX: &str = "[Done] "; +/// The longest title the shell passes on; page titles are short, and a +/// runaway string must not reach AppKit. +const MAX_TITLE_CHARS: usize = 200; +/// A badge beyond this reads as "a lot" and is shown as the cap. +const MAX_BADGE: u32 = 999; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TitleState { + /// The title without the notice and the count. + pub title: String, + /// What the Dock badge shows; zero clears it. + pub badge: u32, +} + +/// Splits a page title into the window title and its badge count. +pub fn parse(raw: &str) -> TitleState { + let (done, rest) = match raw.strip_prefix(DONE_PREFIX) { + Some(rest) => (true, rest), + None => (false, raw), + }; + let (count, rest) = leading_count(rest); + let title: String = rest.trim().chars().take(MAX_TITLE_CHARS).collect(); + let badge = count.saturating_add(u32::from(done)).min(MAX_BADGE); + TitleState { title, badge } +} + +/// `(12) rest` -> `(12, "rest")`; anything else -> `(0, input)`. +fn leading_count(input: &str) -> (u32, &str) { + let Some(inner) = input.strip_prefix('(') else { + return (0, input); + }; + let Some(close) = inner.find(") ") else { + return (0, input); + }; + let digits = &inner[..close]; + if digits.is_empty() || digits.len() > 6 || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return (0, input); + } + match digits.parse::() { + Ok(count) => (count, &inner[close + 2..]), + Err(_) => (0, input), + } +} + +/// Remembers the last badge so the Dock bounces only when it goes up. +#[derive(Default)] +pub struct Attention(Mutex); + +impl Attention { + /// Records `badge` and reports whether it rose since the last title. + pub fn rose(&self, badge: u32) -> bool { + let Ok(mut last) = self.0.lock() else { + return false; + }; + let rose = badge > *last; + *last = badge; + rose + } +} + +/// Applies a page title to the main window. Runs on the main thread (the +/// title callback is delivered there by WebKit). +pub fn apply(window: &tauri::WebviewWindow, raw: &str) { + use tauri::Manager; + + let state = parse(raw); + if !state.title.is_empty() { + let _ = window.set_title(&state.title); + } + let _ = window.set_badge_count(if state.badge == 0 { + None + } else { + Some(i64::from(state.badge)) + }); + let rose = window + .app_handle() + .try_state::() + .is_some_and(|attention| attention.rose(state.badge)); + if rose && !window.is_focused().unwrap_or(true) { + let _ = window.request_user_attention(Some(tauri::UserAttentionType::Informational)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state(title: &str, badge: u32) -> TitleState { + TitleState { + title: title.into(), + badge, + } + } + + #[test] + fn a_plain_title_has_no_badge() { + assert_eq!(parse("Gajae Code App"), state("Gajae Code App", 0)); + assert_eq!( + parse("tidepool — Gajae Code App"), + state("tidepool — Gajae Code App", 0) + ); + } + + #[test] + fn the_leading_count_becomes_the_badge() { + assert_eq!( + parse("(3) Fix the test — Gajae Code App"), + state("Fix the test — Gajae Code App", 3) + ); + } + + #[test] + fn a_completion_notice_counts_once_on_top_of_the_count() { + assert_eq!(parse("[Done] Gajae Code App"), state("Gajae Code App", 1)); + assert_eq!( + parse("[Done] (2) tidepool — Gajae Code App"), + state("tidepool — Gajae Code App", 3) + ); + } + + #[test] + fn only_a_well_formed_count_is_read() { + for raw in [ + "(3)No space", + "(x) Letters", + "() Empty", + "(1234567) Too long", + "(-1) Negative", + " (3) Indented", + ] { + assert_eq!(parse(raw).badge, 0, "{raw}"); + } + assert_eq!(parse("(3)No space").title, "(3)No space"); + } + + #[test] + fn the_badge_and_the_title_are_bounded() { + assert_eq!(parse("(999999) Many").badge, MAX_BADGE); + let long = format!("(1) {}", "x".repeat(MAX_TITLE_CHARS * 2)); + assert_eq!(parse(&long).title.chars().count(), MAX_TITLE_CHARS); + } + + #[test] + fn attention_rises_only_on_an_increase() { + let attention = Attention::default(); + assert!(attention.rose(1)); + assert!(!attention.rose(1)); + assert!(!attention.rose(0)); + assert!(attention.rose(2)); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b5c7584a..ef5eeb15 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,7 +16,9 @@ "visible": false, "minWidth": 960, "minHeight": 640, - "resizable": true + "resizable": true, + "titleBarStyle": "Transparent", + "hiddenTitle": true } ], "security": { diff --git a/src/App.tsx b/src/App.tsx index 47061394..9f6e4ff3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { AuthProvider, ProtectedRoute } from './components/auth'; import { WebSocketProvider } from './contexts/WebSocketContext'; import AppContent from './components/app/AppContent'; import DesktopDeepLinkBridge from './components/app/DesktopDeepLinkBridge'; +import DesktopMenuBridge from './components/app/DesktopMenuBridge'; import PushNavigationBridge from './components/app/PushNavigationBridge'; import { appShellRoutePaths, rootFallbackRoutePath } from './components/app/appRoutes'; import i18n from './i18n/config.js'; @@ -77,6 +78,7 @@ type ApplicationLayoutProps = { function ApplicationRoutes({ routerBasename }: ApplicationLayoutProps) { return + {appShellRoutePaths.map((path) => ( diff --git a/src/components/app/DesktopMenuBridge.dom.bun.test.tsx b/src/components/app/DesktopMenuBridge.dom.bun.test.tsx new file mode 100644 index 00000000..c732f393 --- /dev/null +++ b/src/components/app/DesktopMenuBridge.dom.bun.test.tsx @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; + +import { act, cleanup, render } from '@testing-library/react'; + +import { registerPaletteOps, resetPaletteOps } from '../../stores/usePaletteOpsStore'; + +import DesktopMenuBridge, { DESKTOP_MENU_EVENT } from './DesktopMenuBridge'; + +afterEach(() => { + cleanup(); + resetPaletteOps(); +}); + +const choose = (detail: unknown) => act(() => { + window.dispatchEvent(new CustomEvent(DESKTOP_MENU_EVENT, { detail })); +}); + +test('each native menu command runs its app operation', () => { + const calls: string[] = []; + registerPaletteOps({ + startNewChat: () => calls.push('new'), + createWorkspace: () => calls.push('workspace'), + openSettings: () => calls.push('settings'), + openCommandPalette: () => calls.push('palette'), + toggleSidebar: () => calls.push('sidebar'), + toggleAgentPanel: () => calls.push('agent'), + }); + render(); + + for (const command of ['new-conversation', 'new-workspace', 'settings', 'command-palette', 'toggle-sidebar', 'toggle-agent-panel']) choose(command); + assert.deepEqual(calls, ['new', 'workspace', 'settings', 'palette', 'sidebar', 'agent']); +}); + +test('an unknown or malformed command does nothing', () => { + const calls: string[] = []; + registerPaletteOps({ startNewChat: () => calls.push('new') }); + render(); + + choose('toString'); + choose({ command: 'new-conversation' }); + choose(undefined); + assert.deepEqual(calls, []); +}); diff --git a/src/components/app/DesktopMenuBridge.tsx b/src/components/app/DesktopMenuBridge.tsx new file mode 100644 index 00000000..e2424c31 --- /dev/null +++ b/src/components/app/DesktopMenuBridge.tsx @@ -0,0 +1,46 @@ +import { useEffect } from 'react'; + +import { DISCORD_INVITE_URL, GITHUB_ISSUES_URL, GITHUB_REPOSITORY_URL } from '../../constants/branding'; +import { usePaletteOps, type PaletteOps } from '../../stores/usePaletteOpsStore'; +import { openExternalUrl } from '../../utils/externalLink'; + +/** + * The window event the macOS shell dispatches when a menu command is chosen + * (`src-tauri/src/app_menu.rs`). The shell has no IPC into this origin, so it + * evaluates `window.dispatchEvent(new CustomEvent(DESKTOP_MENU_EVENT, { detail }))` + * with one of the command ids below; anything else is ignored. + */ +export const DESKTOP_MENU_EVENT = 'gajae:menu'; + +export const DESKTOP_MENU_COMMANDS = { + 'new-conversation': (ops) => ops.startNewChat(), + 'new-workspace': (ops) => ops.createWorkspace(), + settings: (ops) => ops.openSettings(), + 'command-palette': (ops) => ops.openCommandPalette(), + 'toggle-sidebar': (ops) => ops.toggleSidebar(), + 'toggle-agent-panel': (ops) => ops.toggleAgentPanel(), + 'report-issue': () => { void openExternalUrl(GITHUB_ISSUES_URL); }, + community: () => { void openExternalUrl(DISCORD_INVITE_URL); }, + repository: () => { void openExternalUrl(GITHUB_REPOSITORY_URL); }, +} as const satisfies Record void>; + +export type DesktopMenuCommand = keyof typeof DESKTOP_MENU_COMMANDS; + +const isCommand = (value: unknown): value is DesktopMenuCommand => + typeof value === 'string' && Object.prototype.hasOwnProperty.call(DESKTOP_MENU_COMMANDS, value); + +/** Runs the app command behind a native menu item. Mounted once at the root. */ +export default function DesktopMenuBridge() { + const ops = usePaletteOps(); + + useEffect(() => { + const run = (event: Event) => { + const command = (event as CustomEvent).detail; + if (isCommand(command)) DESKTOP_MENU_COMMANDS[command](ops); + }; + window.addEventListener(DESKTOP_MENU_EVENT, run); + return () => window.removeEventListener(DESKTOP_MENU_EVENT, run); + }, [ops]); + + return null; +} diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index cc3e1fe2..b4037bff 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -45,6 +45,7 @@ function MainContent({ // The agent sidebar is the one right-hand surface; its hook owns the only // right-rail state left, a persisted open/closed record. const agentSidebar = useAgentSidebar(); + usePaletteOpsRegister({ toggleAgentPanel: agentSidebar.toggle }); const sessionLocation = useSessionLocation(selectedSession?.id); // Where the selected session runs (its worktree, once known) or, with no // session, the project itself. The sidebar's Environment block reads git diff --git a/src/components/settings/view/ThemePicker.dom.bun.test.tsx b/src/components/settings/view/ThemePicker.dom.bun.test.tsx index 78368528..1fc9a7ab 100644 --- a/src/components/settings/view/ThemePicker.dom.bun.test.tsx +++ b/src/components/settings/view/ThemePicker.dom.bun.test.tsx @@ -94,3 +94,16 @@ test('the quick toggle from System pins the opposite of what is on screen', () = assert.equal(localStorage.getItem('theme'), 'light'); assert.equal(isDark(), false); }); + +test('the page states its background as theme-color, which the desktop title bar follows', () => { + fakeSystemAppearance(false); + document.head.querySelectorAll('meta[name="theme-color"]').forEach((meta) => meta.remove()); + mount(); + const meta = () => document.head.querySelectorAll('meta[name="theme-color"]'); + assert.equal(meta().length, 1, 'the provider creates the tag when the page has none'); + const light = meta()[0].getAttribute('content'); + + fireEvent.click(screen.getByRole('radio', { name: 'Dark' })); + assert.equal(meta().length, 1); + assert.notEqual(meta()[0].getAttribute('content'), light, 'the colour follows the theme'); +}); diff --git a/src/components/sidebar/view/Sidebar.tsx b/src/components/sidebar/view/Sidebar.tsx index 039d634d..17412fcb 100644 --- a/src/components/sidebar/view/Sidebar.tsx +++ b/src/components/sidebar/view/Sidebar.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { version as currentVersion } from '../../../../package.json'; @@ -7,10 +7,14 @@ import { useDeviceSettings } from '../../../hooks/useDeviceSettings'; import { useProjectsQuery } from '../../../hooks/useProjectsQuery'; import { useUiPreferences } from '../../../hooks/useUiPreferences'; import { useAppShellStore } from '../../../stores/useAppShellStore'; -import { usePaletteOps } from '../../../stores/usePaletteOpsStore'; +import { needsAttention } from '../../../stores/sessionStatusModel'; +import { usePaletteOps, usePaletteOpsRegister } from '../../../stores/usePaletteOpsStore'; import type { LLMProvider, Project } from '../../../types/app'; +import { setWindowTitle } from '../../../utils/pageTitleNotification'; +import { composeWindowTitle } from '../../../utils/windowTitle'; import { useSessionStatusResolver } from '../hooks/useSessionStatusResolver'; import { useSidebarController } from '../hooks/useSidebarController'; +import { collectWorkRows } from '../utils/workList'; import type { SidebarProps } from '../types/types'; import SidebarCollapsed from './SidebarCollapsed'; @@ -53,6 +57,18 @@ function Sidebar(props: SidebarProps) { sidebarVisible: preferences.sidebarVisible, }); + const setSidebarOpen = useAppShellStore((shell) => shell.setSidebarOpen); + const { isSidebarCollapsed, collapseSidebar, expandSidebar, setShowNewProject } = controller; + // The desktop menu's View and File commands. On a phone the sidebar is a + // drawer, so "toggle" opens or closes the drawer instead. + const toggleSidebar = useCallback(() => { + if (isMobile) setSidebarOpen((open) => !open); + else if (isSidebarCollapsed) expandSidebar(); + else collapseSidebar(); + }, [collapseSidebar, expandSidebar, isMobile, isSidebarCollapsed, setSidebarOpen]); + const createWorkspace = useCallback(() => setShowNewProject(true), [setShowNewProject]); + usePaletteOpsRegister({ toggleSidebar, createWorkspace }); + useEffect(() => { if (typeof document === 'undefined') return; document.documentElement.classList.toggle('pwa-mode', isPWA); @@ -61,6 +77,14 @@ function Sidebar(props: SidebarProps) { const isExplicit = (project: Project) => project.origin === 'explicit'; const visibleProjects = projects.filter(isExplicit); + + // The window's title names where the user is and counts the conversations + // waiting on them; the desktop shell turns that count into the Dock badge. + const attention = collectWorkRows({ filteredProjects: visibleProjects, getProjectSessions: controller.getProjectSessions, getSessionStatus }) + .filter((row) => needsAttention(row.status)).length; + const sessionTitle = typeof selectedSession?.summary === 'string' ? selectedSession.summary : null; + const windowTitle = composeWindowTitle({ attention, place: sessionTitle || selectedProject?.displayName }); + useEffect(() => { setWindowTitle(windowTitle); }, [windowTitle]); const visibleFilteredProjects = controller.filteredProjects.filter(isExplicit); const projectListProps: SidebarProjectListProps = { projects: visibleProjects, diff --git a/src/components/sidebar/view/SidebarFooter.tsx b/src/components/sidebar/view/SidebarFooter.tsx index a6d08e5f..0dcbfba0 100644 --- a/src/components/sidebar/view/SidebarFooter.tsx +++ b/src/components/sidebar/view/SidebarFooter.tsx @@ -1,12 +1,11 @@ import { Archive, Bug, RefreshCw, Settings } from 'lucide-react'; import type { TFunction } from 'i18next'; +import { DISCORD_INVITE_URL, GITHUB_ISSUES_URL, GITHUB_REPOSITORY_URL } from '../../../constants/branding'; + import SidebarDesktopUpdate from './SidebarDesktopUpdate'; import SidebarProviderQuota from './SidebarProviderQuota'; -const GITHUB_ISSUES_URL = 'https://github.com/devswha/gajae-code-app/issues/new'; -const GITHUB_REPO_URL = 'https://github.com/devswha/gajae-code-app'; -const DISCORD_INVITE_URL = 'https://discord.gg/dskZax5JPh'; function DiscordIcon({ className }: { className?: string }) { return ( @@ -65,7 +64,7 @@ export default function SidebarFooter({ ); - useEffect(() => { - let baseTitle = 'Gajae Code App'; - const displayName = selectedProject?.displayName?.trim(); - if (displayName) { - baseTitle = `${displayName} - ${baseTitle}`; - } - document.title = baseTitle; - }, [selectedProject]); - const showProjects = !isLoading && projects.length > 0 && filteredProjects.length > 0; return ( diff --git a/src/constants/branding.ts b/src/constants/branding.ts index 488a405f..056e41f5 100644 --- a/src/constants/branding.ts +++ b/src/constants/branding.ts @@ -5,6 +5,8 @@ export const BRAND_NAME = 'Gajae Code App'; export const GITHUB_REPOSITORY_OWNER = 'devswha'; export const GITHUB_REPOSITORY_NAME = 'gajae-code-app'; export const GITHUB_REPOSITORY_URL = 'https://github.com/devswha/gajae-code-app'; +export const GITHUB_ISSUES_URL = `${GITHUB_REPOSITORY_URL}/issues/new`; +export const DISCORD_INVITE_URL = 'https://discord.gg/dskZax5JPh'; export const DOCUMENTATION_URL = `${GITHUB_REPOSITORY_URL}/blob/main/docs/SELF-HOST.md`; export const RELEASES_URL = `${GITHUB_REPOSITORY_URL}/releases`; export const LICENSE_URL = `${GITHUB_REPOSITORY_URL}/blob/main/LICENSE`; diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx index 069ec0a4..7de4785c 100644 --- a/src/contexts/ThemeContext.tsx +++ b/src/contexts/ThemeContext.tsx @@ -36,8 +36,17 @@ const applyTheme = (dark: boolean) => { const statusBar = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]'); if (statusBar) statusBar.setAttribute('content', dark ? 'black-translucent' : 'default'); - const themeColor = document.querySelector('meta[name="theme-color"]'); - if (themeColor) themeColor.setAttribute('content', dark ? '#141414' : '#f6f4ef'); + // The page background, read from the token so the colour can never drift + // from the palette. Browsers tint their chrome with it, and the macOS shell + // paints the window's title bar with it (src-tauri/src/window_chrome.rs). + let themeColor = document.querySelector('meta[name="theme-color"]'); + if (!themeColor) { + themeColor = document.createElement('meta'); + themeColor.setAttribute('name', 'theme-color'); + document.head.append(themeColor); + } + const background = getComputedStyle(document.documentElement).getPropertyValue('--background').trim(); + themeColor.setAttribute('content', background ? `hsl(${background})` : (dark ? '#141414' : '#f6f4ef')); }; export const useTheme = (): ThemeContextValue => { diff --git a/src/stores/usePaletteOpsStore.ts b/src/stores/usePaletteOpsStore.ts index a87f77aa..f7d1651d 100644 --- a/src/stores/usePaletteOpsStore.ts +++ b/src/stores/usePaletteOpsStore.ts @@ -28,6 +28,12 @@ export type PaletteOps = { openExternalUrl: (url: string) => void; openSettings: (tab?: string) => void; refreshProjects: () => Promise | void; + // Opens the add-workspace dialog. + createWorkspace: () => void; + // Shows or hides the left sidebar (collapses it on desktop). + toggleSidebar: () => void; + // Shows or hides the right-hand agent panel. + toggleAgentPanel: () => void; }; type PaletteOpsRegistry = Partial; @@ -46,6 +52,9 @@ const OPS_KEYS = [ 'openExternalUrl', 'openSettings', 'refreshProjects', + 'createWorkspace', + 'toggleSidebar', + 'toggleAgentPanel', ] as const; /** @@ -119,6 +128,9 @@ export function usePaletteOpsRegister(partial: PaletteOpsRegistry) { openExternalUrl, openSettings, refreshProjects, + createWorkspace, + toggleSidebar, + toggleAgentPanel, } = partial; useEffect(() => { @@ -132,8 +144,11 @@ export function usePaletteOpsRegister(partial: PaletteOpsRegistry) { ...(openExternalUrl ? { openExternalUrl } : {}), ...(openSettings ? { openSettings } : {}), ...(refreshProjects ? { refreshProjects } : {}), + ...(createWorkspace ? { createWorkspace } : {}), + ...(toggleSidebar ? { toggleSidebar } : {}), + ...(toggleAgentPanel ? { toggleAgentPanel } : {}), }); - }, [openCommandPalette, openSessionPicker, startNewChat, openFile, openFileInEditor, openBuiltinBrowser, openExternalUrl, openSettings, refreshProjects]); + }, [openCommandPalette, openSessionPicker, startNewChat, openFile, openFileInEditor, openBuiltinBrowser, openExternalUrl, openSettings, refreshProjects, createWorkspace, toggleSidebar, toggleAgentPanel]); } const read = () => usePaletteOpsRegistryStore.getState().registry; @@ -153,6 +168,9 @@ const ops: PaletteOps = { openExternalUrl: (url) => (read().openExternalUrl ?? (() => undefined))(url), openSettings: (tab) => (read().openSettings ?? (() => undefined))(tab), refreshProjects: () => (read().refreshProjects ?? (() => undefined))(), + createWorkspace: () => (read().createWorkspace ?? (() => undefined))(), + toggleSidebar: () => (read().toggleSidebar ?? (() => undefined))(), + toggleAgentPanel: () => (read().toggleAgentPanel ?? (() => undefined))(), }; /** Kept as a hook-shaped export so consumers only change their import path. */ diff --git a/src/utils/pageTitleNotification.ts b/src/utils/pageTitleNotification.ts index f7738f71..738bc732 100644 --- a/src/utils/pageTitleNotification.ts +++ b/src/utils/pageTitleNotification.ts @@ -65,6 +65,17 @@ export const clearCompletionTitleIndicator = (): void => { resetTitle(); }; +/** + * Sets the title underneath a live completion notice, which it keeps: the + * notice is about the conversation on screen finishing while the window was + * elsewhere, and a title change must not swallow it. + */ +export const setWindowTitle = (title: string): void => { + if (typeof document === 'undefined') return; + const next = document.title.startsWith(DONE_PREFIX) ? `${DONE_PREFIX}${title}` : title; + if (document.title !== next) document.title = next; +}; + export const showCompletionTitleIndicator = (): void => { if (typeof document === 'undefined' || typeof window === 'undefined') return; diff --git a/src/utils/windowTitle.test.ts b/src/utils/windowTitle.test.ts new file mode 100644 index 00000000..527e32ec --- /dev/null +++ b/src/utils/windowTitle.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { composeWindowTitle } from './windowTitle'; + +test('the title leads with the count the Dock badge reads, then the place', () => { + assert.equal(composeWindowTitle({ attention: 2, place: 'Fix the flaky test' }), '(2) Fix the flaky test — Gajae Code App'); + assert.equal(composeWindowTitle({ attention: 0, place: 'tidepool' }), 'tidepool — Gajae Code App'); + assert.equal(composeWindowTitle({ attention: 0 }), 'Gajae Code App'); +}); + +test('no count for nothing, and never a malformed one', () => { + assert.equal(composeWindowTitle({ attention: -1, place: ' ' }), 'Gajae Code App'); + assert.equal(composeWindowTitle({ attention: 1.5 }), 'Gajae Code App'); +}); diff --git a/src/utils/windowTitle.ts b/src/utils/windowTitle.ts new file mode 100644 index 00000000..8421694b --- /dev/null +++ b/src/utils/windowTitle.ts @@ -0,0 +1,13 @@ +import { BRAND_NAME } from '../constants/branding'; + +/** + * The document title, which is also the desktop window's title and the + * source of its Dock badge: the macOS shell reads a leading `(n) ` as the + * number of conversations that need the user (`src-tauri/src/window_title.rs`). + * A browser tab shows the same count, the convention of a mail tab. + */ +export function composeWindowTitle({ attention, place }: { attention: number; place?: string | null }): string { + const count = Number.isSafeInteger(attention) && attention > 0 ? `(${attention}) ` : ''; + const where = place?.trim(); + return `${count}${where ? `${where} — ` : ''}${BRAND_NAME}`; +}