From 913b2bca831f058e66af104c2265cd796c26e0a9 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Sun, 24 May 2026 15:00:46 -0500 Subject: [PATCH 01/11] feat(export): save conversation as Markdown or copy to clipboard Signed-off-by: Logan Nguyen --- bun.lock | 3 + docs/commands.md | 15 + package.json | 1 + src-tauri/Cargo.lock | 67 +++ src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 3 +- .../prompts/generated/slash_commands.txt | 4 +- src-tauri/src/export.rs | 165 ++++++ src-tauri/src/lib.rs | 67 +++ src/App.tsx | 203 ++++++- src/__tests__/App.test.tsx | 411 ++++++++++++++ src/components/CommandSuggestion.tsx | 35 ++ src/components/WindowControls.tsx | 51 ++ src/config/commands.ts | 25 + src/lib/__tests__/exportSerializer.test.ts | 506 ++++++++++++++++++ src/lib/exportSerializer.ts | 278 ++++++++++ src/view/ConversationView.tsx | 11 + 17 files changed, 1842 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/export.rs create mode 100644 src/lib/__tests__/exportSerializer.test.ts create mode 100644 src/lib/exportSerializer.ts diff --git a/bun.lock b/bun.lock index eb23bc97..cfbc4b12 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@fontsource/nunito": "^5.2.7", "@tauri-apps/api": "^2.11.0", + "@tauri-apps/plugin-dialog": "^2.7.1", "framer-motion": "^12.38.0", "katex": "^0.16.0", "react": "^19.2.4", @@ -272,6 +273,8 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="], + "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.1", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], diff --git a/docs/commands.md b/docs/commands.md index 42bdcc7e..6eb6338a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -192,6 +192,21 @@ Explains any concept, term, or code snippet in plain language, always with a con --- +## /export + +Exports the current conversation as a self-contained Markdown file via a native macOS save dialog. + +**Usage:** `/export` + +**Examples:** +- `/export`: opens the save dialog to write a Markdown file of the current chat + +**Behavior:** Opens the native save sheet pre-filled with `thuki-chat-YYYY-MM-DD-HHMM.md`. The resulting file is self-contained: YAML frontmatter (model, exported_at, message_count), role-labelled blocks, collapsible thinking blocks, search source footnotes, and base64-inlined screenshots. No data leaves the machine. Requires at least one message in the current session; submitting `/export` in an empty chat shakes the ask bar with a "No messages to export yet." warning. + +**Composable:** `/export` is a session-level command and is not composed with other slash commands. Any other triggers in the same message are ignored when `/export` is present. + +--- + ## /todos Summarizes what a piece of text is about, then extracts every task, action item, and commitment as a markdown checkbox list. diff --git a/package.json b/package.json index d423093e..d291911f 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "dependencies": { "@fontsource/nunito": "^5.2.7", "@tauri-apps/api": "^2.11.0", + "@tauri-apps/plugin-dialog": "^2.7.1", "framer-motion": "^12.38.0", "katex": "^0.16.0", "react": "^19.2.4", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 130d6c0c..67e8737b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3104,6 +3104,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -4011,6 +4035,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.0.7+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.1" @@ -4233,6 +4299,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-nspanel", + "tauri-plugin-dialog", "tauri-plugin-updater", "tempfile", "thiserror 2.0.18", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ae41a3f2..08901717 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,6 +23,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png", "protocol-asset"] } tauri-plugin-updater = "2" +tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.13.3", features = ["json", "stream"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 87174144..0493dd39 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -8,6 +8,7 @@ "core:window:allow-start-dragging", "core:window:deny-internal-toggle-maximize", "core:window:allow-set-size", - "core:window:allow-set-focus" + "core:window:allow-set-focus", + "dialog:allow-save" ] } diff --git a/src-tauri/prompts/generated/slash_commands.txt b/src-tauri/prompts/generated/slash_commands.txt index 01603614..c23d4b36 100644 --- a/src-tauri/prompts/generated/slash_commands.txt +++ b/src-tauri/prompts/generated/slash_commands.txt @@ -1,6 +1,6 @@ # Supported slash commands -These are Thuki's only built-in slash commands: /search, /extract, /screen, /think, /translate, /rewrite, /tldr, /refine, /bullets, /explain, /todos. +These are Thuki's only built-in slash commands: /search, /extract, /screen, /think, /translate, /rewrite, /tldr, /refine, /bullets, /explain, /export, /todos. If the user asks what slash commands are available, what built-in commands exist, or how to use them, answer with the slash-command list below. Do not answer about generic tools, tool availability, or function calling. @@ -24,4 +24,6 @@ If the user asks what slash commands are available, what built-in commands exist /explain: explain a concept or code snippet in plain language with a concrete example. Also works with attached images or /screen: OCR extracts the text first, then explains it. +/export: export the current conversation to a Markdown file on disk via the native save dialog. + /todos: summarize context and extract tasks as markdown checkboxes. Also works with attached images or /screen: OCR extracts the text first, then extracts to-dos. diff --git a/src-tauri/src/export.rs b/src-tauri/src/export.rs new file mode 100644 index 00000000..3d15d1db --- /dev/null +++ b/src-tauri/src/export.rs @@ -0,0 +1,165 @@ +/*! + * Chat session export. + * + * The frontend serialises the active conversation to a Markdown string + * (frontmatter, role-labelled blocks, inline base64 images via the Tauri + * asset protocol). This module is the trust boundary at which the chosen + * destination path becomes a real write. + * + * The native save dialog is the user's explicit consent: a path returned + * by it is, by construction, where the user wants the file. We do not + * second-guess directory traversal or path scope here. We do reject + * an empty / whitespace-only path because Tauri's `dialog::save` returns + * an opaque `String` that the frontend may relay verbatim, and an empty + * string would otherwise resolve to the current working directory on + * `std::fs::write` and silently overwrite something. + */ + +use std::fs; +use std::path::PathBuf; + +/// Failure modes for [`write_export`]. Mapped to plain strings before +/// crossing the IPC boundary. +#[derive(Debug)] +pub enum ExportError { + /// Path was empty after trimming. Treated as user cancellation rather + /// than an error worth surfacing in detail. + EmptyPath, + /// `std::fs::write` failed. The wrapped `io::Error` includes the + /// OS-level reason (permission denied, no such directory, etc.). + Write(std::io::Error), +} + +impl std::fmt::Display for ExportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ExportError::EmptyPath => write!(f, "Export path is empty"), + ExportError::Write(e) => write!(f, "{e}"), + } + } +} + +/// Writes `content` to `path`, returning the resolved [`PathBuf`]. +/// +/// Trims `path` to be lenient about trailing whitespace from the dialog +/// (some macOS save sheets occasionally append a trailing newline when +/// the user typed into the filename field). An empty or whitespace-only +/// path is rejected so the file is never written to the process working +/// directory by accident. +pub fn write_export(path: &str, content: &str) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err(ExportError::EmptyPath); + } + let target = PathBuf::from(trimmed); + fs::write(&target, content).map_err(ExportError::Write)?; + Ok(target) +} + +/// Tauri command: persists a serialised chat-session Markdown document +/// to the path the user chose in the native save dialog. +/// +/// Thin wrapper over [`write_export`]; covered by the unit tests on +/// `write_export`, which is what the wrapper delegates to. +#[tauri::command] +#[cfg_attr(coverage_nightly, coverage(off))] +pub fn save_chat_export(path: String, content: String) -> Result<(), String> { + write_export(&path, &content) + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn empty_path_is_rejected() { + let err = write_export("", "hello").expect_err("empty path must error"); + assert!(matches!(err, ExportError::EmptyPath)); + } + + #[test] + fn whitespace_only_path_is_rejected() { + let err = write_export(" \t\n", "hello").expect_err("whitespace must error"); + assert!(matches!(err, ExportError::EmptyPath)); + } + + #[test] + fn empty_path_display_is_user_facing() { + assert_eq!( + format!("{}", ExportError::EmptyPath), + "Export path is empty" + ); + } + + #[test] + fn write_error_display_forwards_io_message() { + let io = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"); + let msg = format!("{}", ExportError::Write(io)); + assert!( + msg.contains("denied"), + "io message must propagate, got: {msg}" + ); + } + + #[test] + fn valid_path_writes_content_and_returns_path() { + let dir = tempdir().expect("tempdir"); + let target = dir.path().join("export.md"); + let target_str = target.to_str().expect("utf-8"); + + let returned = write_export(target_str, "# Hello\n\nWorld").expect("write must succeed"); + + assert_eq!(returned, target); + let read_back = fs::read_to_string(&target).expect("file must exist"); + assert_eq!(read_back, "# Hello\n\nWorld"); + } + + #[test] + fn trailing_whitespace_in_path_is_trimmed() { + let dir = tempdir().expect("tempdir"); + let target = dir.path().join("trimmed.md"); + let padded = format!(" {} \n", target.to_str().expect("utf-8")); + + let returned = write_export(&padded, "content").expect("write must succeed"); + + assert_eq!(returned, target); + assert!( + target.exists(), + "file should be written to the trimmed path" + ); + } + + #[test] + fn nonexistent_directory_returns_write_error() { + let dir = tempdir().expect("tempdir"); + let target = dir.path().join("does/not/exist/export.md"); + let target_str = target.to_str().expect("utf-8"); + + let err = write_export(target_str, "x").expect_err("write must fail"); + assert!(matches!(err, ExportError::Write(_))); + } + + #[test] + fn overwrites_existing_file() { + let dir = tempdir().expect("tempdir"); + let target = dir.path().join("rewrite.md"); + fs::write(&target, "old").expect("seed"); + + write_export(target.to_str().expect("utf-8"), "new").expect("overwrite"); + let read_back = fs::read_to_string(&target).expect("file must exist"); + assert_eq!(read_back, "new"); + } + + #[test] + fn empty_content_writes_empty_file() { + let dir = tempdir().expect("tempdir"); + let target = dir.path().join("empty.md"); + write_export(target.to_str().expect("utf-8"), "").expect("empty write"); + let read_back = fs::read_to_string(&target).expect("file must exist"); + assert_eq!(read_back, ""); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b983c20c..64af0913 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,6 +18,7 @@ pub mod commands; pub mod config; pub mod database; +pub mod export; pub mod history; pub mod images; pub mod models; @@ -1027,6 +1028,69 @@ fn init_panel(app_handle: &tauri::AppHandle) { // different after the user clicks elsewhere. The CSS `shadow-bar` provides // a stable, focus-independent elevation effect. panel.set_has_shadow(false); + + // Three NSPanel-layer assertions to keep the overlay visually clean + // through the save-dialog flow, only one of which is strictly novel: + // + // 1. `setBackgroundColor: NSColor.clearColor` + `setOpaque: NO` - + // re-asserted because `to_panel::()` plus the + // subsequent `set_style_mask` rewrite can leave the panel with + // `NSColor.windowBackgroundColor` painted into the backing layer. + // + // 2. `setWorksWhenModal: YES` - keeps the panel receiving keyboard + // and mouse events even while an application-modal session + // (NSSavePanel from `rfd`) is up. Per Apple docs this property + // controls event routing, NOT the AppKit modal dim - which is + // hardcoded on every non-modal window of the app and cannot be + // cleanly opted out of. Still worth setting so the panel stays + // interactive across the modal. + // + // 3. `contentView.layer.cornerRadius` + `masksToBounds` - the + // load-bearing fix for the visible halo around Thuki when the + // save dialog is up. AppKit's modal dim fills the entire NSPanel + // bounds, but the CSS chrome inside the WebView only paints a + // smaller rounded-rect (Tailwind `rounded-lg`, 8 px). The dim + // bleeds out from the dark CSS chrome and shows as a slate-gray + // annular halo. Clipping the content-view layer to the same + // rounded shape the CSS draws gives the dim no pixels to land on + // outside the chrome. Normal-state rendering is untouched: there + // is nothing to clip when the overlay is not being dimmed. + // + // 8 px matches `rounded-lg` used by the chat-mode chrome - the + // only state from which the save dialog can be launched (the + // export button only renders in chat mode and `/export` gates on + // `messages.length > 0`). Ask-bar mode uses `rounded-2xl` + // (16 px), which produces a smaller visible CSS shape than this + // 8 px content-view clip; the clip therefore has no visible + // effect in ask-bar mode (the smaller CSS shape is already + // inside the clip). + if let Ok(ns_window) = window.ns_window() { + if !ns_window.is_null() { + use objc2::rc::autoreleasepool; + use objc2::runtime::AnyObject; + use objc2::{class, msg_send}; + let win = ns_window as *mut AnyObject; + unsafe { + autoreleasepool(|_| { + let clear: *mut AnyObject = msg_send![class!(NSColor), clearColor]; + let _: () = msg_send![win, setBackgroundColor: clear]; + let _: () = msg_send![win, setOpaque: false]; + let _: () = msg_send![win, setWorksWhenModal: true]; + + let content_view: *mut AnyObject = msg_send![win, contentView]; + if !content_view.is_null() { + let _: () = msg_send![content_view, setWantsLayer: true]; + let layer: *mut AnyObject = msg_send![content_view, layer]; + if !layer.is_null() { + let radius: f64 = 8.0; + let _: () = msg_send![layer, setCornerRadius: radius]; + let _: () = msg_send![layer, setMasksToBounds: true]; + } + } + }); + } + } + } } // ─── Settings panel initialisation ────────────────────────────────────────── @@ -1331,6 +1395,7 @@ pub fn run() { builder .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_dialog::init()) .setup(|app| { #[cfg(target_os = "macos")] app.set_activation_policy(ActivationPolicy::Accessory); @@ -1689,6 +1754,8 @@ pub fn run() { screenshot::capture_full_screen_command, #[cfg(not(coverage))] ocr::extract_text_command, + #[cfg(not(coverage))] + export::save_chat_export, notify_overlay_hidden, set_overlay_minimized, notify_frontend_ready, diff --git a/src/App.tsx b/src/App.tsx index c0722419..910f1044 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,6 +54,12 @@ import { SCREEN_CAPTURE_PLACEHOLDER, buildPrompt, } from './config/commands'; +import { save as saveDialog } from '@tauri-apps/plugin-dialog'; +import { + defaultExportFilename, + serializeForClipboard, + serializeForFile, +} from './lib/exportSerializer'; import './App.css'; const OVERLAY_VISIBILITY_EVENT = 'thuki://visibility'; @@ -356,6 +362,13 @@ function App() { /** Whether the model picker panel is currently open. Mutually exclusive with `isHistoryOpen`. */ const [isModelPickerOpen, setIsModelPickerOpen] = useState(false); + /** Whether the chat-header export popover (clipboard / file) is currently open. */ + const [isExportOpen, setIsExportOpen] = useState(false); + /** + * Ref to the export popover root. Used by the outside-click effect to + * keep the popover open while the user is clicking inside it. + */ + const exportPopoverRef = useRef(null); /** * True when the user clicked + while an unsaved conversation is active. * Causes the history dropdown to show a SwitchConfirmation prompt instead @@ -472,9 +485,28 @@ function App() { /** True while waiting for images to finish processing before a deferred * submit. Drives the "waiting" UI state in the ask bar. */ const [isSubmitPending, setIsSubmitPending] = useState(false); - /** Error message from a failed /screen capture. Shown inline above the ask - * bar so the user knows capture failed rather than seeing no response. */ + /** Error message from a failed /screen capture or any other gate that + * surfaces user-facing feedback. Shown inline above the ask bar so the + * user knows the submission did not go through. Auto-clears after a + * short linger so a one-off mistake does not leave the banner up + * forever; the next submit also clears it preemptively. */ const [captureError, setCaptureError] = useState(null); + /** + * Auto-dismiss the capture-error banner after a short linger so a + * one-off mistake (empty `/extract`, empty `/export`, OCR miss, etc.) + * does not leave a red banner up indefinitely. Mirrors the + * `shakeAskBar` self-clearing pattern. + * + * 5 seconds reads as a deliberate auto-hide rather than a flash and + * gives the user time to read a one-line message twice. The banner is + * also cleared at the top of `handleSubmit` so a fresh submit attempt + * always starts clean regardless of timing. + */ + useEffect(() => { + if (!captureError) return; + const timer = setTimeout(() => setCaptureError(null), 5000); + return () => clearTimeout(timer); + }, [captureError]); /** * Set to true when a /screen capture is dispatched, false when it resolves * or when the user cancels. Lets the async tail in handleScreenSubmit @@ -2257,6 +2289,104 @@ function App() { composeCapabilityState, ]); + /** + * Opens the macOS save dialog, serialises the current session as a + * self-contained Markdown file (frontmatter + role-labelled blocks + + * inline base64 images via the Tauri asset protocol), and asks the + * Rust backend to write it to the user's chosen path. + * + * No-ops on an empty session (defensive — the slash-command path is + * gated upstream and the chat-header button only renders in chat mode). + * User cancellation of the save dialog returns silently. A failed + * write surfaces the OS-level error message via the existing + * `captureError` banner. + */ + const runFileExport = useCallback(async () => { + setIsExportOpen(false); + /* v8 ignore start -- defensive: callers gate on messages.length > 0 */ + if (messages.length === 0) return; + /* v8 ignore stop */ + try { + const path = await saveDialog({ + defaultPath: defaultExportFilename(new Date()), + filters: [{ name: 'Markdown', extensions: ['md'] }], + }); + if (path === null) return; + const content = await serializeForFile( + messages, + { fallbackModel: activeModel }, + new Date(), + ); + await invoke('save_chat_export', { path, content }); + } catch (err) { + setCaptureError( + `Failed to export: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }, [messages, activeModel]); + + /** + * Copies the current session to the system clipboard as body-only + * Markdown. Strips the YAML frontmatter (would surface as visible + * noise in chat apps) and substitutes image markers for screenshots + * (a multi-megabyte base64 payload would otherwise jam most paste + * targets). Errors surface via the `captureError` banner. + */ + const runClipboardCopy = useCallback(async () => { + setIsExportOpen(false); + /* v8 ignore start -- defensive: callers gate on messages.length > 0 */ + if (messages.length === 0) return; + /* v8 ignore stop */ + try { + const content = serializeForClipboard(messages); + await navigator.clipboard.writeText(content); + } catch (err) { + setCaptureError( + `Failed to copy: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }, [messages]); + + /** + * Toggles the export popover from the chat-header button. Closes the + * model-picker dropdown when opening so the two popovers (anchored to + * the same `right-3 top-10` corner of the chat header) never overlap. + */ + const handleExportToggle = useCallback(() => { + setIsExportOpen((open) => { + if (!open) { + setIsModelPickerOpen(false); + } + return !open; + }); + }, []); + + /** + * Dismisses the export popover when the user clicks outside it. The + * toggle button itself is excluded so the click that already toggled + * the popover does not also close it on the same gesture. + */ + useEffect(() => { + if (!isExportOpen) return; + const handler = (event: MouseEvent) => { + const target = event.target; + /* v8 ignore start -- happy-dom always yields a Node target */ + if (!(target instanceof Node)) return; + /* v8 ignore stop */ + const popover = exportPopoverRef.current; + /* v8 ignore start -- the ref is attached whenever the popover renders */ + if (popover === null) return; + /* v8 ignore stop */ + if (popover.contains(target)) return; + if (target instanceof Element && target.closest('[data-export-toggle]')) { + return; + } + setIsExportOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [isExportOpen]); + const handleSubmit = useCallback(() => { if ( (query.trim().length === 0 && attachedImages.length === 0) || @@ -2288,6 +2418,23 @@ function App() { return; } + // /export is a session-level command. It must run in isolation from any + // other command in the same message and is gated on "at least one + // message exists". Same shake + banner UX as /extract for consistency. + const hasExport = found.has('/export'); + if (hasExport) { + if (messages.length === 0) { + setCaptureError('No messages to export yet.'); + setShakeAskBar(true); + return; + } + setQuery(''); + /* v8 ignore next */ + inputRef.current!.style.height = 'auto'; + void runFileExport(); + return; + } + // OCR paths (/extract, utility commands with images or /screen) bypass // the Ollama capability/environment gate: Vision OCR runs locally and // utility OCR sends extracted text — never image bytes — to the model. @@ -2483,6 +2630,8 @@ function App() { searchActive, quote.maxContextLength, hasBlockingConflict, + messages.length, + runFileExport, ]); // When a pending submit exists and all images finish processing, dispatch @@ -3008,6 +3157,12 @@ function App() { } isModelPickerOpen={isModelPickerOpen} onMinimize={handleMinimize} + onExportToggle={ + messages.length > 0 + ? handleExportToggle + : undefined + } + isExportOpen={isExportOpen} /> ) : null} @@ -3256,6 +3411,50 @@ function App() { ) : null} + {/* Chat-mode export popover. Anchored to the same right-3 top-10 + corner as the model picker dropdown; the two never overlap + because opening one closes the other. Visual treatment mirrors + the `SwitchConfirmation` prompt (sentence-case title, plain + text rows, primary-highlighted recommended action) so the two + small popovers feel like a single language. */} + + {isChatMode && isExportOpen ? ( + +
+

+ Export chat +

+
+ + +
+
+
+ ) : null} +
+ {/* Chat-mode history dropdown - sibling of the morphing container so it is never clipped by its overflow-hidden. Positioned absolutely within this relative wrapper (same coordinate space as the diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index d7682bf9..fd2c7b68 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -15,6 +15,11 @@ import { enableChannelCaptureWithResponses, getLastChannel, } from '../testUtils/mocks/tauri'; +import { save as saveDialog } from '@tauri-apps/plugin-dialog'; + +vi.mock('@tauri-apps/plugin-dialog', () => ({ + save: vi.fn(), +})); import { __mockWindow, __setWindowGeometry, @@ -8083,4 +8088,410 @@ describe('App', () => { ); }); }); + + // ─── /export command ──────────────────────────────────────────────────────── + + describe('/export command', () => { + let writeText: ReturnType; + let clipboardSpy: { mockRestore: () => void } | null = null; + + beforeEach(() => { + vi.mocked(saveDialog).mockReset(); + writeText = vi.fn().mockResolvedValue(undefined); + // happy-dom defines `navigator.clipboard` as a non-configurable + // property, so a full property redefinition throws. Spy on the + // existing `writeText` method instead. + clipboardSpy = vi + .spyOn(navigator.clipboard, 'writeText') + .mockImplementation(writeText as (data: string) => Promise); + }); + + afterEach(() => { + clipboardSpy?.mockRestore(); + clipboardSpy = null; + }); + + async function enterChatMode() { + render(); + await act(async () => {}); + await showOverlay(); + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: 'seed' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'ok' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + } + + it('shakes ask bar and shows warning when /export is submitted with no messages', async () => { + render(); + await act(async () => {}); + await showOverlay(); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/export' } }); + }); + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + expect( + screen.getByText('No messages to export yet.'), + ).toBeInTheDocument(); + expect(saveDialog).not.toHaveBeenCalled(); + expect(invoke).not.toHaveBeenCalledWith( + 'save_chat_export', + expect.anything(), + ); + }); + + it('opens the save dialog and invokes save_chat_export on /export submit with messages', async () => { + await enterChatMode(); + vi.mocked(saveDialog).mockResolvedValue('/tmp/thuki-chat.md'); + invoke.mockClear(); + + const textarea = screen.getByPlaceholderText('Reply...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/export' } }); + }); + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + expect(saveDialog).toHaveBeenCalledWith( + expect.objectContaining({ + defaultPath: expect.stringMatching( + /^thuki-chat-\d{4}-\d{2}-\d{2}-\d{4}\.md$/, + ), + filters: [{ name: 'Markdown', extensions: ['md'] }], + }), + ); + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'save_chat_export', + expect.objectContaining({ + path: '/tmp/thuki-chat.md', + content: expect.stringContaining('---\napp: Thuki'), + }), + ); + }); + }); + + it('silently no-ops when the user cancels the save dialog', async () => { + await enterChatMode(); + vi.mocked(saveDialog).mockResolvedValue(null); + invoke.mockClear(); + + const textarea = screen.getByPlaceholderText('Reply...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/export' } }); + }); + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + expect(saveDialog).toHaveBeenCalled(); + expect(invoke).not.toHaveBeenCalledWith( + 'save_chat_export', + expect.anything(), + ); + }); + + it('surfaces an error banner when save_chat_export rejects', async () => { + await enterChatMode(); + vi.mocked(saveDialog).mockResolvedValue('/bad/path.md'); + // Override invoke for save_chat_export to reject without disturbing + // the channel-capture seed for unrelated commands. + const prev = invoke.getMockImplementation(); + invoke.mockImplementation(async (cmd, args) => { + if (cmd === 'save_chat_export') { + throw new Error('disk full'); + } + return prev ? prev(cmd, args) : undefined; + }); + + const textarea = screen.getByPlaceholderText('Reply...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/export' } }); + }); + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + await vi.waitFor(() => { + expect( + screen.getByText(/Failed to export: disk full/), + ).toBeInTheDocument(); + }); + }); + + it('surfaces an error banner when the save dialog itself throws', async () => { + await enterChatMode(); + vi.mocked(saveDialog).mockRejectedValue(new Error('dialog blew up')); + + const textarea = screen.getByPlaceholderText('Reply...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/export' } }); + }); + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + await vi.waitFor(() => { + expect( + screen.getByText(/Failed to export: dialog blew up/), + ).toBeInTheDocument(); + }); + }); + + it('falls back to String(err) when the save dialog throws a non-Error', async () => { + await enterChatMode(); + vi.mocked(saveDialog).mockRejectedValue('plain string err'); + + const textarea = screen.getByPlaceholderText('Reply...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/export' } }); + }); + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + await vi.waitFor(() => { + expect( + screen.getByText(/Failed to export: plain string err/), + ).toBeInTheDocument(); + }); + }); + + it('renders the export button in chat mode and the popover opens on click', async () => { + await enterChatMode(); + + const exportButton = screen.getByRole('button', { name: 'Export chat' }); + expect(exportButton).toBeInTheDocument(); + expect(exportButton).toHaveAttribute('aria-expanded', 'false'); + + await act(async () => { + fireEvent.click(exportButton); + }); + + expect(exportButton).toHaveAttribute('aria-expanded', 'true'); + expect( + screen.getByRole('button', { name: /Save as Markdown/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Copy to clipboard/i }), + ).toBeInTheDocument(); + }); + + it('does not render the export button in ask-bar mode (no messages)', async () => { + render(); + await act(async () => {}); + await showOverlay(); + + expect(screen.queryByRole('button', { name: 'Export chat' })).toBeNull(); + }); + + it('invokes save_chat_export when the "Save as Markdown" button is clicked', async () => { + await enterChatMode(); + vi.mocked(saveDialog).mockResolvedValue('/tmp/btn-export.md'); + invoke.mockClear(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Export chat' })); + }); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /Save as Markdown/i }), + ); + }); + + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'save_chat_export', + expect.objectContaining({ path: '/tmp/btn-export.md' }), + ); + }); + }); + + it('writes to the clipboard when the "Copy to clipboard" button is clicked', async () => { + await enterChatMode(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Export chat' })); + }); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /Copy to clipboard/i }), + ); + }); + + await vi.waitFor(() => { + expect(writeText).toHaveBeenCalledWith( + expect.stringContaining('## User'), + ); + }); + }); + + it('shows an error banner when clipboard.writeText rejects', async () => { + await enterChatMode(); + writeText.mockRejectedValueOnce(new Error('clipboard denied')); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Export chat' })); + }); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /Copy to clipboard/i }), + ); + }); + + await vi.waitFor(() => { + expect( + screen.getByText(/Failed to copy: clipboard denied/), + ).toBeInTheDocument(); + }); + }); + + it('falls back to String(err) when the clipboard writer throws a non-Error', async () => { + await enterChatMode(); + writeText.mockRejectedValueOnce('clip-plain'); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Export chat' })); + }); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /Copy to clipboard/i }), + ); + }); + + await vi.waitFor(() => { + expect( + screen.getByText(/Failed to copy: clip-plain/), + ).toBeInTheDocument(); + }); + }); + + it('keeps the popover open when mousedown lands inside it', async () => { + await enterChatMode(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Export chat' })); + }); + const item = screen.getByRole('button', { + name: /Save as Markdown/i, + }); + await act(async () => { + fireEvent.mouseDown(item); + }); + + // popover.contains(target) returned true, so the outside-click + // handler bailed and the popover remains rendered. + expect( + screen.getByRole('button', { name: /Save as Markdown/i }), + ).toBeInTheDocument(); + }); + + it('closes the popover when clicking outside', async () => { + await enterChatMode(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Export chat' })); + }); + expect( + screen.queryByRole('button', { name: /Save as Markdown/i }), + ).toBeInTheDocument(); + + await act(async () => { + fireEvent.mouseDown(document.body); + }); + + expect( + screen.queryByRole('button', { name: /Save as Markdown/i }), + ).toBeNull(); + }); + + it('toggles the popover closed when the export button is clicked a second time', async () => { + await enterChatMode(); + const exportButton = screen.getByRole('button', { name: 'Export chat' }); + + await act(async () => { + fireEvent.click(exportButton); + }); + expect(exportButton).toHaveAttribute('aria-expanded', 'true'); + + // The button has data-export-toggle so a mousedown on it does NOT + // close via the outside-click effect; the subsequent click toggles + // the state to false. + await act(async () => { + fireEvent.mouseDown(exportButton); + }); + await act(async () => { + fireEvent.click(exportButton); + }); + + expect(exportButton).toHaveAttribute('aria-expanded', 'false'); + }); + + it('auto-clears the capture-error banner after a short linger', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + render(); + await act(async () => {}); + await showOverlay(); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/export' } }); + }); + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + expect( + screen.getByText('No messages to export yet.'), + ).toBeInTheDocument(); + + // Auto-dismiss timer is 5s. Advance past it. + await act(async () => { + vi.advanceTimersByTime(5000); + }); + + expect(screen.queryByText('No messages to export yet.')).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('closes the model picker when opening the export popover', async () => { + await enterChatMode(); + + // Open model picker first. + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Choose model' })); + }); + // Then open export popover; model picker should close. + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Export chat' })); + }); + + // Export popover is open. + expect( + screen.getByRole('button', { name: /Save as Markdown/i }), + ).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/CommandSuggestion.tsx b/src/components/CommandSuggestion.tsx index 9c9871f0..a5f218d6 100644 --- a/src/components/CommandSuggestion.tsx +++ b/src/components/CommandSuggestion.tsx @@ -311,6 +311,39 @@ const ACTION_ICON = ( ); +/** Download-arrow-into-tray icon for /export command. */ +const EXPORT_ICON = ( + +); + /** Info-circle icon for /explain command. */ const EXPLAIN_ICON = ( ); +/** Hoisted download-arrow-into-tray icon. */ +const EXPORT_ICON = ( + +); + interface WindowControlsProps { /** Triggers the overlay hide animation sequence. */ onClose: () => void; @@ -150,6 +169,16 @@ interface WindowControlsProps { * dot inert and decorative (ask-bar mode or no conversation to park). */ onMinimize?: () => void; + /** + * Called when the user clicks the export button to open the export + * options popover. Omit to hide the export button entirely. + */ + onExportToggle?: () => void; + /** + * Drives `aria-expanded` on the export button so screen readers reflect + * the popover's open state. + */ + isExportOpen?: boolean; } /** Decorative dot color for inactive buttons. */ @@ -166,6 +195,8 @@ export const WindowControls = memo(function WindowControls({ onModelPickerToggle, isModelPickerOpen = false, onMinimize, + onExportToggle, + isExportOpen = false, }: WindowControlsProps) { // Disabled only when there is nothing to save yet and the conversation hasn't // been saved. Once saved the button stays active so the user can unsave. @@ -350,6 +381,26 @@ export const WindowControls = memo(function WindowControls({ )} + + {onExportToggle !== undefined && ( + + + + )} diff --git a/src/config/commands.ts b/src/config/commands.ts index 1b1c7752..1213fdc5 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -307,6 +307,31 @@ export const COMMANDS: readonly Command[] = [ promptTemplate: 'Explain the following in plain, simple language. Assume the reader is smart but has no background in the topic: avoid jargon and use analogies where helpful. Structure your answer in two parts: a brief explanation of the concept, followed by at least one concrete example that makes it tangible. Be concise. Output only the explanation, no introduction or sign-off.\n\nText: $INPUT', }, + { + trigger: '/export', + label: '/export', + description: 'Export the conversation as a Markdown file', + docs: { + summary: + 'Exports the current conversation as a self-contained Markdown file via a native macOS save dialog.', + usage: '/export', + examples: [ + '`/export`: opens the save dialog to write a Markdown file of the current chat', + ], + behavior: + 'Opens the native save sheet pre-filled with `thuki-chat-YYYY-MM-DD-HHMM.md`. The resulting file is self-contained: YAML frontmatter (model, exported_at, message_count), role-labelled blocks, collapsible thinking blocks, search source footnotes, and base64-inlined screenshots. No data leaves the machine. Requires at least one message in the current session; submitting `/export` in an empty chat shakes the ask bar with a "No messages to export yet." warning.', + composability: + '`/export` is a session-level command and is not composed with other slash commands. Any other triggers in the same message are ignored when `/export` is present.', + }, + promptHelp: { + summary: + 'export the current conversation to a Markdown file on disk via the native save dialog.', + whenToSuggest: + 'Mention this when the user asks to save the conversation, share it, or archive it.', + limit: + 'The command writes a file and does not produce a chat response. It cannot be composed with other commands.', + }, + }, { trigger: '/todos', label: '/todos', diff --git a/src/lib/__tests__/exportSerializer.test.ts b/src/lib/__tests__/exportSerializer.test.ts new file mode 100644 index 00000000..6a16e75e --- /dev/null +++ b/src/lib/__tests__/exportSerializer.test.ts @@ -0,0 +1,506 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import type { Message } from '../../hooks/useOllama'; +import { + defaultExportFilename, + defaultImageLoader, + serializeForClipboard, + serializeForFile, + type FileExportContext, + type ImageLoader, +} from '../exportSerializer'; + +vi.mock('@tauri-apps/api/core', () => ({ + convertFileSrc: (path: string) => `asset://${path}`, +})); + +function makeMessage(overrides: Partial): Message { + return { + id: 'msg-id', + role: 'user', + content: '', + ...overrides, + }; +} + +const CTX: FileExportContext = { fallbackModel: 'default-model' }; + +const stubImageLoader: ImageLoader = async (path) => + `data:image/jpeg;base64,STUB(${path})`; + +describe('defaultExportFilename', () => { + it('formats local date and time with zero padding', () => { + // 2026-01-09T03:07:00 local + const filename = defaultExportFilename(new Date(2026, 0, 9, 3, 7, 0)); + expect(filename).toBe('thuki-chat-2026-01-09-0307.md'); + }); + + it('formats single-digit month and day with zero padding', () => { + const filename = defaultExportFilename(new Date(2026, 5, 4, 12, 30, 0)); + expect(filename).toBe('thuki-chat-2026-06-04-1230.md'); + }); + + it('formats midnight as 0000', () => { + const filename = defaultExportFilename(new Date(2026, 11, 31, 0, 0, 0)); + expect(filename).toBe('thuki-chat-2026-12-31-0000.md'); + }); + + it('formats double-digit hour and minute correctly', () => { + const filename = defaultExportFilename(new Date(2026, 4, 24, 14, 30, 15)); + expect(filename).toBe('thuki-chat-2026-05-24-1430.md'); + }); +}); + +describe('serializeForFile', () => { + const NOW = new Date(2026, 4, 24, 14, 30, 15); + + it('emits YAML frontmatter with model, exported_at, message_count', async () => { + const messages: Message[] = [ + makeMessage({ id: 'u1', role: 'user', content: 'hello' }), + makeMessage({ + id: 'a1', + role: 'assistant', + content: 'hi', + modelName: 'llama3.2:3b', + }), + ]; + + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + + expect(result).toContain('---\napp: Thuki'); + expect(result).toContain('model: llama3.2:3b'); + expect(result).toMatch(/exported_at: 2026-05-24T14:30:15[+-]\d{2}:\d{2}/); + expect(result).toContain('message_count: 2'); + }); + + it('falls back to the supplied fallbackModel when no assistant has modelName', async () => { + const messages: Message[] = [ + makeMessage({ id: 'u1', role: 'user', content: 'hello' }), + makeMessage({ id: 'a1', role: 'assistant', content: 'hi' }), + ]; + + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain('model: default-model'); + }); + + it('emits "unknown" when no assistant modelName and no fallback', async () => { + const messages: Message[] = [ + makeMessage({ id: 'u1', role: 'user', content: 'hello' }), + ]; + const result = await serializeForFile( + messages, + { fallbackModel: null }, + NOW, + stubImageLoader, + ); + expect(result).toContain('model: unknown'); + }); + + it('emits frontmatter even when there are zero messages', async () => { + const result = await serializeForFile([], CTX, NOW, stubImageLoader); + expect(result).toContain('message_count: 0'); + // No trailing message body separator. + expect(result.split('---').length).toBe(3); + }); + + it('renders user messages with the User heading', async () => { + const messages: Message[] = [ + makeMessage({ id: 'u1', role: 'user', content: 'a question' }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain('## User\n\na question'); + }); + + it('renders assistant messages with the model name in parentheses', async () => { + const messages: Message[] = [ + makeMessage({ + id: 'a1', + role: 'assistant', + content: 'an answer', + modelName: 'qwen:7b', + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain('## Assistant (qwen:7b)\n\nan answer'); + }); + + it('renders an unattributed assistant message as plain "Assistant"', async () => { + const messages: Message[] = [ + makeMessage({ id: 'a1', role: 'assistant', content: 'an answer' }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain('## Assistant\n\nan answer'); + }); + + it('renders quoted text as a Markdown blockquote', async () => { + const messages: Message[] = [ + makeMessage({ + id: 'u1', + role: 'user', + content: 'follow up', + quotedText: 'first line\nsecond line', + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain('> first line\n> second line'); + }); + + it('renders thinking content inside a collapsed details block', async () => { + const messages: Message[] = [ + makeMessage({ + id: 'a1', + role: 'assistant', + content: 'final answer', + thinkingContent: 'step 1\nstep 2', + modelName: 'thinker:7b', + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain( + '
\nThinking\n\nstep 1\nstep 2\n\n
', + ); + }); + + it('renders search sources as a numbered list', async () => { + const messages: Message[] = [ + makeMessage({ + id: 'a1', + role: 'assistant', + content: 'See sources.', + searchSources: [ + { title: 'First', url: 'https://example.com/one' }, + { title: 'Second', url: 'https://example.com/two' }, + ], + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain('**Sources** (`/search`):'); + expect(result).toContain('1. [First](https://example.com/one)'); + expect(result).toContain('2. [Second](https://example.com/two)'); + }); + + it('uses the source URL as the link label when the title is empty', async () => { + const messages: Message[] = [ + makeMessage({ + id: 'a1', + role: 'assistant', + content: 'See', + searchSources: [{ title: '', url: 'https://nowhere.example/page' }], + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain( + '1. [https://nowhere.example/page](https://nowhere.example/page)', + ); + }); + + it('skips the sources section entirely when none are present', async () => { + const messages: Message[] = [ + makeMessage({ id: 'a1', role: 'assistant', content: 'no sources' }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).not.toContain('**Sources**'); + }); + + it('inlines images as data URIs from the supplied loader', async () => { + const messages: Message[] = [ + makeMessage({ + id: 'u1', + role: 'user', + content: 'look at this', + imagePaths: ['/Users/me/screen.jpg'], + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain( + '![Screenshot](data:image/jpeg;base64,STUB(/Users/me/screen.jpg))', + ); + }); + + it('renders multiple images for a single message on separate lines', async () => { + const messages: Message[] = [ + makeMessage({ + id: 'u1', + role: 'user', + content: '', + imagePaths: ['/a.jpg', '/b.jpg'], + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain( + '![Screenshot](data:image/jpeg;base64,STUB(/a.jpg))\n\n![Screenshot](data:image/jpeg;base64,STUB(/b.jpg))', + ); + }); + + it('falls back to a textual marker when an image loader rejects', async () => { + const failingLoader: ImageLoader = async () => { + throw new Error('not found'); + }; + const messages: Message[] = [ + makeMessage({ + id: 'u1', + role: 'user', + content: '', + imagePaths: ['/Users/me/missing.jpg'], + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, failingLoader); + expect(result).toContain('_[Screenshot unavailable: missing.jpg]_'); + expect(result).not.toContain('data:image/jpeg'); + }); + + it('separates consecutive messages with a horizontal rule', async () => { + const messages: Message[] = [ + makeMessage({ id: 'u1', role: 'user', content: 'first' }), + makeMessage({ id: 'a1', role: 'assistant', content: 'second' }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain('first\n\n---\n\n## Assistant'); + }); + + it('uses a "+" sign in exported_at for timezones east of UTC', async () => { + // getTimezoneOffset is NEGATIVE east of UTC (e.g., JST = -540). + const spy = vi + .spyOn(Date.prototype, 'getTimezoneOffset') + .mockReturnValue(-540); + try { + const result = await serializeForFile( + [makeMessage({ role: 'user', content: 'hi' })], + CTX, + new Date(2026, 4, 24, 14, 30, 15), + stubImageLoader, + ); + expect(result).toMatch(/exported_at: 2026-05-24T14:30:15\+09:00/); + } finally { + spy.mockRestore(); + } + }); + + it('uses a "-" sign in exported_at for timezones west of UTC', async () => { + // getTimezoneOffset is POSITIVE west of UTC (e.g., EST = +300). + const spy = vi + .spyOn(Date.prototype, 'getTimezoneOffset') + .mockReturnValue(300); + try { + const result = await serializeForFile( + [makeMessage({ role: 'user', content: 'hi' })], + CTX, + new Date(2026, 4, 24, 14, 30, 15), + stubImageLoader, + ); + expect(result).toMatch(/exported_at: 2026-05-24T14:30:15-05:00/); + } finally { + spy.mockRestore(); + } + }); + + it('handles a Windows-style image path in basename fallback', async () => { + const failingLoader: ImageLoader = async () => { + throw new Error('boom'); + }; + const messages: Message[] = [ + makeMessage({ + id: 'u1', + role: 'user', + content: '', + imagePaths: ['C:\\Users\\me\\shot.png'], + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, failingLoader); + expect(result).toContain('_[Screenshot unavailable: shot.png]_'); + }); + + it('uses the bare path when no slash is present for the basename fallback', async () => { + const failingLoader: ImageLoader = async () => { + throw new Error('boom'); + }; + const messages: Message[] = [ + makeMessage({ + id: 'u1', + role: 'user', + content: '', + imagePaths: ['orphan.png'], + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, failingLoader); + expect(result).toContain('_[Screenshot unavailable: orphan.png]_'); + }); +}); + +describe('serializeForClipboard', () => { + it('omits the YAML frontmatter entirely', () => { + const messages: Message[] = [ + makeMessage({ id: 'u1', role: 'user', content: 'hi' }), + ]; + const result = serializeForClipboard(messages); + expect(result).not.toContain('---\napp: Thuki'); + expect(result).not.toContain('exported_at'); + }); + + it('renders role-labelled blocks identical to the file output (text only)', () => { + const messages: Message[] = [ + makeMessage({ id: 'u1', role: 'user', content: 'hi' }), + makeMessage({ + id: 'a1', + role: 'assistant', + content: 'hello', + modelName: 'qwen:7b', + }), + ]; + const result = serializeForClipboard(messages); + expect(result).toContain('## User\n\nhi'); + expect(result).toContain('## Assistant (qwen:7b)\n\nhello'); + }); + + it('replaces images with a textual marker (no base64 payload)', () => { + const messages: Message[] = [ + makeMessage({ + id: 'u1', + role: 'user', + content: 'look', + imagePaths: ['/Users/me/photo.jpg'], + }), + ]; + const result = serializeForClipboard(messages); + expect(result).toContain('_[Screenshot: photo.jpg]_'); + expect(result).not.toContain('data:image'); + }); + + it('renders multiple image markers on separate lines', () => { + const messages: Message[] = [ + makeMessage({ + id: 'u1', + role: 'user', + content: '', + imagePaths: ['/a.jpg', '/b.jpg'], + }), + ]; + const result = serializeForClipboard(messages); + expect(result).toContain('_[Screenshot: a.jpg]_\n\n_[Screenshot: b.jpg]_'); + }); + + it('keeps thinking blocks and search sources', () => { + const messages: Message[] = [ + makeMessage({ + id: 'a1', + role: 'assistant', + content: 'answer', + thinkingContent: 'considering', + searchSources: [{ title: 'Doc', url: 'https://example.com' }], + modelName: 'qwen:7b', + }), + ]; + const result = serializeForClipboard(messages); + expect(result).toContain('
'); + expect(result).toContain('1. [Doc](https://example.com)'); + }); + + it('returns an empty string when there are zero messages', () => { + expect(serializeForClipboard([])).toBe(''); + }); + + it('keeps quoted blockquotes', () => { + const messages: Message[] = [ + makeMessage({ + id: 'u1', + role: 'user', + content: 'context', + quotedText: 'line one', + }), + ]; + const result = serializeForClipboard(messages); + expect(result).toContain('> line one'); + }); +}); + +// `defaultImageLoader` is the production image loader that screenshots flow +// through when the export is triggered for real. It reads an asset-protocol +// URL via `fetch` and base64-encodes the resulting Blob through a FileReader. +// happy-dom ships a real FileReader implementation, so the success path can +// run end-to-end with only `fetch` stubbed. The failure paths swap in a +// fake FileReader because the real implementation never produces a +// non-string result and never fires `onerror` on a freshly fetched Blob. + +describe('defaultImageLoader', () => { + const originalFetch = globalThis.fetch; + const originalFileReader = globalThis.FileReader; + + afterEach(() => { + globalThis.fetch = originalFetch; + globalThis.FileReader = originalFileReader; + }); + + it('reads a file path and returns a data URI', async () => { + const blob = new Blob(['data'], { type: 'image/png' }); + globalThis.fetch = vi.fn(async () => { + return { + blob: async () => blob, + } as unknown as Response; + }); + + const result = await defaultImageLoader('/path/img.png'); + expect(result).toMatch(/^data:image\/png/); + expect(globalThis.fetch).toHaveBeenCalledWith('asset:///path/img.png'); + }); + + it('rejects when FileReader yields a non-string result', async () => { + class NonStringResultFileReader { + result: ArrayBuffer | null = null; + onload: ((this: NonStringResultFileReader) => void) | null = null; + onerror: ((this: NonStringResultFileReader) => void) | null = null; + error: DOMException | null = null; + readAsDataURL() { + this.result = new ArrayBuffer(2); + queueMicrotask(() => this.onload?.call(this)); + } + } + globalThis.FileReader = + NonStringResultFileReader as unknown as typeof FileReader; + const blob = new Blob(['data'], { type: 'image/png' }); + globalThis.fetch = vi.fn( + async () => ({ blob: async () => blob }) as unknown as Response, + ); + + await expect(defaultImageLoader('/x')).rejects.toThrow( + 'FileReader did not return a string data URI', + ); + }); + + it('rejects with the underlying FileReader error when onerror fires', async () => { + const readerError = new Error('read failed') as unknown as DOMException; + class FailingFileReader { + result: string | null = null; + onload: ((this: FailingFileReader) => void) | null = null; + onerror: ((this: FailingFileReader) => void) | null = null; + error: DOMException | null = readerError; + readAsDataURL() { + queueMicrotask(() => this.onerror?.call(this)); + } + } + globalThis.FileReader = FailingFileReader as unknown as typeof FileReader; + const blob = new Blob(['data'], { type: 'image/png' }); + globalThis.fetch = vi.fn( + async () => ({ blob: async () => blob }) as unknown as Response, + ); + + await expect(defaultImageLoader('/x')).rejects.toThrow('read failed'); + }); + + it('rejects with a generic FileReader error when reader.error is null', async () => { + class NullErrorFileReader { + result: string | null = null; + onload: ((this: NullErrorFileReader) => void) | null = null; + onerror: ((this: NullErrorFileReader) => void) | null = null; + error: DOMException | null = null; + readAsDataURL() { + queueMicrotask(() => this.onerror?.call(this)); + } + } + globalThis.FileReader = NullErrorFileReader as unknown as typeof FileReader; + const blob = new Blob(['data'], { type: 'image/png' }); + globalThis.fetch = vi.fn( + async () => ({ blob: async () => blob }) as unknown as Response, + ); + + await expect(defaultImageLoader('/x')).rejects.toThrow('FileReader error'); + }); +}); diff --git a/src/lib/exportSerializer.ts b/src/lib/exportSerializer.ts new file mode 100644 index 00000000..897cedb8 --- /dev/null +++ b/src/lib/exportSerializer.ts @@ -0,0 +1,278 @@ +/** + * Chat session export serialisers. + * + * Two outputs: + * + * - {@link serializeForFile}: self-contained Markdown artefact. Includes a + * YAML frontmatter block, the conditional customised system prompt, and + * inline base64 data URIs for screenshots. Suitable for archival, GitHub, + * Notion, Obsidian, or pasting back into another LLM. + * - {@link serializeForClipboard}: body-only Markdown. Frontmatter is + * stripped and screenshots are replaced with a textual placeholder so + * pasting into Slack, Discord, or a plain editor stays readable and does + * not detonate multi-megabyte base64 payloads on the clipboard. + * + * Both functions are intentionally pure with respect to the date and the + * caller-provided configuration. The caller injects `now: Date` so tests + * can assert deterministic output and so the export captures a single + * coherent moment instead of drifting across nested `new Date()` calls. + */ + +import { convertFileSrc } from '@tauri-apps/api/core'; +import type { Message } from '../hooks/useOllama'; + +/** Configuration relevant to a file export. */ +export interface FileExportContext { + /** + * Slug of the model currently selected at export time. Used only as a + * fallback for assistant messages that have no `modelName` attribution + * (legacy conversations loaded from pre-attribution history rows). + * `undefined` is treated identically to `null`. + */ + readonly fallbackModel: string | null | undefined; +} + +/** + * Returns the default filename suggested in the native save dialog. + * + * Format: `thuki-chat-YYYY-MM-DD-HHMM.md`. Local timezone (matches what + * the user perceives as "now"). No slug from the first user message so + * a privacy-sensitive snippet does not become visible in Finder / + * Spotlight. + */ +export function defaultExportFilename(now: Date): string { + const yyyy = now.getFullYear(); + const mm = pad2(now.getMonth() + 1); + const dd = pad2(now.getDate()); + const hh = pad2(now.getHours()); + const mi = pad2(now.getMinutes()); + return `thuki-chat-${yyyy}-${mm}-${dd}-${hh}${mi}.md`; +} + +/** + * Resolves a screenshot file path to a `data:` URI for inline embedding. + * + * Uses the Tauri asset protocol (`convertFileSrc`) so the renderer can + * fetch the file without any new IPC. Reads via `fetch` + `FileReader` + * because both are first-class browser APIs in the webview and require + * no additional Tauri plugin (`fs:`) scope. + * + * Surfaced as a hook so tests can stub it without driving the asset + * protocol or the network at all. The default implementation is + * exported for production wiring. + */ +export type ImageLoader = (path: string) => Promise; + +export const defaultImageLoader: ImageLoader = (path) => pathToDataUri(path); + +async function pathToDataUri(path: string): Promise { + const url = convertFileSrc(path); + const response = await fetch(url); + const blob = await response.blob(); + return await blobToDataUri(blob); +} + +function blobToDataUri(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result; + if (typeof result === 'string') { + resolve(result); + } else { + reject(new Error('FileReader did not return a string data URI')); + } + }; + reader.onerror = () => + reject(reader.error ?? new Error('FileReader error')); + reader.readAsDataURL(blob); + }); +} + +/** + * Serialises an entire conversation into a self-contained Markdown file. + * + * Asynchronous because screenshots are read from disk and base64-encoded. + * Image read failures fall back to a textual placeholder so a single + * broken file path never aborts the whole export. + */ +export async function serializeForFile( + messages: readonly Message[], + ctx: FileExportContext, + now: Date, + loadImage: ImageLoader = defaultImageLoader, +): Promise { + const frontmatter = buildFrontmatter(messages, ctx, now); + const body = await buildBody(messages, loadImage); + return `${frontmatter}\n${body}`; +} + +/** + * Serialises an entire conversation into clipboard-friendly Markdown. + * + * No frontmatter (would surface as noisy text when pasted into chat + * apps), no base64 images (multi-megabyte clipboards crash paste flows + * in Slack/Discord). Image messages render as a textual marker so the + * context that a screenshot existed is preserved. + */ +export function serializeForClipboard(messages: readonly Message[]): string { + return buildBodyTextOnly(messages); +} + +function buildFrontmatter( + messages: readonly Message[], + ctx: FileExportContext, + now: Date, +): string { + return [ + '---', + 'app: Thuki', + `model: ${pickModel(messages, ctx.fallbackModel)}`, + `exported_at: ${isoLocal(now)}`, + `message_count: ${messages.length}`, + '---', + ].join('\n'); +} + +async function buildBody( + messages: readonly Message[], + loadImage: ImageLoader, +): Promise { + const sections: string[] = []; + for (const message of messages) { + sections.push(await renderMessage(message, loadImage)); + } + return sections.join('\n\n---\n\n').concat(sections.length > 0 ? '\n' : ''); +} + +function buildBodyTextOnly(messages: readonly Message[]): string { + const sections = messages.map(renderMessageTextOnly); + return sections.join('\n\n---\n\n').concat(sections.length > 0 ? '\n' : ''); +} + +async function renderMessage( + message: Message, + loadImage: ImageLoader, +): Promise { + const parts: string[] = [`## ${roleLabel(message)}`]; + const quote = renderQuote(message); + if (quote) parts.push(quote); + if (message.thinkingContent) { + parts.push(renderThinking(message.thinkingContent)); + } + if (message.content) parts.push(message.content); + const images = await renderImages(message, loadImage); + if (images) parts.push(images); + const sources = renderSources(message); + if (sources) parts.push(sources); + return parts.join('\n\n'); +} + +function renderMessageTextOnly(message: Message): string { + const parts: string[] = [`## ${roleLabel(message)}`]; + const quote = renderQuote(message); + if (quote) parts.push(quote); + if (message.thinkingContent) { + parts.push(renderThinking(message.thinkingContent)); + } + if (message.content) parts.push(message.content); + const imageMarkers = renderImagesAsMarkers(message); + if (imageMarkers) parts.push(imageMarkers); + const sources = renderSources(message); + if (sources) parts.push(sources); + return parts.join('\n\n'); +} + +function roleLabel(message: Message): string { + if (message.role === 'user') return 'User'; + return message.modelName ? `Assistant (${message.modelName})` : 'Assistant'; +} + +function renderQuote(message: Message): string | null { + if (!message.quotedText) return null; + const quoted = message.quotedText + .split('\n') + .map((line) => `> ${line}`) + .join('\n'); + return quoted; +} + +function renderThinking(thinking: string): string { + return `
\nThinking\n\n${thinking}\n\n
`; +} + +async function renderImages( + message: Message, + loadImage: ImageLoader, +): Promise { + if (!message.imagePaths || message.imagePaths.length === 0) return null; + const rendered: string[] = []; + for (const path of message.imagePaths) { + rendered.push(await renderSingleImage(path, loadImage)); + } + return rendered.join('\n\n'); +} + +async function renderSingleImage( + path: string, + loadImage: ImageLoader, +): Promise { + try { + const dataUri = await loadImage(path); + return `![Screenshot](${dataUri})`; + } catch { + return `_[Screenshot unavailable: ${basename(path)}]_`; + } +} + +function renderImagesAsMarkers(message: Message): string | null { + if (!message.imagePaths || message.imagePaths.length === 0) return null; + return message.imagePaths + .map((path) => `_[Screenshot: ${basename(path)}]_`) + .join('\n\n'); +} + +function renderSources(message: Message): string | null { + const sources = message.searchSources; + if (!sources || sources.length === 0) return null; + const lines = ['**Sources** (`/search`):']; + sources.forEach((source, index) => { + const title = source.title || source.url; + lines.push(`${index + 1}. [${title}](${source.url})`); + }); + return lines.join('\n'); +} + +function pickModel( + messages: readonly Message[], + fallback: string | null | undefined, +): string { + for (const message of messages) { + if (message.role === 'assistant' && message.modelName) { + return message.modelName; + } + } + return fallback ?? 'unknown'; +} + +function pad2(value: number): string { + return String(value).padStart(2, '0'); +} + +function isoLocal(date: Date): string { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? '+' : '-'; + const absOffset = Math.abs(offsetMinutes); + const offsetH = pad2(Math.floor(absOffset / 60)); + const offsetM = pad2(absOffset % 60); + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2( + date.getDate(), + )}T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2( + date.getSeconds(), + )}${sign}${offsetH}:${offsetM}`; +} + +function basename(path: string): string { + const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return slash === -1 ? path : path.slice(slash + 1); +} diff --git a/src/view/ConversationView.tsx b/src/view/ConversationView.tsx index f64afef6..c56e05d9 100644 --- a/src/view/ConversationView.tsx +++ b/src/view/ConversationView.tsx @@ -86,6 +86,13 @@ interface ConversationViewProps { * Omit when there is no conversation to park (ask-bar mode). */ onMinimize?: () => void; + /** + * Called when the user clicks the export button to toggle the export + * options popover. Omit to hide the export button entirely. + */ + onExportToggle?: () => void; + /** Drives `aria-expanded` on the export button. */ + isExportOpen?: boolean; } /** @@ -114,6 +121,8 @@ export function ConversationView({ onModelPickerToggle, isModelPickerOpen, onMinimize, + onExportToggle, + isExportOpen, }: ConversationViewProps) { const scrollContainerRef = useRef(null); @@ -218,6 +227,8 @@ export function ConversationView({ onModelPickerToggle={onModelPickerToggle} isModelPickerOpen={isModelPickerOpen} onMinimize={onMinimize} + onExportToggle={onExportToggle} + isExportOpen={isExportOpen} />
Date: Sun, 24 May 2026 16:10:57 -0500 Subject: [PATCH 02/11] fix(overlay): drop transparent margin and switch to native NSPanel shadow Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 75 +++++++++++++++----------------------- src/App.tsx | 23 ++++++++---- src/__tests__/App.test.tsx | 53 ++++++++++++++------------- 3 files changed, 73 insertions(+), 78 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 64af0913..e57a8f66 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -995,10 +995,17 @@ fn finish_onboarding( /// fullscreen Spaces (this is what standard `alwaysOnTop` cannot do) /// - `StyleMask::nonactivating_panel()` - prevents the panel from stealing /// focus/activation from the fullscreen application -/// - `set_has_shadow(false)` - disables the native compositor shadow, which -/// renders differently for key vs. non-key windows, causing a visible change -/// when the user clicks elsewhere. CSS `shadow-bar` provides a consistent -/// elevation effect independent of key-window state. +/// - `set_has_shadow(true)` - lets macOS draw the native compositor shadow +/// outside the window frame. The web layer can therefore size the NSPanel +/// to match the painted card exactly, with no transparent margin reserved +/// for a CSS `box-shadow`. The previous CSS-shadow strategy left a +/// transparent ring inside the NSPanel that any overlapping window's dim +/// (notably the save dialog used by `/export`) would render into as a +/// visible "ghost rectangle" around the card. Native shadow + a tight +/// window removes that ring entirely. The macOS-standard cost is that the +/// shadow re-renders with the inactive style when the panel loses key +/// state; Settings and Update windows have always lived with this and the +/// overlay now matches them. #[cfg(target_os = "macos")] fn init_panel(app_handle: &tauri::AppHandle) { let window: WebviewWindow = app_handle @@ -1023,47 +1030,36 @@ fn init_panel(app_handle: &tauri::AppHandle) { // Keep the panel visible when the user clicks back into the fullscreen app. panel.set_hides_on_deactivate(false); - // Disable the native compositor shadow. macOS renders visually distinct - // shadows for key vs. non-key windows, which causes the overlay to appear - // different after the user clicks elsewhere. The CSS `shadow-bar` provides - // a stable, focus-independent elevation effect. - panel.set_has_shadow(false); + // Native compositor shadow. The OS draws the shadow outside the window + // frame and follows the panel's actual rendered alpha (the rounded CSS + // chrome inside the WebView), so the NSPanel itself can be sized + // tightly to the painted card with no transparent margin reserved for + // a CSS shadow. See module-level doc above for the rationale. + panel.set_has_shadow(true); - // Three NSPanel-layer assertions to keep the overlay visually clean - // through the save-dialog flow, only one of which is strictly novel: + // Two NSPanel-layer assertions worth keeping for the export / modal + // flow even though the load-bearing fix is now the native shadow plus + // the tightened window: // // 1. `setBackgroundColor: NSColor.clearColor` + `setOpaque: NO` - // re-asserted because `to_panel::()` plus the // subsequent `set_style_mask` rewrite can leave the panel with // `NSColor.windowBackgroundColor` painted into the backing layer. + // The native shadow needs an honest alpha channel to compute its + // shape from, so any leftover opaque background defeats the + // rounded-shadow effect. // // 2. `setWorksWhenModal: YES` - keeps the panel receiving keyboard // and mouse events even while an application-modal session // (NSSavePanel from `rfd`) is up. Per Apple docs this property - // controls event routing, NOT the AppKit modal dim - which is - // hardcoded on every non-modal window of the app and cannot be - // cleanly opted out of. Still worth setting so the panel stays - // interactive across the modal. + // controls event routing, NOT the AppKit modal dim. Still worth + // setting so the panel stays interactive across the modal. // - // 3. `contentView.layer.cornerRadius` + `masksToBounds` - the - // load-bearing fix for the visible halo around Thuki when the - // save dialog is up. AppKit's modal dim fills the entire NSPanel - // bounds, but the CSS chrome inside the WebView only paints a - // smaller rounded-rect (Tailwind `rounded-lg`, 8 px). The dim - // bleeds out from the dark CSS chrome and shows as a slate-gray - // annular halo. Clipping the content-view layer to the same - // rounded shape the CSS draws gives the dim no pixels to land on - // outside the chrome. Normal-state rendering is untouched: there - // is nothing to clip when the overlay is not being dimmed. - // - // 8 px matches `rounded-lg` used by the chat-mode chrome - the - // only state from which the save dialog can be launched (the - // export button only renders in chat mode and `/export` gates on - // `messages.length > 0`). Ask-bar mode uses `rounded-2xl` - // (16 px), which produces a smaller visible CSS shape than this - // 8 px content-view clip; the clip therefore has no visible - // effect in ask-bar mode (the smaller CSS shape is already - // inside the clip). + // The previous `contentView.layer.cornerRadius` + `masksToBounds` + // clip is intentionally NOT re-applied: with the tightened window + // there is no transparent margin for the AppKit modal dim to spill + // into, and a hard-coded 8 px clip would truncate the ask-bar + // chrome's 16 px (`rounded-2xl`) corners. if let Ok(ns_window) = window.ns_window() { if !ns_window.is_null() { use objc2::rc::autoreleasepool; @@ -1076,17 +1072,6 @@ fn init_panel(app_handle: &tauri::AppHandle) { let _: () = msg_send![win, setBackgroundColor: clear]; let _: () = msg_send![win, setOpaque: false]; let _: () = msg_send![win, setWorksWhenModal: true]; - - let content_view: *mut AnyObject = msg_send![win, contentView]; - if !content_view.is_null() { - let _: () = msg_send![content_view, setWantsLayer: true]; - let layer: *mut AnyObject = msg_send![content_view, layer]; - if !layer.is_null() { - let radius: f64 = 8.0; - let _: () = msg_send![layer, setCornerRadius: radius]; - let _: () = msg_send![layer, setMasksToBounds: true]; - } - } }); } } diff --git a/src/App.tsx b/src/App.tsx index 910f1044..7452c194 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -170,8 +170,19 @@ type PendingSubmit = think: boolean; }; -/** Total transparent padding around the morphing container: pt-2(8) + pb-6(24) + motion py-2(16). */ -const CONTAINER_VERTICAL_PADDING = 48; +/** + * Total transparent padding around the morphing container. + * + * Held at zero now that the NSPanel uses the native compositor shadow + * (`set_has_shadow(true)` in `init_panel`): the OS draws the shadow outside + * the window frame, so there is no reason to inflate the window with a + * transparent ring inside which the CSS shadow used to render. Tightening + * the NSPanel to the painted card is what eliminates the "ghost rectangle" + * other windows could dim into when they covered the previous transparent + * margin. The constant stays as a single knob in case a future surface + * deliberately reintroduces a transparent gutter. + */ +const CONTAINER_VERTICAL_PADDING = 0; /** * Collapsed-bar height used as the seed for the show-time upward-grow Y math @@ -3040,7 +3051,7 @@ function App() { onDragOver={handleRootDragOver} onDragLeave={handleRootDragLeave} onDrop={handleRootDrop} - className={`flex flex-col items-center ${growsUpward ? 'justify-end' : 'justify-start'} h-screen w-screen ${isSettledMinimized ? '' : 'px-3 pt-2 pb-6'} bg-transparent overflow-visible`} + className={`flex flex-col items-center ${growsUpward ? 'justify-end' : 'justify-start'} h-screen w-screen bg-transparent overflow-visible`} > {shouldRenderOverlay ? ( @@ -3053,7 +3064,7 @@ function App() { className={ isSettledMinimized ? 'overflow-visible' - : 'w-full px-4 py-2 overflow-visible' + : 'w-full overflow-visible' } > {/* Relative wrapper - positioning context for absolute-positioned @@ -3090,9 +3101,7 @@ function App() { isSettledMinimized ? '' : `bg-surface-base backdrop-blur-2xl border border-surface-border ${ - isChatMode - ? 'rounded-lg shadow-chat' - : 'rounded-2xl shadow-bar' + isChatMode ? 'rounded-lg' : 'rounded-2xl' }` } > diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index fd2c7b68..16da9151 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -1220,12 +1220,15 @@ describe('App', () => { triggerResize(container!, 60); }); - // bottomY(884) - targetHeight(108) = 776 + // bottomY(884) - targetHeight(60) = 824. The window now matches the + // painted card 1:1 (CONTAINER_VERTICAL_PADDING = 0); the native + // NSPanel shadow renders outside the window frame so no transparent + // ring is reserved inside. expect(invoke).toHaveBeenCalledWith('set_window_frame', { x: 100, - y: 776, + y: 824, width: 600, - height: 108, + height: 60, }); }); @@ -1314,12 +1317,13 @@ describe('App', () => { act(() => { triggerResize(container2!, 60); }); - // bottomY = 804+80 = 884. 884-108 = 776. + // bottomY = 804+80 = 884. 884-60 = 824. The window matches the + // painted card 1:1 now that CONTAINER_VERTICAL_PADDING is 0. expect(invoke).toHaveBeenCalledWith('set_window_frame', { x: 100, - y: 776, + y: 824, width: 600, - height: 108, + height: 60, }); }); }); @@ -7461,27 +7465,24 @@ describe('App', () => { expect(layoutWrappersAfter.length).toBe(0); }); - it('strips padding from root container when minimized and restores on un-minimize', async () => { + it('renders the root container without transparent padding in every mode', async () => { await enterChatMode(); - // Before minimize: root has px-3 in className const rootBefore = document.querySelector('.h-screen'); - expect(rootBefore?.className).toContain('px-3'); - expect(rootBefore?.className).toContain('pt-2'); - expect(rootBefore?.className).toContain('pb-6'); + expect(rootBefore?.className).not.toContain('px-3'); + expect(rootBefore?.className).not.toContain('pt-2'); + expect(rootBefore?.className).not.toContain('pb-6'); const minimizeBtn = screen.getByRole('button', { name: /minimize/i }); await act(async () => { fireEvent.click(minimizeBtn); }); - // After minimize: root must NOT have px-3/pt-2/pb-6 const rootAfter = document.querySelector('.h-screen'); expect(rootAfter?.className).not.toContain('px-3'); expect(rootAfter?.className).not.toContain('pt-2'); expect(rootAfter?.className).not.toContain('pb-6'); - // Restore const restoreBtn = screen.getByRole('button', { name: /restore thuki/i }); await act(async () => { fireEvent.pointerDown(restoreBtn, { clientX: 0, clientY: 0 }); @@ -7489,9 +7490,8 @@ describe('App', () => { }); await act(async () => {}); - // After restore: padding is back const rootRestored = document.querySelector('.h-screen'); - expect(rootRestored?.className).toContain('px-3'); + expect(rootRestored?.className).not.toContain('px-3'); }); it('restores from the icon and clears the unseen indicator', async () => { @@ -7541,14 +7541,15 @@ describe('App', () => { // On restore the OS window is positioned on screen and grown to full // chat size in one native frame set. With the icon away from any edge, - // the window keeps the icon's top-left (200,150). Height includes - // CONTAINER_VERTICAL_PADDING (48) so the bottom composer is not clipped - // before settleMorphPhase's post-settle re-measure. + // the window keeps the icon's top-left (200,150). Height is the + // configured `maxChatHeight` exactly now that the native NSPanel + // shadow lives outside the window frame and the web layer no longer + // reserves a transparent ring (CONTAINER_VERTICAL_PADDING = 0). expect(invoke).toHaveBeenCalledWith('set_window_frame', { x: 200, y: 150, width: DEFAULT_CONFIG.window.overlayWidth, - height: DEFAULT_CONFIG.window.maxChatHeight + 48, + height: DEFAULT_CONFIG.window.maxChatHeight, }); // ConversationView shown again with same messages @@ -7599,7 +7600,7 @@ describe('App', () => { x: 1372 + 68 - DEFAULT_CONFIG.window.overlayWidth, y: 100, width: DEFAULT_CONFIG.window.overlayWidth, - height: DEFAULT_CONFIG.window.maxChatHeight + 48, + height: DEFAULT_CONFIG.window.maxChatHeight, }); }); @@ -7639,9 +7640,9 @@ describe('App', () => { // unfolds upward instead of clipping off the bottom. expect(invoke).toHaveBeenCalledWith('set_window_frame', { x: 100, - y: 832 + 68 - (DEFAULT_CONFIG.window.maxChatHeight + 48), + y: 832 + 68 - DEFAULT_CONFIG.window.maxChatHeight, width: DEFAULT_CONFIG.window.overlayWidth, - height: DEFAULT_CONFIG.window.maxChatHeight + 48, + height: DEFAULT_CONFIG.window.maxChatHeight, }); // Bottom-anchored → the root container grows upward. expect(document.querySelector('.h-screen.justify-end')).not.toBeNull(); @@ -7681,7 +7682,7 @@ describe('App', () => { // The chat now occupies this frame (top-right anchored). Point the // collapse query at it. - const fullHeight = DEFAULT_CONFIG.window.maxChatHeight + 48; + const fullHeight = DEFAULT_CONFIG.window.maxChatHeight; __setWindowGeometry({ x: 1372 + 68 - DEFAULT_CONFIG.window.overlayWidth, y: 100, @@ -7746,7 +7747,7 @@ describe('App', () => { it('recomputes upward growth on restore when near screen bottom', async () => { // Place window near the screen bottom so shouldGrowUp becomes true. - // maxChatHeight=648, CONTAINER_VERTICAL_PADDING=48: need windowY + 648 + 48 > screenBottom. + // maxChatHeight=648, CONTAINER_VERTICAL_PADDING=0 (tightened window): need windowY + 648 > screenBottom. // With monitorHeight=900, monitorY=0: windowY=700 → 700+696=1396 > 900 → growsUpward. __setWindowGeometry({ x: 100, @@ -7912,9 +7913,9 @@ describe('App', () => { // returned it. The clamped top = 900 - (maxChatHeight + 48). expect(invoke).toHaveBeenCalledWith('set_window_frame', { x: 100, - y: 832 + 68 - (DEFAULT_CONFIG.window.maxChatHeight + 48), + y: 832 + 68 - DEFAULT_CONFIG.window.maxChatHeight, width: DEFAULT_CONFIG.window.overlayWidth, - height: DEFAULT_CONFIG.window.maxChatHeight + 48, + height: DEFAULT_CONFIG.window.maxChatHeight, }); expect(document.querySelector('.h-screen.justify-end')).not.toBeNull(); From 9a3fe32a1554352126b4105d69a201fa0d0b008b Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Sun, 24 May 2026 16:37:12 -0500 Subject: [PATCH 03/11] Revert "fix(overlay): drop transparent margin and switch to native NSPanel shadow" This reverts commit 03311ec446959ca0eb4e0cca89b6bdd1c9ee5475. --- src-tauri/src/lib.rs | 75 +++++++++++++++++++++++--------------- src/App.tsx | 23 ++++-------- src/__tests__/App.test.tsx | 53 +++++++++++++-------------- 3 files changed, 78 insertions(+), 73 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e57a8f66..64af0913 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -995,17 +995,10 @@ fn finish_onboarding( /// fullscreen Spaces (this is what standard `alwaysOnTop` cannot do) /// - `StyleMask::nonactivating_panel()` - prevents the panel from stealing /// focus/activation from the fullscreen application -/// - `set_has_shadow(true)` - lets macOS draw the native compositor shadow -/// outside the window frame. The web layer can therefore size the NSPanel -/// to match the painted card exactly, with no transparent margin reserved -/// for a CSS `box-shadow`. The previous CSS-shadow strategy left a -/// transparent ring inside the NSPanel that any overlapping window's dim -/// (notably the save dialog used by `/export`) would render into as a -/// visible "ghost rectangle" around the card. Native shadow + a tight -/// window removes that ring entirely. The macOS-standard cost is that the -/// shadow re-renders with the inactive style when the panel loses key -/// state; Settings and Update windows have always lived with this and the -/// overlay now matches them. +/// - `set_has_shadow(false)` - disables the native compositor shadow, which +/// renders differently for key vs. non-key windows, causing a visible change +/// when the user clicks elsewhere. CSS `shadow-bar` provides a consistent +/// elevation effect independent of key-window state. #[cfg(target_os = "macos")] fn init_panel(app_handle: &tauri::AppHandle) { let window: WebviewWindow = app_handle @@ -1030,36 +1023,47 @@ fn init_panel(app_handle: &tauri::AppHandle) { // Keep the panel visible when the user clicks back into the fullscreen app. panel.set_hides_on_deactivate(false); - // Native compositor shadow. The OS draws the shadow outside the window - // frame and follows the panel's actual rendered alpha (the rounded CSS - // chrome inside the WebView), so the NSPanel itself can be sized - // tightly to the painted card with no transparent margin reserved for - // a CSS shadow. See module-level doc above for the rationale. - panel.set_has_shadow(true); + // Disable the native compositor shadow. macOS renders visually distinct + // shadows for key vs. non-key windows, which causes the overlay to appear + // different after the user clicks elsewhere. The CSS `shadow-bar` provides + // a stable, focus-independent elevation effect. + panel.set_has_shadow(false); - // Two NSPanel-layer assertions worth keeping for the export / modal - // flow even though the load-bearing fix is now the native shadow plus - // the tightened window: + // Three NSPanel-layer assertions to keep the overlay visually clean + // through the save-dialog flow, only one of which is strictly novel: // // 1. `setBackgroundColor: NSColor.clearColor` + `setOpaque: NO` - // re-asserted because `to_panel::()` plus the // subsequent `set_style_mask` rewrite can leave the panel with // `NSColor.windowBackgroundColor` painted into the backing layer. - // The native shadow needs an honest alpha channel to compute its - // shape from, so any leftover opaque background defeats the - // rounded-shadow effect. // // 2. `setWorksWhenModal: YES` - keeps the panel receiving keyboard // and mouse events even while an application-modal session // (NSSavePanel from `rfd`) is up. Per Apple docs this property - // controls event routing, NOT the AppKit modal dim. Still worth - // setting so the panel stays interactive across the modal. + // controls event routing, NOT the AppKit modal dim - which is + // hardcoded on every non-modal window of the app and cannot be + // cleanly opted out of. Still worth setting so the panel stays + // interactive across the modal. // - // The previous `contentView.layer.cornerRadius` + `masksToBounds` - // clip is intentionally NOT re-applied: with the tightened window - // there is no transparent margin for the AppKit modal dim to spill - // into, and a hard-coded 8 px clip would truncate the ask-bar - // chrome's 16 px (`rounded-2xl`) corners. + // 3. `contentView.layer.cornerRadius` + `masksToBounds` - the + // load-bearing fix for the visible halo around Thuki when the + // save dialog is up. AppKit's modal dim fills the entire NSPanel + // bounds, but the CSS chrome inside the WebView only paints a + // smaller rounded-rect (Tailwind `rounded-lg`, 8 px). The dim + // bleeds out from the dark CSS chrome and shows as a slate-gray + // annular halo. Clipping the content-view layer to the same + // rounded shape the CSS draws gives the dim no pixels to land on + // outside the chrome. Normal-state rendering is untouched: there + // is nothing to clip when the overlay is not being dimmed. + // + // 8 px matches `rounded-lg` used by the chat-mode chrome - the + // only state from which the save dialog can be launched (the + // export button only renders in chat mode and `/export` gates on + // `messages.length > 0`). Ask-bar mode uses `rounded-2xl` + // (16 px), which produces a smaller visible CSS shape than this + // 8 px content-view clip; the clip therefore has no visible + // effect in ask-bar mode (the smaller CSS shape is already + // inside the clip). if let Ok(ns_window) = window.ns_window() { if !ns_window.is_null() { use objc2::rc::autoreleasepool; @@ -1072,6 +1076,17 @@ fn init_panel(app_handle: &tauri::AppHandle) { let _: () = msg_send![win, setBackgroundColor: clear]; let _: () = msg_send![win, setOpaque: false]; let _: () = msg_send![win, setWorksWhenModal: true]; + + let content_view: *mut AnyObject = msg_send![win, contentView]; + if !content_view.is_null() { + let _: () = msg_send![content_view, setWantsLayer: true]; + let layer: *mut AnyObject = msg_send![content_view, layer]; + if !layer.is_null() { + let radius: f64 = 8.0; + let _: () = msg_send![layer, setCornerRadius: radius]; + let _: () = msg_send![layer, setMasksToBounds: true]; + } + } }); } } diff --git a/src/App.tsx b/src/App.tsx index 7452c194..910f1044 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -170,19 +170,8 @@ type PendingSubmit = think: boolean; }; -/** - * Total transparent padding around the morphing container. - * - * Held at zero now that the NSPanel uses the native compositor shadow - * (`set_has_shadow(true)` in `init_panel`): the OS draws the shadow outside - * the window frame, so there is no reason to inflate the window with a - * transparent ring inside which the CSS shadow used to render. Tightening - * the NSPanel to the painted card is what eliminates the "ghost rectangle" - * other windows could dim into when they covered the previous transparent - * margin. The constant stays as a single knob in case a future surface - * deliberately reintroduces a transparent gutter. - */ -const CONTAINER_VERTICAL_PADDING = 0; +/** Total transparent padding around the morphing container: pt-2(8) + pb-6(24) + motion py-2(16). */ +const CONTAINER_VERTICAL_PADDING = 48; /** * Collapsed-bar height used as the seed for the show-time upward-grow Y math @@ -3051,7 +3040,7 @@ function App() { onDragOver={handleRootDragOver} onDragLeave={handleRootDragLeave} onDrop={handleRootDrop} - className={`flex flex-col items-center ${growsUpward ? 'justify-end' : 'justify-start'} h-screen w-screen bg-transparent overflow-visible`} + className={`flex flex-col items-center ${growsUpward ? 'justify-end' : 'justify-start'} h-screen w-screen ${isSettledMinimized ? '' : 'px-3 pt-2 pb-6'} bg-transparent overflow-visible`} > {shouldRenderOverlay ? ( @@ -3064,7 +3053,7 @@ function App() { className={ isSettledMinimized ? 'overflow-visible' - : 'w-full overflow-visible' + : 'w-full px-4 py-2 overflow-visible' } > {/* Relative wrapper - positioning context for absolute-positioned @@ -3101,7 +3090,9 @@ function App() { isSettledMinimized ? '' : `bg-surface-base backdrop-blur-2xl border border-surface-border ${ - isChatMode ? 'rounded-lg' : 'rounded-2xl' + isChatMode + ? 'rounded-lg shadow-chat' + : 'rounded-2xl shadow-bar' }` } > diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 16da9151..fd2c7b68 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -1220,15 +1220,12 @@ describe('App', () => { triggerResize(container!, 60); }); - // bottomY(884) - targetHeight(60) = 824. The window now matches the - // painted card 1:1 (CONTAINER_VERTICAL_PADDING = 0); the native - // NSPanel shadow renders outside the window frame so no transparent - // ring is reserved inside. + // bottomY(884) - targetHeight(108) = 776 expect(invoke).toHaveBeenCalledWith('set_window_frame', { x: 100, - y: 824, + y: 776, width: 600, - height: 60, + height: 108, }); }); @@ -1317,13 +1314,12 @@ describe('App', () => { act(() => { triggerResize(container2!, 60); }); - // bottomY = 804+80 = 884. 884-60 = 824. The window matches the - // painted card 1:1 now that CONTAINER_VERTICAL_PADDING is 0. + // bottomY = 804+80 = 884. 884-108 = 776. expect(invoke).toHaveBeenCalledWith('set_window_frame', { x: 100, - y: 824, + y: 776, width: 600, - height: 60, + height: 108, }); }); }); @@ -7465,24 +7461,27 @@ describe('App', () => { expect(layoutWrappersAfter.length).toBe(0); }); - it('renders the root container without transparent padding in every mode', async () => { + it('strips padding from root container when minimized and restores on un-minimize', async () => { await enterChatMode(); + // Before minimize: root has px-3 in className const rootBefore = document.querySelector('.h-screen'); - expect(rootBefore?.className).not.toContain('px-3'); - expect(rootBefore?.className).not.toContain('pt-2'); - expect(rootBefore?.className).not.toContain('pb-6'); + expect(rootBefore?.className).toContain('px-3'); + expect(rootBefore?.className).toContain('pt-2'); + expect(rootBefore?.className).toContain('pb-6'); const minimizeBtn = screen.getByRole('button', { name: /minimize/i }); await act(async () => { fireEvent.click(minimizeBtn); }); + // After minimize: root must NOT have px-3/pt-2/pb-6 const rootAfter = document.querySelector('.h-screen'); expect(rootAfter?.className).not.toContain('px-3'); expect(rootAfter?.className).not.toContain('pt-2'); expect(rootAfter?.className).not.toContain('pb-6'); + // Restore const restoreBtn = screen.getByRole('button', { name: /restore thuki/i }); await act(async () => { fireEvent.pointerDown(restoreBtn, { clientX: 0, clientY: 0 }); @@ -7490,8 +7489,9 @@ describe('App', () => { }); await act(async () => {}); + // After restore: padding is back const rootRestored = document.querySelector('.h-screen'); - expect(rootRestored?.className).not.toContain('px-3'); + expect(rootRestored?.className).toContain('px-3'); }); it('restores from the icon and clears the unseen indicator', async () => { @@ -7541,15 +7541,14 @@ describe('App', () => { // On restore the OS window is positioned on screen and grown to full // chat size in one native frame set. With the icon away from any edge, - // the window keeps the icon's top-left (200,150). Height is the - // configured `maxChatHeight` exactly now that the native NSPanel - // shadow lives outside the window frame and the web layer no longer - // reserves a transparent ring (CONTAINER_VERTICAL_PADDING = 0). + // the window keeps the icon's top-left (200,150). Height includes + // CONTAINER_VERTICAL_PADDING (48) so the bottom composer is not clipped + // before settleMorphPhase's post-settle re-measure. expect(invoke).toHaveBeenCalledWith('set_window_frame', { x: 200, y: 150, width: DEFAULT_CONFIG.window.overlayWidth, - height: DEFAULT_CONFIG.window.maxChatHeight, + height: DEFAULT_CONFIG.window.maxChatHeight + 48, }); // ConversationView shown again with same messages @@ -7600,7 +7599,7 @@ describe('App', () => { x: 1372 + 68 - DEFAULT_CONFIG.window.overlayWidth, y: 100, width: DEFAULT_CONFIG.window.overlayWidth, - height: DEFAULT_CONFIG.window.maxChatHeight, + height: DEFAULT_CONFIG.window.maxChatHeight + 48, }); }); @@ -7640,9 +7639,9 @@ describe('App', () => { // unfolds upward instead of clipping off the bottom. expect(invoke).toHaveBeenCalledWith('set_window_frame', { x: 100, - y: 832 + 68 - DEFAULT_CONFIG.window.maxChatHeight, + y: 832 + 68 - (DEFAULT_CONFIG.window.maxChatHeight + 48), width: DEFAULT_CONFIG.window.overlayWidth, - height: DEFAULT_CONFIG.window.maxChatHeight, + height: DEFAULT_CONFIG.window.maxChatHeight + 48, }); // Bottom-anchored → the root container grows upward. expect(document.querySelector('.h-screen.justify-end')).not.toBeNull(); @@ -7682,7 +7681,7 @@ describe('App', () => { // The chat now occupies this frame (top-right anchored). Point the // collapse query at it. - const fullHeight = DEFAULT_CONFIG.window.maxChatHeight; + const fullHeight = DEFAULT_CONFIG.window.maxChatHeight + 48; __setWindowGeometry({ x: 1372 + 68 - DEFAULT_CONFIG.window.overlayWidth, y: 100, @@ -7747,7 +7746,7 @@ describe('App', () => { it('recomputes upward growth on restore when near screen bottom', async () => { // Place window near the screen bottom so shouldGrowUp becomes true. - // maxChatHeight=648, CONTAINER_VERTICAL_PADDING=0 (tightened window): need windowY + 648 > screenBottom. + // maxChatHeight=648, CONTAINER_VERTICAL_PADDING=48: need windowY + 648 + 48 > screenBottom. // With monitorHeight=900, monitorY=0: windowY=700 → 700+696=1396 > 900 → growsUpward. __setWindowGeometry({ x: 100, @@ -7913,9 +7912,9 @@ describe('App', () => { // returned it. The clamped top = 900 - (maxChatHeight + 48). expect(invoke).toHaveBeenCalledWith('set_window_frame', { x: 100, - y: 832 + 68 - DEFAULT_CONFIG.window.maxChatHeight, + y: 832 + 68 - (DEFAULT_CONFIG.window.maxChatHeight + 48), width: DEFAULT_CONFIG.window.overlayWidth, - height: DEFAULT_CONFIG.window.maxChatHeight, + height: DEFAULT_CONFIG.window.maxChatHeight + 48, }); expect(document.querySelector('.h-screen.justify-end')).not.toBeNull(); From a35ea5063131ba92a815f2931d372e7ebf3f4a21 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Sun, 24 May 2026 23:08:18 -0500 Subject: [PATCH 04/11] fix(overlay): fade out while the native save dialog is on screen Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 122 +++++++++++++++++++++++++++++++++++++ src/App.tsx | 21 +++++++ src/__tests__/App.test.tsx | 101 ++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 64af0913..6180d373 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -819,6 +819,121 @@ fn animate_overlay_frame(app_handle: tauri::AppHandle, width: f64, height: f64, } } +/// Sets the alpha (opacity) of the main overlay NSPanel. +/// +/// Used to temporarily hide Thuki while a foreign system dialog (the +/// `NSSavePanel` invoked by the export flow) is on screen. That dialog +/// ships with its own drop-shadow and `NSVisualEffectView` vibrancy +/// backdrop, both of which bleed onto anything behind them. Thuki's +/// transparent CSS shadow margin would otherwise show through as a +/// dark "ghost" rectangle around the card. +/// +/// Driving alpha to 0 removes Thuki from the compositor for the +/// duration of the dialog without disturbing the NSPanel's state +/// machine, the activator, the trace recorder, or the React tree. +/// Restoring alpha to 1.0 paints the window again with the exact +/// same content it had before. Cheap, idempotent, and unrelated to +/// the window-resize path that the tighten-to-card approach broke. +/// +/// When `duration_ms > 0` the transition is driven through +/// `NSAnimationContext` so the alpha change overlaps the dialog's +/// own fade-in / fade-out. With `duration_ms = 0` the alpha is set +/// instantly. Hiding the panel usually wants `0` (snap out so the +/// dialog's appearance is the only motion the user reads); restoring +/// usually wants a small duration so Thuki gracefully fades back in +/// instead of popping over the dialog dismiss animation. +/// +/// Non-finite values are silently dropped and the magnitude is clamped +/// to `[0.0, 1.0]` so the IPC boundary stays forgiving. Duration is +/// clamped to `[0.0, 2000.0]` ms for the same reason. +#[tauri::command] +#[cfg_attr(coverage_nightly, coverage(off))] +fn set_overlay_alpha(app_handle: tauri::AppHandle, alpha: f64, duration_ms: f64) { + if !alpha.is_finite() { + return; + } + let alpha = alpha.clamp(0.0, 1.0); + let duration_ms = if duration_ms.is_finite() { + duration_ms.clamp(0.0, 2000.0) + } else { + 0.0 + }; + + #[cfg(target_os = "macos")] + { + use objc2::class; + use objc2::msg_send; + use objc2::runtime::AnyObject; + + let handle = app_handle.clone(); + let _ = app_handle.run_on_main_thread(move || { + let Some(window) = handle.get_webview_window("main") else { + return; + }; + let Ok(ns_window) = window.ns_window() else { + return; + }; + if ns_window.is_null() { + return; + } + let win = ns_window as *mut AnyObject; + unsafe { + if duration_ms == 0.0 { + let _: () = msg_send![win, setAlphaValue: alpha]; + } else { + let ctx_cls = class!(NSAnimationContext); + let _: () = msg_send![ctx_cls, beginGrouping]; + let ctx: *mut AnyObject = msg_send![ctx_cls, currentContext]; + let _: () = msg_send![ctx, setDuration: duration_ms / 1000.0]; + let animator: *mut AnyObject = msg_send![win, animator]; + let _: () = msg_send![animator, setAlphaValue: alpha]; + let _: () = msg_send![ctx_cls, endGrouping]; + } + } + }); + } + + #[cfg(not(target_os = "macos"))] + { + let _ = (app_handle, alpha, duration_ms); + } +} + +/// Sets the default appearance of `NSSavePanel` (and its `NSOpenPanel` +/// sibling) to the **compact** layout — no sidebar, no file browser, +/// just the Save As field, a Where popup, and the action buttons. +/// +/// macOS persists the expansion state of these panels per app under +/// the `NSNavPanelExpandedStateForSaveMode` key in `NSUserDefaults`. +/// On a fresh launch the panel inherits the system default, which is +/// the wide expanded layout most apps want. For a Spotlight-style +/// overlay like Thuki where export is a quick action invoked from a +/// floating bar, the compact layout reads as the right shape: the +/// user already picked the file in their head, they just need to +/// confirm the name and location. +/// +/// Writing the key at startup means every save dialog opens compact +/// on a fresh launch. Within a session, macOS rewrites the key when +/// the user manually toggles the disclosure triangle, so their +/// per-save preference is respected until the next launch. +#[cfg(target_os = "macos")] +#[cfg_attr(coverage_nightly, coverage(off))] +fn apply_save_panel_compact_default() { + use objc2::class; + use objc2::msg_send; + use objc2::runtime::AnyObject; + use objc2_foundation::ns_string; + + let key = ns_string!("NSNavPanelExpandedStateForSaveMode"); + unsafe { + let defaults: *mut AnyObject = msg_send![class!(NSUserDefaults), standardUserDefaults]; + if defaults.is_null() { + return; + } + let _: () = msg_send![defaults, setBool: false, forKey: key]; + } +} + /// Synchronizes the Rust-side visibility tracking when the frontend /// completes its exit animation and hides the native window. #[tauri::command] @@ -1408,6 +1523,12 @@ pub fn run() { #[cfg(target_os = "macos")] init_update_panel(app.app_handle()); + // Default the export save dialog to the compact layout. The + // user can still hit the disclosure triangle for a full + // file browser on any individual save. + #[cfg(target_os = "macos")] + apply_save_panel_compact_default(); + // ── System tray icon + menu ─────────────────────────────────── // Order chosen for muscle-memory parity with mac tray apps // (Bartender, CleanShot X, Rectangle): primary action at top, @@ -1761,6 +1882,7 @@ pub fn run() { notify_frontend_ready, set_window_frame, animate_overlay_frame, + set_overlay_alpha, #[cfg(not(coverage))] permissions::check_accessibility_permission, #[cfg(not(coverage))] diff --git a/src/App.tsx b/src/App.tsx index 910f1044..99db25f7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2306,7 +2306,24 @@ function App() { /* v8 ignore start -- defensive: callers gate on messages.length > 0 */ if (messages.length === 0) return; /* v8 ignore stop */ + // Hide Thuki via NSPanel alpha while the native save dialog is on + // screen. The dialog's drop-shadow and vibrancy backdrop would + // otherwise bleed onto Thuki's transparent shadow margin and render + // as a dark "ghost" rectangle around the card. + // + // Both `set_overlay_alpha` calls are fired without `await` so the + // main thread sees them dispatched in the same event-loop tick as + // the save-dialog command. Awaiting alpha serially introduces a + // visible "Thuki invisible, dialog not yet appearing" frame that + // reads as a glitch; the fire-and-forget shape collapses that gap. + // The setter is a thin Rust function that cannot fail in + // practice — IPC bus rejection would be the only path — so an + // unhandled rejection is acceptable. try { + // Hide instantly — the dialog's own appear animation is the + // motion the user reads, so a snap-out keeps the transition + // crisp from Thuki → dialog. + void invoke('set_overlay_alpha', { alpha: 0, durationMs: 0 }); const path = await saveDialog({ defaultPath: defaultExportFilename(new Date()), filters: [{ name: 'Markdown', extensions: ['md'] }], @@ -2322,6 +2339,10 @@ function App() { setCaptureError( `Failed to export: ${err instanceof Error ? err.message : String(err)}`, ); + } finally { + // Fade back in over 150 ms so Thuki re-emerges in step with the + // dialog's dismiss animation instead of snapping in late. + void invoke('set_overlay_alpha', { alpha: 1, durationMs: 150 }); } }, [messages, activeModel]); diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index fd2c7b68..09e5c689 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -8493,5 +8493,106 @@ describe('App', () => { screen.getByRole('button', { name: /Save as Markdown/i }), ).toBeInTheDocument(); }); + + it('drives overlay alpha to 0 before the save dialog and back to 1 after a successful save', async () => { + await enterChatMode(); + vi.mocked(saveDialog).mockResolvedValue('/tmp/alpha-test.md'); + invoke.mockClear(); + + const textarea = screen.getByPlaceholderText('Reply...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/export' } }); + }); + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith('set_overlay_alpha', { + alpha: 0, + durationMs: 0, + }); + }); + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith('set_overlay_alpha', { + alpha: 1, + durationMs: 150, + }); + }); + + // The ghost-rectangle fix relies on the alpha bracketing the + // save_chat_export call. Assert the ordering so a future + // refactor cannot accidentally re-show Thuki mid-dialog. + const calls = vi.mocked(invoke).mock.calls; + const alphaZeroIdx = calls.findIndex( + (call) => + call[0] === 'set_overlay_alpha' && + (call[1] as { alpha: number } | undefined)?.alpha === 0, + ); + const saveIdx = calls.findIndex((call) => call[0] === 'save_chat_export'); + const alphaOneIdx = calls.findIndex( + (call) => + call[0] === 'set_overlay_alpha' && + (call[1] as { alpha: number } | undefined)?.alpha === 1, + ); + expect(alphaZeroIdx).toBeGreaterThanOrEqual(0); + expect(saveIdx).toBeGreaterThan(alphaZeroIdx); + expect(alphaOneIdx).toBeGreaterThan(saveIdx); + }); + + it('restores overlay alpha to 1 when the user cancels the save dialog', async () => { + await enterChatMode(); + vi.mocked(saveDialog).mockResolvedValue(null); + invoke.mockClear(); + + const textarea = screen.getByPlaceholderText('Reply...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/export' } }); + }); + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith('set_overlay_alpha', { + alpha: 1, + durationMs: 150, + }); + }); + expect(invoke).not.toHaveBeenCalledWith( + 'save_chat_export', + expect.anything(), + ); + }); + + it('restores overlay alpha to 1 when save_chat_export rejects', async () => { + await enterChatMode(); + vi.mocked(saveDialog).mockResolvedValue('/tmp/will-fail.md'); + const prev = invoke.getMockImplementation(); + invoke.mockImplementation(async (cmd, args) => { + if (cmd === 'save_chat_export') { + throw new Error('disk full'); + } + return prev ? prev(cmd, args) : undefined; + }); + + const textarea = screen.getByPlaceholderText('Reply...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/export' } }); + }); + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith('set_overlay_alpha', { + alpha: 1, + durationMs: 150, + }); + }); + }); }); }); From 8fa8ad97356cbbcd05c1bc5c58dce623a68f360a Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Sun, 24 May 2026 23:32:12 -0500 Subject: [PATCH 05/11] feat(export): offer plain text (.txt) alongside Markdown in save dialog Signed-off-by: Logan Nguyen --- docs/commands.md | 6 +++--- src-tauri/prompts/generated/slash_commands.txt | 2 +- src/App.tsx | 5 ++++- src/__tests__/App.test.tsx | 5 ++++- src/config/commands.ts | 10 +++++----- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 6eb6338a..0c016e28 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -194,14 +194,14 @@ Explains any concept, term, or code snippet in plain language, always with a con ## /export -Exports the current conversation as a self-contained Markdown file via a native macOS save dialog. +Exports the current conversation as a self-contained file via a native macOS save dialog. The dialog offers Markdown (.md) and Plain text (.txt) as format choices; both write the same content. **Usage:** `/export` **Examples:** -- `/export`: opens the save dialog to write a Markdown file of the current chat +- `/export`: opens the save dialog to write the current chat to disk -**Behavior:** Opens the native save sheet pre-filled with `thuki-chat-YYYY-MM-DD-HHMM.md`. The resulting file is self-contained: YAML frontmatter (model, exported_at, message_count), role-labelled blocks, collapsible thinking blocks, search source footnotes, and base64-inlined screenshots. No data leaves the machine. Requires at least one message in the current session; submitting `/export` in an empty chat shakes the ask bar with a "No messages to export yet." warning. +**Behavior:** Opens the native save sheet pre-filled with `thuki-chat-YYYY-MM-DD-HHMM.md`. Switch the format dropdown to "Plain text" to save the same content as `.txt` instead. The file body is self-contained: YAML frontmatter (model, exported_at, message_count), role-labelled blocks, collapsible thinking blocks, search source footnotes, and base64-inlined screenshots. No data leaves the machine. Requires at least one message in the current session; submitting `/export` in an empty chat shakes the ask bar with a "No messages to export yet." warning. **Composable:** `/export` is a session-level command and is not composed with other slash commands. Any other triggers in the same message are ignored when `/export` is present. diff --git a/src-tauri/prompts/generated/slash_commands.txt b/src-tauri/prompts/generated/slash_commands.txt index c23d4b36..16170024 100644 --- a/src-tauri/prompts/generated/slash_commands.txt +++ b/src-tauri/prompts/generated/slash_commands.txt @@ -24,6 +24,6 @@ If the user asks what slash commands are available, what built-in commands exist /explain: explain a concept or code snippet in plain language with a concrete example. Also works with attached images or /screen: OCR extracts the text first, then explains it. -/export: export the current conversation to a Markdown file on disk via the native save dialog. +/export: export the current conversation to a Markdown (.md) or plain-text (.txt) file on disk via the native save dialog. /todos: summarize context and extract tasks as markdown checkboxes. Also works with attached images or /screen: OCR extracts the text first, then extracts to-dos. diff --git a/src/App.tsx b/src/App.tsx index 99db25f7..e9272156 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2326,7 +2326,10 @@ function App() { void invoke('set_overlay_alpha', { alpha: 0, durationMs: 0 }); const path = await saveDialog({ defaultPath: defaultExportFilename(new Date()), - filters: [{ name: 'Markdown', extensions: ['md'] }], + filters: [ + { name: 'Markdown', extensions: ['md'] }, + { name: 'Plain text', extensions: ['txt'] }, + ], }); if (path === null) return; const content = await serializeForFile( diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 09e5c689..7284d0ec 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -8172,7 +8172,10 @@ describe('App', () => { defaultPath: expect.stringMatching( /^thuki-chat-\d{4}-\d{2}-\d{2}-\d{4}\.md$/, ), - filters: [{ name: 'Markdown', extensions: ['md'] }], + filters: [ + { name: 'Markdown', extensions: ['md'] }, + { name: 'Plain text', extensions: ['txt'] }, + ], }), ); await vi.waitFor(() => { diff --git a/src/config/commands.ts b/src/config/commands.ts index 1213fdc5..754ce078 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -310,22 +310,22 @@ export const COMMANDS: readonly Command[] = [ { trigger: '/export', label: '/export', - description: 'Export the conversation as a Markdown file', + description: 'Export the conversation as a Markdown or plain-text file', docs: { summary: - 'Exports the current conversation as a self-contained Markdown file via a native macOS save dialog.', + 'Exports the current conversation as a self-contained file via a native macOS save dialog. The dialog offers Markdown (.md) and Plain text (.txt) as format choices; both write the same content.', usage: '/export', examples: [ - '`/export`: opens the save dialog to write a Markdown file of the current chat', + '`/export`: opens the save dialog to write the current chat to disk', ], behavior: - 'Opens the native save sheet pre-filled with `thuki-chat-YYYY-MM-DD-HHMM.md`. The resulting file is self-contained: YAML frontmatter (model, exported_at, message_count), role-labelled blocks, collapsible thinking blocks, search source footnotes, and base64-inlined screenshots. No data leaves the machine. Requires at least one message in the current session; submitting `/export` in an empty chat shakes the ask bar with a "No messages to export yet." warning.', + 'Opens the native save sheet pre-filled with `thuki-chat-YYYY-MM-DD-HHMM.md`. Switch the format dropdown to "Plain text" to save the same content as `.txt` instead. The file body is self-contained: YAML frontmatter (model, exported_at, message_count), role-labelled blocks, collapsible thinking blocks, search source footnotes, and base64-inlined screenshots. No data leaves the machine. Requires at least one message in the current session; submitting `/export` in an empty chat shakes the ask bar with a "No messages to export yet." warning.', composability: '`/export` is a session-level command and is not composed with other slash commands. Any other triggers in the same message are ignored when `/export` is present.', }, promptHelp: { summary: - 'export the current conversation to a Markdown file on disk via the native save dialog.', + 'export the current conversation to a Markdown (.md) or plain-text (.txt) file on disk via the native save dialog.', whenToSuggest: 'Mention this when the user asks to save the conversation, share it, or archive it.', limit: From ac0d52757dbade8996a449626caaeedcee8acadf Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Mon, 25 May 2026 00:04:07 -0500 Subject: [PATCH 06/11] feat(export): split popover so Plain text gets its own row Signed-off-by: Logan Nguyen --- src/App.tsx | 118 ++++++++++++--------- src/__tests__/App.test.tsx | 45 ++++++++ src/lib/__tests__/exportSerializer.test.ts | 16 +++ src/lib/exportSerializer.ts | 16 +-- 4 files changed, 140 insertions(+), 55 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index e9272156..500aa403 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2301,53 +2301,66 @@ function App() { * write surfaces the OS-level error message via the existing * `captureError` banner. */ - const runFileExport = useCallback(async () => { - setIsExportOpen(false); - /* v8 ignore start -- defensive: callers gate on messages.length > 0 */ - if (messages.length === 0) return; - /* v8 ignore stop */ - // Hide Thuki via NSPanel alpha while the native save dialog is on - // screen. The dialog's drop-shadow and vibrancy backdrop would - // otherwise bleed onto Thuki's transparent shadow margin and render - // as a dark "ghost" rectangle around the card. - // - // Both `set_overlay_alpha` calls are fired without `await` so the - // main thread sees them dispatched in the same event-loop tick as - // the save-dialog command. Awaiting alpha serially introduces a - // visible "Thuki invisible, dialog not yet appearing" frame that - // reads as a glitch; the fire-and-forget shape collapses that gap. - // The setter is a thin Rust function that cannot fail in - // practice — IPC bus rejection would be the only path — so an - // unhandled rejection is acceptable. - try { - // Hide instantly — the dialog's own appear animation is the - // motion the user reads, so a snap-out keeps the transition - // crisp from Thuki → dialog. - void invoke('set_overlay_alpha', { alpha: 0, durationMs: 0 }); - const path = await saveDialog({ - defaultPath: defaultExportFilename(new Date()), - filters: [ - { name: 'Markdown', extensions: ['md'] }, - { name: 'Plain text', extensions: ['txt'] }, - ], - }); - if (path === null) return; - const content = await serializeForFile( - messages, - { fallbackModel: activeModel }, - new Date(), - ); - await invoke('save_chat_export', { path, content }); - } catch (err) { - setCaptureError( - `Failed to export: ${err instanceof Error ? err.message : String(err)}`, - ); - } finally { - // Fade back in over 150 ms so Thuki re-emerges in step with the - // dialog's dismiss animation instead of snapping in late. - void invoke('set_overlay_alpha', { alpha: 1, durationMs: 150 }); - } - }, [messages, activeModel]); + const runFileExport = useCallback( + async (format: 'md' | 'txt') => { + setIsExportOpen(false); + /* v8 ignore start -- defensive: callers gate on messages.length > 0 */ + if (messages.length === 0) return; + /* v8 ignore stop */ + // The two-row popover lets the user pick Markdown or Plain text + // before opening the dialog. The chosen format becomes the + // PRIMARY filter (top of the dropdown) so the dialog opens with + // the matching extension pre-selected and the default filename + // reflects it. The other format stays available as the second + // entry so the user can still switch without re-opening. + const markdownFilter = { name: 'Markdown', extensions: ['md'] }; + const plainTextFilter = { name: 'Plain text', extensions: ['txt'] }; + const filters = + format === 'md' + ? [markdownFilter, plainTextFilter] + : [plainTextFilter, markdownFilter]; + + // Hide Thuki via NSPanel alpha while the native save dialog is + // on screen. The dialog's drop-shadow and vibrancy backdrop + // would otherwise bleed onto Thuki's transparent shadow margin + // and render as a dark "ghost" rectangle around the card. + // + // Both `set_overlay_alpha` calls are fired without `await` so + // the main thread sees them dispatched in the same event-loop + // tick as the save-dialog command. Awaiting alpha serially + // introduces a visible "Thuki invisible, dialog not yet + // appearing" frame that reads as a glitch; the fire-and-forget + // shape collapses that gap. The setter is a thin Rust function + // that cannot fail in practice — IPC bus rejection would be the + // only path — so an unhandled rejection is acceptable. + try { + // Hide instantly — the dialog's own appear animation is the + // motion the user reads, so a snap-out keeps the transition + // crisp from Thuki → dialog. + void invoke('set_overlay_alpha', { alpha: 0, durationMs: 0 }); + const path = await saveDialog({ + defaultPath: defaultExportFilename(new Date(), format), + filters, + }); + if (path === null) return; + const content = await serializeForFile( + messages, + { fallbackModel: activeModel }, + new Date(), + ); + await invoke('save_chat_export', { path, content }); + } catch (err) { + setCaptureError( + `Failed to export: ${err instanceof Error ? err.message : String(err)}`, + ); + } finally { + // Fade back in over 150 ms so Thuki re-emerges in step with the + // dialog's dismiss animation instead of snapping in late. + void invoke('set_overlay_alpha', { alpha: 1, durationMs: 150 }); + } + }, + [messages, activeModel], + ); /** * Copies the current session to the system clipboard as body-only @@ -2455,7 +2468,7 @@ function App() { setQuery(''); /* v8 ignore next */ inputRef.current!.style.height = 'auto'; - void runFileExport(); + void runFileExport('md'); return; } @@ -3461,11 +3474,18 @@ function App() {
+ diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 6df6757f..63773612 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -15,11 +15,6 @@ import { enableChannelCaptureWithResponses, getLastChannel, } from '../testUtils/mocks/tauri'; -import { save as saveDialog } from '@tauri-apps/plugin-dialog'; - -vi.mock('@tauri-apps/plugin-dialog', () => ({ - save: vi.fn(), -})); import { __mockWindow, __setWindowGeometry, @@ -8089,14 +8084,13 @@ describe('App', () => { }); }); - // ─── /export command ──────────────────────────────────────────────────────── + // ─── chat-header export button ────────────────────────────────────────────── - describe('/export command', () => { + describe('chat-header export button', () => { let writeText: ReturnType; let clipboardSpy: { mockRestore: () => void } | null = null; beforeEach(() => { - vi.mocked(saveDialog).mockReset(); writeText = vi.fn().mockResolvedValue(undefined); // happy-dom defines `navigator.clipboard` as a non-configurable // property, so a full property redefinition throws. Spy on the @@ -8136,199 +8130,234 @@ describe('App', () => { }); } - it('silently no-ops when the user cancels the save dialog', async () => { + /** + * Routes `invoke('prompt_and_save_chat_export', ...)` to a custom + * impl while leaving every other command on the channel-capture + * default. Returns the wrapped impl handle so tests can read calls + * back. Mirrors the previous `save_chat_export` override pattern. + */ + type ExportArgs = { + content: string; + defaultFilename: string; + format: string; + }; + function overrideExportInvoke( + impl: (args: ExportArgs) => Promise, + ) { + const prev = invoke.getMockImplementation(); + invoke.mockImplementation(async (cmd, args) => { + if (cmd === 'prompt_and_save_chat_export') { + return await impl(args as ExportArgs); + } + return prev ? prev(cmd, args) : undefined; + }); + } + + it('renders the export button in chat mode and the popover opens on click', async () => { await enterChatMode(); - vi.mocked(saveDialog).mockResolvedValue(null); - invoke.mockClear(); - await openExportPopover(); + const exportButton = screen.getByRole('button', { name: 'Export chat' }); + expect(exportButton).toBeInTheDocument(); + expect(exportButton).toHaveAttribute('aria-expanded', 'false'); + expect(exportButton).toHaveAttribute('aria-haspopup', 'menu'); + await act(async () => { - fireEvent.click( - screen.getByRole('button', { name: /Save as Markdown/i }), - ); + fireEvent.click(exportButton); }); + + expect(exportButton).toHaveAttribute('aria-expanded', 'true'); + const popover = screen.getByRole('menu', { name: 'Export chat' }); + expect(popover).toBeInTheDocument(); + expect(popover).toHaveAttribute('aria-orientation', 'vertical'); + expect( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('menuitem', { name: /Save as Plain text/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('menuitem', { name: /Copy to clipboard/i }), + ).toBeInTheDocument(); + }); + + it('does not render the export button in ask-bar mode (no messages)', async () => { + render(); await act(async () => {}); + await showOverlay(); - expect(saveDialog).toHaveBeenCalled(); - expect(invoke).not.toHaveBeenCalledWith( - 'save_chat_export', - expect.anything(), - ); + expect(screen.queryByRole('button', { name: 'Export chat' })).toBeNull(); }); - it('surfaces an error banner when save_chat_export rejects', async () => { + it('focuses the first menuitem when the popover opens', async () => { await enterChatMode(); - vi.mocked(saveDialog).mockResolvedValue('/bad/path.md'); - // Override invoke for save_chat_export to reject without disturbing - // the channel-capture seed for unrelated commands. - const prev = invoke.getMockImplementation(); - invoke.mockImplementation(async (cmd, args) => { - if (cmd === 'save_chat_export') { - throw new Error('disk full'); - } - return prev ? prev(cmd, args) : undefined; + await openExportPopover(); + + const firstItem = screen.getByRole('menuitem', { + name: /Save as Markdown/i, + }); + expect(document.activeElement).toBe(firstItem); + }); + + it('invokes prompt_and_save_chat_export with Markdown content when Markdown is clicked', async () => { + await enterChatMode(); + let captured: ExportArgs | null = null; + overrideExportInvoke(async (args) => { + captured = args; + return true; + }); + invoke.mockClear(); + // re-install override after mockClear (mockClear preserves impl) + overrideExportInvoke(async (args) => { + captured = args; + return true; }); await openExportPopover(); await act(async () => { fireEvent.click( - screen.getByRole('button', { name: /Save as Markdown/i }), + screen.getByRole('menuitem', { name: /Save as Markdown/i }), ); }); await act(async () => {}); - await vi.waitFor(() => { - expect( - screen.getByText(/Failed to export: disk full/), - ).toBeInTheDocument(); + await waitFor(() => { + expect(captured).not.toBeNull(); }); + const md = captured as ExportArgs | null; + expect(md?.format).toBe('md'); + // Markdown serialiser emits YAML frontmatter at the top of the file. + expect(md?.content.startsWith('---\napp: ')).toBe(true); + expect(md?.content).toContain('## User'); }); - it('surfaces an error banner when the save dialog itself throws', async () => { + it('invokes prompt_and_save_chat_export with plain text content when Plain text is clicked', async () => { await enterChatMode(); - vi.mocked(saveDialog).mockRejectedValue(new Error('dialog blew up')); + let captured: ExportArgs | null = null; + overrideExportInvoke(async (args) => { + captured = args; + return true; + }); await openExportPopover(); await act(async () => { fireEvent.click( - screen.getByRole('button', { name: /Save as Markdown/i }), + screen.getByRole('menuitem', { name: /Save as Plain text/i }), ); }); await act(async () => {}); - await vi.waitFor(() => { - expect( - screen.getByText(/Failed to export: dialog blew up/), - ).toBeInTheDocument(); + await waitFor(() => { + expect(captured).not.toBeNull(); }); + const txt = captured as ExportArgs | null; + expect(txt?.format).toBe('txt'); + // Plain text serialiser emits a labelled header and NO YAML + // frontmatter / Markdown markers. + expect(txt?.content.startsWith('Thuki chat export\n')).toBe(true); + expect(txt?.content).not.toContain('---\napp:'); + expect(txt?.content).not.toContain('## User'); + expect(txt?.content).toContain('User:'); }); - it('falls back to String(err) when the save dialog throws a non-Error', async () => { + it('forwards the requested filename so the dialog opens with the right extension', async () => { await enterChatMode(); - vi.mocked(saveDialog).mockRejectedValue('plain string err'); + let captured: ExportArgs | null = null; + overrideExportInvoke(async (args) => { + captured = args; + return true; + }); await openExportPopover(); await act(async () => { fireEvent.click( - screen.getByRole('button', { name: /Save as Markdown/i }), + screen.getByRole('menuitem', { name: /Save as Plain text/i }), ); }); await act(async () => {}); - await vi.waitFor(() => { - expect( - screen.getByText(/Failed to export: plain string err/), - ).toBeInTheDocument(); + await waitFor(() => { + expect(captured).not.toBeNull(); }); + const fname = captured as ExportArgs | null; + expect(fname?.defaultFilename).toMatch( + /^thuki-chat-\d{4}-\d{2}-\d{2}-\d{4}\.txt$/, + ); }); - it('renders the export button in chat mode and the popover opens on click', async () => { + it('silently no-ops when the Rust command reports user cancellation (returns false)', async () => { await enterChatMode(); + overrideExportInvoke(async () => false); + invoke.mockClear(); + overrideExportInvoke(async () => false); - const exportButton = screen.getByRole('button', { name: 'Export chat' }); - expect(exportButton).toBeInTheDocument(); - expect(exportButton).toHaveAttribute('aria-expanded', 'false'); - + await openExportPopover(); await act(async () => { - fireEvent.click(exportButton); + fireEvent.click( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ); }); - - expect(exportButton).toHaveAttribute('aria-expanded', 'true'); - expect( - screen.getByRole('button', { name: /Save as Markdown/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: /Save as Plain text/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: /Copy to clipboard/i }), - ).toBeInTheDocument(); - }); - - it('does not render the export button in ask-bar mode (no messages)', async () => { - render(); await act(async () => {}); - await showOverlay(); - expect(screen.queryByRole('button', { name: 'Export chat' })).toBeNull(); + // No banner, dialog cancellation is not an error condition. + expect(screen.queryByText(/Failed to export/)).not.toBeInTheDocument(); + // The Rust command was called. + expect(invoke).toHaveBeenCalledWith( + 'prompt_and_save_chat_export', + expect.objectContaining({ format: 'md' }), + ); }); - it('invokes save_chat_export when the "Save as Markdown" button is clicked', async () => { + it('surfaces an error banner when prompt_and_save_chat_export rejects', async () => { await enterChatMode(); - vi.mocked(saveDialog).mockResolvedValue('/tmp/btn-export.md'); - invoke.mockClear(); - - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Export chat' })); + overrideExportInvoke(async () => { + throw new Error('Permission denied. Choose a writable location.'); }); + + await openExportPopover(); await act(async () => { fireEvent.click( - screen.getByRole('button', { name: /Save as Markdown/i }), + screen.getByRole('menuitem', { name: /Save as Markdown/i }), ); }); + await act(async () => {}); await vi.waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'save_chat_export', - expect.objectContaining({ path: '/tmp/btn-export.md' }), - ); - }); - // Markdown row opens the dialog with .md primary and .txt secondary. - expect(saveDialog).toHaveBeenCalledWith( - expect.objectContaining({ - defaultPath: expect.stringMatching( - /^thuki-chat-\d{4}-\d{2}-\d{2}-\d{4}\.md$/, + expect( + screen.getByText( + /Failed to export: Permission denied\. Choose a writable location\./, ), - filters: [ - { name: 'Markdown', extensions: ['md'] }, - { name: 'Plain text', extensions: ['txt'] }, - ], - }), - ); + ).toBeInTheDocument(); + }); }); - it('invokes save_chat_export with the .txt-first filter when "Save as Plain text" is clicked', async () => { + it('falls back to String(err) when the Rust command throws a non-Error', async () => { await enterChatMode(); - vi.mocked(saveDialog).mockResolvedValue('/tmp/btn-export.txt'); - invoke.mockClear(); - - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Export chat' })); + overrideExportInvoke(async () => { + throw 'rust-plain-string'; }); + + await openExportPopover(); await act(async () => { fireEvent.click( - screen.getByRole('button', { name: /Save as Plain text/i }), + screen.getByRole('menuitem', { name: /Save as Markdown/i }), ); }); + await act(async () => {}); await vi.waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'save_chat_export', - expect.objectContaining({ path: '/tmp/btn-export.txt' }), - ); + expect( + screen.getByText(/Failed to export: rust-plain-string/), + ).toBeInTheDocument(); }); - expect(saveDialog).toHaveBeenCalledWith( - expect.objectContaining({ - defaultPath: expect.stringMatching( - /^thuki-chat-\d{4}-\d{2}-\d{2}-\d{4}\.txt$/, - ), - filters: [ - { name: 'Plain text', extensions: ['txt'] }, - { name: 'Markdown', extensions: ['md'] }, - ], - }), - ); }); - it('writes to the clipboard when the "Copy to clipboard" button is clicked', async () => { + it('writes to the clipboard when the Copy to clipboard menuitem is clicked', async () => { await enterChatMode(); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Export chat' })); - }); + await openExportPopover(); await act(async () => { fireEvent.click( - screen.getByRole('button', { name: /Copy to clipboard/i }), + screen.getByRole('menuitem', { name: /Copy to clipboard/i }), ); }); @@ -8343,12 +8372,10 @@ describe('App', () => { await enterChatMode(); writeText.mockRejectedValueOnce(new Error('clipboard denied')); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Export chat' })); - }); + await openExportPopover(); await act(async () => { fireEvent.click( - screen.getByRole('button', { name: /Copy to clipboard/i }), + screen.getByRole('menuitem', { name: /Copy to clipboard/i }), ); }); @@ -8363,12 +8390,10 @@ describe('App', () => { await enterChatMode(); writeText.mockRejectedValueOnce('clip-plain'); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Export chat' })); - }); + await openExportPopover(); await act(async () => { fireEvent.click( - screen.getByRole('button', { name: /Copy to clipboard/i }), + screen.getByRole('menuitem', { name: /Copy to clipboard/i }), ); }); @@ -8381,32 +8406,26 @@ describe('App', () => { it('keeps the popover open when mousedown lands inside it', async () => { await enterChatMode(); + await openExportPopover(); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Export chat' })); - }); - const item = screen.getByRole('button', { + const item = screen.getByRole('menuitem', { name: /Save as Markdown/i, }); await act(async () => { fireEvent.mouseDown(item); }); + await act(async () => {}); - // popover.contains(target) returned true, so the outside-click - // handler bailed and the popover remains rendered. expect( - screen.getByRole('button', { name: /Save as Markdown/i }), + screen.queryByRole('menuitem', { name: /Save as Markdown/i }), ).toBeInTheDocument(); }); it('closes the popover when clicking outside', async () => { await enterChatMode(); - - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Export chat' })); - }); + await openExportPopover(); expect( - screen.queryByRole('button', { name: /Save as Markdown/i }), + screen.queryByRole('menuitem', { name: /Save as Markdown/i }), ).toBeInTheDocument(); await act(async () => { @@ -8414,7 +8433,7 @@ describe('App', () => { }); expect( - screen.queryByRole('button', { name: /Save as Markdown/i }), + screen.queryByRole('menuitem', { name: /Save as Markdown/i }), ).toBeNull(); }); @@ -8449,8 +8468,8 @@ describe('App', () => { // /extract with no image triggers the same captureError surface // we want to auto-dismiss. Used as the harness here because - // /export is button-only now and the button does not render - // until chat mode (so it can't trigger an empty-state error). + // the chat-header export button does not render until chat mode + // (so it cannot trigger an empty-state error). const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); act(() => { fireEvent.change(textarea, { target: { value: '/extract' } }); @@ -8494,19 +8513,173 @@ describe('App', () => { // Export popover is open. expect( - screen.getByRole('button', { name: /Save as Markdown/i }), + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeInTheDocument(); + }); + + it('closes the export popover when the user opens the history dropdown', async () => { + await enterChatMode(); + // HistoryPanel renders when the dropdown opens and iterates over + // the conversations list — stub the IPC source so it gets []. + const prev = invoke.getMockImplementation(); + invoke.mockImplementation(async (cmd, args) => { + if (cmd === 'list_conversations') return []; + return prev ? prev(cmd, args) : undefined; + }); + await openExportPopover(); + expect( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Open history' })); + }); + + expect( + screen.queryByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeNull(); + }); + + it('closes the export popover when the user opens the model picker', async () => { + await enterChatMode(); + await openExportPopover(); + expect( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Choose model' })); + }); + + expect( + screen.queryByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeNull(); + }); + + it('closes the export popover when the user minimizes the overlay', async () => { + await enterChatMode(); + await openExportPopover(); + expect( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), ).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Minimize' })); + }); + + expect( + screen.queryByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeNull(); }); - it('drives overlay alpha to 0 before the save dialog and back to 1 after a successful save', async () => { + it('closes the export popover when the user starts a new conversation', async () => { await enterChatMode(); - vi.mocked(saveDialog).mockResolvedValue('/tmp/alpha-test.md'); + // The "New conversation" handler routes through HistoryPanel as + // the SwitchConfirmation host when the session is unsaved, so the + // panel may mount; stub list_conversations to be safe. + const prev = invoke.getMockImplementation(); + invoke.mockImplementation(async (cmd, args) => { + if (cmd === 'list_conversations') return []; + return prev ? prev(cmd, args) : undefined; + }); + await openExportPopover(); + expect( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeInTheDocument(); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'New conversation' }), + ); + }); + + expect( + screen.queryByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeNull(); + }); + + it('Escape dismisses the popover and returns focus to the toggle button (does not close the overlay)', async () => { + await enterChatMode(); + await openExportPopover(); + const toggle = screen.getByRole('button', { name: 'Export chat' }); + + await act(async () => { + fireEvent.keyDown(window, { key: 'Escape' }); + }); + await act(async () => {}); + + expect( + screen.queryByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeNull(); + expect(document.activeElement).toBe(toggle); + // The overlay is still mounted (the export button is still there). + expect(toggle).toBeInTheDocument(); + }); + + it('drops a re-entrant export click while the first is still in flight', async () => { + await enterChatMode(); + let resolveFirst: ((v: boolean) => void) | undefined; + let calls = 0; + overrideExportInvoke( + () => + new Promise((resolve) => { + calls += 1; + if (calls === 1) { + resolveFirst = resolve; + } else { + resolve(true); + } + }), + ); + + // First click — popover closes, runFileExport is in flight. + await openExportPopover(); + await act(async () => { + fireEvent.click( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ); + }); + await act(async () => {}); + + // Second click — reopen popover and click again. Should NOT + // dispatch a second prompt_and_save_chat_export. + await openExportPopover(); + await act(async () => { + fireEvent.click( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ); + }); + await act(async () => {}); + + expect(calls).toBe(1); + + // Resolve the first call; verify a subsequent click then succeeds. + await act(async () => { + resolveFirst?.(true); + }); + await act(async () => {}); + + await openExportPopover(); + await act(async () => { + fireEvent.click( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ); + }); + await act(async () => {}); + + expect(calls).toBe(2); + }); + + it('drives overlay alpha to 0 before the IPC call and back to 1 after success', async () => { + await enterChatMode(); + overrideExportInvoke(async () => true); invoke.mockClear(); + overrideExportInvoke(async () => true); await openExportPopover(); await act(async () => { fireEvent.click( - screen.getByRole('button', { name: /Save as Markdown/i }), + screen.getByRole('menuitem', { name: /Save as Markdown/i }), ); }); await act(async () => {}); @@ -8524,35 +8697,38 @@ describe('App', () => { }); }); - // The ghost-rectangle fix relies on the alpha bracketing the - // save_chat_export call. Assert the ordering so a future - // refactor cannot accidentally re-show Thuki mid-dialog. + // Assert ordering: alpha:0 → prompt_and_save_chat_export → alpha:1 + // so the overlay stays hidden for exactly the dialog+write + // window and not a frame longer. const calls = vi.mocked(invoke).mock.calls; const alphaZeroIdx = calls.findIndex( (call) => call[0] === 'set_overlay_alpha' && (call[1] as { alpha: number } | undefined)?.alpha === 0, ); - const saveIdx = calls.findIndex((call) => call[0] === 'save_chat_export'); + const promptIdx = calls.findIndex( + (call) => call[0] === 'prompt_and_save_chat_export', + ); const alphaOneIdx = calls.findIndex( (call) => call[0] === 'set_overlay_alpha' && (call[1] as { alpha: number } | undefined)?.alpha === 1, ); expect(alphaZeroIdx).toBeGreaterThanOrEqual(0); - expect(saveIdx).toBeGreaterThan(alphaZeroIdx); - expect(alphaOneIdx).toBeGreaterThan(saveIdx); + expect(promptIdx).toBeGreaterThan(alphaZeroIdx); + expect(alphaOneIdx).toBeGreaterThan(promptIdx); }); - it('restores overlay alpha to 1 when the user cancels the save dialog', async () => { + it('restores overlay alpha to 1 when the Rust command reports cancellation', async () => { await enterChatMode(); - vi.mocked(saveDialog).mockResolvedValue(null); + overrideExportInvoke(async () => false); invoke.mockClear(); + overrideExportInvoke(async () => false); await openExportPopover(); await act(async () => { fireEvent.click( - screen.getByRole('button', { name: /Save as Markdown/i }), + screen.getByRole('menuitem', { name: /Save as Markdown/i }), ); }); await act(async () => {}); @@ -8563,27 +8739,20 @@ describe('App', () => { durationMs: 150, }); }); - expect(invoke).not.toHaveBeenCalledWith( - 'save_chat_export', - expect.anything(), - ); + // No banner on a clean cancellation. + expect(screen.queryByText(/Failed to export/)).not.toBeInTheDocument(); }); - it('restores overlay alpha to 1 when save_chat_export rejects', async () => { + it('restores overlay alpha to 1 when the Rust command rejects', async () => { await enterChatMode(); - vi.mocked(saveDialog).mockResolvedValue('/tmp/will-fail.md'); - const prev = invoke.getMockImplementation(); - invoke.mockImplementation(async (cmd, args) => { - if (cmd === 'save_chat_export') { - throw new Error('disk full'); - } - return prev ? prev(cmd, args) : undefined; + overrideExportInvoke(async () => { + throw new Error('disk full'); }); await openExportPopover(); await act(async () => { fireEvent.click( - screen.getByRole('button', { name: /Save as Markdown/i }), + screen.getByRole('menuitem', { name: /Save as Markdown/i }), ); }); await act(async () => {}); diff --git a/src/components/WindowControls.tsx b/src/components/WindowControls.tsx index 63dcb8d5..a8d6a6a0 100644 --- a/src/components/WindowControls.tsx +++ b/src/components/WindowControls.tsx @@ -389,7 +389,7 @@ export const WindowControls = memo(function WindowControls({ onClick={onExportToggle} aria-label="Export chat" aria-expanded={isExportOpen} - aria-haspopup="dialog" + aria-haspopup="menu" data-export-toggle className={`w-7 h-7 flex items-center justify-center rounded-lg transition-colors duration-150 cursor-pointer ${ isExportOpen diff --git a/src/lib/__tests__/exportSerializer.test.ts b/src/lib/__tests__/exportSerializer.test.ts index d9ea44fb..d533fd16 100644 --- a/src/lib/__tests__/exportSerializer.test.ts +++ b/src/lib/__tests__/exportSerializer.test.ts @@ -3,8 +3,13 @@ import type { Message } from '../../hooks/useOllama'; import { defaultExportFilename, defaultImageLoader, + escapeMarkdownLinkText, + formatMarkdownSourceLine, + isSafeHttpUrl, serializeForClipboard, serializeForFile, + serializeForFileAsText, + yamlQuote, type FileExportContext, type ImageLoader, } from '../exportSerializer'; @@ -82,9 +87,9 @@ describe('serializeForFile', () => { const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); - expect(result).toContain('---\napp: Thuki'); - expect(result).toContain('model: llama3.2:3b'); - expect(result).toMatch(/exported_at: 2026-05-24T14:30:15[+-]\d{2}:\d{2}/); + expect(result).toContain('---\napp: "Thuki"'); + expect(result).toContain('model: "llama3.2:3b"'); + expect(result).toMatch(/exported_at: "2026-05-24T14:30:15[+-]\d{2}:\d{2}"/); expect(result).toContain('message_count: 2'); }); @@ -95,7 +100,7 @@ describe('serializeForFile', () => { ]; const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); - expect(result).toContain('model: default-model'); + expect(result).toContain('model: "default-model"'); }); it('emits "unknown" when no assistant modelName and no fallback', async () => { @@ -108,7 +113,7 @@ describe('serializeForFile', () => { NOW, stubImageLoader, ); - expect(result).toContain('model: unknown'); + expect(result).toContain('model: "unknown"'); }); it('emits frontmatter even when there are zero messages', async () => { @@ -190,8 +195,8 @@ describe('serializeForFile', () => { ]; const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); expect(result).toContain('**Sources** (`/search`):'); - expect(result).toContain('1. [First](https://example.com/one)'); - expect(result).toContain('2. [Second](https://example.com/two)'); + expect(result).toContain('1. [First]()'); + expect(result).toContain('2. [Second]()'); }); it('uses the source URL as the link label when the title is empty', async () => { @@ -205,7 +210,7 @@ describe('serializeForFile', () => { ]; const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); expect(result).toContain( - '1. [https://nowhere.example/page](https://nowhere.example/page)', + '1. [https://nowhere.example/page]()', ); }); @@ -285,7 +290,7 @@ describe('serializeForFile', () => { new Date(2026, 4, 24, 14, 30, 15), stubImageLoader, ); - expect(result).toMatch(/exported_at: 2026-05-24T14:30:15\+09:00/); + expect(result).toMatch(/exported_at: "2026-05-24T14:30:15\+09:00"/); } finally { spy.mockRestore(); } @@ -303,7 +308,7 @@ describe('serializeForFile', () => { new Date(2026, 4, 24, 14, 30, 15), stubImageLoader, ); - expect(result).toMatch(/exported_at: 2026-05-24T14:30:15-05:00/); + expect(result).toMatch(/exported_at: "2026-05-24T14:30:15-05:00"/); } finally { spy.mockRestore(); } @@ -348,7 +353,7 @@ describe('serializeForClipboard', () => { makeMessage({ id: 'u1', role: 'user', content: 'hi' }), ]; const result = serializeForClipboard(messages); - expect(result).not.toContain('---\napp: Thuki'); + expect(result).not.toContain('---\napp:'); expect(result).not.toContain('exported_at'); }); @@ -407,7 +412,7 @@ describe('serializeForClipboard', () => { ]; const result = serializeForClipboard(messages); expect(result).toContain('
'); - expect(result).toContain('1. [Doc](https://example.com)'); + expect(result).toContain('1. [Doc]()'); }); it('returns an empty string when there are zero messages', () => { @@ -520,3 +525,320 @@ describe('defaultImageLoader', () => { await expect(defaultImageLoader('/x')).rejects.toThrow('FileReader error'); }); }); + +describe('yamlQuote', () => { + it('wraps a plain string in double quotes', () => { + expect(yamlQuote('Thuki')).toBe('"Thuki"'); + }); + + it('escapes embedded double quotes', () => { + expect(yamlQuote('he said "hi"')).toBe('"he said \\"hi\\""'); + }); + + it('escapes backslashes', () => { + expect(yamlQuote('a\\b')).toBe('"a\\\\b"'); + }); + + it('escapes newline / carriage return / tab as YAML escape sequences', () => { + expect(yamlQuote('a\nb')).toBe('"a\\nb"'); + expect(yamlQuote('a\rb')).toBe('"a\\rb"'); + expect(yamlQuote('a\tb')).toBe('"a\\tb"'); + }); + + it('encodes ASCII control characters as \\xHH', () => { + expect(yamlQuote('\x00\x01\x1f')).toBe('"\\x00\\x01\\x1f"'); + }); + + it('encodes DEL (0x7f) as \\x7f', () => { + expect(yamlQuote('\x7f')).toBe('"\\x7f"'); + }); + + it('neutralises a YAML-injection attempt via embedded "---" + newline', () => { + const malicious = 'my-model\n---\ninjected: true'; + const quoted = yamlQuote(malicious); + expect(quoted).toBe('"my-model\\n---\\ninjected: true"'); + expect(quoted).not.toContain('\n'); + }); + + it('preserves printable ASCII verbatim', () => { + expect(yamlQuote('abc 123 :-/')).toBe('"abc 123 :-/"'); + }); +}); + +describe('escapeMarkdownLinkText', () => { + it('escapes backslashes', () => { + expect(escapeMarkdownLinkText('a\\b')).toBe('a\\\\b'); + }); + + it('escapes square brackets', () => { + expect(escapeMarkdownLinkText('foo[bar]baz')).toBe('foo\\[bar\\]baz'); + }); + + it('escapes nested ](', () => { + expect(escapeMarkdownLinkText('] (')).toBe('\\] ('); + }); + + it('leaves a plain string untouched', () => { + expect(escapeMarkdownLinkText('clean title')).toBe('clean title'); + }); +}); + +describe('isSafeHttpUrl', () => { + it('accepts http and https URLs', () => { + expect(isSafeHttpUrl('http://example.com')).toBe(true); + expect(isSafeHttpUrl('https://example.com/page?q=1')).toBe(true); + expect(isSafeHttpUrl('HTTPS://EXAMPLE.COM')).toBe(true); + }); + + it('rejects javascript:, data:, file:, mailto:, vbscript: URLs', () => { + expect(isSafeHttpUrl('javascript:alert(1)')).toBe(false); + expect(isSafeHttpUrl('data:text/html,')).toBe(false); + expect(isSafeHttpUrl('file:///etc/passwd')).toBe(false); + expect(isSafeHttpUrl('mailto:x@y.z')).toBe(false); + expect(isSafeHttpUrl('vbscript:msgbox(1)')).toBe(false); + }); + + it('rejects URLs containing control characters', () => { + expect(isSafeHttpUrl('https://example.com/\n')).toBe(false); + expect(isSafeHttpUrl('https://example.com/\r')).toBe(false); + expect(isSafeHttpUrl('https://example.com/\t')).toBe(false); + }); + + it('rejects bare strings without a scheme', () => { + expect(isSafeHttpUrl('example.com')).toBe(false); + expect(isSafeHttpUrl('')).toBe(false); + }); +}); + +describe('formatMarkdownSourceLine', () => { + it('renders a safe https URL as an angle-bracketed Markdown link', () => { + expect( + formatMarkdownSourceLine(1, 'Example', 'https://example.com/a?b=c'), + ).toBe('1. [Example]()'); + }); + + it('escapes link-text metacharacters in the title', () => { + expect( + formatMarkdownSourceLine(2, 'Has ] bracket', 'https://example.com'), + ).toBe('2. [Has \\] bracket]()'); + }); + + it('degrades a javascript: URL to a non-clickable line that preserves the raw URL', () => { + const line = formatMarkdownSourceLine( + 3, + 'Click here', + 'javascript:alert(1)', + ); + expect(line).toBe('3. Click here (link omitted: javascript:alert(1))'); + expect(line).not.toContain('[Click here]'); + expect(line).not.toMatch(/\]\(javascript:/); + }); + + it('degrades a URL containing angle brackets even with an http scheme', () => { + const line = formatMarkdownSourceLine( + 4, + 'Bad', + 'https://example.com/