From 0be08b9904c0508bca151dce35ad5ed5899cc22f Mon Sep 17 00:00:00 2001 From: Nick <60738984+hardbeat920@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:22:21 +0100 Subject: [PATCH 01/27] Add global and project chat background images - Add image storage, validation, and project-specific overrides - Add appearance controls for visibility and session scope - Add background dialog, project menu entry, and coverage tests --- src-tauri/src/chat_background.rs | 210 +++++++++++++++++++++++ src-tauri/src/lib.rs | 5 + src/chrome/Composer.tsx | 4 +- src/chrome/ProjectBackgroundDialog.tsx | 224 ++++++++++++++++++++++++ src/chrome/ProjectRail.tsx | 25 ++- src/index.css | 25 +++ src/lib/appearance.test.ts | 48 ++++++ src/lib/appearance.ts | 109 +++++++++++- src/lib/chatBackground.test.ts | 83 +++++++++ src/lib/chatBackground.ts | 54 ++++++ src/lib/projectChatBackground.test.ts | 112 ++++++++++++ src/lib/projectChatBackground.ts | 113 ++++++++++++ src/lib/projectData.ts | 4 + src/surfaces/SessionPane.tsx | 28 ++- src/surfaces/SettingsView.tsx | 228 ++++++++++++++++++++++++- 15 files changed, 1264 insertions(+), 8 deletions(-) create mode 100644 src-tauri/src/chat_background.rs create mode 100644 src/chrome/ProjectBackgroundDialog.tsx create mode 100644 src/lib/chatBackground.test.ts create mode 100644 src/lib/chatBackground.ts create mode 100644 src/lib/projectChatBackground.test.ts create mode 100644 src/lib/projectChatBackground.ts diff --git a/src-tauri/src/chat_background.rs b/src-tauri/src/chat_background.rs new file mode 100644 index 00000000..dbf3597e --- /dev/null +++ b/src-tauri/src/chat_background.rs @@ -0,0 +1,210 @@ +use std::path::{Path, PathBuf}; + +use tauri::{AppHandle, Manager}; + +use crate::fs::expand_home; + +const MAX_BACKGROUND_BYTES: u64 = 25 * 1024 * 1024; +const ALLOWED_EXT: [&str; 5] = ["png", "jpg", "jpeg", "gif", "webp"]; + +fn backgrounds_dir(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("backgrounds"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir) +} + +fn remove_existing_backgrounds(dir: &Path) -> Result<(), String> { + let entries = std::fs::read_dir(dir).map_err(|e| e.to_string())?; + for entry in entries { + let entry = entry.map_err(|e| e.to_string())?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if name == "chat-background" || name.starts_with("chat-background.") { + match std::fs::remove_file(entry.path()) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.to_string()), + } + } + } + Ok(()) +} + +fn project_background_stem(project: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in project.trim().as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100_0000_01b3); + } + format!("project-{hash:016x}") +} + +fn remove_project_background(dir: &Path, project: &str) -> Result<(), String> { + let stem = project_background_stem(project); + let prefix = format!("{stem}."); + let entries = std::fs::read_dir(dir).map_err(|e| e.to_string())?; + for entry in entries { + let entry = entry.map_err(|e| e.to_string())?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if name == stem || name.starts_with(&prefix) { + match std::fs::remove_file(entry.path()) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.to_string()), + } + } + } + Ok(()) +} + +fn background_extension(source: &Path) -> Result { + let ext = source + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if ALLOWED_EXT.contains(&ext.as_str()) { + Ok(ext) + } else { + Err("Background must be a PNG, JPG, GIF, or WebP image.".into()) + } +} + +fn save_chat_background_sync(app: &AppHandle, source_path: &str) -> Result { + let source = expand_home(source_path); + let meta = std::fs::metadata(&source).map_err(|e| format!("{}: {e}", source.display()))?; + if !meta.is_file() { + return Err("Not a file".into()); + } + if meta.len() > MAX_BACKGROUND_BYTES { + return Err(format!( + "Background is too large (maximum {} MB).", + MAX_BACKGROUND_BYTES / 1024 / 1024 + )); + } + let ext = background_extension(&source)?; + let dir = backgrounds_dir(app)?; + let dest = dir.join(format!("chat-background.{ext}")); + let temp = dir.join(".chat-background-upload"); + + // Copy first so choosing the currently saved image remains safe. + std::fs::copy(&source, &temp).map_err(|e| format!("{}: {e}", temp.display()))?; + remove_existing_backgrounds(&dir)?; + std::fs::rename(&temp, &dest).map_err(|e| format!("{}: {e}", dest.display()))?; + Ok(dest.to_string_lossy().into_owned()) +} + +fn save_project_chat_background_sync( + app: &AppHandle, + project: &str, + source_path: &str, +) -> Result { + if project.trim().is_empty() { + return Err("Project is required".into()); + } + let source = expand_home(source_path); + let meta = std::fs::metadata(&source).map_err(|e| format!("{}: {e}", source.display()))?; + if !meta.is_file() { + return Err("Not a file".into()); + } + if meta.len() > MAX_BACKGROUND_BYTES { + return Err(format!( + "Background is too large (maximum {} MB).", + MAX_BACKGROUND_BYTES / 1024 / 1024 + )); + } + let ext = background_extension(&source)?; + let dir = backgrounds_dir(app)?; + let stem = project_background_stem(project); + let dest = dir.join(format!("{stem}.{ext}")); + let temp = dir.join(format!(".{stem}-upload")); + + std::fs::copy(&source, &temp).map_err(|e| format!("{}: {e}", temp.display()))?; + remove_project_background(&dir, project)?; + std::fs::rename(&temp, &dest).map_err(|e| format!("{}: {e}", dest.display()))?; + Ok(dest.to_string_lossy().into_owned()) +} + +#[tauri::command] +pub async fn save_chat_background(app: AppHandle, source_path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || save_chat_background_sync(&app, &source_path)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn remove_chat_background(app: AppHandle) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let dir = backgrounds_dir(&app)?; + remove_existing_backgrounds(&dir) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn save_project_chat_background( + app: AppHandle, + project: String, + source_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + save_project_chat_background_sync(&app, &project, &source_path) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn remove_project_chat_background(app: AppHandle, project: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let dir = backgrounds_dir(&app)?; + remove_project_background(&dir, &project) + }) + .await + .map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_supported_extensions_case_insensitively() { + assert_eq!( + background_extension(Path::new("wallpaper.JPEG")).unwrap(), + "jpeg" + ); + assert_eq!( + background_extension(Path::new("wallpaper.webp")).unwrap(), + "webp" + ); + } + + #[test] + fn rejects_files_the_webview_cannot_render_as_backgrounds() { + assert!(background_extension(Path::new("wallpaper.txt")).is_err()); + assert!(background_extension(Path::new("wallpaper")).is_err()); + } + + #[test] + fn project_background_stems_are_stable_and_distinct() { + assert_eq!( + project_background_stem("/Users/me/agent"), + project_background_stem("/Users/me/agent") + ); + assert_ne!( + project_background_stem("/Users/me/agent"), + project_background_stem("/Users/other/agent") + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 33eb1886..2af652bf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ use tauri::Manager; +mod chat_background; mod checkpoint; mod cursor_store; mod fs; @@ -317,6 +318,10 @@ pub fn run() { window::enable_window_glass, window_transfer::stage_window_transfer, window_transfer::take_window_transfer, + chat_background::save_chat_background, + chat_background::remove_chat_background, + chat_background::save_project_chat_background, + chat_background::remove_project_chat_background, project_logo::save_project_logo, project_logo::remove_project_logo, project_logo::forget_logo_file, diff --git a/src/chrome/Composer.tsx b/src/chrome/Composer.tsx index 1e29f3c4..4fe91814 100644 --- a/src/chrome/Composer.tsx +++ b/src/chrome/Composer.tsx @@ -1147,7 +1147,7 @@ export function Composer({
void; +}; + +export function ProjectBackgroundDialog({ project, name, onClose }: Props) { + const initial = loadProjectChatBackground(project); + const [path, setPath] = useState(initial?.path ?? null); + const [opacity, setOpacity] = useState( + initial?.opacity ?? loadChatBackgroundOpacity(), + ); + const [scope, setScope] = useState( + initial?.scope ?? loadChatBackgroundScope(), + ); + const [revision, setRevision] = useState(projectChatBackgroundRevision); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const globalPath = loadChatBackgroundPath(); + const previewSrc = path + ? projectChatBackgroundSrc(path, revision) + : chatBackgroundSrc(globalPath); + + const save = ( + nextPath: string, + nextOpacity: number, + nextScope: ChatBackgroundScope, + ) => { + saveProjectChatBackground(project, { + path: nextPath, + opacity: nextOpacity, + scope: nextScope, + }); + setRevision(projectChatBackgroundRevision()); + }; + + const choose = async () => { + setBusy(true); + setError(null); + try { + const nextPath = await pickAndSaveProjectChatBackground(project); + if (!nextPath) return; + save(nextPath, opacity, scope); + setPath(nextPath); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusy(false); + } + }; + + const removeImage = async () => { + setBusy(true); + setError(null); + try { + await clearProjectChatBackground(project); + clearProjectChatBackgroundSetting(project); + setPath(null); + setOpacity(loadChatBackgroundOpacity()); + setScope(loadChatBackgroundScope()); + setRevision(projectChatBackgroundRevision()); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusy(false); + } + }; + + const updateOpacity = (percent: number) => { + const next = Math.min( + CHAT_BACKGROUND_OPACITY_MAX, + Math.max(CHAT_BACKGROUND_OPACITY_MIN, percent / 100), + ); + setOpacity(next); + if (path) save(path, next, scope); + }; + + const updateScope = (next: ChatBackgroundScope) => { + setScope(next); + if (path) save(path, opacity, next); + }; + + return ( + +
+
+
+ {previewSrc ? ( + + ) : ( +
+ No background selected +
+ )} +
+ +

+ {path + ? "This image overrides the global background for this project." + : "This project currently follows the global Appearance setting."} +

+ {error ? ( +

{error}

+ ) : null} +
+ + +
+ {[ + { value: "empty" as const, label: "Empty only" }, + { value: "all" as const, label: "All sessions" }, + ].map((option) => ( + + ))} +
+
+ + +
+ updateOpacity(Number(event.target.value))} + /> + + {Math.round(opacity * 100)}% + +
+
+ + {path ? ( + + ) : null} +
+
+ ); +} + +function ProjectBackgroundRow({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( +
+ {label} + {children} +
+ ); +} diff --git a/src/chrome/ProjectRail.tsx b/src/chrome/ProjectRail.tsx index 06d72ebe..6967a65a 100644 --- a/src/chrome/ProjectRail.tsx +++ b/src/chrome/ProjectRail.tsx @@ -5,6 +5,7 @@ import { ChevronUp, CircleAlert, FolderOpen, + ImagePlus, Inbox, MoreHorizontal, Pin, @@ -61,6 +62,7 @@ import { import { formatLiveElapsed, type LiveAgent } from "../lib/liveAgents"; import { HarnessIcon } from "./HarnessIcon"; import { ProjectLogoIcon } from "./ProjectLogoIcon"; +import { ProjectBackgroundDialog } from "./ProjectBackgroundDialog"; import { ProjectMascot } from "./ProjectMascot"; import { RailAction, RailSearch } from "./RailAction"; import { RemoveProjectDialog } from "./RemoveProjectDialog"; @@ -84,6 +86,11 @@ function projectMenuExtraItems( canRemove: boolean, ): TabGroupMenuExtraItem[] { const items: TabGroupMenuExtraItem[] = [ + { + id: "background", + label: "Background image", + icon: ImagePlus, + }, pinned ? { id: "unpin", label: "Unpin project", icon: PinOff } : { id: "pin", label: "Pin project", icon: Pin }, @@ -189,6 +196,10 @@ export function ProjectRail({ path: string; name: string; } | null>(null); + const [backgroundProject, setBackgroundProject] = useState<{ + project: string; + name: string; + } | null>(null); const lockOverscroll = useLockOverscroll(); const scrollRef = useRef(null); const groupLogos = useTabGroupLogos(); @@ -323,7 +334,12 @@ export function ProjectRail({ if (!projectMenu) return; const { path, projectKey } = projectMenu; if (action === "pin" || action === "unpin") onTogglePin(path); - else if (action === "reveal") void revealPath(path); + else if (action === "background") { + setBackgroundProject({ + project: projectKey, + name: resolveTabGroupLabel(projectKey, groupLabels, basename(path)), + }); + } else if (action === "reveal") void revealPath(path); else if (action === "archive") { onRemoveProject?.(path, { purgeData: false }); } else if (action === "delete") { @@ -540,6 +556,13 @@ export function ProjectRail({ onCancel={() => setRemoving(null)} /> ) : null} + {backgroundProject ? ( + setBackgroundProject(null)} + /> + ) : null}
(); @@ -84,6 +95,43 @@ describe("transcript prompt-to-top setting", () => { }); }); +describe("chat background setting", () => { + beforeEach(mockLocalStorage); + afterEach(() => { + localStorage.removeItem(CHAT_BACKGROUND_PATH_KEY); + localStorage.removeItem(CHAT_BACKGROUND_OPACITY_KEY); + localStorage.removeItem(CHAT_BACKGROUND_SCOPE_KEY); + }); + + it("stores and clears the app-owned background path", () => { + expect(loadChatBackgroundPath()).toBeNull(); + saveChatBackgroundPath("/app-data/backgrounds/chat-background.webp"); + expect(loadChatBackgroundPath()).toBe( + "/app-data/backgrounds/chat-background.webp", + ); + saveChatBackgroundPath(null); + expect(loadChatBackgroundPath()).toBeNull(); + }); + + it("defaults and clamps background visibility", () => { + expect(loadChatBackgroundOpacity()).toBe(CHAT_BACKGROUND_OPACITY_DEFAULT); + saveChatBackgroundOpacity(1); + expect(loadChatBackgroundOpacity()).toBe(0.65); + saveChatBackgroundOpacity(0); + expect(loadChatBackgroundOpacity()).toBe(0.05); + }); + + it("persists where the background is shown", () => { + expect(loadChatBackgroundScope()).toBe(CHAT_BACKGROUND_SCOPE_DEFAULT); + saveChatBackgroundScope("empty"); + expect(loadChatBackgroundScope()).toBe("empty"); + saveChatBackgroundScope("all"); + expect(loadChatBackgroundScope()).toBe("all"); + localStorage.setItem(CHAT_BACKGROUND_SCOPE_KEY, "transcript"); + expect(loadChatBackgroundScope()).toBe(CHAT_BACKGROUND_SCOPE_DEFAULT); + }); +}); + function mockSystemScheme(scheme: "dark" | "light") { Object.defineProperty(globalThis, "window", { value: { diff --git a/src/lib/appearance.ts b/src/lib/appearance.ts index 38ae84d6..363f8e7c 100644 --- a/src/lib/appearance.ts +++ b/src/lib/appearance.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; import { HAS_NATIVE_GLASS, IS_MAC } from "./platform"; import { applyUiScale, loadUiScale } from "./uiScale"; @@ -13,10 +13,15 @@ const SIDEBAR_TAB_ORDER_KEY = "monocode.sidebarTabOrder"; const PROJECT_RAIL_WIDTH_KEY = "monocode.projectRailWidth"; const TRANSCRIPT_LAYOUT_KEY = "monocode.transcriptLayout"; const TRANSCRIPT_ANCHOR_KEY = "monocode.transcriptAnchor"; +const CHAT_BACKGROUND_PATH_KEY = "monocode.chatBackgroundPath"; +const CHAT_BACKGROUND_OPACITY_KEY = "monocode.chatBackgroundOpacity"; +const CHAT_BACKGROUND_SCOPE_KEY = "monocode.chatBackgroundScope"; +let chatBackgroundRevision = Date.now(); export type ColorScheme = "dark" | "light"; export type ThemePreference = ColorScheme | "system"; export type TranscriptLayout = "full" | "chat"; +export type ChatBackgroundScope = "empty" | "all"; export const THEME_PREFERENCE_DEFAULT: ThemePreference = "dark"; @@ -64,6 +69,11 @@ export const PROJECT_RAIL_WIDTH_DEFAULT = 200; export const BODY_GLASS_DEFAULT = true; +export const CHAT_BACKGROUND_OPACITY_MIN = 0.05; +export const CHAT_BACKGROUND_OPACITY_MAX = 0.65; +export const CHAT_BACKGROUND_OPACITY_DEFAULT = 0.24; +export const CHAT_BACKGROUND_SCOPE_DEFAULT: ChatBackgroundScope = "all"; + function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } @@ -163,6 +173,9 @@ export function initAppearance() { applySidebarOpacity(loadSidebarOpacity()); applySidebarBlur(loadSidebarBlur()); applyBodyGlass(loadBodyGlass()); + applyChatBackground(loadChatBackgroundPath()); + applyChatBackgroundOpacity(loadChatBackgroundOpacity()); + applyChatBackgroundScope(loadChatBackgroundScope()); void applyUiScale(loadUiScale()); } @@ -282,6 +295,100 @@ export function applyBodyGlass(value: boolean) { return value; } +export function loadChatBackgroundPath(): string | null { + try { + return localStorage.getItem(CHAT_BACKGROUND_PATH_KEY)?.trim() || null; + } catch { + return null; + } +} + +export function saveChatBackgroundPath(value: string | null) { + try { + if (value) localStorage.setItem(CHAT_BACKGROUND_PATH_KEY, value); + else localStorage.removeItem(CHAT_BACKGROUND_PATH_KEY); + } catch { + // private mode / quota + } +} + +export function applyChatBackground(path: string | null) { + const root = document.documentElement; + root.classList.toggle("has-chat-background", !!path); + if (!path) { + root.style.removeProperty("--chat-background-image"); + return null; + } + chatBackgroundRevision += 1; + const src = chatBackgroundSrc(path); + root.style.setProperty( + "--chat-background-image", + `url(${JSON.stringify(src)})`, + ); + return path; +} + +export function chatBackgroundSrc(path: string | null): string | null { + return path ? `${convertFileSrc(path)}?v=${chatBackgroundRevision}` : null; +} + +export function loadChatBackgroundOpacity(): number { + return clamp( + readNumber(CHAT_BACKGROUND_OPACITY_KEY) ?? CHAT_BACKGROUND_OPACITY_DEFAULT, + CHAT_BACKGROUND_OPACITY_MIN, + CHAT_BACKGROUND_OPACITY_MAX, + ); +} + +export function saveChatBackgroundOpacity(value: number) { + writeNumber( + CHAT_BACKGROUND_OPACITY_KEY, + clamp(value, CHAT_BACKGROUND_OPACITY_MIN, CHAT_BACKGROUND_OPACITY_MAX), + ); +} + +export function applyChatBackgroundOpacity(value: number) { + const next = clamp( + value, + CHAT_BACKGROUND_OPACITY_MIN, + CHAT_BACKGROUND_OPACITY_MAX, + ); + document.documentElement.style.setProperty( + "--chat-background-opacity", + String(next), + ); + return next; +} + +function isChatBackgroundScope(value: unknown): value is ChatBackgroundScope { + return value === "empty" || value === "all"; +} + +export function loadChatBackgroundScope(): ChatBackgroundScope { + try { + const raw = localStorage.getItem(CHAT_BACKGROUND_SCOPE_KEY); + return isChatBackgroundScope(raw) ? raw : CHAT_BACKGROUND_SCOPE_DEFAULT; + } catch { + return CHAT_BACKGROUND_SCOPE_DEFAULT; + } +} + +export function saveChatBackgroundScope(value: ChatBackgroundScope) { + try { + localStorage.setItem(CHAT_BACKGROUND_SCOPE_KEY, value); + } catch { + // private mode / quota + } +} + +export function applyChatBackgroundScope(value: ChatBackgroundScope) { + document.documentElement.classList.toggle( + "chat-background-empty-only", + value === "empty", + ); + return value; +} + function isSidebarTabId(value: unknown): value is SidebarTabId { return ( value === "files" || diff --git a/src/lib/chatBackground.test.ts b/src/lib/chatBackground.test.ts new file mode 100644 index 00000000..6819cf37 --- /dev/null +++ b/src/lib/chatBackground.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearProjectChatBackground, + pickAndSaveChatBackground, + pickAndSaveProjectChatBackground, + projectChatBackgroundSrc, + removeChatBackground, +} from "./chatBackground"; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + open: vi.fn(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + convertFileSrc: (path: string) => `asset://${path}`, + invoke: mocks.invoke, +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open })); + +describe("chat background image", () => { + beforeEach(() => { + mocks.invoke.mockReset(); + mocks.open.mockReset(); + }); + + it("copies a picked image into app storage", async () => { + mocks.open.mockResolvedValue("/Pictures/aurora.webp"); + mocks.invoke.mockResolvedValue( + "/app-data/backgrounds/chat-background.webp", + ); + + await expect(pickAndSaveChatBackground()).resolves.toBe( + "/app-data/backgrounds/chat-background.webp", + ); + expect(mocks.invoke).toHaveBeenCalledWith("save_chat_background", { + sourcePath: "/Pictures/aurora.webp", + }); + }); + + it("leaves the current background alone when picking is cancelled", async () => { + mocks.open.mockResolvedValue(null); + await expect(pickAndSaveChatBackground()).resolves.toBeNull(); + expect(mocks.invoke).not.toHaveBeenCalled(); + }); + + it("removes the saved background", async () => { + mocks.invoke.mockResolvedValue(undefined); + await removeChatBackground(); + expect(mocks.invoke).toHaveBeenCalledWith("remove_chat_background"); + }); + + it("copies a picked image into project-specific app storage", async () => { + mocks.open.mockResolvedValue("/Pictures/grid.png"); + mocks.invoke.mockResolvedValue("/app-data/backgrounds/project-abc.png"); + + await expect( + pickAndSaveProjectChatBackground("/work/agent-terminal"), + ).resolves.toBe("/app-data/backgrounds/project-abc.png"); + expect(mocks.invoke).toHaveBeenCalledWith("save_project_chat_background", { + project: "/work/agent-terminal", + sourcePath: "/Pictures/grid.png", + }); + }); + + it("removes only the selected project's saved background", async () => { + mocks.invoke.mockResolvedValue(undefined); + + await clearProjectChatBackground("/work/agent-terminal"); + + expect(mocks.invoke).toHaveBeenCalledWith( + "remove_project_chat_background", + { project: "/work/agent-terminal" }, + ); + }); + + it("cache-busts project background URLs after replacement", () => { + expect(projectChatBackgroundSrc("/app-data/background.png", 42)).toBe( + "asset:///app-data/background.png?v=42", + ); + }); +}); diff --git a/src/lib/chatBackground.ts b/src/lib/chatBackground.ts new file mode 100644 index 00000000..c68c44f7 --- /dev/null +++ b/src/lib/chatBackground.ts @@ -0,0 +1,54 @@ +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; +import { open } from "@tauri-apps/plugin-dialog"; + +export async function pickAndSaveChatBackground(): Promise { + const sourcePath = await open({ + multiple: false, + directory: false, + title: "Choose chat background", + filters: [ + { + name: "Images", + extensions: ["png", "jpg", "jpeg", "gif", "webp"], + }, + ], + }); + if (typeof sourcePath !== "string" || !sourcePath) return null; + return invoke("save_chat_background", { sourcePath }); +} + +export function removeChatBackground(): Promise { + return invoke("remove_chat_background"); +} + +export async function pickAndSaveProjectChatBackground( + project: string, +): Promise { + const sourcePath = await open({ + multiple: false, + directory: false, + title: "Choose project chat background", + filters: [ + { + name: "Images", + extensions: ["png", "jpg", "jpeg", "gif", "webp"], + }, + ], + }); + if (typeof sourcePath !== "string" || !sourcePath) return null; + return invoke("save_project_chat_background", { + project, + sourcePath, + }); +} + +export function clearProjectChatBackground(project: string): Promise { + return invoke("remove_project_chat_background", { project }); +} + +export function projectChatBackgroundSrc( + path: string, + revision: number, +): string { + return `${convertFileSrc(path)}?v=${revision}`; +} diff --git a/src/lib/projectChatBackground.test.ts b/src/lib/projectChatBackground.test.ts new file mode 100644 index 00000000..d7cab8e3 --- /dev/null +++ b/src/lib/projectChatBackground.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + clearProjectChatBackgroundSetting, + loadProjectChatBackground, + saveProjectChatBackground, +} from "./projectChatBackground"; + +const KEY = "monocode:project-chat-backgrounds"; + +function mockBrowserStorage() { + const data = new Map(); + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => data.set(key, value), + removeItem: (key: string) => data.delete(key), + clear: () => data.clear(), + key: (index: number) => [...data.keys()][index] ?? null, + get length() { + return data.size; + }, + }, + configurable: true, + }); + Object.defineProperty(globalThis, "window", { + value: { dispatchEvent: () => true }, + configurable: true, + }); +} + +describe("project chat background settings", () => { + beforeEach(mockBrowserStorage); + + it("stores independent overrides for each project", () => { + saveProjectChatBackground("/work/alpha", { + path: "/backgrounds/alpha.webp", + opacity: 0.22, + scope: "empty", + }); + saveProjectChatBackground("/work/beta", { + path: "/backgrounds/beta.png", + opacity: 0.48, + scope: "all", + }); + + expect(loadProjectChatBackground("/work/alpha")).toEqual({ + path: "/backgrounds/alpha.webp", + opacity: 0.22, + scope: "empty", + }); + expect(loadProjectChatBackground("/work/beta")).toEqual({ + path: "/backgrounds/beta.png", + opacity: 0.48, + scope: "all", + }); + }); + + it("clamps visibility to the supported range", () => { + saveProjectChatBackground("/work/alpha", { + path: "/backgrounds/alpha.webp", + opacity: 1, + scope: "all", + }); + saveProjectChatBackground("/work/beta", { + path: "/backgrounds/beta.webp", + opacity: 0, + scope: "all", + }); + + expect(loadProjectChatBackground("/work/alpha")?.opacity).toBe(0.65); + expect(loadProjectChatBackground("/work/beta")?.opacity).toBe(0.05); + }); + + it("falls back safely when stored project data is malformed", () => { + localStorage.setItem( + KEY, + JSON.stringify({ + "/work/alpha": { + path: "/backgrounds/alpha.webp", + opacity: "bright", + scope: "transcript", + }, + }), + ); + + expect(loadProjectChatBackground("/work/alpha")).toEqual({ + path: "/backgrounds/alpha.webp", + opacity: 0.24, + scope: "all", + }); + }); + + it("clears one project without changing the others", () => { + saveProjectChatBackground("/work/alpha", { + path: "/backgrounds/alpha.webp", + opacity: 0.2, + scope: "empty", + }); + saveProjectChatBackground("/work/beta", { + path: "/backgrounds/beta.webp", + opacity: 0.3, + scope: "all", + }); + + clearProjectChatBackgroundSetting("/work/alpha"); + + expect(loadProjectChatBackground("/work/alpha")).toBeNull(); + expect(loadProjectChatBackground("/work/beta")?.path).toBe( + "/backgrounds/beta.webp", + ); + }); +}); diff --git a/src/lib/projectChatBackground.ts b/src/lib/projectChatBackground.ts new file mode 100644 index 00000000..a8a5b300 --- /dev/null +++ b/src/lib/projectChatBackground.ts @@ -0,0 +1,113 @@ +import { + CHAT_BACKGROUND_OPACITY_MAX, + CHAT_BACKGROUND_OPACITY_MIN, + CHAT_BACKGROUND_SCOPE_DEFAULT, + loadChatBackgroundOpacity, + loadChatBackgroundScope, + type ChatBackgroundScope, +} from "./appearance"; + +const KEY = "monocode:project-chat-backgrounds"; + +export const PROJECT_CHAT_BACKGROUND_CHANGED = + "monocode:project-chat-background-changed"; + +export type ProjectChatBackground = { + path: string; + opacity: number; + scope: ChatBackgroundScope; +}; + +type StoredProjectChatBackground = Partial; + +let revision = Date.now(); + +function clampOpacity(value: number): number { + return Math.min( + CHAT_BACKGROUND_OPACITY_MAX, + Math.max(CHAT_BACKGROUND_OPACITY_MIN, value), + ); +} + +function read(): Record { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === "object" + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function write(value: Record) { + try { + localStorage.setItem(KEY, JSON.stringify(value)); + } catch { + // private mode / quota + } +} + +function validScope(value: unknown): value is ChatBackgroundScope { + return value === "empty" || value === "all"; +} + +export function loadProjectChatBackground( + project: string, +): ProjectChatBackground | null { + const stored = read()[project]; + const path = typeof stored?.path === "string" ? stored.path.trim() : ""; + if (!path) return null; + const opacity = + typeof stored.opacity === "number" && Number.isFinite(stored.opacity) + ? clampOpacity(stored.opacity) + : loadChatBackgroundOpacity(); + return { + path, + opacity, + scope: validScope(stored.scope) ? stored.scope : loadChatBackgroundScope(), + }; +} + +export function saveProjectChatBackground( + project: string, + value: ProjectChatBackground, +) { + const path = value.path.trim(); + if (!project || !path) return; + const next = read(); + next[project] = { + path, + opacity: clampOpacity(value.opacity), + scope: validScope(value.scope) + ? value.scope + : CHAT_BACKGROUND_SCOPE_DEFAULT, + }; + write(next); + notifyProjectChatBackgroundChanged(); +} + +export function clearProjectChatBackgroundSetting(project: string) { + const next = read(); + if (!(project in next)) return; + delete next[project]; + write(next); + notifyProjectChatBackgroundChanged(); +} + +export function notifyProjectChatBackgroundChanged() { + revision += 1; + window.dispatchEvent(new CustomEvent(PROJECT_CHAT_BACKGROUND_CHANGED)); +} + +export function projectChatBackgroundRevision(): number { + return revision; +} + +export function subscribeProjectChatBackground(listener: () => void) { + window.addEventListener(PROJECT_CHAT_BACKGROUND_CHANGED, listener); + return () => + window.removeEventListener(PROJECT_CHAT_BACKGROUND_CHANGED, listener); +} diff --git a/src/lib/projectData.ts b/src/lib/projectData.ts index cccfef0f..9b42f668 100644 --- a/src/lib/projectData.ts +++ b/src/lib/projectData.ts @@ -1,5 +1,7 @@ import { projectKey } from "./paths"; import { clearProjectLogo } from "./projectLogos"; +import { clearProjectChatBackground } from "./chatBackground"; +import { clearProjectChatBackgroundSetting } from "./projectChatBackground"; import { normalizeProjectPath } from "./recents"; import { deleteSession, listSessionsByProject } from "./sessionStore"; import { clearTabGroupSettings } from "./tabGroups"; @@ -20,5 +22,7 @@ export async function removeProjectData(path: string): Promise { } // Drops the copied image from app data; the localStorage entry goes with it. await clearProjectLogo(key).catch(() => undefined); + await clearProjectChatBackground(key).catch(() => undefined); + clearProjectChatBackgroundSetting(key); clearTabGroupSettings(key); } diff --git a/src/surfaces/SessionPane.tsx b/src/surfaces/SessionPane.tsx index 3bff1dbe..6c5cac23 100644 --- a/src/surfaces/SessionPane.tsx +++ b/src/surfaces/SessionPane.tsx @@ -6,6 +6,7 @@ import { useRef, useState, useSyncExternalStore, + type CSSProperties, type PointerEvent as ReactPointerEvent, } from "react"; import { Composer } from "../chrome/Composer"; @@ -42,6 +43,13 @@ import { loadNotesEnabled, subscribeNotesEnabled } from "../lib/settings"; import { resolveModel } from "../lib/models"; import { isAstraModel } from "../lib/astraWelcome"; import { AstraWelcome } from "./AstraWelcome"; +import { projectKey } from "../lib/paths"; +import { + loadProjectChatBackground, + projectChatBackgroundRevision, + subscribeProjectChatBackground, +} from "../lib/projectChatBackground"; +import { projectChatBackgroundSrc } from "../lib/chatBackground"; type Props = { session: Session; @@ -160,6 +168,20 @@ export const SessionPane = memo(function SessionPane({ onPaneDragStart, }: Props) { const title = sessionDisplayTitle(session.title, session.harness); + const backgroundRevision = useSyncExternalStore( + subscribeProjectChatBackground, + projectChatBackgroundRevision, + projectChatBackgroundRevision, + ); + const projectBackground = loadProjectChatBackground(projectKey(session.cwd)); + const projectBackgroundStyle = projectBackground + ? ({ + "--chat-background-image": `url(${JSON.stringify( + projectChatBackgroundSrc(projectBackground.path, backgroundRevision), + )})`, + "--chat-background-opacity": String(projectBackground.opacity), + } as CSSProperties) + : undefined; const approve = useCallback( (requestId: number, decision: ApprovalDecision) => onApproval(session.id, requestId, decision), @@ -334,7 +356,11 @@ export const SessionPane = memo(function SessionPane({ return (
onFocus(session.id)} > {astraWelcomeRun !== null && visible ? ( diff --git a/src/surfaces/SettingsView.tsx b/src/surfaces/SettingsView.tsx index a2b46524..6bdf0f29 100644 --- a/src/surfaces/SettingsView.tsx +++ b/src/surfaces/SettingsView.tsx @@ -1,6 +1,7 @@ import { ArrowDownCircle, Check, + ImagePlus, Loader, RefreshCw, RotateCcw, @@ -21,14 +22,25 @@ import { RemoveProjectDialog } from "../chrome/RemoveProjectDialog"; import { WindowControls } from "../chrome/WindowControls"; import { useLockOverscroll } from "../hooks/useLockOverscroll"; import { + applyChatBackground, + applyChatBackgroundOpacity, + applyChatBackgroundScope, applyBodyGlass, applyThemePreference, applySidebarBlur, applySidebarOpacity, applyThemeTint, BODY_GLASS_DEFAULT, + CHAT_BACKGROUND_OPACITY_DEFAULT, + CHAT_BACKGROUND_OPACITY_MAX, + CHAT_BACKGROUND_OPACITY_MIN, + CHAT_BACKGROUND_SCOPE_DEFAULT, THEME_PREFERENCE_DEFAULT, + chatBackgroundSrc, loadBodyGlass, + loadChatBackgroundOpacity, + loadChatBackgroundPath, + loadChatBackgroundScope, loadThemePreference, loadSidebarBlur, loadSidebarOpacity, @@ -37,6 +49,9 @@ import { loadTranscriptLayout, loadTranscriptAnchor, saveBodyGlass, + saveChatBackgroundOpacity, + saveChatBackgroundPath, + saveChatBackgroundScope, saveThemePreference, saveSidebarBlur, saveSidebarOpacity, @@ -58,8 +73,13 @@ import { THEME_SATURATION_MAX, THEME_SATURATION_MIN, type ThemePreference, + type ChatBackgroundScope, type TranscriptLayout, } from "../lib/appearance"; +import { + pickAndSaveChatBackground, + removeChatBackground, +} from "../lib/chatBackground"; import { applyUiScale, loadUiScale, @@ -769,6 +789,18 @@ function useAppearanceSettings() { const [themeHue, setThemeHue] = useState(loadThemeHue); const [themeSaturation, setThemeSaturation] = useState(loadThemeSaturation); const [bodyGlass, setBodyGlass] = useState(loadBodyGlass); + const [chatBackgroundPath, setChatBackgroundPath] = useState( + loadChatBackgroundPath, + ); + const [chatBackgroundOpacity, setChatBackgroundOpacity] = useState( + loadChatBackgroundOpacity, + ); + const [chatBackgroundScope, setChatBackgroundScope] = + useState(loadChatBackgroundScope); + const [chatBackgroundBusy, setChatBackgroundBusy] = useState(false); + const [chatBackgroundError, setChatBackgroundError] = useState( + null, + ); const [uiScale, setUiScale] = useState(loadUiScale); useEffect(() => subscribeUiScale(() => setUiScale(loadUiScale())), []); @@ -805,6 +837,53 @@ function useAppearanceSettings() { setBodyGlass(next); }, []); + const onChooseChatBackground = useCallback(async () => { + setChatBackgroundBusy(true); + setChatBackgroundError(null); + try { + const path = await pickAndSaveChatBackground(); + if (!path) return; + saveChatBackgroundPath(path); + applyChatBackground(path); + setChatBackgroundPath(path); + } catch (error) { + setChatBackgroundError( + error instanceof Error ? error.message : String(error), + ); + } finally { + setChatBackgroundBusy(false); + } + }, []); + + const onClearChatBackground = useCallback(async () => { + setChatBackgroundBusy(true); + setChatBackgroundError(null); + try { + await removeChatBackground(); + saveChatBackgroundPath(null); + applyChatBackground(null); + setChatBackgroundPath(null); + } catch (error) { + setChatBackgroundError( + error instanceof Error ? error.message : String(error), + ); + } finally { + setChatBackgroundBusy(false); + } + }, []); + + const onChatBackgroundOpacity = useCallback((percent: number) => { + const next = applyChatBackgroundOpacity(percent / 100); + saveChatBackgroundOpacity(next); + setChatBackgroundOpacity(next); + }, []); + + const onChatBackgroundScope = useCallback((next: ChatBackgroundScope) => { + applyChatBackgroundScope(next); + saveChatBackgroundScope(next); + setChatBackgroundScope(next); + }, []); + const onUiScale = useCallback((percent: number) => { const next = saveUiScale(percent / 100); setUiScale(next); @@ -817,8 +896,22 @@ function useAppearanceSettings() { onBlur(SIDEBAR_BLUR_DEFAULT); onTint(THEME_HUE_DEFAULT, THEME_SATURATION_DEFAULT); onBodyGlass(BODY_GLASS_DEFAULT); + onChatBackgroundOpacity(Math.round(CHAT_BACKGROUND_OPACITY_DEFAULT * 100)); + onChatBackgroundScope(CHAT_BACKGROUND_SCOPE_DEFAULT); + if (chatBackgroundPath) void onClearChatBackground(); onUiScale(Math.round(UI_SCALE_DEFAULT * 100)); - }, [onBlur, onBodyGlass, onThemePreference, onOpacity, onTint, onUiScale]); + }, [ + chatBackgroundPath, + onBlur, + onBodyGlass, + onChatBackgroundOpacity, + onChatBackgroundScope, + onClearChatBackground, + onThemePreference, + onOpacity, + onTint, + onUiScale, + ]); return { themePreference, @@ -827,12 +920,21 @@ function useAppearanceSettings() { themeHue, themeSaturation, bodyGlass, + chatBackgroundPath, + chatBackgroundOpacity, + chatBackgroundScope, + chatBackgroundBusy, + chatBackgroundError, uiScale, onThemePreference, onOpacity, onBlur, onTint, onBodyGlass, + onChooseChatBackground, + onClearChatBackground, + onChatBackgroundOpacity, + onChatBackgroundScope, onUiScale, restoreDefaults, }; @@ -919,6 +1021,7 @@ function AppearancePage({ appearance }: { appearance: AppearanceSettings }) { onChange={appearance.onBodyGlass} /> + +
+
+
+ Chat background +
+

+ An image behind your chat panes. It stays on this device. +

+
+ {hasImage ? ( +
+ void appearance.onChooseChatBackground()} + disabled={busy} + > + {busy ? ( + + ) : null} + Change + + void appearance.onClearChatBackground()} + disabled={busy} + danger + > + Remove + +
+ ) : null} +
+ +
+ {hasImage ? ( +
+ + + Preview at {visibility}% + +
+ ) : ( + + )} + {hasImage ? ( +
+
+
+
Show on
+

+ Empty sessions only, or every conversation. +

+
+ +
+
+
+
Visibility
+

+ Keep it subtle so long conversations stay readable. +

+
+ +
+
+ ) : null} +
+ {appearance.chatBackgroundError ? ( +

+ {appearance.chatBackgroundError} +

+ ) : null} +
+ ); +} + function KeybindingsPage() { const [query, setQuery] = useState(""); const rows = useMemo(() => filterKeybindings(KEYBINDINGS, query), [query]); @@ -1389,7 +1611,7 @@ function Segmented({
{options.map((option) => ( @@ -1399,7 +1621,7 @@ function Segmented({ role="radio" aria-checked={value === option.value} onClick={() => onChange(option.value)} - className={`min-w-0 rounded-[5px] px-1.5 py-1 ${ + className={`min-w-0 whitespace-nowrap rounded-[5px] px-2.5 py-1 ${ value === option.value ? "bg-content/10 text-content" : "text-content/50 hover:text-content" From ac46198289ae2f46bd7dd40bdc1bffc4a60f5ddc Mon Sep 17 00:00:00 2001 From: Nick <60738984+hardbeat920@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:48:09 +0100 Subject: [PATCH 02/27] Fix session selection cleanup when closing menu - Clear multi-select when pointer lands outside session cards - Reset selection state when closing context menu - Track context-menu selections to distinguish them from other interactions --- src/chrome/Sidebar.tsx | 40 +++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/src/chrome/Sidebar.tsx b/src/chrome/Sidebar.tsx index a0eb7751..d4c7af56 100644 --- a/src/chrome/Sidebar.tsx +++ b/src/chrome/Sidebar.tsx @@ -331,6 +331,7 @@ function SidebarComponent({ const [selectedSessionIds, setSelectedSessionIds] = useState>( () => new Set(), ); + const contextSelectionRef = useRef(false); const [folderMenu, setFolderMenu] = useState<{ x: number; y: number; @@ -561,7 +562,7 @@ function SidebarComponent({ useEffect(() => { if (!sessionMenu && !folderMenu && !filterMenu) return; const onScroll = () => { - setSessionMenu(null); + closeSessionMenu(); setFolderMenu(null); setFilterMenu(null); }; @@ -572,13 +573,29 @@ function SidebarComponent({ useEffect(() => { if (selectedSessionIds.size === 0) return; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; + const clear = () => { + contextSelectionRef.current = false; setSelectedSessionIds(new Set()); setSessionMenu(null); }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + clear(); + }; + // A pointer landing off the cards drops the selection; a menu acting on + // it stays open, and the cards handle their own clicks. + const onPointerDown = (event: PointerEvent) => { + const target = event.target; + const el = target instanceof Element ? target : null; + if (el?.closest("[data-session-card],[data-popover-side]")) return; + clear(); + }; window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); + window.addEventListener("pointerdown", onPointerDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("pointerdown", onPointerDown); + }; }, [selectedSessionIds.size]); const commitSessionFolders = (next: SessionFolder[]) => { @@ -717,7 +734,8 @@ function SidebarComponent({ ) => { e.preventDefault(); e.stopPropagation(); - if (!selectedSessionIds.has(sessionId)) { + contextSelectionRef.current = !selectedSessionIds.has(sessionId); + if (contextSelectionRef.current) { setSelectedSessionIds(new Set([sessionId])); } setFilterMenu(null); @@ -725,6 +743,13 @@ function SidebarComponent({ setSessionMenu({ x: e.clientX, y: e.clientY, sessionId }); }; + const closeSessionMenu = () => { + setSessionMenu(null); + if (!contextSelectionRef.current) return; + contextSelectionRef.current = false; + setSelectedSessionIds(new Set()); + }; + const onFolderContextMenu = ( folderId: string, e: ReactMouseEvent, @@ -742,7 +767,7 @@ function SidebarComponent({ const sessionIds = menuSessionIds; const archived = allMenuSessionsArchived; const pinned = allMenuSessionsPinned; - setSessionMenu(null); + closeSessionMenu(); if (id === "pin") { if (sessionIds.length > 1 && onPinSessions) { onPinSessions(sessionIds, !pinned); @@ -849,6 +874,7 @@ function SidebarComponent({ event: ReactMouseEvent, ) => { if (event.shiftKey) { + contextSelectionRef.current = false; setSessionMenu(null); setSelectedSessionIds((current) => toggleSessionSelection(current, sessionId), @@ -1409,7 +1435,7 @@ function SidebarComponent({ : "Session actions" } onPick={onSessionMenuPick} - onClose={() => setSessionMenu(null)} + onClose={closeSessionMenu} /> ) : null} {folderMenu ? ( From 3a65e69c7d1acb11a6bc636afd2a1f00e884a6d2 Mon Sep 17 00:00:00 2001 From: Nick <60738984+hardbeat920@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:05:52 +0100 Subject: [PATCH 03/27] Hide empty-session arcade behind chat backgrounds - Sync global chat background path changes across session panes - Add regression tests for arcade visibility --- src/lib/appearance.ts | 15 +++++++++++++++ src/surfaces/EmptySession.test.ts | 25 +++++++++++++++++++++++++ src/surfaces/EmptySession.tsx | 5 +++-- src/surfaces/SessionPane.tsx | 12 ++++++++++++ 4 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 src/surfaces/EmptySession.test.ts diff --git a/src/lib/appearance.ts b/src/lib/appearance.ts index 363f8e7c..fbf5f991 100644 --- a/src/lib/appearance.ts +++ b/src/lib/appearance.ts @@ -18,6 +18,9 @@ const CHAT_BACKGROUND_OPACITY_KEY = "monocode.chatBackgroundOpacity"; const CHAT_BACKGROUND_SCOPE_KEY = "monocode.chatBackgroundScope"; let chatBackgroundRevision = Date.now(); +export const CHAT_BACKGROUND_PATH_CHANGE_EVENT = + "monocode:chat-background-path-change"; + export type ColorScheme = "dark" | "light"; export type ThemePreference = ColorScheme | "system"; export type TranscriptLayout = "full" | "chat"; @@ -310,6 +313,18 @@ export function saveChatBackgroundPath(value: string | null) { } catch { // private mode / quota } + if (typeof window === "undefined") return; + window.dispatchEvent(new Event(CHAT_BACKGROUND_PATH_CHANGE_EVENT)); +} + +export function subscribeChatBackgroundPath(onStoreChange: () => void) { + if (typeof window === "undefined") return () => {}; + window.addEventListener(CHAT_BACKGROUND_PATH_CHANGE_EVENT, onStoreChange); + return () => + window.removeEventListener( + CHAT_BACKGROUND_PATH_CHANGE_EVENT, + onStoreChange, + ); } export function applyChatBackground(path: string | null) { diff --git a/src/surfaces/EmptySession.test.ts b/src/surfaces/EmptySession.test.ts new file mode 100644 index 00000000..b738b76e --- /dev/null +++ b/src/surfaces/EmptySession.test.ts @@ -0,0 +1,25 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { EmptySession } from "./EmptySession"; + +describe("empty session background", () => { + it("renders the arcade when no chat background is selected", () => { + const markup = renderToStaticMarkup( + createElement(EmptySession, { cwd: "/work/demo" }), + ); + + expect(markup).toContain(" { + const markup = renderToStaticMarkup( + createElement(EmptySession, { + cwd: "/work/demo", + hasChatBackground: true, + }), + ); + + expect(markup).not.toContain("(); const arcadeEnabled = useSyncExternalStore( subscribeGridArcadeEnabled, @@ -30,7 +31,7 @@ export function EmptySession({ cwd, composer }: Props) { ref={lockOverscroll} className="relative flex h-full min-h-0 overflow-y-auto overscroll-none" > - {arcadeEnabled ? : null} + {arcadeEnabled && !hasChatBackground ? : null} {composer ? (
diff --git a/src/surfaces/SessionPane.tsx b/src/surfaces/SessionPane.tsx index 6c5cac23..2b45d6bc 100644 --- a/src/surfaces/SessionPane.tsx +++ b/src/surfaces/SessionPane.tsx @@ -50,6 +50,10 @@ import { subscribeProjectChatBackground, } from "../lib/projectChatBackground"; import { projectChatBackgroundSrc } from "../lib/chatBackground"; +import { + loadChatBackgroundPath, + subscribeChatBackgroundPath, +} from "../lib/appearance"; type Props = { session: Session; @@ -173,6 +177,11 @@ export const SessionPane = memo(function SessionPane({ projectChatBackgroundRevision, projectChatBackgroundRevision, ); + const globalBackgroundPath = useSyncExternalStore( + subscribeChatBackgroundPath, + loadChatBackgroundPath, + loadChatBackgroundPath, + ); const projectBackground = loadProjectChatBackground(projectKey(session.cwd)); const projectBackgroundStyle = projectBackground ? ({ @@ -422,6 +431,9 @@ export const SessionPane = memo(function SessionPane({ ) : ( ) From 7f006e58bbf5271aa0310539543e7d848f765027 Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 8 Sep 2026 09:24:59 +0300 Subject: [PATCH 04/27] Play the Sounds switch cue when it turns on (#111) --- src/surfaces/SettingsView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/surfaces/SettingsView.tsx b/src/surfaces/SettingsView.tsx index 6bdf0f29..96131d06 100644 --- a/src/surfaces/SettingsView.tsx +++ b/src/surfaces/SettingsView.tsx @@ -1709,8 +1709,8 @@ function Toggle({ aria-label={label} aria-checked={on} onClick={() => { - playCue("switch"); onChange(!on); + playCue("switch"); }} className={`relative h-5 w-9 shrink-0 rounded-full transition-colors ${ on ? "bg-accent" : "bg-content/20" From 3f375b3a37f03d33c7cf034798fc7a102ce2ff9d Mon Sep 17 00:00:00 2001 From: kualta Date: Tue, 8 Sep 2026 01:35:22 -0500 Subject: [PATCH 05/27] Add a shortcut to archive the focused conversation (#89) * Add a shortcut to archive the focused conversation * Check archive eligibility before consuming the shortcut * Simplify archive routing and remove added test dependencies * Restore focused archive shortcut tests without new dependencies --- src/App.tsx | 33 +++++++ src/lib/archiveShortcut.test.ts | 149 ++++++++++++++++++++++++++++++++ src/lib/archiveShortcut.ts | 52 +++++++++++ src/lib/settings.test.ts | 4 +- src/lib/settings.ts | 5 ++ src/lib/tabKeys.test.ts | 21 +++++ src/lib/tabKeys.ts | 3 + 7 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 src/lib/archiveShortcut.test.ts create mode 100644 src/lib/archiveShortcut.ts diff --git a/src/App.tsx b/src/App.tsx index 9547b975..b8b1b464 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -275,6 +275,7 @@ import { setWindowFocused, } from "./lib/notifications"; import { playCue } from "./lib/sounds"; +import { archiveFocusedSession } from "./lib/archiveShortcut"; import { adjacentItemId, deferUnhandledEscape, @@ -2868,6 +2869,32 @@ export default function App({ [onRemoveHistorySession], ); + const onArchiveFocusedSession = useCallback( + (event: KeyboardEvent) => { + archiveFocusedSession( + event, + { + activeTabId: activeTabIdRef.current, + tabs: tabsRef.current, + sessions: sessionsRef.current, + projectTerminalFocused: projectTerminalFocusedRef.current, + surfaceOpen: Boolean( + searchViewOpenRef.current || + inboxViewOpenRef.current || + notesViewOpenRef.current || + settingsOpenRef.current || + filePickerOpenRef.current || + whatsNewVersionRef.current, + ), + }, + (sessionId) => { + void onArchiveHistorySession(sessionId, true); + }, + ); + }, + [onArchiveHistorySession], + ); + const onPinHistorySession = useCallback( async (sessionId: string, pinned: boolean) => { const open = sessionsRef.current.find( @@ -4703,6 +4730,7 @@ export default function App({ const actions = useRef({ onNew, + onArchiveFocusedSession, onCloseOtherTabs, onClosePane, onNext, @@ -4729,6 +4757,7 @@ export default function App({ }); actions.current = { onNew, + onArchiveFocusedSession, onCloseOtherTabs, onClosePane, onNext, @@ -4787,6 +4816,10 @@ export default function App({ } const cmd = tabCommand(e); if (cmd) { + if (cmd === "archive-session") { + actions.current.onArchiveFocusedSession(e); + return; + } const target = e.target instanceof Element ? e.target : null; const listNavigation = cmd === "prev-session" || diff --git a/src/lib/archiveShortcut.test.ts b/src/lib/archiveShortcut.test.ts new file mode 100644 index 00000000..128b81a7 --- /dev/null +++ b/src/lib/archiveShortcut.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { archiveFocusedSession } from "./archiveShortcut"; + +// Only the DOM operations used by the handler are needed in the Node suite. +class ElementStub { + constructor(readonly ancestors: string[] = []) {} + closest(selector: string) { + return selector.split(", ").some((part) => this.ancestors.includes(part)) + ? this + : null; + } + getClientRects = vi.fn(() => [{}]); +} + +afterEach(() => vi.unstubAllGlobals()); + +function fixture() { + const overlays: { selector: string; element: ElementStub }[] = []; + vi.stubGlobal("Element", ElementStub); + vi.stubGlobal("document", { + querySelectorAll: (selector: string) => + overlays + .filter((overlay) => selector.split(", ").includes(overlay.selector)) + .map((overlay) => overlay.element), + }); + const style = vi.fn(() => ({ visibility: "visible" })); + vi.stubGlobal("getComputedStyle", style); + const context = { + activeTabId: "tab", + tabs: [{ id: "tab", focusedId: "session", diffFocused: false }], + sessions: [{ id: "session" }, { id: "other" }], + projectTerminalFocused: false, + surfaceOpen: false, + }; + const event = { + defaultPrevented: false, + target: new ElementStub(["textarea", "[data-composer]"]), + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + const archive = vi.fn(); + return { + context, + event, + archive, + overlays, + style, + run: () => + archiveFocusedSession( + event as unknown as KeyboardEvent, + context, + archive, + ), + }; +} + +function expectUntouched(f: ReturnType) { + f.run(); + expect(f.event.preventDefault).not.toHaveBeenCalled(); + expect(f.event.stopPropagation).not.toHaveBeenCalled(); + expect(f.archive).not.toHaveBeenCalled(); +} + +describe("archive shortcut routing", () => { + it("consumes the event before archiving exactly the focused session", () => { + const f = fixture(); + f.context.tabs[0].focusedId = "other"; + f.archive.mockImplementation(() => { + expect(f.event.preventDefault).toHaveBeenCalledOnce(); + expect(f.event.stopPropagation).toHaveBeenCalledOnce(); + }); + f.run(); + expect(f.archive).toHaveBeenCalledExactlyOnceWith("other"); + }); + + it.each(["editor", "terminal"])( + "preserves the key in a focused %s pane", + (pane) => { + const f = fixture(); + f.context.tabs[0].focusedId = pane; + expectUntouched(f); + }, + ); + + it.each(["diff", "dock", "surface", "missing tab", "already handled"])( + "preserves the key when blocked by %s", + (reason) => { + const f = fixture(); + if (reason === "diff") f.context.tabs[0].diffFocused = true; + if (reason === "dock") f.context.projectTerminalFocused = true; + if (reason === "surface") f.context.surfaceOpen = true; + if (reason === "missing tab") f.context.activeTabId = "missing"; + if (reason === "already handled") f.event.defaultPrevented = true; + expectUntouched(f); + }, + ); + + it.each([".cm-editor", ".monocode-terminal", "input"])( + "respects %s DOM focus even before workspace focus updates", + (ancestor) => { + const f = fixture(); + f.event.target = new ElementStub([ancestor]); + expectUntouched(f); + }, + ); + + it.each([ + '[role="menu"]', + '[role="dialog"]', + '[role="alertdialog"]', + "[data-popover-side]", + "[data-skill-picker]", + "[data-mention-picker]", + ])( + "blocks an open %s even when focus remains in the composer", + (selector) => { + const f = fixture(); + f.overlays.push({ selector, element: new ElementStub() }); + expectUntouched(f); + f.overlays.pop(); + f.run(); + expect(f.archive).toHaveBeenCalledExactlyOnceWith("session"); + }, + ); + + it("leaves a TabGroupMenu rename input event untouched", () => { + const f = fixture(); + f.event.target = new ElementStub(['[role="menu"]', "input"]); + f.overlays.push({ selector: '[role="menu"]', element: new ElementStub() }); + expectUntouched(f); + }); + + it.each([ + "no layout", + "hidden visibility", + "[hidden]", + "[inert]", + '[aria-hidden="true"]', + ])("ignores an inactive overlay (%s)", (hidden) => { + const f = fixture(); + const element = new ElementStub([hidden]); + if (hidden === "no layout") element.getClientRects.mockReturnValue([]); + if (hidden === "hidden visibility") + f.style.mockReturnValue({ visibility: "hidden" }); + f.overlays.push({ selector: "[data-skill-picker]", element }); + f.run(); + expect(f.archive).toHaveBeenCalledExactlyOnceWith("session"); + }); +}); diff --git a/src/lib/archiveShortcut.ts b/src/lib/archiveShortcut.ts new file mode 100644 index 00000000..6ce9b61b --- /dev/null +++ b/src/lib/archiveShortcut.ts @@ -0,0 +1,52 @@ +type ArchiveContext = { + activeTabId: string; + tabs: readonly { id: string; focusedId: string; diffFocused?: boolean }[]; + sessions: readonly { id: string }[]; + projectTerminalFocused: boolean; + surfaceOpen: boolean; +}; + +/** Archive only after confirming that the focused conversation owns the key. */ +export function archiveFocusedSession( + event: KeyboardEvent, + context: ArchiveContext, + archive: (sessionId: string) => void, +): void { + if ( + event.defaultPrevented || + context.projectTerminalFocused || + context.surfaceOpen + ) + return; + + const tab = context.tabs.find((entry) => entry.id === context.activeTabId); + if (!tab || tab.diffFocused) return; + const session = context.sessions.find((entry) => entry.id === tab.focusedId); + if (!session) return; + + const target = event.target instanceof Element ? event.target : null; + if (target?.closest(".cm-editor, .monocode-terminal")) return; + if ( + target?.closest('input, textarea, select, [contenteditable="true"]') && + !target.closest("[data-composer]") + ) + return; + + // Popovers can leave focus in the composer. Check the whole document, + // excluding overlays in hidden or inactive surfaces. + const overlayOpen = Array.from( + document.querySelectorAll( + '[data-popover-side], [role="dialog"], [role="alertdialog"], [role="menu"], [data-skill-picker], [data-mention-picker]', + ), + ).some( + (element) => + element.getClientRects().length > 0 && + getComputedStyle(element).visibility !== "hidden" && + !element.closest('[hidden], [inert], [aria-hidden="true"]'), + ); + if (overlayOpen) return; + + event.preventDefault(); + event.stopPropagation(); + archive(session.id); +} diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 1a472f1b..3005d87f 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -157,9 +157,7 @@ describe("grid arcade enabled setting", () => { describe("workspace navigation keybindings", () => { it("documents session and project cycling in the shortcut list", () => { const rows = KEYBINDINGS.filter( - (row) => - row.command.startsWith("Session:") || - row.command.startsWith("Project:"), + (row) => /^(Session|Project): (Previous|Next)$/.test(row.command), ); expect(rows.map((row) => row.command)).toEqual([ "Session: Previous", diff --git a/src/lib/settings.ts b/src/lib/settings.ts index d65f097c..14a74708 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -345,6 +345,11 @@ export const KEYBINDINGS: KeybindingRow[] = [ { command: "Tab: Forward", keys: `${MOD}]`, when: "Always" }, { command: "Tab: Activate 1–8", keys: `${MOD}1 … ${MOD}8`, when: "Always" }, { command: "Tab: Activate Last", keys: `${MOD}9`, when: "Always" }, + { + command: "Session: Archive", + keys: `${MOD}${SHIFT}A`, + when: "sessionFocus && !overlay", + }, { command: "Session: Previous", keys: `${MOD}${SHIFT}↑`, diff --git a/src/lib/tabKeys.test.ts b/src/lib/tabKeys.test.ts index 6000f949..55b575e9 100644 --- a/src/lib/tabKeys.test.ts +++ b/src/lib/tabKeys.test.ts @@ -39,6 +39,27 @@ function key( } describe("tabCommand", () => { + it("archives with Cmd+Shift+A or Ctrl+Shift+A", () => { + expect( + tabCommand(key({ key: "A", metaKey: true, shiftKey: true })), + ).toBe("archive-session"); + expect( + tabCommand(key({ key: "a", ctrlKey: true, shiftKey: true })), + ).toBe("archive-session"); + }); + + it.each([ + {}, + { metaKey: true }, + { ctrlKey: true }, + { shiftKey: true }, + { metaKey: true, shiftKey: true, altKey: true }, + { metaKey: true, shiftKey: true, isComposing: true }, + { metaKey: true, shiftKey: true, repeat: true }, + ])("leaves other A key events alone (%j)", (modifiers) => { + expect(tabCommand(key({ key: "a", ...modifiers }))).toBeNull(); + }); + it("opens a terminal pane with cmd-backtick", () => { expect(tabCommand(key({ key: "`", code: "Backquote", metaKey: true }))).toBe( "new-terminal", diff --git a/src/lib/tabKeys.ts b/src/lib/tabKeys.ts index 91be9aff..b8bd6b53 100644 --- a/src/lib/tabKeys.ts +++ b/src/lib/tabKeys.ts @@ -22,6 +22,7 @@ * Reset zoom cmd-0 * Previous session shift-cmd-up * Next session shift-cmd-down + * Archive session shift-cmd-a * Previous project shift-cmd-left * Next project shift-cmd-right * Stop focused turn escape @@ -44,6 +45,7 @@ export type TabCommand = | "toggle-terminal" | "prev-session" | "next-session" + | "archive-session" | "prev-project" | "next-project" | { activate: number } @@ -76,6 +78,7 @@ export function tabCommand(e: KeyboardEvent): TabCommand | null { const key = e.key.toLowerCase(); if (e.shiftKey) { + if (key === "a" && !e.repeat) return "archive-session"; if (e.key === "]" || e.key === "}") return "next"; if (e.key === "[" || e.key === "{") return "prev"; if (e.key === "ArrowUp") return "prev-session"; From acf79d19657fa2d8e934e9454cc80c6dafde56e3 Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 8 Sep 2026 09:47:55 +0300 Subject: [PATCH 06/27] Add prompt outline to session transcript (#90) --- src/chrome/PromptOutline.tsx | 352 +++++++++++++++++++++++++++++++ src/index.css | 22 ++ src/lib/promptOutline.test.ts | 68 ++++++ src/lib/promptOutline.ts | 76 +++++++ src/surfaces/AgentTranscript.tsx | 38 +++- src/surfaces/SessionPane.tsx | 19 +- 6 files changed, 572 insertions(+), 3 deletions(-) create mode 100644 src/chrome/PromptOutline.tsx create mode 100644 src/lib/promptOutline.test.ts create mode 100644 src/lib/promptOutline.ts diff --git a/src/chrome/PromptOutline.tsx b/src/chrome/PromptOutline.tsx new file mode 100644 index 00000000..8d118f84 --- /dev/null +++ b/src/chrome/PromptOutline.tsx @@ -0,0 +1,352 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type RefObject, +} from "react"; +import { + activePromptId, + barWindow, + promptBlocks, + promptLabel, + type OutlineAnchor, + type OutlineBand, +} from "../lib/promptOutline"; +import type { Block } from "../lib/session"; +import { Popover } from "./Popover"; + +const OPEN_DELAY_MS = 25; +const CLOSE_DELAY_MS = 100; +const SCROLL_INSET_PX = 8; +const POPOVER_WIDTH = 288; +const POPOVER_MAX_HEIGHT = 360; +const MIN_PROMPTS = 2; +const BAR_HEIGHT_PX = 2; +const BAR_GAP_PX = 5; +const BAR_GAP_MIN_PX = 1; +const BAR_STACK_MAX_PX = 330; +const BAR_STACK_PANE_SHARE = 0.75; +const SCROLLER = ".agent-transcript"; +const TURN = ".transcript-turn"; +const ANCHOR = "[data-prompt-anchor]"; + +type Props = { + blocks: Block[]; + scope: RefObject; + visible?: boolean; + /** Renders the turn that holds the block. Returns false when the block is unknown. */ + revealBlock?: (blockId: string) => boolean; +}; + +export function PromptOutline({ + blocks, + scope, + visible = true, + revealBlock, +}: Props) { + const prompts = useMemo(() => promptBlocks(blocks), [blocks]); + const [activeId, setActiveId] = useState(null); + const [stackBudget, setStackBudget] = useState(BAR_STACK_MAX_PX); + const [open, setOpen] = useState(false); + const [point, setPoint] = useState<{ x: number; y: number } | null>(null); + const trigger = useRef(null); + const list = useRef(null); + const activeRow = useRef(null); + const frame = useRef(null); + const openTimer = useRef(null); + const closeTimer = useRef(null); + const reopenBlockedUntilLeave = useRef(false); + + const measure = useCallback(() => { + const scroller = scope.current?.querySelector(SCROLLER); + if (!scroller) { + setActiveId(null); + return; + } + const viewport = scroller.getBoundingClientRect(); + // A hidden tab has zero-size boxes. The rule would then select the last prompt. + if (viewport.height === 0) return; + setStackBudget( + Math.min( + BAR_STACK_MAX_PX, + Math.floor(viewport.height * BAR_STACK_PANE_SHARE), + ), + ); + const anchors: OutlineAnchor[] = []; + for (const el of scroller.querySelectorAll(ANCHOR)) { + const id = el.dataset.promptAnchor; + if (id) anchors.push({ id, ...promptBand(el, viewport) }); + } + setActiveId( + activePromptId( + { top: viewport.top, bottom: viewport.bottom }, + anchors, + scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight, + ), + ); + }, [scope]); + + const schedule = useCallback(() => { + if (frame.current != null) return; + frame.current = window.requestAnimationFrame(() => { + frame.current = null; + measure(); + }); + }, [measure]); + + useEffect(() => { + const scroller = scope.current?.querySelector(SCROLLER); + if (!scroller) return; + scroller.addEventListener("scroll", schedule, { passive: true }); + const observer = new ResizeObserver(schedule); + observer.observe(scroller); + // Content growth moves the anchors without a scroll event. + if (scroller.firstElementChild) + observer.observe(scroller.firstElementChild); + schedule(); + return () => { + scroller.removeEventListener("scroll", schedule); + observer.disconnect(); + if (frame.current != null) { + window.cancelAnimationFrame(frame.current); + frame.current = null; + } + }; + }, [schedule, scope]); + + useEffect(() => { + schedule(); + }, [schedule, blocks, visible]); + + const cancelOpen = () => { + if (openTimer.current == null) return; + window.clearTimeout(openTimer.current); + openTimer.current = null; + }; + const cancelClose = () => { + if (closeTimer.current == null) return; + window.clearTimeout(closeTimer.current); + closeTimer.current = null; + }; + useEffect( + () => () => { + cancelOpen(); + cancelClose(); + }, + [], + ); + + const openNow = () => { + cancelOpen(); + cancelClose(); + const rect = trigger.current?.getBoundingClientRect(); + if (!rect) return; + // Anchor the list at the top-right corner of the trigger. The list covers + // the trigger, so the pointer crosses no gap. + setPoint({ x: rect.right, y: rect.top }); + setOpen(true); + }; + const closeNow = () => { + cancelOpen(); + cancelClose(); + setOpen(false); + }; + const scheduleClose = () => { + cancelClose(); + closeTimer.current = window.setTimeout(() => { + closeTimer.current = null; + setOpen(false); + }, CLOSE_DELAY_MS); + }; + const enterTrigger = () => { + cancelClose(); + if (open || reopenBlockedUntilLeave.current || openTimer.current != null) { + return; + } + openTimer.current = window.setTimeout(() => { + openTimer.current = null; + openNow(); + }, OPEN_DELAY_MS); + }; + const leaveTrigger = () => { + reopenBlockedUntilLeave.current = false; + cancelOpen(); + if (open) scheduleClose(); + }; + + useEffect(() => { + if (!open) return; + // Wait one frame for placement. Then the list has its final height. + const id = window.requestAnimationFrame(() => { + const row = activeRow.current; + const box = list.current; + if (!row || !box) return; + const top = row.offsetTop; + const bottom = top + row.offsetHeight; + if (top < box.scrollTop) box.scrollTop = top; + else if (bottom > box.scrollTop + box.clientHeight) { + box.scrollTop = bottom - box.clientHeight; + } + }); + return () => window.cancelAnimationFrame(id); + }, [open]); + + const jumpTo = (id: string) => { + const scroller = scope.current?.querySelector(SCROLLER); + if (!scroller) return; + // The transcript scrolls to the bottom on each streaming update until a + // wheel-up event occurs. Send one, so the jump stays. + scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 })); + const selector = `[data-prompt-anchor="${CSS.escape(id)}"]`; + let anchor = scroller.querySelector(selector); + if (!anchor && revealBlock?.(id)) { + anchor = scroller.querySelector(selector); + } + if (!anchor) { + scroller.scrollTop = 0; + return; + } + const target = anchor.closest(TURN) ?? anchor; + const align = () => { + const delta = + target.getBoundingClientRect().top - + scroller.getBoundingClientRect().top - + SCROLL_INSET_PX; + if (Math.abs(delta) > 2) scroller.scrollTop += delta; + }; + align(); + // Turns that enter the screen get their real height. That can move the + // target. + window.requestAnimationFrame(align); + }; + + const onRowClick = (event: ReactMouseEvent, id: string) => { + jumpTo(id); + const rect = trigger.current?.getBoundingClientRect(); + reopenBlockedUntilLeave.current = + !!rect && + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom; + closeNow(); + }; + + if (prompts.length < MIN_PROMPTS) return null; + + const activeIndex = prompts.findIndex((prompt) => prompt.id === activeId); + const stack = barStack( + prompts.length, + activeIndex >= 0 ? activeIndex : null, + stackBudget, + ); + const bars = prompts.slice(stack.start, stack.end); + + return ( +
+ + {open && point ? ( + +
+ {prompts.map((prompt) => { + const active = prompt.id === activeId; + return ( + + ); + })} +
+
+ ) : null} +
+ ); +} + +/** + * content-visibility skips off-screen turns. A read inside a skipped turn + * forces its layout. Use the turn box for an off-screen turn. Use the exact + * prompt box for an on-screen turn. + */ +function promptBand(anchor: HTMLElement, viewport: DOMRect): OutlineBand { + const turn = anchor.closest(TURN) ?? anchor; + const turnBox = turn.getBoundingClientRect(); + const onScreen = + turnBox.bottom > viewport.top && turnBox.top < viewport.bottom; + const box = + turn !== anchor && onScreen ? anchor.getBoundingClientRect() : turnBox; + return { top: box.top, bottom: box.bottom }; +} + +/** One bar per prompt while the bars fit the budget. The gap shrinks first. Past that, a window slides. */ +function barStack(count: number, activeIndex: number | null, budget: number) { + const fit = Math.max( + 1, + Math.floor((budget + BAR_GAP_MIN_PX) / (BAR_HEIGHT_PX + BAR_GAP_MIN_PX)), + ); + const window_ = barWindow(count, activeIndex, fit); + const shown = window_.end - window_.start; + const gap = + shown > 1 + ? Math.min( + BAR_GAP_PX, + Math.max( + BAR_GAP_MIN_PX, + Math.floor((budget - shown * BAR_HEIGHT_PX) / (shown - 1)), + ), + ) + : 0; + return { ...window_, gap }; +} diff --git a/src/index.css b/src/index.css index 9c3327b0..6a1e00d2 100644 --- a/src/index.css +++ b/src/index.css @@ -301,6 +301,28 @@ html.is-resizing * { scrollbar-width: none; } +/* Prompt outline list: a thin thumb, always visible, on all platforms. + Replaces the native overlay bar. */ +html .prompt-outline-list::-webkit-scrollbar { + width: 12px; +} + +html .prompt-outline-list::-webkit-scrollbar-track { + background: transparent; + margin-block: 6px; +} + +html .prompt-outline-list::-webkit-scrollbar-thumb { + border: 3px solid transparent; + border-radius: 9999px; + background-color: color-mix(in srgb, var(--color-content) 16%, transparent); + background-clip: padding-box; +} + +html .prompt-outline-list::-webkit-scrollbar-thumb:hover { + background-color: color-mix(in srgb, var(--color-content) 32%, transparent); +} + [data-tauri-drag-region] button { -webkit-app-region: no-drag; app-region: no-drag; diff --git a/src/lib/promptOutline.test.ts b/src/lib/promptOutline.test.ts new file mode 100644 index 00000000..2c2eb7cf --- /dev/null +++ b/src/lib/promptOutline.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { activePromptId } from "./promptOutline"; + +const viewport = { top: 100, bottom: 500 }; + +function anchor(id: string, top: number, bottom: number) { + return { id, top, bottom }; +} + +describe("activePromptId", () => { + it("returns null with no anchors", () => { + expect(activePromptId(viewport, [])).toBeNull(); + }); + + it("picks the topmost prompt inside the viewport", () => { + const anchors = [ + anchor("a", 0, 40), + anchor("b", 150, 190), + anchor("c", 300, 340), + anchor("d", 600, 640), + ]; + expect(activePromptId(viewport, anchors)).toBe("b"); + }); + + it("counts a prompt cut by the viewport top as inside", () => { + const anchors = [anchor("a", 80, 120), anchor("b", 200, 240)]; + expect(activePromptId(viewport, anchors)).toBe("a"); + }); + + it("lets a prompt that peeks in at the bottom win over the reply above", () => { + const anchors = [anchor("a", 0, 40), anchor("b", 480, 520)]; + expect(activePromptId(viewport, anchors)).toBe("b"); + }); + + it("falls back to the last prompt above the viewport", () => { + const anchors = [ + anchor("a", 0, 20), + anchor("b", 40, 60), + anchor("c", 600, 640), + ]; + expect(activePromptId(viewport, anchors)).toBe("b"); + }); + + it("treats a prompt that ends at the viewport top as above", () => { + const anchors = [anchor("a", 60, 100), anchor("b", 700, 740)]; + expect(activePromptId(viewport, anchors)).toBe("a"); + }); + + it("falls back to the first prompt when all sit below", () => { + const anchors = [anchor("a", 600, 640), anchor("b", 700, 740)]; + expect(activePromptId(viewport, anchors)).toBe("a"); + }); + + it("marks the last prompt at the end of the transcript", () => { + const anchors = [ + anchor("a", 120, 160), + anchor("b", 260, 300), + anchor("c", 400, 440), + ]; + expect(activePromptId(viewport, anchors, 0)).toBe("c"); + expect(activePromptId(viewport, anchors, 16)).toBe("c"); + }); + + it("keeps the topmost visible prompt while there is room to scroll", () => { + const anchors = [anchor("a", 120, 160), anchor("b", 400, 440)]; + expect(activePromptId(viewport, anchors, 17)).toBe("a"); + }); +}); diff --git a/src/lib/promptOutline.ts b/src/lib/promptOutline.ts new file mode 100644 index 00000000..55ef4815 --- /dev/null +++ b/src/lib/promptOutline.ts @@ -0,0 +1,76 @@ +import type { Block } from "./session"; + +/** A vertical span in viewport coordinates. */ +export type OutlineBand = { top: number; bottom: number }; + +export type OutlineAnchor = OutlineBand & { id: string }; + +export const NEAR_END_PX = 16; + +export function promptBlocks(blocks: Block[]): Block[] { + return blocks.filter((block) => block.role === "user"); +} + +/** + * Selects the topmost prompt inside the viewport. With no prompt inside, + * selects the last prompt above the viewport, else the first prompt. Near the + * end of the transcript, selects the last prompt: the prompts on the final + * screen can not reach the top. + */ +export function activePromptId( + viewport: OutlineBand, + anchors: OutlineAnchor[], + distanceToEnd = Number.POSITIVE_INFINITY, +): string | null { + if (anchors.length === 0) return null; + if (distanceToEnd <= NEAR_END_PX) return anchors[anchors.length - 1].id; + const inside = anchors.find( + (anchor) => anchor.bottom > viewport.top && anchor.top < viewport.bottom, + ); + if (inside) return inside.id; + let above: OutlineAnchor | undefined; + for (const anchor of anchors) { + if (anchor.bottom <= viewport.top) above = anchor; + } + return (above ?? anchors[0]).id; +} + +/** Selects at most `max` prompts. The window slides to keep the active prompt inside. It prefers the newest prompts. */ +export function barWindow( + count: number, + activeIndex: number | null, + max: number, +): { start: number; end: number } { + if (count <= max) return { start: 0, end: count }; + const newest = count - max; + const start = + activeIndex == null ? newest : Math.max(0, Math.min(newest, activeIndex)); + return { start, end: start + max }; +} + +export function promptLabel(block: Block): string { + const card = block.secondOpinion; + const textShown = !card || card.kind === "handoff"; + const text = textShown ? firstLine(block.text) : ""; + if (text) return text; + if (card) { + if (card.kind === "handoff") return "Handoff"; + const request = firstLine(card.request ?? ""); + return request ? `Second opinion: ${request}` : "Second opinion"; + } + if (block.noteCard?.title) return block.noteCard.title; + const files = block.attachments ?? []; + if (files.length > 0) { + const [first] = files; + return files.length > 1 ? `${first.name} +${files.length - 1}` : first.name; + } + return "Empty message"; +} + +function firstLine(text: string): string { + const line = text + .split(/\r?\n/) + .map((part) => part.trim()) + .find(Boolean); + return (line ?? "").replace(/\s+/g, " "); +} diff --git a/src/surfaces/AgentTranscript.tsx b/src/surfaces/AgentTranscript.tsx index b10bc82f..ae111937 100644 --- a/src/surfaces/AgentTranscript.tsx +++ b/src/surfaces/AgentTranscript.tsx @@ -22,6 +22,7 @@ import { useState, type ReactNode, } from "react"; +import { flushSync } from "react-dom"; import { AttachmentChip } from "../chrome/AttachmentChip"; import { FilePreview } from "../chrome/FilePreview"; import { FileTypeIcon } from "../chrome/FileTypeIcon"; @@ -116,6 +117,8 @@ type Props = { onHandoff?: (harness: HarnessId, turn: Block[], model: string) => void; onJumpToBottomChange?: (show: boolean) => void; onJumpToBottomReady?: (jump: () => void) => void; + /** Passes a function that renders the turn that holds a block. The render completes before the function returns. */ + onRevealReady?: (reveal: (blockId: string) => boolean) => void; /** False while another tab is in front; local transcript state is retained. */ visible?: boolean; }; @@ -138,6 +141,7 @@ function AgentTranscriptComponent({ onHandoff, onJumpToBottomChange, onJumpToBottomReady, + onRevealReady, visible = true, }: Props) { const lockOverscroll = useLockOverscroll(); @@ -293,6 +297,10 @@ function AgentTranscriptComponent({ const turns = groupTurns(blocks); const firstVisibleTurn = Math.max(0, turns.length - visibleTurnCount); const visibleTurns = turns.slice(firstVisibleTurn); + const turnsRef = useRef(turns); + turnsRef.current = turns; + const visibleTurnCountRef = useRef(visibleTurnCount); + visibleTurnCountRef.current = visibleTurnCount; useLayoutEffect(() => { const previousHeight = prependHeight.current; @@ -304,21 +312,46 @@ function AgentTranscriptComponent({ el.scrollHeight - el.scrollTop - el.clientHeight; }, [visibleTurnCount]); - const loadEarlier = () => { + const prepareToPrepend = useCallback(() => { const el = scroller.current; if (el) prependHeight.current = el.scrollHeight; stickToBottom.current = false; + }, []); + + const loadEarlier = () => { + prepareToPrepend(); setVisibleTurnCount((count) => Math.min(turns.length, count + TURN_PAGE_SIZE), ); }; + const revealBlock = useCallback( + (blockId: string): boolean => { + const all = turnsRef.current; + const index = all.findIndex((turn) => + turn.some((block) => block.id === blockId), + ); + if (index < 0) return false; + const needed = all.length - index; + if (needed <= visibleTurnCountRef.current) return true; + prepareToPrepend(); + // Synchronous. The caller finds the turn in the DOM after this call. + flushSync(() => setVisibleTurnCount(needed)); + return true; + }, + [prepareToPrepend], + ); + + useEffect(() => { + onRevealReady?.(revealBlock); + }, [revealBlock, onRevealReady]); + return (
-
+
{firstVisibleTurn > 0 ? (
) : null} -
+
{isEmpty ? ( session.inboxAsk ? (
@@ -468,6 +478,13 @@ export const SessionPane = memo(function SessionPane({ } onJumpToBottomChange={setShowJumpToBottom} onJumpToBottomReady={onJumpToBottomReady} + onRevealReady={onRevealReady} + /> + {showJumpToBottom ? (
From 898572f0dcd111770ee6685557eabb31c055b59d Mon Sep 17 00:00:00 2001 From: Nick <60738984+hardbeat920@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:59:08 +0100 Subject: [PATCH 07/27] Add note image attachments and theme-aware window glass - Support image drag-and-drop into notes with secure asset storage and rendering - Toggle native window translucency based on light/dark appearance --- src-tauri/src/lib.rs | 4 +- src-tauri/src/macos.rs | 48 +++++-- src-tauri/src/notes.rs | 192 +++++++++++++++++++++++++++- src-tauri/src/window.rs | 23 ++-- src/index.css | 12 +- src/lib/appearance.ts | 12 ++ src/lib/noteImages.test.ts | 54 ++++++++ src/lib/noteImages.ts | 135 ++++++++++++++++++++ src/main.tsx | 5 +- src/surfaces/AgentMarkdown.test.ts | 15 +++ src/surfaces/AgentMarkdown.tsx | 45 ++++++- src/surfaces/NotesView.tsx | 195 ++++++++++++++++++++++++++--- src/surfaces/SettingsView.tsx | 37 +++++- 13 files changed, 728 insertions(+), 49 deletions(-) create mode 100644 src/lib/noteImages.test.ts create mode 100644 src/lib/noteImages.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2af652bf..f78b0cea 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -301,6 +301,8 @@ pub fn run() { notes::notes_get, notes::notes_upsert, notes::notes_delete, + notes::notes_save_image, + notes::notes_image_path, checkpoint::session_checkpoint_ensure, checkpoint::session_checkpoint_prepare, checkpoint::session_checkpoint_capture, @@ -315,7 +317,7 @@ pub fn run() { window::hide_window, window::destroy_window, window::confirm_quit, - window::enable_window_glass, + window::set_window_glass_enabled, window_transfer::stage_window_transfer, window_transfer::take_window_transfer, chat_background::save_chat_background, diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 73e2312b..ee185cee 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -19,7 +19,7 @@ //! shadow without that outline. use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::ffi::{c_char, c_int, c_void}; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::{Mutex, OnceLock}; @@ -54,6 +54,7 @@ const RTLD_DEFAULT: *mut c_void = -2isize as *mut c_void; static PINNED: AtomicBool = AtomicBool::new(false); static BLUR_RADIUS: AtomicU8 = AtomicU8::new(BLUR_DEFAULT); static WINDOW_BADGES: OnceLock>> = OnceLock::new(); +static GLASS_WINDOWS: OnceLock>> = OnceLock::new(); type CgsConnection = usize; type SetBlurFn = unsafe extern "C" fn(CgsConnection, c_int, c_int) -> c_int; @@ -77,7 +78,10 @@ pub fn install(window: &WebviewWindow) { WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => { stretch_titlebar(&event_window); } - WindowEvent::Destroyed => set_window_badge(&event_window, 0), + WindowEvent::Destroyed => { + set_window_badge(&event_window, 0); + set_glass_enabled(&event_window, false); + } _ => {} }); } @@ -148,7 +152,31 @@ pub fn set_visible(window: &WebviewWindow, visible: bool) { pub fn set_background_blur_radius(window: &WebviewWindow, radius: u8) { let radius = radius.clamp(BLUR_MIN, BLUR_MAX); BLUR_RADIUS.store(radius, Ordering::Relaxed); - apply_blur(window, radius); + if glass_enabled(window) { + apply_blur(window, radius); + } +} + +fn glass_windows() -> &'static Mutex> { + GLASS_WINDOWS.get_or_init(|| Mutex::new(HashSet::new())) +} + +fn glass_enabled(window: &WebviewWindow) -> bool { + glass_windows() + .lock() + .unwrap_or_else(|err| err.into_inner()) + .contains(window.label()) +} + +fn set_glass_enabled(window: &WebviewWindow, enabled: bool) { + let mut windows = glass_windows() + .lock() + .unwrap_or_else(|err| err.into_inner()); + if enabled { + windows.insert(window.label().to_string()); + } else { + windows.remove(window.label()); + } } /// Solid field behind the dock bounce. Same colour as the HTML sheet. @@ -177,10 +205,18 @@ fn set_launch_background(window: &WebviewWindow, r: u8, g: u8, b: u8) { /// Turn on desktop blur after the first UI paint. pub fn enable_glass(window: &WebviewWindow) { + set_glass_enabled(window, true); prepare_glass(window); apply_blur(window, BLUR_RADIUS.load(Ordering::Relaxed)); } +/// Light mode stays opaque because pale desktop content makes translucent UI illegible. +pub fn disable_glass(window: &WebviewWindow) { + set_glass_enabled(window, false); + apply_blur(window, 0); + set_launch_background(window, 247, 247, 247); +} + fn prepare_glass(window: &WebviewWindow) { let Some(ns_window) = ns_window(window) else { return; @@ -208,11 +244,7 @@ fn apply_blur(window: &WebviewWindow, radius: u8) { return; } unsafe { - set_blur( - connection, - window_number as c_int, - radius.max(BLUR_MIN) as c_int, - ); + set_blur(connection, window_number as c_int, radius as c_int); } } diff --git a/src-tauri/src/notes.rs b/src-tauri/src/notes.rs index 0e028818..9b823547 100644 --- a/src-tauri/src/notes.rs +++ b/src-tauri/src/notes.rs @@ -1,11 +1,18 @@ +use std::path::{Component, Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; -use tauri::State; +use tauri::{AppHandle, Manager, State}; +use crate::fs::expand_home; use crate::session_store::{now_millis, validate_id, SessionStore}; const TITLE_MAX: usize = 200; const BODY_MAX: usize = 1_000_000; +const IMAGE_MAX_BYTES: u64 = 20 * 1024 * 1024; +const IMAGE_EXTENSIONS: [&str; 6] = ["png", "jpg", "jpeg", "gif", "webp", "svg"]; +const NOTE_ASSET_DIR: &str = "note-assets"; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -34,6 +41,13 @@ pub struct NoteUpsert { pub source_cwd: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NoteImageAsset { + pub name: String, + pub markdown_path: String, +} + pub fn ensure_notes_table(conn: &Connection) -> rusqlite::Result<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS notes ( @@ -80,10 +94,160 @@ pub fn notes_upsert(store: State<'_, SessionStore>, note: NoteUpsert) -> Result< } #[tauri::command(async)] -pub fn notes_delete(store: State<'_, SessionStore>, id: String) -> Result<(), String> { +pub fn notes_delete( + app: AppHandle, + store: State<'_, SessionStore>, + id: String, +) -> Result<(), String> { validate_id(&id, "note")?; let conn = store.lock_conn()?; - delete_note(&conn, &id).map_err(|e| e.to_string()) + delete_note(&conn, &id).map_err(|e| e.to_string())?; + drop(conn); + // The note deletion is authoritative. A cleanup failure should not leave a + // successfully deleted note visible in the UI. + let _ = remove_note_assets(&app, &id); + Ok(()) +} + +#[tauri::command] +pub async fn notes_save_image( + app: AppHandle, + note_id: String, + source_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || save_note_image_sync(&app, ¬e_id, &source_path)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command(async)] +pub fn notes_image_path(app: AppHandle, asset: String) -> Result { + let relative = validate_note_asset_path(&asset)?; + let path = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join(relative); + if !path.is_file() { + return Err("Note image was not found".into()); + } + Ok(path.to_string_lossy().into_owned()) +} + +fn note_assets_dir(app: &AppHandle, note_id: &str) -> Result { + validate_id(note_id, "note")?; + Ok(app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join(NOTE_ASSET_DIR) + .join(note_id)) +} + +fn save_note_image_sync( + app: &AppHandle, + note_id: &str, + source_path: &str, +) -> Result { + let source = expand_home(source_path); + let meta = std::fs::metadata(&source).map_err(|e| format!("{}: {e}", source.display()))?; + if !meta.is_file() { + return Err("Not a file".into()); + } + if meta.len() > IMAGE_MAX_BYTES { + return Err(format!( + "Image is too large (maximum {} MB).", + IMAGE_MAX_BYTES / 1024 / 1024 + )); + } + + let (display_name, safe_name) = note_image_names(&source)?; + let dir = note_assets_dir(app, note_id)?; + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let stored_name = format!("{stamp}-{safe_name}"); + let destination = dir.join(&stored_name); + std::fs::copy(&source, &destination).map_err(|e| format!("{}: {e}", destination.display()))?; + + Ok(NoteImageAsset { + name: display_name, + markdown_path: format!("/{NOTE_ASSET_DIR}/{note_id}/{stored_name}"), + }) +} + +fn note_image_names(source: &Path) -> Result<(String, String), String> { + let extension = source + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if !IMAGE_EXTENSIONS.contains(&extension.as_str()) { + return Err("Image must be a PNG, JPG, GIF, WebP, or SVG file.".into()); + } + let display_name = source + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("image") + .to_string(); + let stem = source + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("image"); + let mut safe_stem: String = stem + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '-' + } + }) + .take(80) + .collect(); + safe_stem = safe_stem.trim_matches('-').to_string(); + if safe_stem.is_empty() { + safe_stem = "image".into(); + } + Ok((display_name, format!("{safe_stem}.{extension}"))) +} + +fn validate_note_asset_path(asset: &str) -> Result { + let relative = asset + .strip_prefix('/') + .ok_or_else(|| "Invalid note image path".to_string())?; + let path = Path::new(relative); + let parts = path + .components() + .map(|part| match part { + Component::Normal(value) => value.to_str().map(str::to_string), + _ => None, + }) + .collect::>>() + .ok_or_else(|| "Invalid note image path".to_string())?; + if parts.len() != 3 || parts[0] != NOTE_ASSET_DIR { + return Err("Invalid note image path".into()); + } + validate_id(&parts[1], "note")?; + if parts[2].is_empty() + || !parts[2] + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err("Invalid note image path".into()); + } + Ok(path.to_path_buf()) +} + +fn remove_note_assets(app: &AppHandle, note_id: &str) -> Result<(), String> { + let dir = note_assets_dir(app, note_id)?; + match std::fs::remove_dir_all(dir) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.to_string()), + } } fn list_notes(conn: &Connection) -> rusqlite::Result> { @@ -354,6 +518,28 @@ mod tests { assert_eq!(note.slug, "untitled"); } + #[test] + fn note_image_names_are_safe_and_keep_supported_extensions() { + assert_eq!( + note_image_names(Path::new("/tmp/Architecture draft [2].PNG")).unwrap(), + ( + "Architecture draft [2].PNG".into(), + "Architecture-draft--2.png".into() + ) + ); + assert!(note_image_names(Path::new("/tmp/archive.zip")).is_err()); + } + + #[test] + fn note_asset_paths_cannot_escape_app_data() { + assert_eq!( + validate_note_asset_path("/note-assets/note-1/123-image.png").unwrap(), + PathBuf::from("note-assets/note-1/123-image.png") + ); + assert!(validate_note_asset_path("/note-assets/note-1/../secret.png").is_err()); + assert!(validate_note_asset_path("/other/note-1/image.png").is_err()); + } + #[test] fn delete_removes_the_row() { let store = SessionStore::open_in_memory().unwrap(); diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index 0555e302..666dd90f 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -42,22 +42,31 @@ pub fn open_new_window(app: &AppHandle) -> Result<(), String> { Ok(()) } -/// Desktop blur goes on after the first UI paint, not during the dock bounce. +/// Desktop blur goes on after the first UI paint and only in dark mode. #[tauri::command] -pub fn enable_window_glass(window: WebviewWindow) { +pub fn set_window_glass_enabled(window: WebviewWindow, enabled: bool) { #[cfg(target_os = "macos")] { - let _ = window.set_background_color(Some(Color(0, 0, 0, 3))); - crate::macos::enable_glass(&window); + if enabled { + let _ = window.set_background_color(Some(Color(0, 0, 0, 3))); + crate::macos::enable_glass(&window); + } else { + crate::macos::disable_glass(&window); + } } #[cfg(target_os = "windows")] { - let _ = window.set_background_color(Some(Color(0, 0, 0, 0))); - let _ = window.set_effects(EffectsBuilder::new().effect(Effect::Acrylic).build()); + if enabled { + let _ = window.set_background_color(Some(Color(0, 0, 0, 0))); + let _ = window.set_effects(EffectsBuilder::new().effect(Effect::Acrylic).build()); + } else { + let _ = window.set_effects(None); + let _ = window.set_background_color(Some(Color(247, 247, 247, 255))); + } } #[cfg(not(any(target_os = "macos", target_os = "windows")))] { - let _ = window; + let _ = (window, enabled); } } diff --git a/src/index.css b/src/index.css index 6a1e00d2..458065a2 100644 --- a/src/index.css +++ b/src/index.css @@ -96,9 +96,9 @@ html:not(.is-mac) ::-webkit-scrollbar-corner { background: transparent; } -html.has-native-glass, -html.has-native-glass body, -html.has-native-glass #root { +html.has-native-glass:not(.theme-light), +html.has-native-glass:not(.theme-light) body, +html.has-native-glass:not(.theme-light) #root { background: transparent; } @@ -107,10 +107,10 @@ html.has-native-glass #root { } html.theme-light .sidebar-glass { - background: color-mix(in srgb, var(--color-background-base) 93%, black 7%); + background: var(--color-background-base); } -html.has-native-glass .sidebar-glass { +html.has-native-glass:not(.theme-light) .sidebar-glass { background: hsl( var(--theme-hue) var(--theme-saturation) var(--background-lightness) / var(--sidebar-opacity) @@ -168,7 +168,7 @@ html.chat-background-empty-only min-height: var(--transcript-viewport, 0px); } -html.has-native-glass.glass-body .body-glass { +html.has-native-glass.glass-body:not(.theme-light) .body-glass { background: color-mix( in srgb, var(--color-background-base) calc(var(--sidebar-opacity) * 100%), diff --git a/src/lib/appearance.ts b/src/lib/appearance.ts index fbf5f991..6d88bc4d 100644 --- a/src/lib/appearance.ts +++ b/src/lib/appearance.ts @@ -17,6 +17,7 @@ const CHAT_BACKGROUND_PATH_KEY = "monocode.chatBackgroundPath"; const CHAT_BACKGROUND_OPACITY_KEY = "monocode.chatBackgroundOpacity"; const CHAT_BACKGROUND_SCOPE_KEY = "monocode.chatBackgroundScope"; let chatBackgroundRevision = Date.now(); +let nativeGlassReady = false; export const CHAT_BACKGROUND_PATH_CHANGE_EVENT = "monocode:chat-background-path-change"; @@ -223,12 +224,23 @@ export function isLightScheme(): boolean { export function applyThemePreference(value: ThemePreference): ColorScheme { const next = resolveColorScheme(value); document.documentElement.classList.toggle("theme-light", next === "light"); + if (nativeGlassReady) syncNativeGlass(next); window.dispatchEvent( new CustomEvent(SCHEME_CHANGE_EVENT, { detail: next }), ); return next; } +function syncNativeGlass(scheme: ColorScheme) { + void invoke("set_window_glass_enabled", { enabled: scheme === "dark" }); +} + +/** Applies native transparency once the opaque launch cover can be removed. */ +export function activateWindowAppearance() { + nativeGlassReady = true; + syncNativeGlass(isLightScheme() ? "light" : "dark"); +} + /** Keeps the "system" preference in sync when the OS flips appearance. */ export function watchSystemColorScheme() { const query = systemQuery(); diff --git a/src/lib/noteImages.test.ts b/src/lib/noteImages.test.ts new file mode 100644 index 00000000..c7102f29 --- /dev/null +++ b/src/lib/noteImages.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + insertNoteImagesMarkdown, + isNoteImagePath, + noteImageMarkdown, + type NoteImageAsset, +} from "./noteImages"; + +const image: NoteImageAsset = { + name: "Architecture [draft].png", + markdownPath: "/note-assets/note-1/123-architecture-draft.png", +}; + +describe("note image markdown", () => { + it("escapes image names used as alt text", () => { + expect(noteImageMarkdown(image)).toBe( + "![Architecture \\[draft\\].png](/note-assets/note-1/123-architecture-draft.png)", + ); + }); + + it("inserts images as blocks at the cursor", () => { + expect(insertNoteImagesMarkdown("BeforeAfter", 6, 6, [image])).toEqual({ + value: + "Before\n\n![Architecture \\[draft\\].png](/note-assets/note-1/123-architecture-draft.png)\n\nAfter", + cursor: 85, + }); + }); + + it("replaces the selection and separates multiple images", () => { + const second = { + name: "flow.png", + markdownPath: "/note-assets/note-1/456-flow.png", + }; + expect( + insertNoteImagesMarkdown("Top\nreplace\nBottom", 4, 12, [image, second]), + ).toEqual({ + value: [ + "Top", + "", + noteImageMarkdown(image), + "", + noteImageMarkdown(second), + "", + "Bottom", + ].join("\n"), + cursor: 129, + }); + }); + + it("recognizes only note asset references", () => { + expect(isNoteImagePath(image.markdownPath)).toBe(true); + expect(isNoteImagePath("https://example.com/image.png")).toBe(false); + }); +}); diff --git a/src/lib/noteImages.ts b/src/lib/noteImages.ts new file mode 100644 index 00000000..3c8e5498 --- /dev/null +++ b/src/lib/noteImages.ts @@ -0,0 +1,135 @@ +import { invoke } from "@tauri-apps/api/core"; +import { + attachmentsFromFiles, + attachmentsFromPaths, + revokeAttachment, +} from "./attachments"; +import type { Attachment } from "./session"; + +export const NOTE_IMAGE_PREFIX = "/note-assets/"; + +export type NoteImageAsset = { + name: string; + markdownPath: string; +}; + +export type MarkdownInsertion = { + value: string; + cursor: number; +}; + +export async function saveNoteImagesFromFiles( + noteId: string, + files: File[], +): Promise { + return saveNoteImageAttachments(noteId, await attachmentsFromFiles(files)); +} + +export async function saveNoteImagesFromPaths( + noteId: string, + paths: string[], +): Promise { + return saveNoteImageAttachments(noteId, await attachmentsFromPaths(paths)); +} + +async function saveNoteImageAttachments( + noteId: string, + attachments: Attachment[], +): Promise { + const images = attachments.filter((file) => file.kind === "image"); + if (images.length === 0) { + throw new Error("Drop a PNG, JPG, GIF, WebP, or SVG image."); + } + + const saved: NoteImageAsset[] = []; + let failure: unknown; + try { + for (const image of images) { + let sourcePath = image.path; + let temporary = false; + if (!sourcePath && image.data) { + sourcePath = await invoke("write_attachment", { + name: image.name, + data: image.data, + }); + temporary = true; + } + if (!sourcePath) continue; + try { + saved.push( + await invoke("notes_save_image", { + noteId, + sourcePath, + }), + ); + } catch (err: unknown) { + failure ??= err; + } finally { + if (temporary) { + await invoke("delete_path", { path: sourcePath }).catch( + () => undefined, + ); + } + } + } + } finally { + for (const image of images) revokeAttachment(image); + } + + if (saved.length === 0) { + if (failure instanceof Error) throw failure; + if (failure) throw new Error(String(failure)); + throw new Error("None of the dropped images could be added to the note."); + } + return saved; +} + +export function noteImageMarkdown(image: NoteImageAsset): string { + const alt = image.name + .replace(/[\r\n]+/g, " ") + .replace(/\\/g, "\\\\") + .replace(/([\[\]])/g, "\\$1"); + return `![${alt}](${image.markdownPath})`; +} + +export function insertNoteImagesMarkdown( + value: string, + start: number, + end: number, + images: NoteImageAsset[], +): MarkdownInsertion { + if (images.length === 0) { + const cursor = Math.max(0, Math.min(start, value.length)); + return { value, cursor }; + } + + const from = Math.max(0, Math.min(start, value.length)); + const to = Math.max(from, Math.min(end, value.length)); + const before = value.slice(0, from); + const after = value.slice(to); + const block = images.map(noteImageMarkdown).join("\n\n"); + const leading = before + ? before.endsWith("\n\n") + ? "" + : before.endsWith("\n") + ? "\n" + : "\n\n" + : ""; + const trailing = after + ? after.startsWith("\n\n") + ? "" + : after.startsWith("\n") + ? "\n" + : "\n\n" + : ""; + const inserted = `${leading}${block}`; + + return { + value: `${before}${inserted}${trailing}${after}`, + cursor: before.length + inserted.length, + }; +} + +export function isNoteImagePath(value: string): boolean { + return value.startsWith(NOTE_IMAGE_PREFIX); +} diff --git a/src/main.tsx b/src/main.tsx index e2fd8e81..9330f2f8 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,9 +1,8 @@ import React, { useLayoutEffect } from "react"; import ReactDOM from "react-dom/client"; -import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import App from "./App"; -import { initAppearance } from "./lib/appearance"; +import { activateWindowAppearance, initAppearance } from "./lib/appearance"; import { initSounds } from "./lib/sounds"; import { handleQuitRequested, loadBootWorkspace } from "./lib/appLifecycle"; import { consumeInstalledUpdate } from "./lib/updateNotice"; @@ -17,7 +16,7 @@ function dismissBootSplash() { if (!splash || splash.dataset.dismissed === "1") return; splash.dataset.dismissed = "1"; const fade = () => { - void invoke("enable_window_glass"); + activateWindowAppearance(); splash.classList.add("boot-splash-out"); window.setTimeout(() => splash.remove(), 180); }; diff --git a/src/surfaces/AgentMarkdown.test.ts b/src/surfaces/AgentMarkdown.test.ts index d56e639d..194ab6e8 100644 --- a/src/surfaces/AgentMarkdown.test.ts +++ b/src/surfaces/AgentMarkdown.test.ts @@ -66,3 +66,18 @@ describe("AgentMarkdown inline code", () => { expect(classes).not.toContain("h-6"); }); }); + +describe("AgentMarkdown note images", () => { + it("keeps app-owned note image references for the async image resolver", () => { + const markup = renderToStaticMarkup( + createElement(AgentMarkdown, { + text: "![Diagram](/note-assets/note-1/123-diagram.png)", + }), + ); + + expect(markup).toContain( + 'data-note-image="/note-assets/note-1/123-diagram.png"', + ); + expect(markup).toContain('alt="Diagram"'); + }); +}); diff --git a/src/surfaces/AgentMarkdown.tsx b/src/surfaces/AgentMarkdown.tsx index 529ccd7d..c6d09a30 100644 --- a/src/surfaces/AgentMarkdown.tsx +++ b/src/surfaces/AgentMarkdown.tsx @@ -1,4 +1,5 @@ import { code } from "@streamdown/code"; +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; import { createContext, isValidElement, @@ -28,6 +29,7 @@ import { useColorScheme } from "../hooks/useColorScheme"; import { useLockOverscroll } from "../hooks/useLockOverscroll"; import { copyText } from "../lib/clipboard"; import { INBOX_MEDIA_PREFIXES, isInboxMediaUrl } from "../lib/inboxMedia"; +import { isNoteImagePath } from "../lib/noteImages"; import { InboxMedia } from "./InboxMedia"; const MERMAID_BASE_CONFIG = { @@ -51,7 +53,9 @@ const MARKDOWN_REHYPE_PLUGINS: PluggableList = [ [ harden, { - allowedImagePrefixes: [] as string[], + // MarkdownImage remains the final allowlist. The wildcard lets app-owned + // relative note URLs reach that component without changing link parsing. + allowedImagePrefixes: ["*"], allowedLinkPrefixes: ["*"], allowDataImages: true, imageBlockPolicy: "remove" as const, @@ -302,6 +306,42 @@ function CodeCopyButton({ code }: { code: string }) { type MarkdownImageProps = ComponentProps<"img"> & { node?: unknown }; +const noteImageSrcCache = new Map(); + +function NoteAssetImage({ + asset, + alt, + ...props +}: Omit & { asset: string }) { + const [src, setSrc] = useState(() => noteImageSrcCache.get(asset)); + + useEffect(() => { + if (src) return; + let cancelled = false; + void invoke("notes_image_path", { asset }) + .then((path) => { + const next = convertFileSrc(path); + noteImageSrcCache.set(asset, next); + if (!cancelled) setSrc(next); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [asset, src]); + + return ( + {alt + ); +} + function MarkdownImage({ src, alt, @@ -313,6 +353,9 @@ function MarkdownImage({ if (url.startsWith("data:image/")) { return {alt; } + if (isNoteImagePath(url)) { + return ; + } if (!allowRemoteMedia || !url || !isInboxMediaUrl(url)) return null; return ; } diff --git a/src/surfaces/NotesView.tsx b/src/surfaces/NotesView.tsx index 74a96e7e..fd0c6503 100644 --- a/src/surfaces/NotesView.tsx +++ b/src/surfaces/NotesView.tsx @@ -1,4 +1,5 @@ import { LoaderCircle, Plus, Search, File, Trash2 } from "../chrome/icons"; +import { getCurrentWebview } from "@tauri-apps/api/webview"; import { Fragment, useCallback, @@ -6,6 +7,7 @@ import { useMemo, useRef, useState, + type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { useMarkdownMode } from "../chrome/MarkdownModeToggle"; @@ -28,6 +30,12 @@ import { requestAddNoteToChat, type Note, } from "../lib/notes"; +import { + insertNoteImagesMarkdown, + saveNoteImagesFromFiles, + saveNoteImagesFromPaths, + type NoteImageAsset, +} from "../lib/noteImages"; import { projectKey, projectName } from "../lib/paths"; import { IS_MAC } from "../lib/platform"; import { looksLikeProject } from "../lib/recents"; @@ -500,9 +508,14 @@ function NoteEditor({ const [title, setTitle] = useState(note.title); const [body, setBody] = useState(note.body); const [saveError, setSaveError] = useState(null); + const [imageDrag, setImageDrag] = useState(false); + const [imageBusy, setImageBusy] = useState(false); const titleRef = useRef(title); const bodyRef = useRef(body); const noteRef = useRef(note); + const dropZoneRef = useRef(null); + const sourceFieldRef = useRef(null); + const lastDropAt = useRef(0); const skipSave = useRef(false); const saveTimer = useRef(null); const saveQueue = useRef(Promise.resolve()); @@ -553,6 +566,111 @@ function NoteEditor({ }, 400); }, [persist]); + const insertionRange = useCallback(() => { + const field = sourceFieldRef.current; + if (!field) { + const end = bodyRef.current.length; + return { start: end, end }; + } + return { + start: field.selectionStart, + end: field.selectionEnd, + }; + }, []); + + const addDroppedImages = useCallback( + async ( + load: () => Promise, + range: { start: number; end: number }, + ) => { + setImageBusy(true); + setImageDrag(false); + try { + const images = await load(); + const inserted = insertNoteImagesMarkdown( + bodyRef.current, + range.start, + range.end, + images, + ); + bodyRef.current = inserted.value; + setBody(inserted.value); + setSaveError(null); + scheduleSave(); + window.requestAnimationFrame(() => { + const field = sourceFieldRef.current; + if (!field) return; + field.focus(); + field.setSelectionRange(inserted.cursor, inserted.cursor); + }); + } catch (err: unknown) { + setSaveError(err instanceof Error ? err.message : String(err)); + } finally { + setImageBusy(false); + } + }, + [scheduleSave], + ); + + useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | undefined; + + const toClientPoint = (x: number, y: number) => { + const scale = window.devicePixelRatio || 1; + if (scale !== 1 && (x > window.innerWidth || y > window.innerHeight)) { + return { x: x / scale, y: y / scale }; + } + return { x, y }; + }; + const overDropZone = (x: number, y: number) => { + const zone = dropZoneRef.current; + if (!zone) return false; + const point = toClientPoint(x, y); + const rect = zone.getBoundingClientRect(); + return ( + point.x >= rect.left && + point.x <= rect.right && + point.y >= rect.top && + point.y <= rect.bottom + ); + }; + + void getCurrentWebview() + .onDragDropEvent((event) => { + if (event.payload.type === "leave") { + setImageDrag(false); + return; + } + const { x, y } = event.payload.position; + const over = overDropZone(x, y); + if (event.payload.type === "enter" || event.payload.type === "over") { + setImageDrag(over); + return; + } + if (event.payload.type !== "drop") return; + setImageDrag(false); + if (!over || Date.now() - lastDropAt.current < 250) return; + lastDropAt.current = Date.now(); + const range = insertionRange(); + const paths = event.payload.paths; + void addDroppedImages( + () => saveNoteImagesFromPaths(note.id, paths), + range, + ); + }) + .then((fn) => { + if (cancelled) fn(); + else unlisten = fn; + }) + .catch(() => undefined); + + return () => { + cancelled = true; + unlisten?.(); + }; + }, [addDroppedImages, insertionRange, note.id]); + useEffect(() => { return () => { if (saveTimer.current != null) window.clearTimeout(saveTimer.current); @@ -658,20 +776,59 @@ function NoteEditor({ onSelect={() => setMode("source")} />
- {mode === "source" ? ( - { - setBody(next); - scheduleSave(); - }} - /> - ) : body.trim() ? ( - - ) : ( -

No description

- )} +
) => { + if (!hasDroppedFiles(event.dataTransfer)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + setImageDrag(true); + }} + onDragLeave={(event: ReactDragEvent) => { + const next = event.relatedTarget as Node | null; + if (next && event.currentTarget.contains(next)) return; + setImageDrag(false); + }} + onDrop={(event: ReactDragEvent) => { + if (!hasDroppedFiles(event.dataTransfer)) return; + event.preventDefault(); + setImageDrag(false); + if (Date.now() - lastDropAt.current < 250) return; + lastDropAt.current = Date.now(); + const files = [...event.dataTransfer.files]; + if (files.length === 0) return; + const range = insertionRange(); + void addDroppedImages( + () => saveNoteImagesFromFiles(note.id, files), + range, + ); + }} + > + {imageDrag || imageBusy ? ( +
+ {imageBusy ? "Adding images…" : "Drop images here"} +
+ ) : null} + {mode === "source" ? ( + { + setBody(next); + scheduleSave(); + }} + /> + ) : body.trim() ? ( + + ) : ( +

No description

+ )} +
); @@ -680,10 +837,12 @@ function NoteEditor({ function NoteSource({ value, onChange, + textareaRef, autoFocus = false, }: { value: string; onChange: (value: string) => void; + textareaRef: { current: HTMLTextAreaElement | null }; autoFocus?: boolean; }) { const lines = value.split("\n"); @@ -716,6 +875,7 @@ function NoteSource({ style={{ left: gutterWidth }} />