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}`;
+}