Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<meta name="theme-color">` (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
Expand Down
6 changes: 6 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) <conversation or workspace> — 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.
Expand Down
1 change: 0 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
name="viewport"
>
<meta content="telephone=no" name="format-detection">
<meta content="#f04e23" name="theme-color">
<meta content="#f04e23" name="msapplication-TileColor">

<!-- Installed instances retain their product name and native-looking chrome. -->
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
166 changes: 166 additions & 0 deletions src-tauri/src/app_menu.rs
Original file line number Diff line number Diff line change
@@ -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: '<id>' }))`
//! 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<tauri::menu::MenuItem<Wry>> {
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<Menu<Wry>> {
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<Item = &'static str> {
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'-'));
}
}
}
60 changes: 60 additions & 0 deletions src-tauri/src/app_window.rs
Original file line number Diff line number Diff line change
@@ -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<WindowConfig> {
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::<Vec<_>>(),
["main"]
);
assert!(config.app.windows.iter().all(|window| !window.create));
assert!(
defer(&mut config).is_empty(),
"a second pass finds nothing left to create"
);
}
}
29 changes: 22 additions & 7 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand All @@ -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)
};

Expand Down Expand Up @@ -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::<qa_profile::QaProfile>() {
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")]
{
Expand Down
18 changes: 2 additions & 16 deletions src-tauri/src/qa_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> {
let mut result = BTreeMap::from([
("PATH".into(), "/usr/bin:/bin:/usr/sbin:/sbin".into()),
Expand Down
Loading