diff --git a/.github/workflows/pr-build-validation.yml b/.github/workflows/pr-build-validation.yml index 6d130bf8..1052ec75 100644 --- a/.github/workflows/pr-build-validation.yml +++ b/.github/workflows/pr-build-validation.yml @@ -11,7 +11,7 @@ on: jobs: build: name: Build Validation - runs-on: ubuntu-latest + runs-on: macos-latest steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/CLAUDE.md b/CLAUDE.md index 15fd714b..50a3994f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,13 +56,15 @@ The UI morphs between two states: a compact spotlight-style input bar → an exp - **`App.tsx`** — orchestrates all state: messages, streaming, window resizing via ResizeObserver + Tauri `setSize()` - **`hooks/useOllama.ts`** — Tauri Channel-based streaming hook; emits `Token`, `Done`, `Cancelled`, `Error` variants - **`view/ConversationView.tsx`** — smart auto-scroll (pins to bottom unless user scrolls up) -- **`view/AskBarView.tsx`** — auto-expanding textarea (max 144px), morphs logo size +- **`view/AskBarView.tsx`** — auto-expanding textarea (max 144px), morphs logo size, renders slash command tab-completion suggestions - **`components/ChatBubble.tsx`** — markdown rendering via Streamdown (rehype-sanitize for XSS protection) +- **`config/commands.ts`** — slash command registry: defines supported commands and the `SCREEN_CAPTURE_PLACEHOLDER` sentinel used to show a loading tile in chat while a `/screen` capture is in flight ### Backend (`src-tauri/src/`) - **`lib.rs`** — app setup: converts window to NSPanel (fullscreen overlay), registers tray, spawns hotkey listener, intercepts close events (hides instead of quits) - **`commands.rs`** — `ask_ollama` Tauri command: streams newline-delimited JSON from Ollama, sends chunks via Tauri Channel +- **`screenshot.rs`** — `capture_full_screen_command` Tauri command: uses CoreGraphics FFI (`CGWindowListCreateImage`) to capture all displays excluding Thuki's own windows, writes a JPEG to a temp dir, and returns the path - **`activator.rs`** — Core Graphics event tap watching for double-tap Control key (400ms window, 600ms cooldown); prompts for Accessibility permission, retries up to 6× ### Sandbox (`sandbox/`) @@ -102,6 +104,6 @@ Do not consider the task done if either step produces any warnings or errors. Fi ## Key Design Constraints -- **macOS only** — uses NSPanel, Core Graphics event taps, macOS Command key +- **macOS only** — uses NSPanel, Core Graphics event taps, macOS Control key - **Privacy-first** — Ollama runs locally; Docker sandbox drops all capabilities and isolates network - **Accessibility permission required** — hotkey listener uses a CGEventTap at session level diff --git a/README.md b/README.md index 3eeec395..28e4758c 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,8 @@ Most AI tools require accounts, API keys, or subscriptions that bill you per tok - **Conversation history:** persist and revisit past conversations across sessions - **Fully local LLM:** powered by Ollama; no API keys, no accounts, no cost per query - **Isolated sandbox:** optionally run models in a hardened Docker container with capability dropping, read-only volumes, and localhost-only networking -- **Image input:** paste or drag screenshots directly into the chat +- **Image input:** paste or drag images and screenshots directly into the chat +- **Screen capture:** type `/screen` to instantly capture your entire screen and attach it to your question as context - **Privacy-first:** zero-trust architecture, all data stays on your device ## Getting Started @@ -203,7 +204,7 @@ The big leap: from answering questions to taking action. - **Internet search:** let Thuki look things up in real time, not just reason from its training data - **Tool integrations via [MCP](https://modelcontextprotocol.io/):** connect Thuki to Gmail, Slack, Discord, Google Calendar, and any other MCP-compatible service; ask it to draft a reply, summarize a thread, or schedule a meeting without ever leaving your current app -- **Slash commands:** type `/summarize`, `/translate`, `/explain`, `/rewrite`, and more to instantly trigger built-in prompts without typing a full question +- **More slash commands:** `/screen` is live; `/summarize`, `/translate`, `/explain`, `/rewrite`, and more are on the way to instantly trigger built-in prompts without typing a full question ### Better AI Control @@ -219,7 +220,7 @@ More flexibility over the model powering Thuki. Give Thuki more to work with. - **Voice input:** dictate your question instead of typing -- **Auto-capture screen context:** activate Thuki and have it automatically read the active window or selected region as context +- **Auto-capture screen context:** activate Thuki and have it automatically read the active window or selected region as context (partial: `/screen` captures the full screen today; targeted region capture is next) - **File and document drop:** drag a PDF, image, or text file directly into Thuki as context for your question --- diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 00000000..2e6a6a66 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,19 @@ +# Commands + +Commands are typed at the start of a message using the `/` prefix. Press `/` to open the command suggestion menu, then Tab to complete or Enter to select. + +## /screen + +Captures your screen and attaches it as context for the current message. + +**Usage:** `/screen [optional message]` + +**Examples:** +- `/screen`: sends a screenshot with no additional message +- `/screen what is this error?`: attaches a screenshot and asks the question + +**Behavior:** The screenshot is taken the moment you press Enter. Thuki's own window is excluded from the capture: no flicker, no hide. The image appears in your message bubble exactly like a pasted screenshot. + +**Limit:** One `/screen` capture per message. You may also attach up to 3 images manually (paste, drag, or the camera button) for a total of 4 images per message. + +**Permission:** Requires Screen Recording permission. On first use, macOS will prompt you to grant it. If denied, Thuki cannot capture the screen. Grant access in System Settings > Privacy & Security > Screen Recording. diff --git a/src-tauri/src/images.rs b/src-tauri/src/images.rs index 684b486b..6db4cb05 100644 --- a/src-tauri/src/images.rs +++ b/src-tauri/src/images.rs @@ -32,8 +32,8 @@ const MAX_DIMENSION: u32 = 1920; /// for vision model consumption. const JPEG_QUALITY: u8 = 85; -/// Maximum number of images allowed per message. -pub const MAX_IMAGES_PER_MESSAGE: usize = 3; +/// Maximum number of images allowed per message (3 manual + 1 /screen = 4). +pub const MAX_IMAGES_PER_MESSAGE: usize = 4; /// Resolves the root images directory: `/images/`. pub fn images_root(base_dir: &Path) -> PathBuf { @@ -471,8 +471,8 @@ mod tests { } #[test] - fn max_images_per_message_is_three() { - assert_eq!(MAX_IMAGES_PER_MESSAGE, 3); + fn max_images_per_message_is_four() { + assert_eq!(MAX_IMAGES_PER_MESSAGE, 4); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4342ba92..af464478 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -607,6 +607,8 @@ pub fn run() { images::cleanup_orphaned_images_command, #[cfg(not(coverage))] screenshot::capture_screenshot_command, + #[cfg(not(coverage))] + screenshot::capture_full_screen_command, notify_overlay_hidden, set_window_frame ]) diff --git a/src-tauri/src/screenshot.rs b/src-tauri/src/screenshot.rs index 111f0bb2..0afb274c 100644 --- a/src-tauri/src/screenshot.rs +++ b/src-tauri/src/screenshot.rs @@ -1,14 +1,21 @@ /*! * Screenshot capture. * - * Exposes a single Tauri command that hides the main window, invokes the - * macOS `screencapture -i` tool (interactive crosshair region select), and - * returns the captured image as a base64 string — or `None` if the user - * cancelled (pressed Escape without selecting). + * Exposes two Tauri commands: + * + * 1. `capture_screenshot_command`: hides the main window, invokes the + * macOS `screencapture -i` tool (interactive crosshair region select), and + * returns the captured image as a base64 string, or `None` if the user + * cancelled (pressed Escape without selecting). + * + * 2. `capture_full_screen_command`: silently captures all screens using + * CoreGraphics `CGWindowListCreateImageFromArray`, excluding Thuki's own + * windows by PID. No window hide, no flicker. Returns the absolute file + * path of the saved image in `/images/`. * * `temp_screenshot_path` and `encode_as_base64` are pure helpers extracted * from the command wrapper so they can be unit-tested without Tauri context. - * The command wrapper itself is excluded from coverage (thin I/O wrapper). + * The command wrappers themselves are excluded from coverage (thin I/O wrappers). */ use std::path::PathBuf; @@ -58,14 +65,16 @@ pub fn process_screenshot_result(path: &PathBuf) -> Result, Strin pub async fn capture_screenshot_command( app_handle: tauri::AppHandle, ) -> Result, String> { - use tauri_nspanel::ManagerExt; - - let window = app_handle - .get_webview_window("main") - .ok_or_else(|| "main window not found".to_string())?; - - window - .hide() + // Hide the window on the main thread. Tauri commands run on a tokio pool + // thread, but AppKit window APIs (hide, show, makeKey) must only be called + // from the main thread to avoid crashes. + let hide_handle = app_handle.clone(); + app_handle + .run_on_main_thread(move || { + if let Some(w) = hide_handle.get_webview_window("main") { + let _ = w.hide(); + } + }) .map_err(|e| format!("failed to hide window: {e}"))?; tokio::time::sleep(std::time::Duration::from_millis(200)).await; @@ -75,24 +84,321 @@ pub async fn capture_screenshot_command( .to_str() .ok_or_else(|| "temp path is not valid UTF-8".to_string())?; - // Ignore exit status — user cancellation exits 0 but creates no file. + // Ignore exit status: user cancellation exits 0 but creates no file. let _ = std::process::Command::new("screencapture") .args(["-i", "-x", path_str]) .status(); - // Re-show via show_and_make_key() so the NSPanel becomes the key window, - // guaranteeing the WebView textarea receives keyboard focus (mirrors lib.rs). - match app_handle.get_webview_panel("main") { - Ok(panel) => panel.show_and_make_key(), - Err(_) => { - let _ = window.show(); - let _ = window.set_focus(); + // Re-show on the main thread via show_and_make_key() so the NSPanel + // becomes the key window, guaranteeing the WebView textarea receives + // keyboard focus (mirrors the pattern in lib.rs). + let show_handle = app_handle.clone(); + let _ = app_handle.run_on_main_thread(move || { + use tauri_nspanel::ManagerExt; + match show_handle.get_webview_panel("main") { + Ok(panel) => panel.show_and_make_key(), + Err(_) => { + if let Some(w) = show_handle.get_webview_window("main") { + let _ = w.show(); + let _ = w.set_focus(); + } + } } - } + }); process_screenshot_result(&path) } +// ─── Full-screen silent capture (macOS) ──────────────────────────────────── + +/// Captures raw RGBA pixel bytes of the full screen using CoreGraphics. +/// +/// Captures all on-screen content below Thuki's own window in the Z-order, +/// effectively excluding Thuki from the screenshot without hiding the window. +/// Returns `(width, height, rgba_bytes)` on success. +/// +/// MUST run on the macOS main thread. CoreGraphics APIs internally dispatch +/// to the main thread; calling them from a background thread deadlocks. +/// +/// Requires Screen Recording permission (macOS Privacy & Security). If the +/// permission has not been granted, `CGWindowListCopyWindowInfo` returns NULL +/// and this function returns an informative error string. +/// +/// Excluded from coverage: thin wrapper over macOS CoreGraphics FFI that +/// requires Screen Recording permission and a running display server. +#[cfg(target_os = "macos")] +#[cfg_attr(coverage_nightly, coverage(off))] +fn capture_full_screen_raw() -> Result<(u32, u32, Vec), String> { + use core_foundation::base::TCFType; + use core_foundation::string::CFString; + use core_graphics::geometry::{CGPoint, CGRect, CGSize}; + use std::ffi::c_void; + + // CoreFoundation / CoreGraphics opaque pointer types for our raw FFI. + type CFArrayRef = *const c_void; + type CFDictionaryRef = *const c_void; + + // CGWindowListOption flags. + const K_CG_WINDOW_LIST_OPTION_ON_SCREEN_ONLY: u32 = 1; + const K_CG_WINDOW_LIST_OPTION_ON_SCREEN_BELOW_WINDOW: u32 = 1 << 2; + const K_CG_WINDOW_LIST_EXCLUDE_DESKTOP_ELEMENTS: u32 = 1 << 4; + const K_CG_NULL_WINDOW_ID: u32 = 0; + const K_CG_WINDOW_IMAGE_DEFAULT: u32 = 0; + + // CFNumber type selector: kCFNumberSInt32Type (PID and window ID are 32-bit). + const K_CF_NUMBER_S_INT32_TYPE: i32 = 3; + + // CGBitmapInfo for BGRA (native macOS little-endian, premultiplied alpha). + const K_CG_BITMAP_BYTE_ORDER32_HOST: u32 = 2 << 12; // 8192 + const K_CG_IMAGE_ALPHA_PREMULTIPLIED_FIRST: u32 = 2; + const BGRA_BITMAP_INFO: u32 = + K_CG_BITMAP_BYTE_ORDER32_HOST | K_CG_IMAGE_ALPHA_PREMULTIPLIED_FIRST; + + #[link(name = "ApplicationServices", kind = "framework")] + extern "C" { + fn CGWindowListCopyWindowInfo(option: u32, relativeToWindow: u32) -> CFArrayRef; + fn CGWindowListCreateImage( + screenBounds: CGRect, + listOption: u32, + relativeToWindow: u32, + imageOption: u32, + ) -> *const c_void; + fn CGMainDisplayID() -> u32; + fn CGDisplayBounds(display: u32) -> CGRect; + fn CGImageGetWidth(image: *const c_void) -> usize; + fn CGImageGetHeight(image: *const c_void) -> usize; + fn CGImageRelease(image: *const c_void); + fn CGColorSpaceCreateDeviceRGB() -> *const c_void; + fn CGColorSpaceRelease(cs: *const c_void); + fn CGBitmapContextCreate( + data: *mut c_void, + width: usize, + height: usize, + bitsPerComponent: usize, + bytesPerRow: usize, + colorSpace: *const c_void, + bitmapInfo: u32, + ) -> *const c_void; + fn CGContextDrawImage(ctx: *const c_void, rect: CGRect, image: *const c_void); + fn CGContextRelease(ctx: *const c_void); + } + + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + fn CFArrayGetCount(array: CFArrayRef) -> isize; + fn CFArrayGetValueAtIndex(array: CFArrayRef, idx: isize) -> *const c_void; + fn CFDictionaryGetValue(dict: CFDictionaryRef, key: *const c_void) -> *const c_void; + fn CFNumberGetValue(number: *const c_void, theType: i32, valuePtr: *mut c_void) -> bool; + fn CFRelease(cf: *const c_void); + } + + let our_pid = std::process::id() as i32; + + unsafe { + // Use the actual main display bounds instead of abstract CGRectNull + // or CGRectInfinite, which have platform-dependent representations + // that can cause CGWindowListCreateImage to return null. + let screen_bounds = CGDisplayBounds(CGMainDisplayID()); + + // Probe Screen Recording permission. CGWindowListCopyWindowInfo + // returns NULL when the permission has not been granted. + let option = + K_CG_WINDOW_LIST_OPTION_ON_SCREEN_ONLY | K_CG_WINDOW_LIST_EXCLUDE_DESKTOP_ELEMENTS; + let window_info_list = CGWindowListCopyWindowInfo(option, K_CG_NULL_WINDOW_ID); + + if window_info_list.is_null() { + return Err("Screen Recording permission is required to use /screen. \ + Grant it in System Settings > Privacy & Security > Screen Recording." + .to_string()); + } + + // Find Thuki's own topmost window ID so we can capture everything + // below it in Z-order. The window list is front-to-back, so the + // first entry matching our PID is the topmost. + let count = CFArrayGetCount(window_info_list); + let pid_key = CFString::new("kCGWindowOwnerPID"); + let wid_key = CFString::new("kCGWindowNumber"); + + let mut our_window_id: u32 = K_CG_NULL_WINDOW_ID; + for i in 0..count { + let dict = CFArrayGetValueAtIndex(window_info_list, i) as CFDictionaryRef; + if dict.is_null() { + continue; + } + let pid_val = + CFDictionaryGetValue(dict, pid_key.as_concrete_TypeRef() as *const c_void); + if pid_val.is_null() { + continue; + } + let mut owner_pid: i32 = 0; + CFNumberGetValue( + pid_val, + K_CF_NUMBER_S_INT32_TYPE, + &mut owner_pid as *mut i32 as *mut c_void, + ); + if owner_pid == our_pid { + let wid_val = + CFDictionaryGetValue(dict, wid_key.as_concrete_TypeRef() as *const c_void); + if !wid_val.is_null() { + let mut wid: u32 = 0; + CFNumberGetValue( + wid_val, + K_CF_NUMBER_S_INT32_TYPE, + &mut wid as *mut u32 as *mut c_void, + ); + our_window_id = wid; + } + break; + } + } + CFRelease(window_info_list); + + // Capture all on-screen windows below our panel. Since Thuki is an + // always-on-top NSPanel, "below" is effectively every other window. + // If we could not find our own window ID (shouldn't happen), fall + // back to capturing all on-screen windows. + let (list_option, relative_to) = if our_window_id != K_CG_NULL_WINDOW_ID { + ( + K_CG_WINDOW_LIST_OPTION_ON_SCREEN_BELOW_WINDOW + | K_CG_WINDOW_LIST_EXCLUDE_DESKTOP_ELEMENTS, + our_window_id, + ) + } else { + ( + K_CG_WINDOW_LIST_OPTION_ON_SCREEN_ONLY | K_CG_WINDOW_LIST_EXCLUDE_DESKTOP_ELEMENTS, + K_CG_NULL_WINDOW_ID, + ) + }; + + let cg_image = CGWindowListCreateImage( + screen_bounds, + list_option, + relative_to, + K_CG_WINDOW_IMAGE_DEFAULT, + ); + + if cg_image.is_null() { + return Err( + "Screen capture failed. Ensure Screen Recording permission is \ + granted in System Settings > Privacy & Security > Screen Recording." + .to_string(), + ); + } + + let width = CGImageGetWidth(cg_image); + let height = CGImageGetHeight(cg_image); + + if width == 0 || height == 0 { + CGImageRelease(cg_image); + return Err("Screen capture returned an empty image.".to_string()); + } + + // Render CGImage into a BGRA bitmap buffer. + let bytes_per_row = width * 4; + let mut pixel_bytes: Vec = vec![0u8; height * bytes_per_row]; + + let color_space = CGColorSpaceCreateDeviceRGB(); + let ctx = CGBitmapContextCreate( + pixel_bytes.as_mut_ptr() as *mut c_void, + width, + height, + 8, + bytes_per_row, + color_space, + BGRA_BITMAP_INFO, + ); + CGColorSpaceRelease(color_space); + + if ctx.is_null() { + CGImageRelease(cg_image); + return Err("Failed to create bitmap context for screen capture.".to_string()); + } + + let draw_rect = CGRect { + origin: CGPoint::new(0.0, 0.0), + size: CGSize::new(width as f64, height as f64), + }; + CGContextDrawImage(ctx, draw_rect, cg_image); + CGContextRelease(ctx); + CGImageRelease(cg_image); + + // Convert BGRA to RGBA in-place (swap B and R channels). + // CoreGraphics BGRA layout: [B, G, R, A] per pixel. + // image crate Rgba layout: [R, G, B, A] per pixel. + for chunk in pixel_bytes.chunks_exact_mut(4) { + chunk.swap(0, 2); // Swap B <-> R + } + + Ok((width as u32, height as u32, pixel_bytes)) + } +} + +/// Captures raw RGBA pixel bytes from the screen. Must be called on the macOS +/// main thread because CoreGraphics APIs internally dispatch there and will +/// deadlock if called from a background thread. +/// +/// Returns `(width, height, rgba_bytes)` on success. +#[cfg(target_os = "macos")] +#[cfg_attr(coverage_nightly, coverage(off))] +fn capture_full_screen_pixels() -> Result<(u32, u32, Vec), String> { + capture_full_screen_raw() +} + +/// Non-macOS stub: full-screen capture is macOS-only. +#[cfg(not(target_os = "macos"))] +fn capture_full_screen_pixels() -> Result<(u32, u32, Vec), String> { + Err("full-screen capture is only supported on macOS".to_string()) +} + +/// Tauri command: silently captures the full screen (excluding Thuki's own +/// windows) and returns the absolute file path of the saved image. +/// +/// CoreGraphics APIs internally dispatch to the main thread, so calling them +/// from a tokio pool thread (via `spawn_blocking`) causes a deadlock. Instead, +/// `capture_full_screen` runs on the main thread via `run_on_main_thread`, +/// producing raw RGBA pixel bytes. The heavy image encoding and disk I/O then +/// happen on a blocking thread to avoid stalling the UI. +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg_attr(not(coverage), tauri::command)] +pub async fn capture_full_screen_command(app_handle: tauri::AppHandle) -> Result { + use tauri::Manager; + let base_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("failed to resolve app data dir: {e}"))?; + + // Phase 1: Capture raw RGBA pixels on the main thread (CoreGraphics + // requirement). Returns (width, height, rgba_bytes). + let (tx, rx) = tokio::sync::oneshot::channel::), String>>(); + app_handle + .run_on_main_thread(move || { + tx.send(capture_full_screen_pixels()).ok(); + }) + .map_err(|e| format!("failed to dispatch capture to main thread: {e}"))?; + + let (width, height, rgba_bytes) = rx + .await + .map_err(|_| "main thread capture channel closed unexpectedly".to_string())??; + + // Phase 2: Encode to PNG and save via the images pipeline on a blocking + // thread so the main thread stays responsive. + tokio::task::spawn_blocking(move || { + let buf = + image::ImageBuffer::, Vec>::from_raw(width, height, rgba_bytes) + .ok_or_else(|| "Failed to create image buffer from captured pixels.".to_string())?; + let dynamic = image::DynamicImage::ImageRgba8(buf); + + let mut png: Vec = Vec::new(); + dynamic + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .map_err(|e| format!("Failed to encode screen capture as PNG: {e}"))?; + + crate::images::save_image(&base_dir, &png) + }) + .await + .map_err(|e| format!("image encoding task failed: {e}"))? +} + // ─── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -159,4 +465,12 @@ mod tests { fn encode_as_base64_empty_input() { assert_eq!(encode_as_base64(b""), ""); } + + #[cfg(not(target_os = "macos"))] + #[test] + fn capture_full_screen_returns_err_on_non_macos() { + let result = capture_full_screen_pixels(); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("only supported on macOS")); + } } diff --git a/src/App.tsx b/src/App.tsx index 3626162e..591ac514 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { HistoryPanel } from './components/HistoryPanel'; import { ImagePreviewModal } from './components/ImagePreviewModal'; import type { AttachedImage } from './types/image'; import { quote } from './config'; +import { SCREEN_CAPTURE_PLACEHOLDER } from './config/commands'; import './App.css'; /** Ollama model used for this session — must match the Rust DEFAULT_MODEL_NAME. */ @@ -135,6 +136,24 @@ 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. */ + const [captureError, setCaptureError] = useState(null); + /** + * Set to true when a /screen capture is dispatched, false when it resolves + * or when the user cancels. Lets the async tail in handleScreenSubmit + * detect a mid-flight cancellation and skip the ask() call. + */ + const screenCapturePendingRef = useRef(false); + /** + * Stores the input state (query + context) captured just before a /screen + * submit clears them. Used by handleCancel to restore the ask bar if the + * user aborts the in-flight capture. + */ + const screenCaptureInputSnapshotRef = useRef<{ + query: string; + context: string | undefined; + } | null>(null); /** User message shown in the chat while waiting for images to finish * processing. Cleared when `ask()` fires and adds the real message. */ const [pendingUserMessage, setPendingUserMessage] = useState( @@ -340,8 +359,11 @@ function App() { return []; }); pendingSubmitRef.current = null; + screenCapturePendingRef.current = false; + screenCaptureInputSnapshotRef.current = null; setIsSubmitPending(false); setPendingUserMessage(null); + setCaptureError(null); reset(); resetHistory(); @@ -362,6 +384,8 @@ function App() { outerContainerRef.current.style.minHeight = ''; } /* v8 ignore stop */ + screenCapturePendingRef.current = false; + screenCaptureInputSnapshotRef.current = null; setSelectedContext(null); setPreviewImageUrl(null); setAttachedImages((prev) => { @@ -565,6 +589,8 @@ function App() { return []; }); pendingSubmitRef.current = null; + screenCapturePendingRef.current = false; + screenCaptureInputSnapshotRef.current = null; setIsSubmitPending(false); setPendingUserMessage(null); }, [reset, resetHistory]); @@ -719,12 +745,127 @@ function App() { [ask, attachedImages, setSelectedContext], ); + /** + * Async handler for the `/screen` command path. Invokes the Rust + * `capture_full_screen_command`, which silently captures the screen + * (excluding Thuki's own windows) and returns the saved file path. + * On success, merges the screenshot path with any manually attached + * images and calls ask(). On error, restores the query so no input is lost. + */ + const handleScreenSubmit = useCallback(async () => { + // eslint-disable-next-line no-control-regex + const CONTROL_CHARS = /[\x00-\x08\x0b\x0c\x0e-\x1f]/g; + const sanitized = selectedContext + ?.replace(CONTROL_CHARS, '') + .slice(0, quote.maxContextLength); + const context = sanitized?.trim() ? sanitized : undefined; + + const trimmed = query.trimStart(); + const cleanQuery = trimmed.slice('/screen'.length).trimStart(); + + // Snapshot display paths for the pending bubble: use resolved file paths + // for already-processed images, blob URLs for still-processing ones. + const existingDisplayPaths = attachedImages.map( + (img) => img.filePath ?? img.blobUrl, + ); + + // Store the original input so handleCancel can restore it if the user + // aborts the capture before it resolves. + const restoredQuery = `/screen${cleanQuery ? ` ${cleanQuery}` : ''}`; + screenCaptureInputSnapshotRef.current = { query: restoredQuery, context }; + + // Immediately show the user's message in chat with a loading placeholder + // for the screenshot. This prevents double-submit spam and gives instant + // feedback that the capture is in progress. + screenCapturePendingRef.current = true; + setIsSubmitPending(true); + setPendingUserMessage({ + id: crypto.randomUUID(), + role: 'user', + content: cleanQuery, + quotedText: context, + imagePaths: [...existingDisplayPaths, SCREEN_CAPTURE_PLACEHOLDER], + }); + setQuery(''); + setSelectedContext(null); + /* v8 ignore start -- inputRef always set when overlay is visible */ + if (inputRef.current) inputRef.current.style.height = 'auto'; + /* v8 ignore stop */ + + let screenshotPath: string; + try { + screenshotPath = await invoke('capture_full_screen_command'); + } catch (e) { + screenCapturePendingRef.current = false; + screenCaptureInputSnapshotRef.current = null; + // Capture failed: restore input state so the user can retry or edit. + setIsSubmitPending(false); + setPendingUserMessage(null); + setQuery(restoredQuery); + setSelectedContext(context ?? null); + // Surface the Rust error directly: the backend already provides + // descriptive messages (permission prompts, null-image diagnostics, etc.). + // Tauri v2 rejects with the Err(String) value as a plain string. + setCaptureError( + typeof e === 'string' ? e : e instanceof Error ? e.message : String(e), + ); + return; + } + + // Check for mid-flight cancellation before touching any state. + // handleCancel sets screenCapturePendingRef.current = false as a signal. + const wasCancelled = !screenCapturePendingRef.current; + screenCapturePendingRef.current = false; + screenCaptureInputSnapshotRef.current = null; + if (wasCancelled) return; + + // Capture succeeded: finalize the submit. + setCaptureError(null); + setIsSubmitPending(false); + setPendingUserMessage(null); + + const readyPaths = attachedImages + .filter((img) => img.filePath !== null) + .map((img) => img.filePath as string); + readyPaths.push(screenshotPath); + + ask(cleanQuery, context, readyPaths); + for (const img of attachedImages) { + URL.revokeObjectURL(img.blobUrl); + } + setAttachedImages([]); + }, [ + query, + selectedContext, + attachedImages, + ask, + setSelectedContext, + setCaptureError, + ]); + const handleSubmit = useCallback(() => { if ( (query.trim().length === 0 && attachedImages.length === 0) || isGenerating ) return; + + // Clear any stale capture error from a previous attempt. + setCaptureError(null); + + // Detect /screen command at the very start of the message. + const trimmedQuery = query.trimStart(); + const isScreenCommand = + trimmedQuery.startsWith('/screen') && + (trimmedQuery.length === '/screen'.length || + trimmedQuery['/screen'.length] === ' '); + + if (isScreenCommand) { + // Fire-and-forget: the async path handles cleanup and ask() invocation. + void handleScreenSubmit(); + return; + } + // Sanitize externally-sourced context: strip control characters and enforce // a length cap to limit prompt-injection surface from host-app selections. // eslint-disable-next-line no-control-regex @@ -766,9 +907,11 @@ function App() { query, isGenerating, executeSubmit, + handleScreenSubmit, selectedContext, setSelectedContext, attachedImages, + setCaptureError, ]); // When a pending submit exists and all images finish processing, fire it. @@ -810,18 +953,46 @@ function App() { }, [attachedImages, ask, setSelectedContext]); /* eslint-enable @eslint-react/set-state-in-effect */ - /** Unified cancel handler: reverts a pending submit (undo-send) or cancels - * an active Ollama generation. When reverting, restores the user's query - * and keeps attached images so they can re-submit or edit. */ + /** + * Unified cancel handler: reverts a pending submit (undo-send), clears an + * in-flight /screen capture, or cancels an active Ollama generation. + * + * Three cases: + * 1. Image-processing pending (`pendingSubmitRef.current` is set): restore + * query and attached images so the user can re-submit or edit. + * 2. Screen-capture in-flight (`isSubmitPending` true but ref is null): + * clear pending state. The async capture may still complete on the Rust + * side, but `isSubmitPending` being false when the result arrives will + * cause `handleScreenSubmit` to attempt ask() on stale state. To prevent + * that, we track the abandonment via a flag so the async tail is a no-op. + * 3. Ollama generation active: delegate to the streaming cancel. + */ const handleCancel = useCallback(() => { if (isSubmitPending && pendingSubmitRef.current) { - // Undo send — restore input state to before the user hit Enter. + // Case 1: image-processing pending. Restore input state. setQuery(pendingSubmitRef.current.query); setSelectedContext(pendingSubmitRef.current.context ?? null); pendingSubmitRef.current = null; setIsSubmitPending(false); setPendingUserMessage(null); - // Re-focus the textarea so the user can immediately edit. + requestAnimationFrame(() => inputRef.current?.focus()); + return; + } + if (isSubmitPending) { + // Case 2: /screen capture in flight. Signal cancellation via ref so the + // async tail in handleScreenSubmit skips ask() when capture resolves. + // Restore the ask bar to what it looked like before the capture started. + screenCapturePendingRef.current = false; + const snapshot = screenCaptureInputSnapshotRef.current; + screenCaptureInputSnapshotRef.current = null; + setIsSubmitPending(false); + setPendingUserMessage(null); + /* v8 ignore start -- snapshot is always set when isSubmitPending is true via /screen */ + if (snapshot) { + setQuery(snapshot.query); + setSelectedContext(snapshot.context ?? null); + } + /* v8 ignore stop */ requestAnimationFrame(() => inputRef.current?.focus()); return; } @@ -1039,8 +1210,11 @@ function App() { animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ - height: { duration: 0.25, ease: [0.16, 1, 0.3, 1] }, - opacity: { duration: 0.2 }, + height: { + duration: 0.3, + ease: [0.33, 1, 0.68, 1], + }, + opacity: { duration: 0.2, delay: 0.08 }, }} style={{ overflow: 'hidden' }} className="border-t border-surface-border" @@ -1059,6 +1233,16 @@ function App() { )} + {/* Capture error banner: shown when /screen capture fails so + the user knows why the message was not sent. */} + {captureError && ( +
+

+ {captureError} +

+
+ )} + {/* Input Bar — always pinned to the bottom */} { // Old messages should be gone expect(screen.queryByText('First response')).toBeNull(); }); + + // ─── /screen command ───────────────────────────────────────────────────────── + + describe('/screen command', () => { + it('invokes capture_full_screen_command and calls ask with screenshot path', async () => { + enableChannelCaptureWithResponses({ + capture_full_screen_command: '/tmp/screen.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Use "/screen " (with trailing space) so the suggestion popover is dismissed + // and Enter goes to the submit handler directly. + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/screen ' } }); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + await act(async () => {}); + + expect(invoke).toHaveBeenCalledWith('capture_full_screen_command'); + expect(invoke).toHaveBeenCalledWith( + 'ask_ollama', + expect.objectContaining({ + imagePaths: ['/tmp/screen.jpg'], + message: '', + }), + ); + }); + + it('strips the /screen trigger and sends the remaining text as message', async () => { + enableChannelCaptureWithResponses({ + capture_full_screen_command: '/tmp/screen.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay(); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { + target: { value: '/screen what is this error?' }, + }); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + await act(async () => {}); + + expect(invoke).toHaveBeenCalledWith( + 'ask_ollama', + expect.objectContaining({ + message: 'what is this error?', + imagePaths: ['/tmp/screen.jpg'], + }), + ); + }); + + it('does not invoke capture_full_screen_command when /screen is not at start', async () => { + enableChannelCapture(); + + render(); + await act(async () => {}); + await showOverlay(); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { + target: { value: 'hello /screen there' }, + }); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + await act(async () => {}); + + expect(invoke).not.toHaveBeenCalledWith('capture_full_screen_command'); + expect(invoke).toHaveBeenCalledWith( + 'ask_ollama', + expect.objectContaining({ message: 'hello /screen there' }), + ); + }); + + it('does not call ask when capture_full_screen_command throws', async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'capture_full_screen_command') { + throw new Error('Permission denied'); + } + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Use "/screen " (with trailing space) so the suggestion popover is dismissed + // and Enter goes directly to the submit handler. + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/screen ' } }); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + await act(async () => {}); + + expect(invoke).toHaveBeenCalledWith('capture_full_screen_command'); + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); + // The actual Rust error message is surfaced directly. + expect(screen.getByText('Permission denied')).toBeInTheDocument(); + }); + + it('surfaces string errors from Tauri invoke directly', async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'capture_full_screen_command') { + // Tauri v2 rejects with the Err(String) value as a plain string. + return Promise.reject( + 'Screen Recording permission is required to use /screen.', + ); + } + }); + + render(); + await act(async () => {}); + await showOverlay(); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/screen ' } }); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + await act(async () => {}); + + expect( + screen.getByText( + 'Screen Recording permission is required to use /screen.', + ), + ).toBeInTheDocument(); + }); + + it('handles non-Error non-string rejection values', async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'capture_full_screen_command') { + return Promise.reject(42); + } + }); + + render(); + await act(async () => {}); + await showOverlay(); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/screen ' } }); + }); + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + expect(screen.getByText('42')).toBeInTheDocument(); + }); + + it('clears capture error when a new submit is attempted', async () => { + enableChannelCapture(); + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'capture_full_screen_command') { + throw new Error('capture failed'); + } + }); + + render(); + await act(async () => {}); + await showOverlay(); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + // First attempt fails; error banner appears. + act(() => { + fireEvent.change(textarea, { target: { value: '/screen ' } }); + }); + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + expect(screen.getByText('capture failed')).toBeInTheDocument(); + + // Typing a new query and submitting normal text clears the error banner. + act(() => { + fireEvent.change(textarea, { target: { value: 'hello' } }); + }); + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + expect(screen.queryByText('capture failed')).toBeNull(); + }); + + it('merges screenshot path with existing attached images', async () => { + // Set up mocks: save_image_command for image attachment, then screen capture. + enableChannelCaptureWithResponses({ + save_image_command: '/tmp/attached.jpg', + capture_full_screen_command: '/tmp/screen.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Paste an image first. This exercises the filter/map on attachedImages inside + // handleScreenSubmit, covering the lines for non-null filePath images. + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + const file = new File(['img'], 'photo.png', { type: 'image/png' }); + await act(async () => { + fireEvent.paste(textarea, { + clipboardData: { + items: [{ type: 'image/png', getAsFile: () => file }], + }, + }); + }); + + // Wait for the image to be processed (filePath resolved). + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'save_image_command', + expect.anything(), + ); + }); + + // Now type /screen and submit. + act(() => { + fireEvent.change(textarea, { target: { value: '/screen describe' } }); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + await act(async () => {}); + + expect(invoke).toHaveBeenCalledWith('capture_full_screen_command'); + expect(invoke).toHaveBeenCalledWith( + 'ask_ollama', + expect.objectContaining({ + message: 'describe', + imagePaths: ['/tmp/attached.jpg', '/tmp/screen.jpg'], + }), + ); + }); + + it('handles /screen with selected context', async () => { + enableChannelCaptureWithResponses({ + capture_full_screen_command: '/tmp/screen.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay('some context'); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/screen explain' } }); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + await act(async () => {}); + + expect(invoke).toHaveBeenCalledWith( + 'ask_ollama', + expect.objectContaining({ + message: 'explain', + quotedText: 'some context', + imagePaths: ['/tmp/screen.jpg'], + }), + ); + }); + + it('shows pending chat bubble immediately on submit before capture resolves', async () => { + let resolveCapture!: (path: string) => void; + enableChannelCaptureWithResponses({ + capture_full_screen_command: new Promise((res) => { + resolveCapture = res; + }), + }); + + render(); + await act(async () => {}); + await showOverlay(); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/screen check this' } }); + }); + + // Submit; capture is now in-flight (pending) + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + // Before capture resolves: query should be cleared and app in pending mode + expect((textarea as HTMLTextAreaElement).value).toBe(''); + + // Resolve the capture and let async work settle + await act(async () => { + resolveCapture('/tmp/screen.jpg'); + }); + await act(async () => {}); + + // After capture resolves: ask_ollama should be called + expect(invoke).toHaveBeenCalledWith( + 'ask_ollama', + expect.objectContaining({ message: 'check this' }), + ); + }); + + it('restores query with cleanQuery text when capture fails mid-message', async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'capture_full_screen_command') { + throw new Error('Screen capture timed out'); + } + }); + + render(); + await act(async () => {}); + await showOverlay(); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { + target: { value: '/screen what is this?' }, + }); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + // Query should be restored with the full original message + expect((textarea as HTMLTextAreaElement).value).toBe( + '/screen what is this?', + ); + expect(screen.getByText('Screen capture timed out')).toBeInTheDocument(); + }); + + it('uses blobUrl for still-processing attached images in the pending bubble', async () => { + // save_image_command never resolves: image stays in null-filePath state. + // Use enableChannelCaptureWithResponses so channel capture (for ask_ollama) + // still works alongside the custom per-command responses. + enableChannelCaptureWithResponses({ + save_image_command: new Promise(() => {}), + capture_full_screen_command: '/tmp/screen.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Paste an image; save_image_command hangs, so filePath stays null + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + const file = new File(['img'], 'photo.png', { type: 'image/png' }); + await act(async () => { + fireEvent.paste(textarea, { + clipboardData: { + items: [{ type: 'image/png', getAsFile: () => file }], + }, + }); + }); + + // Submit /screen immediately; image still processing (filePath === null) + act(() => { + fireEvent.change(textarea, { target: { value: '/screen ' } }); + }); + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + // Capture succeeded; ask_ollama called with only the screenshot + // (the attached image never resolved its filePath) + expect(invoke).toHaveBeenCalledWith('capture_full_screen_command'); + expect(invoke).toHaveBeenCalledWith( + 'ask_ollama', + expect.objectContaining({ + imagePaths: ['/tmp/screen.jpg'], + }), + ); + }); + + it('cancelling during in-flight capture prevents ask from being called', async () => { + let resolveCapture!: (path: string) => void; + enableChannelCaptureWithResponses({ + capture_full_screen_command: new Promise((res) => { + resolveCapture = res; + }), + }); + + render(); + await act(async () => {}); + await showOverlay(); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/screen ' } }); + }); + + // Submit; capture is now in-flight + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + // Cancel while capture is pending (Stop button) + const stopButton = screen.getByRole('button', { name: /stop|cancel/i }); + act(() => { + fireEvent.click(stopButton); + }); + + // Resolve the capture after cancel + await act(async () => { + resolveCapture('/tmp/screen.jpg'); + }); + await act(async () => {}); + + // ask_ollama must NOT be called since the user cancelled + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); + }); + }); }); diff --git a/src/components/ChatBubble.tsx b/src/components/ChatBubble.tsx index b791f59b..ec1fc429 100644 --- a/src/components/ChatBubble.tsx +++ b/src/components/ChatBubble.tsx @@ -5,6 +5,7 @@ import { ImageThumbnails } from './ImageThumbnails'; import { convertFileSrc } from '@tauri-apps/api/core'; import { formatQuotedText } from '../utils/formatQuote'; import { quote } from '../config'; +import { SCREEN_CAPTURE_PLACEHOLDER } from '../config/commands'; interface ChatBubbleProps { /** The message role determines alignment and color treatment. */ @@ -90,8 +91,14 @@ export function ChatBubble({ ({ id: p, - src: p.startsWith('blob:') ? p : convertFileSrc(p), + src: + p === SCREEN_CAPTURE_PLACEHOLDER + ? p + : p.startsWith('blob:') + ? p + : convertFileSrc(p), loading: p.startsWith('blob:'), + placeholder: p === SCREEN_CAPTURE_PLACEHOLDER, }))} onPreview={onImagePreview} size={48} diff --git a/src/components/CommandSuggestion.tsx b/src/components/CommandSuggestion.tsx new file mode 100644 index 00000000..f15bb63a --- /dev/null +++ b/src/components/CommandSuggestion.tsx @@ -0,0 +1,144 @@ +/** + * CommandSuggestion: slash command autocomplete popover. + * + * Renders above the ask bar when the user types a "/" prefix. + * The parent (AskBarView) is responsible for computing `filteredCommands` + * and managing `highlightedIndex`. This component is purely presentational. + */ + +import type React from 'react'; +import type { Command } from '../config/commands'; + +/** Hoisted static screen-capture SVG icon. */ +const SCREEN_ICON = ( + +); + +/** Returns the icon for a given command trigger. Currently all commands use SCREEN_ICON. */ +function iconForTrigger(trigger: string): React.ReactNode { + switch (trigger) { + case '/screen': + default: + return SCREEN_ICON; + } +} + +interface CommandSuggestionProps { + /** Filtered list of matching commands to display (computed by parent). */ + commands: readonly Command[]; + /** Index of the currently highlighted row (-1 means nothing highlighted). */ + highlightedIndex: number; + /** Called with the trigger string when a row is clicked. */ + onSelect: (trigger: string) => void; +} + +/** + * Renders the slash command suggestion popover. + * + * When `commands` is empty, shows a "No commands found" placeholder row. + * Otherwise renders one row per command with an icon, label, description, + * and a Tab badge on the highlighted row. + */ +export function CommandSuggestion({ + commands, + highlightedIndex, + onSelect, +}: CommandSuggestionProps) { + return ( +
+ {/* Header */} +
+ + Commands + +
+ + {commands.length === 0 ? ( +
+ No commands found +
+ ) : ( +
    + {commands.map((cmd, index) => { + const isHighlighted = index === highlightedIndex; + return ( +
  • { + // Use mousedown + preventDefault so the textarea doesn't lose + // focus before the click is registered. + e.preventDefault(); + onSelect(cmd.trigger); + }} + > + {/* Icon */} + + {iconForTrigger(cmd.trigger)} + + + {/* Trigger label */} + + {cmd.label} + + + {/* Description */} + + {cmd.description} + + + {/* Tab badge on highlighted row only */} + {isHighlighted && ( + + Tab + + )} +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/components/ImageThumbnails.tsx b/src/components/ImageThumbnails.tsx index 60532fea..033533db 100644 --- a/src/components/ImageThumbnails.tsx +++ b/src/components/ImageThumbnails.tsx @@ -7,6 +7,12 @@ interface ThumbnailItem { src: string; /** Whether the image is still being processed by the backend. */ loading?: boolean; + /** + * When true, renders a branded screen-capture loading tile instead of an + * image. Use this when no preview image is available yet (e.g. the /screen + * capture is in flight and there is no blob URL to show). + */ + placeholder?: boolean; } interface ImageThumbnailsProps { @@ -50,25 +56,36 @@ export function ImageThumbnails({ className="relative group" role="listitem" > - + aria-label="Capturing screen..." + > +
+
+ ) : ( + + )} {onRemove && ( + + )} + +
+
+ Thuki -