From 527cff94b8dbd2ac4a62ca315d8df54e73e3b733 Mon Sep 17 00:00:00 2001 From: willem Date: Thu, 17 Sep 2026 16:30:54 -0400 Subject: [PATCH 1/6] feat(app): add persistent project colour tags Add personal per-project colour choices and an optional shared header stripe, with canonical folder identity, native cross-window updates and isolated preference storage. Native macOS verification exposed CSS zoom coordinate differences between WebKit and Chromium. Normalize anchor coordinates and bound the picker at supported zoom levels; cover both engines in regression tests. --- gg-app/README.md | 13 + gg-app/src-tauri/src/lib.rs | 5 + gg-app/src-tauri/src/project_colours.rs | 371 ++++++++++++++++++++++++ gg-app/src/App.css | 151 +++++++++- gg-app/src/ProjectColourPicker.test.tsx | 257 ++++++++++++++++ gg-app/src/ProjectColourPicker.tsx | 311 ++++++++++++++++++++ gg-app/src/WorkspaceHeader.test.tsx | 49 ++-- gg-app/src/WorkspaceHeader.tsx | 23 +- gg-app/src/project-colours.test.ts | 194 +++++++++++++ gg-app/src/project-colours.ts | 262 +++++++++++++++++ gg-app/src/projectAccent.test.ts | 37 +++ gg-app/src/projectAccent.ts | 42 ++- 12 files changed, 1675 insertions(+), 40 deletions(-) create mode 100644 gg-app/src-tauri/src/project_colours.rs create mode 100644 gg-app/src/ProjectColourPicker.test.tsx create mode 100644 gg-app/src/ProjectColourPicker.tsx create mode 100644 gg-app/src/project-colours.test.ts create mode 100644 gg-app/src/project-colours.ts create mode 100644 gg-app/src/projectAccent.test.ts diff --git a/gg-app/README.md b/gg-app/README.md index d55b9d172..67056216f 100644 --- a/gg-app/README.md +++ b/gg-app/README.md @@ -24,6 +24,19 @@ pnpm --filter gg-app test # vitest pnpm --filter gg-app lint ``` +## Project colour tags + +Click the colour button beside an open project's name to choose a personal colour. +**Automatic** restores the generated accent; **None** removes the decoration without +hiding the button. **Show header stripe** adds an app-wide stripe and is off by default. +None suppresses the stripe for that project. + +Choices belong to the full project folder path, so same-named folders stay separate. +Native windows share preferences in `~/.gg/gg-app-project-colours.json`, independently +of other app settings. These preferences are personal, survive restarts, and are not +written into the project or shared with teammates. Browser previews use local storage +instead; they do not share the native app's preferences. + ## Architecture Each window runs its **own** Node agent sidecar pointed at its **own** project folder. diff --git a/gg-app/src-tauri/src/lib.rs b/gg-app/src-tauri/src/lib.rs index 7155300f9..71536cbd3 100644 --- a/gg-app/src-tauri/src/lib.rs +++ b/gg-app/src-tauri/src/lib.rs @@ -5,6 +5,8 @@ use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Mutex; +mod project_colours; + #[cfg(unix)] use std::os::unix::process::CommandExt; #[cfg(windows)] @@ -5088,6 +5090,7 @@ pub fn run() { ..Default::default() }) .manage(Windows::default()) + .manage(project_colours::ProjectColours::default()) .manage(RestoreTargets::default()) .manage(AppExiting::default()) .manage(FocusedWindow::default()) @@ -5156,6 +5159,8 @@ pub fn run() { agent_set_project_hidden, app_settings_get, app_settings_save, + project_colours::project_colours_get, + project_colours::project_colours_save, app_create_project, app_auth_status, app_auth_apikey, diff --git a/gg-app/src-tauri/src/project_colours.rs b/gg-app/src-tauri/src/project_colours.rs new file mode 100644 index 000000000..3804583cc --- /dev/null +++ b/gg-app/src-tauri/src/project_colours.rs @@ -0,0 +1,371 @@ +//! Personal project colours: separate from gg-app.json so the sidecar/settings +//! writer cannot erase them. All native windows share this serialized writer. +use std::collections::BTreeMap; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use tauri::{Emitter, State}; + +const EVENT: &str = "project-colours-changed"; +const MAX_FILE_BYTES: u64 = 1024 * 1024; + +#[derive(Default)] +pub(crate) struct ProjectColours(Arc>); + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Snapshot { + overrides: BTreeMap, + stripe: bool, + revision: u64, + project_key: Option, +} + +fn valid_choice(choice: &str) -> bool { + matches!( + choice, + "None" + | "Blue" + | "Violet" + | "Green" + | "Amber" + | "Coral" + | "Teal" + | "Pink" + | "Lime" + | "Cyan" + | "Orchid" + ) +} + +fn project_key(cwd: &str) -> Result { + let path = PathBuf::from(cwd); + if cwd.is_empty() || cwd.contains('\0') || !path.is_absolute() { + return Err("A project folder is required".into()); + } + let canonical = + std::fs::canonicalize(&path).map_err(|_| "Cannot resolve the project folder")?; + if !canonical.is_dir() { + return Err("A project folder is required".into()); + } + let path = crate::strip_extended_prefix(canonical); + let key = path + .to_str() + .ok_or("Cannot identify the project folder")? + .to_string(); + #[cfg(windows)] + let key = key.to_lowercase(); + if key.len() > 4096 { + return Err("Project folder path is too long".into()); + } + Ok(key) +} + +fn preferences_path() -> PathBuf { + crate::home_dir() + .join(".gg") + .join("gg-app-project-colours.json") +} + +fn read_preferences(path: &Path) -> Result { + let file = match std::fs::File::open(path) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(serde_json::json!({})), + Err(_) => return Err("Cannot read project colour preferences".into()), + }; + let mut bytes = Vec::new(); + file.take(MAX_FILE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| "Cannot read project colour preferences")?; + if bytes.len() as u64 > MAX_FILE_BYTES { + return Err("Project colour preferences are too large".into()); + } + let value: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|_| "Project colour preferences are malformed; the file was left unchanged")?; + if !value.is_object() { + return Err("Project colour preferences are malformed; the file was left unchanged".into()); + } + Ok(value) +} + +fn snapshot(value: &serde_json::Value, revision: u64, key: Option) -> Snapshot { + let overrides = value + .get("overrides") + .and_then(|v| v.as_object()) + .into_iter() + .flat_map(|map| map.iter()) + .filter_map(|(key, value)| { + let choice = value.as_str()?; + (key.len() <= 4096 && !key.is_empty() && valid_choice(choice)) + .then(|| (key.clone(), choice.to_string())) + }) + .collect(); + Snapshot { + overrides, + stripe: value + .get("stripe") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + revision, + project_key: key, + } +} + +// Same-directory temp + rename: a failed write leaves the previous file intact. +fn write_preferences(path: &Path, value: &serde_json::Value) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(value).map_err(|_| "Cannot encode project colours")?; + if bytes.len() as u64 > MAX_FILE_BYTES { + return Err("Project colour preferences are too large".into()); + } + let parent = path + .parent() + .ok_or("Cannot locate project colour preferences")?; + std::fs::create_dir_all(parent).map_err(|_| "Cannot create project colour preferences")?; + let temp = parent.join(format!(".project-colours-{}.tmp", uuid::Uuid::new_v4())); + let result = (|| -> std::io::Result<()> { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&temp)?; + file.write_all(&bytes)?; + file.sync_all()?; + drop(file); + std::fs::rename(&temp, path) + })(); + if result.is_err() { + let _ = std::fs::remove_file(temp); + return Err("Cannot save project colour preferences; the previous choice was kept".into()); + } + Ok(()) +} + +fn update_preferences( + path: &Path, + key: Option<&str>, + choice: Option<&str>, + stripe: Option, +) -> Result { + if choice.is_none() == stripe.is_none() { + return Err("Change either a project colour or stripe visibility".into()); + } + if let Some(choice) = choice { + if choice != "Automatic" && !valid_choice(choice) { + return Err("Unknown project colour".into()); + } + if key.is_none() { + return Err("A project folder is required".into()); + } + } + // Re-read under the shared lock; never write a stale window's whole map. + let mut value = read_preferences(path)?; + if let Some(choice) = choice { + let overrides = value + .as_object_mut() + .unwrap() + .entry("overrides") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .ok_or("Project colour preferences are malformed; the file was left unchanged")?; + let key = key.unwrap(); + if choice == "Automatic" { + overrides.remove(key); + } else { + overrides.insert(key.to_string(), serde_json::json!(choice)); + } + } + if let Some(stripe) = stripe { + value["stripe"] = serde_json::json!(stripe); + } + write_preferences(path, &value)?; + Ok(value) +} + +#[tauri::command] +pub(crate) async fn project_colours_get( + state: State<'_, ProjectColours>, + cwd: Option, +) -> Result { + let lock = state.0.clone(); + tauri::async_runtime::spawn_blocking(move || { + let key = cwd.as_deref().map(project_key).transpose()?; + let revision = lock + .lock() + .map_err(|_| "Project colour preferences are unavailable")?; + Ok(snapshot( + &read_preferences(&preferences_path())?, + *revision, + key, + )) + }) + .await + .map_err(|_| "Cannot load project colours".to_string())? +} + +#[tauri::command] +pub(crate) async fn project_colours_save( + app: tauri::AppHandle, + state: State<'_, ProjectColours>, + cwd: Option, + choice: Option, + stripe: Option, +) -> Result { + let lock = state.0.clone(); + tauri::async_runtime::spawn_blocking(move || { + let key = cwd.as_deref().map(project_key).transpose()?; + let mut revision = lock + .lock() + .map_err(|_| "Project colour preferences are unavailable")?; + let value = update_preferences( + &preferences_path(), + key.as_deref(), + choice.as_deref(), + stripe, + )?; + *revision += 1; + let saved = snapshot(&value, *revision, key); + // Emit while serialized, so every webview sees the same write order. + if app.emit(EVENT, &saved).is_err() { + log::warn!("Could not broadcast project colour preferences"); + } + Ok(saved) + }) + .await + .map_err(|_| "Cannot save project colours".to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Fixture(PathBuf); + impl Fixture { + fn new() -> Self { + let path = + std::env::temp_dir().join(format!("gg-project-colours-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + fn file(&self) -> PathBuf { + self.0.join("colours.json") + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn choices_restart_reset_and_none_preserve_other_preferences() { + let fixture = Fixture::new(); + let path = fixture.file(); + assert!(!snapshot(&read_preferences(&path).unwrap(), 0, None).stripe); + for choice in [ + "Blue", "Violet", "Green", "Amber", "Coral", "Teal", "Pink", "Lime", "Cyan", "Orchid", + "None", + ] { + update_preferences(&path, Some("/a/project"), Some(choice), None).unwrap(); + let restarted = snapshot(&read_preferences(&path).unwrap(), 0, None); + assert_eq!(restarted.overrides["/a/project"], choice); + } + update_preferences(&path, Some("/b/project"), Some("Green"), None).unwrap(); + update_preferences(&path, None, None, Some(true)).unwrap(); + update_preferences(&path, Some("/a/project"), Some("Automatic"), None).unwrap(); + let saved = snapshot(&read_preferences(&path).unwrap(), 0, None); + assert!(!saved.overrides.contains_key("/a/project")); + assert_eq!(saved.overrides["/b/project"], "Green"); + assert!(saved.stripe); + update_preferences(&path, None, None, Some(false)).unwrap(); + assert!(!snapshot(&read_preferences(&path).unwrap(), 0, None).stripe); + } + + #[test] + fn concurrent_windows_merge_instead_of_replacing_other_projects() { + let fixture = Fixture::new(); + let lock = Arc::new(Mutex::new(0)); + let handles: Vec<_> = (0..20) + .map(|index| { + let lock = lock.clone(); + let path = fixture.file(); + std::thread::spawn(move || { + let mut revision = lock.lock().unwrap(); + update_preferences( + &path, + Some(&format!("/work/{index}/project")), + Some("Blue"), + None, + ) + .unwrap(); + *revision += 1; + }) + }) + .collect(); + for handle in handles { + handle.join().unwrap(); + } + assert_eq!( + snapshot( + &read_preferences(&fixture.file()).unwrap(), + *lock.lock().unwrap(), + None + ) + .overrides + .len(), + 20 + ); + } + + #[test] + fn rejects_invalid_choices_folderless_updates_and_malformed_files() { + let fixture = Fixture::new(); + let path = fixture.file(); + assert!(update_preferences(&path, None, Some("Blue"), None).is_err()); + assert!(update_preferences(&path, Some("/a"), Some("red; display:none"), None).is_err()); + assert!(!path.exists()); + std::fs::write(&path, "{broken").unwrap(); + assert!(update_preferences(&path, Some("/a"), Some("Blue"), None).is_err()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "{broken"); + let parsed = snapshot( + &serde_json::json!({"overrides":{"/a":"url(secret)","/b":"Green"},"stripe":"yes"}), + 0, + None, + ); + assert_eq!(parsed.overrides.len(), 1); + assert!(!parsed.stripe); + } + + #[test] + fn failed_save_keeps_previous_file_and_unknown_fields_survive() { + let fixture = Fixture::new(); + let path = fixture.file(); + std::fs::write(&path, r#"{"futureSetting":42,"overrides":{"/a":"Blue"}}"#).unwrap(); + let value = update_preferences(&path, Some("/b"), Some("Green"), None).unwrap(); + assert_eq!(value["futureSetting"], 42); + let before = std::fs::read(&path).unwrap(); + let too_large = serde_json::json!({"extra":"x".repeat(MAX_FILE_BYTES as usize)}); + assert!(write_preferences(&path, &too_large).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), before); + assert!(write_preferences(&fixture.0, &value).is_err()); + } + + #[test] + fn project_identity_uses_the_whole_canonical_path() { + let fixture = Fixture::new(); + for dir in ["a/project", "b/project"] { + std::fs::create_dir_all(fixture.0.join(dir)).unwrap(); + } + let a = project_key(fixture.0.join("a/project").to_str().unwrap()).unwrap(); + let b = project_key(fixture.0.join("b/project").to_str().unwrap()).unwrap(); + assert_ne!(a, b); + assert_eq!( + a, + project_key(fixture.0.join("a/../a/project/.").to_str().unwrap()).unwrap() + ); + assert!(project_key("").is_err()); + assert!(project_key("relative/project").is_err()); + } +} diff --git a/gg-app/src/App.css b/gg-app/src/App.css index 58a939216..68d4e3258 100644 --- a/gg-app/src/App.css +++ b/gg-app/src/App.css @@ -1289,20 +1289,153 @@ select { flex-direction: column; border-bottom: 1px solid var(--border); } -/* Per-project accent identity. `--project-accent` is set inline per window by - WorkspaceHeader (derived from the project folder name, see projectAccent.ts). - Deliberately restrained: a single dot by the project name, nothing more. The - header keeps the standard `--border` hairline so the chrome stays neutral. */ -.chat-head-accent-dot { +/* Personal colour only decorates identity, never actions or status indicators. */ +.chat-head-project-stripe::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: var(--project-accent); + pointer-events: none; +} +.project-colour-trigger { + appearance: none; flex: 0 0 auto; + align-self: center; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + margin-right: 2px; + background: none; + border: none; + border-radius: var(--radius-button); + cursor: pointer; +} +.project-colour-trigger:hover, +.project-colour-trigger[aria-expanded="true"] { + background: var(--surface-3); +} +.chat-head-accent-dot { width: 6px; height: 6px; - margin-right: 7px; border-radius: 50%; background: var(--project-accent); - /* The title row is baseline-aligned, so nudge the dot onto the text's optical - centre instead of letting it sit on the baseline. */ - transform: translateY(-1px); + pointer-events: none; +} +.project-colour-trigger-none .chat-head-accent-dot { + width: 8px; + height: 8px; + background: none; + border: 1px solid var(--text-muted); +} +.project-colour-trigger:focus-visible, +.project-colour-picker button:focus-visible, +.project-colour-picker input:focus-visible { + outline: 2px solid var(--text); + outline-offset: -2px; +} +.project-colour-picker { + position: fixed; + z-index: 100; + width: 264px; + max-width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + overflow-y: auto; + padding: 10px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-card); + background: var(--surface-3); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5); + color: var(--text); + font-family: var(--sans); + font-size: 13px; +} +.project-colour-picker-heading { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; + font-weight: 600; +} +.project-colour-close { + appearance: none; + width: 28px; + height: 28px; + border: none; + border-radius: var(--radius-button); + background: none; + color: var(--text-muted); + font-size: 20px; + cursor: pointer; +} +.project-colour-choices { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; +} +.project-colour-choice { + appearance: none; + display: flex; + align-items: center; + gap: 7px; + min-height: 34px; + padding: 5px 7px; + border: 1px solid transparent; + border-radius: var(--radius-button); + background: none; + color: var(--text); + font: inherit; + cursor: pointer; +} +.project-colour-choice[aria-pressed="true"] { + border-color: var(--border-strong); + background: var(--surface-2); +} +.project-colour-choice:hover, +.project-colour-close:hover { + background: var(--surface-2); +} +.project-colour-choice[aria-disabled="true"] { + color: var(--text-dim); + cursor: default; +} +.project-colour-swatch { + flex: 0 0 auto; + width: 10px; + height: 10px; + border-radius: 50%; +} +.project-colour-swatch-none { + border: 1px solid var(--text-muted); +} +.project-colour-check { + flex: 0 0 auto; + margin-left: auto; +} +.project-colour-stripe-setting { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border); + cursor: pointer; +} +.project-colour-stripe-setting small { + display: block; + margin-top: 3px; + color: var(--text-muted); + font-size: 11px; +} +.project-colour-error { + margin: 10px 0 0; + color: var(--error); + line-height: 1.4; } .chat-head-strip { display: flex; diff --git a/gg-app/src/ProjectColourPicker.test.tsx b/gg-app/src/ProjectColourPicker.test.tsx new file mode 100644 index 000000000..6b680a891 --- /dev/null +++ b/gg-app/src/ProjectColourPicker.test.tsx @@ -0,0 +1,257 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; + +const { openFolder } = vi.hoisted(() => ({ openFolder: vi.fn().mockResolvedValue(undefined) })); +vi.mock("./agent", () => ({ openProjectPath: openFolder, openUrl: vi.fn() })); +import { WorkspaceHeader } from "./WorkspaceHeader"; +import { PROJECT_ACCENTS, PROJECT_COLOUR_NAMES, projectAccent } from "./projectAccent"; +import { projectColourStore } from "./project-colours"; + +beforeEach(() => { + localStorage.clear(); + openFolder.mockClear(); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.documentElement.style.removeProperty("zoom"); +}); + +function header(cwd: string | undefined = "/work/project", mode: "code" | "chat" = "code") { + return ( + + + + ); +} + +async function openPicker(): Promise { + const trigger = screen.getByRole("button", { name: "Project colour: Automatic" }); + fireEvent.click(trigger); + const picker = screen.getByRole("dialog", { name: "Project colour" }); + await waitFor(() => + expect(within(picker).getByRole("button", { name: "Blue" }).getAttribute("aria-disabled")).toBe( + "false", + ), + ); + return picker; +} + +async function choose(picker: HTMLElement, name: string): Promise { + fireEvent.click(within(picker).getByRole("button", { name })); + await waitFor(() => + expect(within(picker).getByRole("button", { name }).getAttribute("aria-pressed")).toBe("true"), + ); +} + +describe("project colour picker in the shared header", () => { + it("defaults to the original automatic dot with no stripe and preserves folder opening", async () => { + const { container } = render(header()); + expect( + container + .querySelector(".chat-head") + ?.style.getPropertyValue("--project-accent"), + ).toBe(projectAccent("/work/project")); + expect(container.querySelector(".chat-head-project-stripe")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "project" })); + expect(openFolder).toHaveBeenCalledWith("/work/project"); + expect(screen.queryByRole("dialog")).toBeNull(); + const picker = await openPicker(); + expect(within(picker).getByRole("button", { name: "Automatic" })).toBe(document.activeElement); + expect(picker.closest(".chat-head-title")).toBeNull(); + for (const button of [ + screen.getByRole("button", { name: "project" }), + screen.getByRole("button", { name: "Project colour: Automatic" }), + ...within(picker).getAllByRole("button"), + ]) { + expect(button.hasAttribute("data-tauri-drag-region")).toBe(false); + } + expect( + container.querySelector(".chat-head-strip")?.hasAttribute("data-tauri-drag-region"), + ).toBe(true); + }); + + it("applies every palette choice, optional stripe, None, and Automatic reset", async () => { + const { container } = render(header()); + const picker = await openPicker(); + for (const [index, colour] of PROJECT_COLOUR_NAMES.entries()) { + await choose(picker, colour); + expect( + container + .querySelector(".chat-head") + ?.style.getPropertyValue("--project-accent"), + ).toBe(PROJECT_ACCENTS[index]); + expect( + within(picker).getByRole("button", { name: colour }).querySelector("svg"), + ).not.toBeNull(); + } + const stripe = within(picker).getByRole("checkbox", { name: /Show header stripe/ }); + fireEvent.click(stripe); + await waitFor(() => + expect(container.querySelector(".chat-head-project-stripe")).not.toBeNull(), + ); + await choose(picker, "None"); + expect(container.querySelector(".chat-head-project-stripe")).toBeNull(); + expect( + container + .querySelector(".chat-head") + ?.style.getPropertyValue("--project-accent"), + ).toBe(""); + fireEvent.keyDown(picker, { key: "Escape" }); + const neutral = screen.getByRole("button", { name: "Project colour: None" }); + expect(neutral.classList.contains("project-colour-trigger-none")).toBe(true); + expect(document.activeElement).toBe(neutral); + fireEvent.keyDown(neutral, { key: "ArrowDown" }); + const reopened = screen.getByRole("dialog", { name: "Project colour" }); + await choose(reopened, "Automatic"); + expect( + container + .querySelector(".chat-head") + ?.style.getPropertyValue("--project-accent"), + ).toBe(projectAccent("/work/project")); + expect(localStorage.getItem("gg-project-colour:/work/project")).toBeNull(); + fireEvent.click(within(reopened).getByRole("checkbox", { name: /Show header stripe/ })); + await waitFor(() => expect(container.querySelector(".chat-head-project-stripe")).toBeNull()); + }); + + it("supports keyboard opening/navigation, Escape, outside press, and focus leaving", async () => { + render(header()); + const trigger = screen.getByRole("button", { name: "Project colour: Automatic" }); + trigger.focus(); + fireEvent.keyDown(trigger, { key: "ArrowDown" }); + const picker = screen.getByRole("dialog", { name: "Project colour" }); + const automatic = within(picker).getByRole("button", { name: "Automatic" }); + expect(document.activeElement).toBe(automatic); + fireEvent.keyDown(automatic, { key: "ArrowRight" }); + expect(document.activeElement).toBe(within(picker).getByRole("button", { name: "None" })); + fireEvent.keyDown(document.activeElement!, { key: "End" }); + expect(document.activeElement).toBe(within(picker).getByRole("button", { name: "Orchid" })); + fireEvent.keyDown(document.activeElement!, { key: "Home" }); + expect(document.activeElement).toBe(automatic); + fireEvent.keyDown(automatic, { key: "Escape" }); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.activeElement).toBe(trigger); + await openPicker(); + fireEvent.pointerDown(document.body); + expect(screen.queryByRole("dialog")).toBeNull(); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + await openPicker(); + act(() => screen.getByRole("button", { name: "project" }).focus()); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.activeElement).toBe(screen.getByRole("button", { name: "project" })); + }); + + it.each( + [0.5, 1, 1.25, 2].flatMap((zoom) => [ + { zoom, rectZoom: zoom }, + { zoom, rectZoom: 1 }, + ]), + )( + "keeps the picker within a minimum-size window at $zoom zoom with $rectZoom rect scaling", + async ({ zoom, rectZoom }) => { + document.documentElement.style.setProperty("zoom", String(zoom)); + vi.stubGlobal("innerWidth", 480); + vi.stubGlobal("innerHeight", 360); + vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function ( + this: HTMLElement, + ) { + return this.classList.contains("project-colour-trigger") ? 24 : 264; + }); + vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(333); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue( + new DOMRect(82 * rectZoom, 7 * rectZoom, 24 * rectZoom, 24 * rectZoom), + ); + render(header()); + const picker = await openPicker(); + const left = Number.parseFloat(picker.style.left); + const top = Number.parseFloat(picker.style.top); + const width = Math.min(264, Number.parseFloat(picker.style.maxWidth)); + const height = Math.min(333, Number.parseFloat(picker.style.maxHeight)); + expect(Number.parseFloat(picker.style.maxWidth)).toBe(480 / zoom - 16); + expect(Number.parseFloat(picker.style.maxHeight)).toBe(360 / zoom - 16); + expect(left * zoom).toBeGreaterThanOrEqual(0); + expect(top * zoom).toBeGreaterThanOrEqual(0); + expect((left + width) * zoom).toBeLessThanOrEqual(480); + expect((top + height) * zoom).toBeLessThanOrEqual(360); + }, + ); + + it("cancels delayed focus restoration when the header unmounts", async () => { + const { unmount } = render(header()); + await openPicker(); + const schedule = vi.spyOn(window, "requestAnimationFrame").mockReturnValue(123); + const cancel = vi.spyOn(window, "cancelAnimationFrame"); + fireEvent.pointerDown(document.body); + expect(schedule).toHaveBeenCalledOnce(); + unmount(); + expect(cancel).toHaveBeenCalledWith(123); + }); + + it("updates all headers for one project but not a same-named project elsewhere", async () => { + const { container } = render( + <> + {header()} + {header("/other/project", "chat")} + {header()} + , + ); + await waitFor(() => + expect(screen.getAllByRole("button", { name: "Project colour: Automatic" })).toHaveLength(3), + ); + await act(async () => { + await projectColourStore.setChoice("/work/project", "Blue"); + }); + await waitFor(() => + expect(screen.getAllByRole("button", { name: "Project colour: Blue" })).toHaveLength(2), + ); + expect(screen.getAllByRole("button", { name: "Project colour: Automatic" })).toHaveLength(1); + await act(async () => { + await projectColourStore.setStripe(true); + }); + expect(container.querySelectorAll(".chat-head-project-stripe")).toHaveLength(3); + }); + + it.each([undefined, ""])( + "does not create a picker or preferences in folderless Code/Chat contexts (%s)", + (cwd) => { + render( + <> + + + + + + + , + ); + expect(screen.getByText("GG Coder")).toBeDefined(); + expect(screen.getByText("GG Chat")).toBeDefined(); + expect(screen.queryByRole("button", { name: /Project colour/ })).toBeNull(); + expect(localStorage.length).toBe(0); + }, + ); + + it("keeps the confirmed choice and reports failed persistence", async () => { + render(header()); + const picker = await openPicker(); + await choose(picker, "Green"); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("quota"); + }); + fireEvent.click(within(picker).getByRole("button", { name: "Blue" })); + expect(await screen.findByRole("alert")).toBeDefined(); + expect(screen.getByRole("button", { name: "Project colour: Green" })).toBeDefined(); + expect(localStorage.getItem("gg-project-colour:/work/project")).toBe("Green"); + expect(within(picker).getByRole("button", { name: "Blue" }).getAttribute("aria-pressed")).toBe( + "false", + ); + }); +}); diff --git a/gg-app/src/ProjectColourPicker.tsx b/gg-app/src/ProjectColourPicker.tsx new file mode 100644 index 000000000..4883c0559 --- /dev/null +++ b/gg-app/src/ProjectColourPicker.tsx @@ -0,0 +1,311 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Check } from "lucide-react"; +import { + PROJECT_ACCENTS, + PROJECT_COLOUR_NAMES, + resolveProjectAccent, + type ProjectColourChoice, +} from "./projectAccent"; +import { projectColourStore } from "./project-colours"; + +const CHOICES: readonly ProjectColourChoice[] = ["Automatic", "None", ...PROJECT_COLOUR_NAMES]; + +interface Props { + cwd: string; + choice: ProjectColourChoice; + stripe: boolean; + ready: boolean; + loadError: string | null; +} + +/** Compact non-modal picker. Portal escapes the title's overflow clipping. */ +export function ProjectColourPicker({ + cwd, + choice, + stripe, + ready, + loadError, +}: Props): React.ReactElement { + const [open, setOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [pos, setPos] = useState(null); + const triggerRef = useRef(null); + const pickerRef = useRef(null); + const mounted = useRef(true); + const savingRef = useRef(false); + const focusFrame = useRef(null); + const id = useId(); + const accent = resolveProjectAccent(cwd, choice); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + if (focusFrame.current !== null) window.cancelAnimationFrame(focusFrame.current); + }; + }, []); + + useLayoutEffect(() => { + if (!open) return; + const place = (): void => { + const trigger = triggerRef.current; + const picker = pickerRef.current; + if (!trigger || !picker) return; + const rect = trigger.getBoundingClientRect(); + // WebKit returns unzoomed rects; Chromium includes the app's CSS zoom. + // Fixed-position styles and offsets use unzoomed CSS pixels in both. + const zoom = + Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("zoom")) || 1; + const rectZoom = rect.width / trigger.offsetWidth || 1; + const viewportWidth = window.innerWidth / zoom; + const viewportHeight = window.innerHeight / zoom; + const margin = 8; + const maxWidth = Math.max(0, viewportWidth - margin * 2); + const maxHeight = Math.max(0, viewportHeight - margin * 2); + setPos({ + left: Math.max( + margin, + Math.min( + rect.left / rectZoom, + viewportWidth - Math.min(picker.offsetWidth, maxWidth) - margin, + ), + ), + top: Math.max( + margin, + Math.min( + rect.bottom / rectZoom + 6, + viewportHeight - Math.min(picker.offsetHeight, maxHeight) - margin, + ), + ), + maxWidth, + maxHeight, + }); + }; + let frame: number | null = null; + const schedulePlace = (): void => { + if (frame !== null) return; + frame = window.requestAnimationFrame(() => { + frame = null; + place(); + }); + }; + place(); + window.addEventListener("resize", schedulePlace); + const zoomObserver = new MutationObserver(schedulePlace); + zoomObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["style"], + }); + const sizeObserver = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedulePlace); + if (pickerRef.current) sizeObserver?.observe(pickerRef.current); + return () => { + window.removeEventListener("resize", schedulePlace); + if (frame !== null) window.cancelAnimationFrame(frame); + zoomObserver.disconnect(); + sizeObserver?.disconnect(); + }; + }, [open, error, loadError]); + + useLayoutEffect(() => { + if (open) pickerRef.current?.querySelector('[aria-pressed="true"]')?.focus(); + }, [open]); + + useEffect(() => { + if (!open) return; + const dismiss = (restore: boolean): void => { + setOpen(false); + if (restore) triggerRef.current?.focus(); + }; + const onPointer = (event: PointerEvent): void => { + const target = event.target as Node; + if (triggerRef.current?.contains(target) || pickerRef.current?.contains(target)) return; + // Let a clicked control receive focus, but do not leave focus in a portal + // that is about to disappear when the click lands on non-interactive space. + const interactive = + target instanceof Element && + target.closest("button, a, input, select, textarea, [tabindex]"); + const restore = !interactive && Boolean(pickerRef.current?.contains(document.activeElement)); + dismiss(false); + if (restore) { + // The browser's default mouse-down focus happens after pointerdown. + // Restore on the next frame, unless the click focused another control. + if (focusFrame.current !== null) window.cancelAnimationFrame(focusFrame.current); + focusFrame.current = window.requestAnimationFrame(() => { + focusFrame.current = null; + if ( + document.activeElement === document.body || + document.activeElement === document.documentElement + ) { + triggerRef.current?.focus(); + } + }); + } + }; + const onKey = (event: KeyboardEvent): void => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + dismiss(true); + }; + const onFocus = (event: FocusEvent): void => { + const target = event.target as Node; + if (!pickerRef.current?.contains(target) && !triggerRef.current?.contains(target)) + dismiss(false); + }; + document.addEventListener("pointerdown", onPointer); + document.addEventListener("keydown", onKey, true); + document.addEventListener("focusin", onFocus); + return () => { + document.removeEventListener("pointerdown", onPointer); + document.removeEventListener("keydown", onKey, true); + document.removeEventListener("focusin", onFocus); + }; + }, [open]); + + async function save(action: () => Promise): Promise { + if (savingRef.current) return; + savingRef.current = true; + setSaving(true); + setError(null); + try { + await action(); + } catch { + if (mounted.current) setError("Could not save or confirm this choice. Please try again."); + } finally { + savingRef.current = false; + if (mounted.current) setSaving(false); + } + } + + return ( + <> + + {open && + createPortal( + , + document.body, + )} + + ); +} diff --git a/gg-app/src/WorkspaceHeader.test.tsx b/gg-app/src/WorkspaceHeader.test.tsx index 41e9e09a3..a9c3e0a5c 100644 --- a/gg-app/src/WorkspaceHeader.test.tsx +++ b/gg-app/src/WorkspaceHeader.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; // WorkspaceHeader imports agent.ts (openUrl), which reads the current webview // window at module load — stub it for jsdom. @@ -16,6 +16,13 @@ import { formatWorkspaceTitle, WorkspaceHeader } from "./WorkspaceHeader"; afterEach(cleanup); +// Settle asynchronous project identity before asserting header metadata. +async function renderHeader(ui: React.ReactElement): Promise { + await act(async () => { + render(ui); + }); +} + function ChatHeaderHarness(): React.ReactElement { const [navHidden, setNavHidden] = useState(false); @@ -31,11 +38,12 @@ function ChatHeaderHarness(): React.ReactElement { } describe("WorkspaceHeader", () => { - it("renders the chevron in chat mode and toggles the navbar", () => { - render(); + it("renders the chevron in chat mode and toggles the navbar", async () => { + await renderHeader(); expect(screen.getByText("GG Chat")).toBeDefined(); expect(screen.getByRole("button", { name: "New chat" })).toBeDefined(); + expect(screen.queryByRole("button", { name: /^Project colour:/ })).toBeNull(); const hideToggle = screen.getByRole("button", { name: "Hide nav buttons" }); expect(hideToggle.getAttribute("aria-expanded")).toBe("true"); @@ -59,12 +67,12 @@ describe("WorkspaceHeader", () => { expect(formatWorkspaceTitle("/work/app", null, "GG Coder", 1)).toBe("app │ 1 uncommitted"); }); - it("shows GitHub issue/PR counts and appends them to the window title", () => { + it("shows GitHub issue/PR counts and appends them to the window title", async () => { expect(formatWorkspaceTitle("/work/app", "main", "GG Coder", 0, 4, 1)).toBe( "app │ ⎇ main │ 4 issues │ 1 PR", ); - render( + await renderHeader( { expect(screen.getByRole("button", { name: "1 PR" })).toBeDefined(); }); - it("shows an added-roots badge and appends it to the window title", () => { + it("shows an added-roots badge and appends it to the window title", async () => { expect( formatWorkspaceTitle("/work/app", "main", "GG Coder", 0, null, null, ["/work/sdk"]), ).toBe("app │ +1 root │ ⎇ main"); - render( + await renderHeader( { expect(screen.getByText("+2 roots")).toBeDefined(); }); - it("hides the GitHub chips when the counts are unknown", () => { - render( + it("hides the GitHub chips when the counts are unknown", async () => { + await renderHeader( { expect(screen.queryByText(/PRs?$/)).toBeNull(); }); - it("hides a zero-count chip but keeps a non-zero one", () => { + it("hides a zero-count chip but keeps a non-zero one", async () => { // 3 open issues, 0 open PRs → issues chip shows, PR chip is hidden. expect(formatWorkspaceTitle("/work/app", "main", "GG Coder", 0, 3, 0)).toBe( "app │ ⎇ main │ 3 issues", ); - render( + await renderHeader( { expect(screen.queryByRole("button", { name: /PRs?$/ })).toBeNull(); }); - it("makes the folder a click-to-open-location button and the branch a repo link", () => { - render( + it("makes the folder a click-to-open-location button and the branch a repo link", async () => { + await renderHeader( { const folder = screen.getByRole("button", { name: "gg-coder" }); expect(folder.getAttribute("title")).toBe("/work/gg-coder — open folder"); + const colour = screen.getByRole("button", { name: "Project colour: Automatic" }); + expect(colour).not.toBe(folder); + expect(colour.getAttribute("aria-haspopup")).toBe("dialog"); + expect(colour.getAttribute("aria-expanded")).toBe("false"); + expect(colour.hasAttribute("data-tauri-drag-region")).toBe(false); + expect(folder.hasAttribute("data-tauri-drag-region")).toBe(false); + const branch = screen.getByRole("button", { name: "⎇ main" }); expect(branch.getAttribute("title")).toContain("github.com/kenkaiiii/gg-coder"); }); - it("leaves the branch as static text when there is no GitHub repo URL", () => { - render( + it("leaves the branch as static text when there is no GitHub repo URL", async () => { + await renderHeader( { expect(screen.getByText("⎇ main")).toBeDefined(); }); - it("shows the current directory, branch, and dirty count instead of a session title", () => { - render( + it("shows the current directory, branch, and dirty count instead of a session title", async () => { + await renderHeader(
@@ -92,7 +92,16 @@ export function WorkspaceHeader({ > {directory ? ( <> - {accent &&
+ ); +} + +class MarkdownRenderBoundary extends Component< + { content: string; children: ReactNode }, + { failed: boolean } +> { + state = { failed: false }; + + static getDerivedStateFromError(): { failed: boolean } { + return { failed: true }; + } + + render(): ReactNode { + if (!this.state.failed) return this.props.children; + return ( + <> +

Rich text could not load. Showing plain text; reopen the app to retry.

+ + + ); + } +} interface Props { children: string; @@ -278,8 +325,7 @@ function isPromptBlockComplete(raw: string): boolean { return /`{3,}\s*$/.test(body); } -const ANIMATED_PLUGINS = [rehypeHighlight, rehypeAnimateWords]; -const PLUGINS = [rehypeHighlight]; +const MARKDOWN_COMPONENTS = { a: ExternalLink, pre: PreBlock }; const MemoizedMarkdownBlock = memo( function MarkdownBlock({ @@ -294,13 +340,19 @@ const MemoizedMarkdownBlock = memo( const normalized = content.replace(/\\n/g, "\n").replace(/^\n+|\n+$/g, ""); return ( - - {normalized} - + + + + + } + > + + {normalized} + + + ); }, diff --git a/gg-app/src/MarkdownRenderer.tsx b/gg-app/src/MarkdownRenderer.tsx new file mode 100644 index 000000000..6852339f6 --- /dev/null +++ b/gg-app/src/MarkdownRenderer.tsx @@ -0,0 +1,29 @@ +import ReactMarkdown, { type Components } from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeHighlight from "rehype-highlight"; +import { rehypeAnimateWords } from "./rehype-animate-words"; +import "highlight.js/styles/github-dark.css"; + +const ANIMATED_PLUGINS = [rehypeHighlight, rehypeAnimateWords]; +const PLUGINS = [rehypeHighlight]; + +/** Loaded only when a message needs rich text, not during workspace startup. */ +export function MarkdownRenderer({ + children, + animate, + components, +}: { + children: string; + animate: boolean; + components: Components; +}): React.ReactElement { + return ( + + {children} + + ); +} diff --git a/gg-app/src/collapse.measure.test.tsx b/gg-app/src/collapse.measure.test.tsx index 7c24fe1d7..6bc144b4d 100644 --- a/gg-app/src/collapse.measure.test.tsx +++ b/gg-app/src/collapse.measure.test.tsx @@ -5,7 +5,7 @@ * markup, and a single window rendering a day's session passed `1.5 GB`. * Node count is deterministic and runs anywhere, unlike RSS. */ -import { fireEvent, render } from "@testing-library/react"; +import { fireEvent, render, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { ROW_COLLAPSE_CHARS, visibleBlockCount } from "./collapse"; @@ -30,8 +30,12 @@ const heavyRow = [ ].join("\n"); describe("folding measured on a realistic heavy row", () => { - it("mounts an order of magnitude fewer DOM nodes for a big tool dump", () => { + it("mounts an order of magnitude fewer DOM nodes for a big tool dump", async () => { const { container, unmount } = render({heavyRow}); + await waitFor(() => { + expect(container.querySelector('[aria-busy="true"]')).toBeNull(); + expect(container.querySelector("p")?.textContent).toBe("Here is the output:"); + }); const folded = container.querySelectorAll("*").length; const foldedChars = (container.textContent ?? "").length; @@ -58,9 +62,10 @@ describe("folding measured on a realistic heavy row", () => { ); }); - it("keeps a normal reply at full fidelity", () => { + it("keeps a normal reply at full fidelity", async () => { const normal = "I fixed the bug.\n\n```ts\nconst x = 1;\n```\n\nAll tests pass."; const { container } = render({normal}); + await waitFor(() => expect(container.querySelector(".code-block")).toBeTruthy()); expect(container.querySelector("button.code-expand")).toBeNull(); expect(container.textContent).toContain("All tests pass."); }); diff --git a/gg-app/vite.config.ts b/gg-app/vite.config.ts index ff962ad1f..4c48b56d9 100644 --- a/gg-app/vite.config.ts +++ b/gg-app/vite.config.ts @@ -1,13 +1,24 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { env } from "node:process"; -// @ts-expect-error process is a nodejs global -const host = process.env.TAURI_DEV_HOST; +const host = env.TAURI_DEV_HOST; // https://vite.dev/config/ export default defineConfig(async () => ({ plugins: [react()], - build: { manifest: true }, // Lets CI budget initial JS separately from lazy chunks. + build: { + manifest: true, // Lets CI budget initial JS separately from lazy chunks. + rolldownOptions: { + output: { + codeSplitting: { + groups: [ + { name: "react-vendor", test: /node_modules[\\/](?:react|react-dom|scheduler)[\\/]/ }, + ], + }, + }, + }, + }, // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // From e94911764b6baacb20cbadd04544ae8fbeb61fe2 Mon Sep 17 00:00:00 2001 From: willem Date: Thu, 17 Sep 2026 23:43:49 -0400 Subject: [PATCH 3/6] fix(app): preserve native project colour selection --- gg-app/src/App.css | 13 ++- gg-app/src/App.focus.test.tsx | 132 ++++++++++++++++++++++++ gg-app/src/App.tsx | 16 ++- gg-app/src/ProjectColourPicker.test.tsx | 78 ++++++++++++++ gg-app/src/ProjectColourPicker.tsx | 21 ++-- 5 files changed, 242 insertions(+), 18 deletions(-) create mode 100644 gg-app/src/App.focus.test.tsx diff --git a/gg-app/src/App.css b/gg-app/src/App.css index 68d4e3258..34fe628fa 100644 --- a/gg-app/src/App.css +++ b/gg-app/src/App.css @@ -1290,13 +1290,13 @@ select { border-bottom: 1px solid var(--border); } /* Personal colour only decorates identity, never actions or status indicators. */ -.chat-head-project-stripe::before { +.chat-head-project-stripe .chat-head-strip::after { content: ""; position: absolute; - top: 0; + bottom: 0; left: 0; right: 0; - height: 2px; + height: 6px; background: var(--project-accent); pointer-events: none; } @@ -1321,15 +1321,13 @@ select { background: var(--surface-3); } .chat-head-accent-dot { - width: 6px; - height: 6px; + width: 10px; + height: 10px; border-radius: 50%; background: var(--project-accent); pointer-events: none; } .project-colour-trigger-none .chat-head-accent-dot { - width: 8px; - height: 8px; background: none; border: 1px solid var(--text-muted); } @@ -1438,6 +1436,7 @@ select { line-height: 1.4; } .chat-head-strip { + position: relative; display: flex; align-items: center; gap: 12px; diff --git a/gg-app/src/App.focus.test.tsx b/gg-app/src/App.focus.test.tsx new file mode 100644 index 000000000..8cf2b67e6 --- /dev/null +++ b/gg-app/src/App.focus.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { clearMocks, mockIPC, mockWindows } from "@tauri-apps/api/mocks"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type AppComponent from "./App"; +import type { WorkspaceHeader as WorkspaceHeaderComponent } from "./WorkspaceHeader"; + +let App: typeof AppComponent; +let WorkspaceHeader: typeof WorkspaceHeaderComponent; +const originalScrollTo = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTo"); + +function nativeIPC(command: string): unknown { + if (command === "window_restore_target") { + return { mode: "code", cwd: "/work/focus-project", sessionPath: null }; + } + // Mount the real workspace and composer, without starting a daemon or model. + return new Promise(() => {}); +} + +beforeAll(async () => { + mockWindows("main"); + mockIPC(nativeIPC); + App = (await import("./App")).default; + WorkspaceHeader = (await import("./WorkspaceHeader")).WorkspaceHeader; +}); + +beforeEach(() => { + // jsdom lacks element scrolling; preserve the scroll position the app requests. + Object.defineProperty(HTMLElement.prototype, "scrollTo", { + configurable: true, + value(this: HTMLElement, options: ScrollToOptions): void { + this.scrollTop = options.top ?? 0; + }, + }); + localStorage.clear(); + mockWindows("main"); + mockIPC(nativeIPC); +}); + +afterEach(() => { + cleanup(); + clearMocks(); + vi.restoreAllMocks(); + if (originalScrollTo) Object.defineProperty(HTMLElement.prototype, "scrollTo", originalScrollTo); + else Reflect.deleteProperty(HTMLElement.prototype, "scrollTo"); +}); + +async function workspace(withPicker = false): Promise { + render( + <> + + {withPicker && ( + + + + )} + +
Background
+ , + ); + return await waitFor(() => { + const input = document.querySelector("textarea"); + expect(input).not.toBeNull(); + return input!; + }); +} + +async function openPicker(): Promise { + fireEvent.click(screen.getByRole("button", { name: "Project colour: Automatic" })); + const picker = screen.getByRole("dialog", { name: "Project colour" }); + await waitFor(() => + expect(within(picker).getByRole("button", { name: "Blue" }).getAttribute("aria-disabled")).toBe( + "false", + ), + ); + return picker; +} + +function webkitMouseDown(button: HTMLElement): void { + fireEvent.pointerDown(button); + fireEvent.mouseDown(button); + // Mac WebKit blurs the focused choice but does not focus the clicked button. + act(() => (document.activeElement as HTMLElement).blur()); + expect(document.activeElement).toBe(document.body); +} + +describe("composer focus with Mac WebKit mouse behaviour", () => { + it("allows a colour click to finish without the composer closing the picker", async () => { + const input = await workspace(true); + const picker = await openPicker(); + const blue = within(picker).getByRole("button", { name: "Blue" }); + webkitMouseDown(blue); + fireEvent.mouseUp(blue); + expect(document.activeElement).not.toBe(input); + expect(screen.getByRole("dialog", { name: "Project colour" })).toBe(picker); + fireEvent.click(blue); + await waitFor(() => + expect(screen.getByRole("button", { name: "Project colour: Blue" })).toBeDefined(), + ); + expect(localStorage.getItem("gg-project-colour:/work/focus-project")).toBe("Blue"); + }); + + it("does not steal focus when the window gains focus with the picker open", async () => { + const input = await workspace(true); + const picker = await openPicker(); + act(() => (document.activeElement as HTMLElement).blur()); + fireEvent.focus(window); + expect(document.activeElement).not.toBe(input); + expect(screen.getByRole("dialog", { name: "Project colour" })).toBe(picker); + }); + + it("leaves other clicked buttons alone even when only their child is targeted", async () => { + const input = await workspace(); + act(() => input.blur()); + fireEvent.mouseUp(screen.getByText("Other action")); + expect(document.activeElement).not.toBe(input); + }); + + it("still focuses the composer after clicking ordinary background", async () => { + const input = await workspace(); + act(() => input.blur()); + fireEvent.mouseUp(screen.getByTestId("background")); + expect(document.activeElement).toBe(input); + }); +}); diff --git a/gg-app/src/App.tsx b/gg-app/src/App.tsx index 347421dc7..378aa6ca2 100644 --- a/gg-app/src/App.tsx +++ b/gg-app/src/App.tsx @@ -1217,14 +1217,20 @@ function App(): React.ReactElement { // a second click. Skips when the user is selecting text or focused elsewhere // intentionally (e.g. a menu button). useEffect(() => { - const focusInput = (): void => { + const focusInput = (event: Event): void => { + // Mac WebKit does not focus clicked buttons. Check the actual target too, + // or mouseup steals focus and dismisses a picker before its click runs. + if ( + event.target instanceof Element && + event.target.closest("button, a, input, select, textarea, label, [tabindex]") + ) + return; const active = document.activeElement; if (active && active !== document.body && active.tagName === "BUTTON") return; if (window.getSelection()?.toString()) return; - // A modal/overlay owns keyboard focus while open — stealing it back to the - // chat input means the user can't type in the modal's fields. Bail when one - // is present (every modal renders inside `.modal-backdrop`). - if (document.querySelector(".modal-backdrop")) return; + // Both modal and non-modal dialogs own focus while open, including when + // this window regains focus before WebKit focuses the clicked control. + if (document.querySelector('.modal-backdrop, [role="dialog"]')) return; // Don't yank focus out of another editable field (a different input, // textarea, or contenteditable) the user is intentionally typing in. if ( diff --git a/gg-app/src/ProjectColourPicker.test.tsx b/gg-app/src/ProjectColourPicker.test.tsx index 6b680a891..71f231370 100644 --- a/gg-app/src/ProjectColourPicker.test.tsx +++ b/gg-app/src/ProjectColourPicker.test.tsx @@ -239,6 +239,84 @@ describe("project colour picker in the shared header", () => { }, ); + it("saves the latest colour clicked while an earlier choice is still saving", async () => { + const { container } = render(header()); + const picker = await openPicker(); + const actualSave = projectColourStore.setChoice; + let finishFirstSave!: () => void; + const firstSave = new Promise((resolve) => { + finishFirstSave = resolve; + }); + const save = vi + .spyOn(projectColourStore, "setChoice") + .mockImplementationOnce(async (cwd, choice) => { + await firstSave; + await actualSave(cwd, choice); + }); + fireEvent.click(within(picker).getByRole("button", { name: "Blue" })); + fireEvent.click(within(picker).getByRole("button", { name: "Green" })); + fireEvent.click(within(picker).getByRole("button", { name: "Orchid" })); + await act(async () => { + finishFirstSave(); + await firstSave; + }); + await waitFor(() => + expect(screen.getByRole("button", { name: "Project colour: Orchid" })).toBeDefined(), + ); + expect(localStorage.getItem("gg-project-colour:/work/project")).toBe("Orchid"); + expect( + container + .querySelector(".chat-head") + ?.style.getPropertyValue("--project-accent"), + ).toBe(PROJECT_ACCENTS[9]); + expect(save.mock.calls.map(([, choice]) => choice)).toEqual(["Blue", "Orchid"]); + }); + + it("still saves the latest queued choice when the earlier save fails", async () => { + render(header()); + const picker = await openPicker(); + let failFirstSave!: () => void; + const firstSave = new Promise((_, reject) => { + failFirstSave = () => reject(new Error("temporary write failure")); + }); + vi.spyOn(projectColourStore, "setChoice").mockImplementationOnce(() => firstSave); + fireEvent.click(within(picker).getByRole("button", { name: "Blue" })); + fireEvent.click(within(picker).getByRole("button", { name: "Green" })); + await act(async () => { + failFirstSave(); + await firstSave.catch(() => undefined); + }); + await waitFor(() => + expect(screen.getByRole("button", { name: "Project colour: Green" })).toBeDefined(), + ); + expect(localStorage.getItem("gg-project-colour:/work/project")).toBe("Green"); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("persists an accepted queued choice after its header unmounts", async () => { + const { unmount } = render(header()); + const picker = await openPicker(); + const actualSave = projectColourStore.setChoice; + let finishFirstSave!: () => void; + const firstSave = new Promise((resolve) => { + finishFirstSave = resolve; + }); + vi.spyOn(projectColourStore, "setChoice").mockImplementationOnce(async (cwd, choice) => { + await firstSave; + await actualSave(cwd, choice); + }); + fireEvent.click(within(picker).getByRole("button", { name: "Blue" })); + fireEvent.click(within(picker).getByRole("button", { name: "Orchid" })); + unmount(); + await act(async () => { + finishFirstSave(); + await firstSave; + }); + await waitFor(() => + expect(localStorage.getItem("gg-project-colour:/work/project")).toBe("Orchid"), + ); + }); + it("keeps the confirmed choice and reports failed persistence", async () => { render(header()); const picker = await openPicker(); diff --git a/gg-app/src/ProjectColourPicker.tsx b/gg-app/src/ProjectColourPicker.tsx index 4883c0559..f20ab75ff 100644 --- a/gg-app/src/ProjectColourPicker.tsx +++ b/gg-app/src/ProjectColourPicker.tsx @@ -35,6 +35,7 @@ export function ProjectColourPicker({ const pickerRef = useRef(null); const mounted = useRef(true); const savingRef = useRef(false); + const queuedSave = useRef<(() => Promise) | null>(null); const focusFrame = useRef(null); const id = useId(); const accent = resolveProjectAccent(cwd, choice); @@ -166,14 +167,22 @@ export function ProjectColourPicker({ }, [open]); async function save(action: () => Promise): Promise { + // Keep only the latest waiting choice so rapid clicks finish on that colour. + queuedSave.current = action; if (savingRef.current) return; savingRef.current = true; setSaving(true); - setError(null); try { - await action(); - } catch { - if (mounted.current) setError("Could not save or confirm this choice. Please try again."); + while (queuedSave.current) { + const next = queuedSave.current; + queuedSave.current = null; + if (mounted.current) setError(null); + try { + await next(); + } catch { + if (mounted.current) setError("Could not save or confirm this choice. Please try again."); + } + } } finally { savingRef.current = false; if (mounted.current) setSaving(false); @@ -236,9 +245,9 @@ export function ProjectColourPicker({ key={option} className="project-colour-choice" aria-pressed={option === choice} - aria-disabled={!ready || saving} + aria-disabled={!ready} onClick={() => { - if (ready && !savingRef.current) { + if (ready) { void save(() => projectColourStore.setChoice(cwd, option)); } }} From 0042d4bae3c14e2cae29c983c8758267b97426d9 Mon Sep 17 00:00:00 2001 From: willem Date: Thu, 17 Sep 2026 23:48:50 -0400 Subject: [PATCH 4/6] refactor(app): route project preferences through the native bridge --- gg-app/src/agent.ts | 24 ++++++++++++++ gg-app/src/project-colours.test.ts | 51 ++++++++++++++++++++++++++++-- gg-app/src/project-colours.ts | 24 +++++++------- 3 files changed, 84 insertions(+), 15 deletions(-) diff --git a/gg-app/src/agent.ts b/gg-app/src/agent.ts index 9d026b9df..b9432620e 100644 --- a/gg-app/src/agent.ts +++ b/gg-app/src/agent.ts @@ -4,8 +4,10 @@ // - invoke("agent_state" | "agent_prompt" | "agent_cancel") // - listen("agent-event") ← forwarded SSE frames import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { error as logError, info as logInfo } from "@tauri-apps/plugin-log"; +import type { ProjectColourChoice } from "./projectAccent"; // Per-window event bus. The Rust side emits agent traffic with `emit_to` the // specific window label, so each window must listen on ITS OWN webview target — @@ -1167,6 +1169,28 @@ export async function saveSettings(projectsRoot: string): Promise { await invoke("app_settings_save", { projectsRoot }); } +/** Personal project preferences are handled in Rust, without the sidecar. + * The store validates these untrusted native payloads before publishing them. */ +export function getProjectColourPreferences(cwd: string | null): Promise { + return invoke("project_colours_get", { cwd }); +} + +/** Save a project choice or the app-wide stripe setting. Throws on failure. */ +export function saveProjectColourPreferences( + cwd: string | null, + choice: ProjectColourChoice | null, + stripe: boolean | null, +): Promise { + return invoke("project_colours_save", { cwd, choice, stripe }); +} + +/** Unlike agent traffic, preferences are broadcast to every app window. */ +export function listenProjectColourPreferences( + onChange: (payload: unknown) => void, +): Promise { + return listen("project-colours-changed", (event) => onChange(event.payload)); +} + export interface InstalledPlugin { schemaVersion: 1; id: string; diff --git a/gg-app/src/project-colours.test.ts b/gg-app/src/project-colours.test.ts index 29145129f..a4b8b43f9 100644 --- a/gg-app/src/project-colours.test.ts +++ b/gg-app/src/project-colours.test.ts @@ -1,19 +1,30 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { clearMocks, mockWindows } from "@tauri-apps/api/mocks"; const { invokeMock, listenMock } = vi.hoisted(() => ({ invokeMock: vi.fn(), listenMock: vi.fn() })); -vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock, isTauri: () => false })); +vi.mock("@tauri-apps/api/core", async (importOriginal) => ({ + ...(await importOriginal()), + invoke: invokeMock, + isTauri: () => false, +})); vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); -import { createProjectColourStore, projectColourKey } from "./project-colours"; import { PROJECT_COLOUR_NAMES } from "./projectAccent"; +// The real native bridge resolves this webview's label during module loading. +mockWindows("main"); +const { createProjectColourStore, projectColourKey } = await import("./project-colours"); + beforeEach(() => { + mockWindows("main"); vi.restoreAllMocks(); invokeMock.mockReset(); listenMock.mockReset(); localStorage.clear(); }); +afterEach(() => clearMocks()); + describe("personal project colours in the browser", () => { it("normalizes full identities but keeps same-named projects independent", () => { expect(projectColourKey("/a/project/../project/.")).toBe("/a/project"); @@ -163,6 +174,40 @@ describe("native-window preference transport", () => { expect(callbacks.size).toBe(0); }); + it("uses the app-wide event and exact native reset and stripe command payloads", async () => { + listenMock.mockResolvedValue(vi.fn()); + invokeMock.mockImplementation( + async (command: string, args: { cwd: string | null; stripe?: boolean | null }) => ({ + ...initial, + projectKey: command === "project_colours_get" ? args.cwd : null, + stripe: args.stripe ?? false, + revision: command === "project_colours_save" ? 1 : 0, + }), + ); + const store = createProjectColourStore(true); + const stop = store.subscribe(vi.fn()); + expect(await store.resolveProject("/a/project")).toBe("/a/project"); + expect(listenMock).toHaveBeenCalledWith("project-colours-changed", expect.any(Function)); + expect(invokeMock).toHaveBeenLastCalledWith("project_colours_get", { cwd: "/a/project" }); + + await store.setChoice("/a/project", "Automatic"); + expect(invokeMock).toHaveBeenLastCalledWith("project_colours_save", { + cwd: "/a/project", + choice: "Automatic", + stripe: null, + }); + await store.setStripe(true); + expect(invokeMock).toHaveBeenLastCalledWith("project_colours_save", { + cwd: null, + choice: null, + stripe: true, + }); + expect(store.getSnapshot().stripe).toBe(true); + await store.setStripe(false); + expect(store.getSnapshot().stripe).toBe(false); + stop(); + }); + it("keeps the last confirmed native choice when persistence fails", async () => { listenMock.mockResolvedValue(vi.fn()); invokeMock.mockResolvedValue({ ...initial, overrides: { "/a/project": "Green" } }); diff --git a/gg-app/src/project-colours.ts b/gg-app/src/project-colours.ts index a390c0724..f2320473a 100644 --- a/gg-app/src/project-colours.ts +++ b/gg-app/src/project-colours.ts @@ -1,11 +1,15 @@ import { useEffect, useState, useSyncExternalStore } from "react"; -import { invoke, isTauri } from "@tauri-apps/api/core"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { isTauri } from "@tauri-apps/api/core"; +import type { UnlistenFn } from "@tauri-apps/api/event"; +import { + getProjectColourPreferences, + listenProjectColourPreferences, + saveProjectColourPreferences, +} from "./agent"; import { isProjectColourChoice, type ProjectColourChoice } from "./projectAccent"; const PREFIX = "gg-project-colour:"; const STRIPE_KEY = "gg-project-colour-stripe"; -const EVENT = "project-colours-changed"; type Override = Exclude; interface Preferences { @@ -126,7 +130,7 @@ export function createProjectColourStore(native = isTauri()) { } } async function loadNative(cwd: string | null = null): Promise { - const next = applyNative(await invoke("project_colours_get", { cwd })); + const next = applyNative(await getProjectColourPreferences(cwd)); if (!next) throw new Error("Invalid project colour preferences"); return next.projectKey; } @@ -145,8 +149,8 @@ export function createProjectColourStore(native = isTauri()) { if (native) { // Listen FIRST, then read. The revision rejects an older read/response // arriving after a newer change from another native window. - ready = listen(EVENT, (event: { payload: unknown }) => { - if (active) applyNative(event.payload); + ready = listenProjectColourPreferences((payload) => { + if (active) applyNative(payload); }).then((off: UnlistenFn) => { if (!active) { off(); @@ -205,9 +209,7 @@ export function createProjectColourStore(native = isTauri()) { throw new Error("A project and a known colour are required"); if (native) { await ready; - const saved = applyNative( - await invoke("project_colours_save", { cwd, choice, stripe: null }), - ); + const saved = applyNative(await saveProjectColourPreferences(cwd, choice, null)); if (!saved) throw new Error("Could not confirm the saved project colour"); } else { // A key per project, NOT a read-modify-write map: other windows cannot @@ -221,9 +223,7 @@ export function createProjectColourStore(native = isTauri()) { if (typeof stripe !== "boolean") throw new Error("Invalid stripe visibility"); if (native) { await ready; - const saved = applyNative( - await invoke("project_colours_save", { cwd: null, choice: null, stripe }), - ); + const saved = applyNative(await saveProjectColourPreferences(null, null, stripe)); if (!saved) throw new Error("Could not confirm saved stripe visibility"); } else { window.localStorage.setItem(STRIPE_KEY, stripe ? "1" : "0"); From 5afbcf754e586ed0ce223d9aa509ac7863d41512 Mon Sep 17 00:00:00 2001 From: willem Date: Thu, 17 Sep 2026 23:49:59 -0400 Subject: [PATCH 5/6] test(app): launch the bundle check through Node on every platform --- gg-app/scripts/markdown-split.test.mjs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/gg-app/scripts/markdown-split.test.mjs b/gg-app/scripts/markdown-split.test.mjs index 775d2f9fc..f357a2beb 100644 --- a/gg-app/scripts/markdown-split.test.mjs +++ b/gg-app/scripts/markdown-split.test.mjs @@ -8,13 +8,19 @@ import { expect, it } from "vitest"; it("keeps rich-text rendering outside startup and production JS chunks below the warning limit", () => { const out = mkdtempSync(path.join(tmpdir(), "gg-markdown-split-")); try { - const build = spawnSync("pnpm", ["exec", "vite", "build", "--outDir", out, "--emptyOutDir"], { - cwd: fileURLToPath(new URL("../", import.meta.url)), - encoding: "utf8", - // Vitest sets NODE_ENV=test; measure the shipped React build instead. - env: { ...process.env, NODE_ENV: "production" }, - timeout: 30_000, - }); + // Run the installed JS entry with Node, not a platform-specific pnpm shim. + const viteCli = fileURLToPath(new URL("../node_modules/vite/bin/vite.js", import.meta.url)); + const build = spawnSync( + process.execPath, + [viteCli, "build", "--outDir", out, "--emptyOutDir"], + { + cwd: fileURLToPath(new URL("../", import.meta.url)), + encoding: "utf8", + // Vitest sets NODE_ENV=test; measure the shipped React build instead. + env: { ...process.env, NODE_ENV: "production" }, + timeout: 30_000, + }, + ); expect(build.error).toBeUndefined(); expect(build.status, build.stdout + build.stderr).toBe(0); const manifest = JSON.parse(readFileSync(path.join(out, ".vite/manifest.json"), "utf8")); From 2ab93a20f802eed7b91cd7c100d446a5b6ecca2e Mon Sep 17 00:00:00 2001 From: willem Date: Thu, 17 Sep 2026 23:52:39 -0400 Subject: [PATCH 6/6] test(app): follow the explicit native SDK type import convention --- gg-app/src/project-colours.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gg-app/src/project-colours.test.ts b/gg-app/src/project-colours.test.ts index a4b8b43f9..7f8a95849 100644 --- a/gg-app/src/project-colours.test.ts +++ b/gg-app/src/project-colours.test.ts @@ -1,10 +1,11 @@ // @vitest-environment jsdom import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; import { clearMocks, mockWindows } from "@tauri-apps/api/mocks"; +import type * as TauriCore from "@tauri-apps/api/core"; const { invokeMock, listenMock } = vi.hoisted(() => ({ invokeMock: vi.fn(), listenMock: vi.fn() })); vi.mock("@tauri-apps/api/core", async (importOriginal) => ({ - ...(await importOriginal()), + ...(await importOriginal()), invoke: invokeMock, isTauri: () => false, }));