From 6761c8fc02749db9a6fcbdd849564980ea3bd470 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Tue, 15 Sep 2026 19:20:03 +0800 Subject: [PATCH] feat(desktop): add native file drag previews Add controller-local Windows drag capture with bounded image thumbnails, file type icons, stacked previews, and cyan count badges. Dismiss the Shell drag image before showing the custom preview and declare Windows compatibility for layered receiver windows. Use a frosted pane overlay with a single Lucide file icon at 20% opacity. Cover native window and Shell image lifecycles, frontend drag handling, and manifest wiring. --- docs/interactive-capabilities/README.md | 4 +- .../technical/product-control-open-audit.json | 1 + .../technical/tauri-command-map.json | 22 +- .../cargo-dependency-boundaries.mjs | 4 +- scripts/desktop-tauri-build.test.mjs | 10 + src/apps/desktop/AGENTS.md | 25 + src/apps/desktop/Cargo.toml | 11 +- src/apps/desktop/build.rs | 6 +- .../desktop/src/api/file_drop_preview_api.rs | 790 ++++++++++++++++++ src/apps/desktop/src/api/mod.rs | 1 + src/apps/desktop/src/lib.rs | 1 + src/apps/desktop/windows-app.manifest | 19 + .../generated/remote-surface-registry.json | 14 +- .../src/remote_surface/table.rs | 1 + .../interactive-capabilities/catalog.json | 4 + .../scenes/session/ChatFileDropOverlay.tsx | 59 ++ .../src/app/scenes/session/ChatPane.scss | 146 +++- .../src/app/scenes/session/ChatPane.tsx | 20 +- .../session/FileDropPreviewCards.test.tsx | 52 ++ .../scenes/session/FileDropPreviewCards.tsx | 45 + .../app/scenes/session/fileDropPreviewKind.ts | 20 + .../src/flow_chat/components/ChatInput.tsx | 16 + .../api/generated/remoteSurface.ts | 3 +- .../files/useWindowsFileDropPreview.test.tsx | 125 +++ .../files/useWindowsFileDropPreview.ts | 147 ++++ src/web-ui/src/locales/en-US/flow-chat.json | 3 + src/web-ui/src/locales/zh-CN/flow-chat.json | 3 + src/web-ui/src/locales/zh-TW/flow-chat.json | 3 + .../src/shared/types/fileDropPreview.ts | 12 + 29 files changed, 1531 insertions(+), 36 deletions(-) create mode 100644 src/apps/desktop/src/api/file_drop_preview_api.rs create mode 100644 src/apps/desktop/windows-app.manifest create mode 100644 src/web-ui/src/app/scenes/session/ChatFileDropOverlay.tsx create mode 100644 src/web-ui/src/app/scenes/session/FileDropPreviewCards.test.tsx create mode 100644 src/web-ui/src/app/scenes/session/FileDropPreviewCards.tsx create mode 100644 src/web-ui/src/app/scenes/session/fileDropPreviewKind.ts create mode 100644 src/web-ui/src/infrastructure/files/useWindowsFileDropPreview.test.tsx create mode 100644 src/web-ui/src/infrastructure/files/useWindowsFileDropPreview.ts create mode 100644 src/web-ui/src/shared/types/fileDropPreview.ts diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index 542079c12a..a6332277c4 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -27,9 +27,9 @@ OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, a - Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json` - Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json` -说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **656** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **657** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 -Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **656** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. +Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **657** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. ## 控制边界 / Control boundary diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index 5bb857df58..43848b2768 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -734,6 +734,7 @@ "evidence": [ "command:get_clipboard_files", "command:resolve_browser_dropped_file_paths", + "command:set_file_drop_preview_target", "command:upload_image_contexts", "source:src/web-ui/src/flow_chat/utils/externalFileIntake.ts#buildExternalFileContexts", "source:src/web-ui/src/flow_chat/utils/externalFileIntake.test.ts#buildExternalFileContexts", diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index b2a988162a..6001ee7057 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -2,10 +2,10 @@ "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", "catalogDigest": "5473331e06ecd4bfafbf9b547d48b4fe1561ccf69d86fdfde51ea10dc919531c", - "commandCount": 656, + "commandCount": 657, "coverage": { - "commandCount": 656, - "documentedCommandCount": 612, + "commandCount": 657, + "documentedCommandCount": 613, "implementationCommandCount": 44, "implementationDigest": "012cebbd8d7c908d8e4c0787f73a2fa5f43184c240e7fde68cb941667574ba8f" }, @@ -8601,6 +8601,22 @@ "signature": "fn set_external_tool_targets_enabled_command( request: SetExternalToolTargetsEnabledRequest, ) -> ExternalSourceOperationResult", "remoteWorkspacePolicy": "RemoteUnsupported" }, + { + "id": "set_file_drop_preview_target", + "moduleId": "file_drop_preview", + "capabilityId": "feature.files-editor", + "capabilityIds": [ + "feature.files-editor" + ], + "documentedItemIds": [ + "feature.files-editor:attachments" + ], + "visibility": "documented", + "rustPath": "api::file_drop_preview_api::set_file_drop_preview_target", + "sourceFile": "src/apps/desktop/src/api/file_drop_preview_api.rs", + "signature": "fn set_file_drop_preview_target( window: tauri::WebviewWindow, request: SetFileDropPreviewTargetRequest, ) -> Result<(), String>", + "remoteWorkspacePolicy": "LocalOnly" + }, { "id": "set_global_skill_disabled", "moduleId": "skill", diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index ecc60a51d3..4b797fb152 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -553,7 +553,9 @@ const THIRD_PARTY_CAPABILITY_PROFILES = new Map([ optional: true, useDefaultFeatures: false, })], - ['openbitfun-desktop', dependencyProfile(['jpeg', 'png'], { + // Desktop owns bounded controller-local drag thumbnail decoding. + // Keep only its raster input formats and PNG/JPEG output codecs. + ['openbitfun-desktop', dependencyProfile(['bmp', 'gif', 'jpeg', 'png', 'webp'], { useDefaultFeatures: false, })], ['openbitfun-miniapp-market-service', dependencyProfile(['jpeg', 'png', 'webp'], { diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index e62ba26050..df26017690 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -21,6 +21,16 @@ const FAILED_BUILD = { status: 1 }; const DMG_ARGS = ['--target', 'x86_64-apple-darwin', '--bundles', 'app,dmg']; const ROOT = join(import.meta.dirname, '..'); +test('Windows desktop manifest opts into layered child windows and preserves common controls', () => { + const desktop = join(ROOT, 'src', 'apps', 'desktop'); + const manifest = readFileSync(join(desktop, 'windows-app.manifest'), 'utf8'); + const build = readFileSync(join(desktop, 'build.rs'), 'utf8'); + assert.match(build, /app_manifest\(include_str!\("windows-app\.manifest"\)\)/); + assert.match(build, /rerun-if-changed=windows-app\.manifest/); + assert.match(manifest, /name="Microsoft\.Windows\.Common-Controls"[\s\S]*?version="6\.0\.0\.0"/); + assert.match(manifest, /[\s\S]*?[\s\S]*? { const appleEnv = { [WEB_FONT_PROFILE_ENV]: HARMONY_BUNDLED_FONT_PROFILE }; assert.equal( diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index 0de3bb0a70..aa024c3b22 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -128,6 +128,31 @@ run `cargo test -p openbitfun-desktop --lib api::local_file_download::tests`. After changing its registration, also run `cargo test -p openbitfun-desktop --lib remote_workspace_policy`. +For Windows external-file drag previews, run +`cargo test --locked -p openbitfun-desktop --lib file_drop_preview_api` and +`pnpm --dir src/web-ui run test:run src/infrastructure/files/useWindowsFileDropPreview.test.tsx src/app/scenes/session/FileDropPreviewCards.test.tsx`. +After rebuilding Desktop, manually check Explorer drags with one image, more than +four images, mixed file formats, Escape, leaving the pane, and a scene switch. +Verify HTML text/tab/file-tree drags still work. The temporary OLE child only +covers the active chat target during an external file drag; do not enable Wry's +window-wide Windows handler as a replacement. After command registration changes, +also run `cargo test -p openbitfun-desktop --lib remote_workspace_policy`. + +The layered receiver requires the compatibility declaration in +`windows-app.manifest`; keep it wired through `build.rs` for dev and release. +After changes to that contract, run +`node --test scripts/desktop-tauri-build.test.mjs` and `cargo build -p openbitfun-desktop`. +The focused native test creates and destroys 50 real, hidden receiver windows. +The Shell regression also uses a real `IDataObject` and drag-image helpers to +verify 20 takeovers leave no `SysDragImage`, and that the next target can restore +the source image and clean it up on drop/cancel. `IDropTargetHelper::Show(false)` +alone does not dismiss the modern layered image; end the Shell renderer session +before displaying the custom preview, while keeping the OLE receiver active. +If the Windows Tauri library test loader fails before running tests, embed the +same desktop manifest into a temporary copy of the generated test executable +using the Windows SDK `mt.exe`, then run the `file_drop_preview_api` filter on +that copy. A Common Controls-only manifest cannot exercise layered children. + For staged application-update cache and signature behavior, use `cargo test -p openbitfun-desktop --lib api::update_api::tests`. For peer system-info response compatibility, run diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 9bd5fae4ae..cf9039cf72 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -84,7 +84,8 @@ sha2 = { workspace = true } minisign-verify = { workspace = true } screenshots = { workspace = true } enigo = { workspace = true } -image = { workspace = true, features = ["jpeg", "png"] } +# Controller-local drag thumbnails decode these formats; other files use UI icons. +image = { workspace = true, features = ["bmp", "gif", "jpeg", "png", "webp"] } resvg = { workspace = true } tempfile = { workspace = true } @@ -123,6 +124,8 @@ windows = { workspace = true, features = [ "Win32_Graphics_Dxgi_Common", "Win32_Graphics_Gdi", "Win32_Storage_FileSystem", + "Win32_System_LibraryLoader", + "Win32_System_SystemServices", "Win32_Storage_Xps", "Win32_System_Com", "Win32_System_Ole", @@ -132,11 +135,17 @@ windows = { workspace = true, features = [ "Win32_System_WinRT_Graphics", "Win32_System_WinRT_Graphics_Capture", "Win32_UI_Accessibility", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] } windows-core = { workspace = true } webview2-com = { workspace = true } +[target.'cfg(windows)'.dev-dependencies] +# Real Shell IDataObject fixtures for native drag-image lifecycle regressions. +windows = { workspace = true, features = ["Win32_UI_Shell_Common"] } + [features] default = [] # Enable webview devtools and element inspector for development builds. diff --git a/src/apps/desktop/build.rs b/src/apps/desktop/build.rs index 67ac4082d4..9efaccb4b0 100644 --- a/src/apps/desktop/build.rs +++ b/src/apps/desktop/build.rs @@ -9,5 +9,9 @@ fn main() { // large debug invoke dispatcher cannot exhaust the default 1 MiB stack. #[cfg(target_os = "windows")] println!("cargo:rustc-link-arg-bins=/STACK:8388608"); - tauri_build::build(); + println!("cargo:rerun-if-changed=windows-app.manifest"); + let windows = + tauri_build::WindowsAttributes::new().app_manifest(include_str!("windows-app.manifest")); + tauri_build::try_build(tauri_build::Attributes::new().windows_attributes(windows)) + .expect("failed to build desktop platform resources"); } diff --git a/src/apps/desktop/src/api/file_drop_preview_api.rs b/src/apps/desktop/src/api/file_drop_preview_api.rs new file mode 100644 index 0000000000..df2545cb74 --- /dev/null +++ b/src/apps/desktop/src/api/file_drop_preview_api.rs @@ -0,0 +1,790 @@ +//! A temporary, chat-sized OLE target. It never replaces WebView2's drop target: +//! HTML text, tabs and workspace-file drags keep their normal browser path. +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PreviewBounds { + x: f64, + y: f64, + width: f64, + height: f64, + scale: f64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetFileDropPreviewTargetRequest { + target_id: String, + bounds: Option, +} + +impl SetFileDropPreviewTargetRequest { + fn validate(&self) -> Result<(), String> { + if self.target_id.is_empty() || self.target_id.len() > 128 { + return Err("Invalid file preview target id".into()); + } + if let Some(b) = &self.bounds { + if ![b.x, b.y, b.width, b.height, b.scale] + .iter() + .all(|v| v.is_finite()) + || b.width <= 0.0 + || b.height <= 0.0 + || b.scale <= 0.0 + || b.scale > 10.0 + { + return Err("Invalid file preview target bounds".into()); + } + } + Ok(()) + } +} + +#[tauri::command] +pub async fn set_file_drop_preview_target( + window: tauri::WebviewWindow, + request: SetFileDropPreviewTargetRequest, +) -> Result<(), String> { + request.validate()?; + if window.label() != "main" { + return Err("File drag previews require the main desktop window".into()); + } + #[cfg(target_os = "windows")] + { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let owner = window.clone(); + window + .run_on_main_thread(move || { + let release = SetFileDropPreviewTargetRequest { + target_id: request.target_id.clone(), + bounds: None, + }; + let result = native::set_target(owner.clone(), request); + if let Err(error) = &result { + log::warn!("Native file preview target activation failed: {error}"); + // A failed positioning call may still have shown the child. + // Release it before handing the drag back to the browser. + let _ = native::set_target(owner, release); + } + let _ = sender.send(result); + }) + .map_err(|e| e.to_string())?; + receiver.await.map_err(|e| e.to_string())? + } + #[cfg(not(target_os = "windows"))] + Err("Native file drag previews are only available on Windows".into()) +} + +#[cfg(target_os = "windows")] +mod native { + use super::*; + use base64::Engine; + use serde::Serialize; + use std::{ + cell::{Cell, RefCell}, + collections::HashMap, + ffi::OsString, + io::Cursor, + os::windows::ffi::OsStringExt, + ptr, + rc::Rc, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, + }; + use tauri::Emitter; + use windows::{ + core::{implement, w}, + Win32::{ + Foundation::{COLORREF, HWND, LPARAM, LRESULT, POINT, POINTL, RECT, WPARAM}, + Graphics::Gdi::ScreenToClient, + System::{ + Com::{ + CoCreateInstance, IDataObject, CLSCTX_INPROC_SERVER, DVASPECT_CONTENT, + FORMATETC, TYMED_HGLOBAL, + }, + LibraryLoader::GetModuleHandleW, + Ole::{ + IDropTarget, IDropTarget_Impl, OleInitialize, OleUninitialize, + RegisterDragDrop, ReleaseStgMedium, RevokeDragDrop, CF_HDROP, DROPEFFECT, + DROPEFFECT_COPY, DROPEFFECT_NONE, + }, + SystemServices::MODIFIERKEYS_FLAGS, + }, + UI::{ + Input::KeyboardAndMouse::{GetAsyncKeyState, VK_LBUTTON}, + Shell::{CLSID_DragDropHelper, DragQueryFileW, IDropTargetHelper, HDROP}, + WindowsAndMessaging::*, + }, + }, + }; + + const EVENT: &str = "openbitfun://file-drop-preview"; + const TIMER: usize = 1; + const PREVIEW_FILES: usize = 1; + + fn dismiss_shell_drag_image( + helper: &IDropTargetHelper, + hwnd: HWND, + data: &IDataObject, + pt: &POINTL, + ) -> windows::core::Result<()> { + // Show(false) can return S_OK while the modern layered SysDragImage + // remains visible. Claim the incoming image, then end only the Shell + // renderer's session before publishing our custom preview. The OLE + // target remains active and still receives DragOver/Drop normally. + // Do not forward those callbacks to the retired renderer or mutate the + // source bitmap: the next target can recreate its image on DragEnter. + unsafe { + let entered = + helper.DragEnter(hwnd, data, &POINT { x: pt.x, y: pt.y }, DROPEFFECT_COPY); + // Also clean up a partially initialized renderer on enter failure. + let dismissed = helper.DragLeave(); + entered.and(dismissed) + } + } + + fn native_error(operation: &str, error: windows::core::Error) -> String { + format!("File preview {operation} failed: {error}") + } + thread_local! { + static TARGETS: RefCell> = RefCell::new(HashMap::new()); + } + struct TargetWindow { + hwnd: HWND, + target_id: Arc>, + scale: Rc>, + _ole: OleApartment, + } + + struct OleApartment; + impl Drop for OleApartment { + fn drop(&mut self) { + unsafe { + OleUninitialize(); + } + } + } + + #[derive(Clone, Serialize)] + #[serde(rename_all = "camelCase")] + struct PreviewFile { + name: String, + thumbnail: Option, + } + #[derive(Clone, Serialize)] + #[serde(rename_all = "camelCase")] + struct PreviewEvent { + target_id: String, + generation: u64, + kind: &'static str, + x: f64, + y: f64, + count: usize, + files: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + paths: Option>, + } + + // A layered child with alpha=1 is visually transparent but still receives + // OLE hit testing. A zero-alpha or WS_EX_TRANSPARENT child would pass through. + unsafe extern "system" fn window_proc(hwnd: HWND, msg: u32, w: WPARAM, l: LPARAM) -> LRESULT { + unsafe { + match msg { + WM_TIMER if GetAsyncKeyState(VK_LBUTTON.0 as i32) >= 0 => { + let _ = ShowWindow(hwnd, SW_HIDE); + let _ = KillTimer(Some(hwnd), TIMER); + } + WM_DESTROY => { + let _ = RevokeDragDrop(hwnd); + } + WM_MOUSEACTIVATE => return LRESULT(MA_NOACTIVATE as isize), + _ => {} + } + DefWindowProcW(hwnd, msg, w, l) + } + } + + // This must run under the executable's Windows 8+ compatibility manifest. + // Windows otherwise rejects WS_EX_LAYERED children, sometimes with error 0. + fn create_preview_window(parent: HWND) -> Result { + let instance = + unsafe { GetModuleHandleW(None) }.map_err(|e| native_error("GetModuleHandleW", e))?; + let class = w!("OpenBitFunFilePreviewTarget"); + let wc = WNDCLASSW { + lpfnWndProc: Some(window_proc), + hInstance: instance.into(), + lpszClassName: class, + ..Default::default() + }; + if unsafe { RegisterClassW(&wc) } == 0 { + let error = unsafe { windows::Win32::Foundation::GetLastError() }; + if error != windows::Win32::Foundation::ERROR_CLASS_ALREADY_EXISTS { + return Err(native_error("RegisterClassW", error.into())); + } + } + unsafe { + CreateWindowExW( + WS_EX_LAYERED | WS_EX_NOACTIVATE, + class, + w!(""), + WS_CHILD, + 0, + 0, + 1, + 1, + Some(parent), + None, + Some(instance.into()), + None, + ) + } + .map_err(|e| native_error("CreateWindowExW", e)) + } + + pub(super) fn set_target( + window: tauri::WebviewWindow, + request: SetFileDropPreviewTargetRequest, + ) -> Result<(), String> { + let parent = window + .hwnd() + .map_err(|e| format!("File preview main-window handle failed: {e}"))?; + TARGETS.with(|targets| { + let mut targets = targets.borrow_mut(); + let key = parent.0 as isize; + if request.bounds.is_none() { + if let Some(target) = targets.get(&key) { + if *target.target_id.lock().unwrap_or_else(|e| e.into_inner()) + == request.target_id + { + unsafe { + let _ = KillTimer(Some(target.hwnd), TIMER); + let _ = ShowWindow(target.hwnd, SW_HIDE); + } + } + } + return Ok(()); + } + // Do not leave a late activation covering the page after mouse-up. + if unsafe { GetAsyncKeyState(VK_LBUTTON.0 as i32) } >= 0 { + return Ok(()); + } + if targets + .get(&key) + .is_some_and(|target| !unsafe { IsWindow(Some(target.hwnd)).as_bool() }) + { + targets.remove(&key); + } + if !targets.contains_key(&key) { + // CoInitializeEx alone is insufficient for RegisterDragDrop. + // Balance our own OLE reference even when the host initialized it. + unsafe { OleInitialize(None) }.map_err(|e| native_error("OleInitialize", e))?; + let ole = OleApartment; + let helper: IDropTargetHelper = + unsafe { CoCreateInstance(&CLSID_DragDropHelper, None, CLSCTX_INPROC_SERVER) } + .map_err(|e| native_error("CoCreateInstance(DragDropHelper)", e))?; + let hwnd = create_preview_window(parent)?; + let target_id = Arc::new(std::sync::Mutex::new(request.target_id.clone())); + let scale = Rc::new(Cell::new(1.0)); + let target: IDropTarget = FileTarget { + hwnd, + parent, + owner: window, + target_id: Arc::clone(&target_id), + helper, + generation: Arc::new(AtomicU64::new(0)), + paths: RefCell::new(Vec::new()), + scale: Rc::clone(&scale), + } + .into(); + let registered = unsafe { + SetLayeredWindowAttributes(hwnd, COLORREF(0), 1, LWA_ALPHA) + .map_err(|e| native_error("SetLayeredWindowAttributes", e)) + .and_then(|_| { + RegisterDragDrop(hwnd, &target) + .map_err(|e| native_error("RegisterDragDrop", e)) + }) + }; + if let Err(e) = registered { + unsafe { + let _ = DestroyWindow(hwnd); + } + return Err(e); + } + targets.insert( + key, + TargetWindow { + hwnd, + target_id, + scale, + _ole: ole, + }, + ); + } + let target = targets.get(&key).expect("target inserted above"); + *target.target_id.lock().unwrap_or_else(|e| e.into_inner()) = request.target_id; + let b = request.bounds.expect("bounds checked above"); + let mut client = RECT::default(); + unsafe { GetClientRect(parent, &mut client) } + .map_err(|e| native_error("GetClientRect", e))?; + let x = (b.x * b.scale).round().clamp(0.0, client.right as f64) as i32; + let y = (b.y * b.scale).round().clamp(0.0, client.bottom as f64) as i32; + let width = (b.width * b.scale) + .round() + .clamp(0.0, (client.right - x) as f64) as i32; + let height = (b.height * b.scale) + .round() + .clamp(0.0, (client.bottom - y) as f64) as i32; + target.scale.set(b.scale); + unsafe { + SetWindowPos( + target.hwnd, + Some(HWND_TOP), + x, + y, + width, + height, + SWP_NOACTIVATE | SWP_SHOWWINDOW, + ) + .map_err(|e| native_error("SetWindowPos", e))?; + SetTimer(Some(target.hwnd), TIMER, 100, None); + } + Ok(()) + }) + } + + #[implement(IDropTarget)] + struct FileTarget { + hwnd: HWND, + parent: HWND, + owner: tauri::WebviewWindow, + target_id: Arc>, + helper: IDropTargetHelper, + generation: Arc, + paths: RefCell>, + scale: Rc>, + } + + fn file_paths(data: &IDataObject) -> windows::core::Result> { + let format = FORMATETC { + cfFormat: CF_HDROP.0, + ptd: ptr::null_mut(), + dwAspect: DVASPECT_CONTENT.0, + lindex: -1, + tymed: TYMED_HGLOBAL.0 as u32, + }; + unsafe { + let mut medium = data.GetData(&format)?; + let drop = HDROP(medium.u.hGlobal.0); + let count = DragQueryFileW(drop, u32::MAX, None); + if count > 4096 { + ReleaseStgMedium(&mut medium); + return Err(windows::core::Error::new( + windows::Win32::Foundation::E_INVALIDARG, + "File drop exceeds the browser intake limit", + )); + } + let mut paths = Vec::with_capacity(count as usize); + for index in 0..count { + let length = DragQueryFileW(drop, index, None) as usize; + let mut buf = vec![0; length + 1]; + DragQueryFileW(drop, index, Some(&mut buf)); + paths.push( + OsString::from_wide(&buf[..length]) + .to_string_lossy() + .into_owned(), + ); + } + ReleaseStgMedium(&mut medium); + Ok(paths) + } + } + + fn thumbnail(path: &str) -> Option { + let extension = std::path::Path::new(path) + .extension()? + .to_str()? + .to_ascii_lowercase(); + if !matches!( + extension.as_str(), + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" + ) { + return None; + } + let file = std::fs::File::open(path).ok()?; + if file.metadata().ok()?.len() > 32 * 1024 * 1024 { + return None; + } + let mut reader = image::ImageReader::new(std::io::BufReader::new(file)) + .with_guessed_format() + .ok()?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(64 * 1024 * 1024); + reader.limits(limits); + let image = reader.decode().ok()?.thumbnail(200, 160); + let mut bytes = Cursor::new(Vec::new()); + image.write_to(&mut bytes, image::ImageFormat::Png).ok()?; + Some(format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes.into_inner()) + )) + } + + impl FileTarget_Impl { + fn event(&self, kind: &'static str, pt: &POINTL) -> PreviewEvent { + let mut point = POINT { x: pt.x, y: pt.y }; + unsafe { + let _ = ScreenToClient(self.parent, &mut point); + } + let paths = self.paths.borrow(); + PreviewEvent { + target_id: self + .target_id + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(), + generation: self.generation.load(Ordering::Relaxed), + kind, + x: point.x as f64 / self.scale.get(), + y: point.y as f64 / self.scale.get(), + count: paths.len(), + files: paths + .iter() + .take(PREVIEW_FILES) + .map(|path| PreviewFile { + name: std::path::Path::new(path) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(), + thumbnail: None, + }) + .collect(), + paths: (kind == "drop").then(|| paths.clone()), + } + } + fn finish(&self, kind: &'static str, pt: &POINTL) { + let _ = self + .owner + .emit_to(self.owner.label(), EVENT, self.event(kind, pt)); + self.generation.fetch_add(1, Ordering::Relaxed); + self.paths.borrow_mut().clear(); + unsafe { + let _ = KillTimer(Some(self.hwnd), TIMER); + let _ = ShowWindow(self.hwnd, SW_HIDE); + } + } + } + + #[allow(non_snake_case)] + impl IDropTarget_Impl for FileTarget_Impl { + fn DragEnter( + &self, + data: windows_core::Ref<'_, IDataObject>, + _keys: MODIFIERKEYS_FLAGS, + pt: &POINTL, + effect: *mut DROPEFFECT, + ) -> windows::core::Result<()> { + let Some(data) = data.as_ref() else { + unsafe { + *effect = DROPEFFECT_NONE; + } + self.finish("unavailable", pt); + return Ok(()); + }; + let paths = match file_paths(data) { + Ok(paths) if !paths.is_empty() => paths, + _ => { + unsafe { + *effect = DROPEFFECT_NONE; + } + self.finish("unavailable", pt); + return Ok(()); + } + }; + *self.paths.borrow_mut() = paths.clone(); + self.generation.fetch_add(1, Ordering::Relaxed); + unsafe { + *effect = DROPEFFECT_COPY; + let _ = KillTimer(Some(self.hwnd), TIMER); + if let Err(error) = dismiss_shell_drag_image(&self.helper, self.hwnd, data, pt) { + log::warn!("Native file drag preview is unavailable: {error}"); + *effect = DROPEFFECT_NONE; + self.finish("unavailable", pt); + return Ok(()); + } + } + let event = self.event("enter", pt); + let _ = self.owner.emit_to(self.owner.label(), EVENT, &event); + let owner = self.owner.clone(); + let generation = Arc::clone(&self.generation); + tauri::async_runtime::spawn_blocking(move || { + let mut update = event; + update.kind = "thumbnails"; + for (file, path) in update.files.iter_mut().zip(paths.iter()) { + if generation.load(Ordering::Relaxed) != update.generation { + return; + } + file.thumbnail = thumbnail(path); + } + if generation.load(Ordering::Relaxed) == update.generation { + let _ = owner.emit_to(owner.label(), EVENT, update); + } + }); + Ok(()) + } + fn DragOver( + &self, + _keys: MODIFIERKEYS_FLAGS, + pt: &POINTL, + effect: *mut DROPEFFECT, + ) -> windows::core::Result<()> { + unsafe { + *effect = DROPEFFECT_COPY; + } + let _ = self + .owner + .emit_to(self.owner.label(), EVENT, self.event("over", pt)); + Ok(()) + } + fn DragLeave(&self) -> windows::core::Result<()> { + self.finish("leave", &POINTL::default()); + Ok(()) + } + fn Drop( + &self, + data: windows_core::Ref<'_, IDataObject>, + _keys: MODIFIERKEYS_FLAGS, + pt: &POINTL, + effect: *mut DROPEFFECT, + ) -> windows::core::Result<()> { + let paths = data.as_ref().and_then(|data| file_paths(data).ok()); + let Some(paths) = paths.filter(|paths| !paths.is_empty()) else { + unsafe { + *effect = DROPEFFECT_NONE; + } + self.finish("unavailable", pt); + return Ok(()); + }; + *self.paths.borrow_mut() = paths; + unsafe { + *effect = DROPEFFECT_COPY; + } + self.finish("drop", pt); + Ok(()) + } + } + + #[cfg(test)] + mod tests { + use super::*; + use windows::{ + core::{Interface, BOOL}, + Win32::{ + Foundation::SIZE, + Graphics::Gdi::{CreateBitmap, DeleteObject}, + UI::Shell::{IDragSourceHelper, SHCreateDataObject, SHDRAGIMAGE}, + }, + }; + + struct TestWindow(HWND); + impl TestWindow { + fn new() -> Self { + Self(unsafe { + CreateWindowExW( + WINDOW_EX_STYLE::default(), + w!("STATIC"), + w!(""), + WS_OVERLAPPED, + 0, + 0, + 640, + 480, + None, + None, + None, + None, + ) + .unwrap() + }) + } + } + impl Drop for TestWindow { + fn drop(&mut self) { + unsafe { + let _ = DestroyWindow(self.0); + } + } + } + + // Inspect only this test thread's windows, never Explorer/user windows. + fn shell_image_count(parent: HWND) -> usize { + unsafe extern "system" fn count(hwnd: HWND, context: LPARAM) -> BOOL { + let mut class = [0u16; 64]; + unsafe { + let length = GetClassNameW(hwnd, &mut class) as usize; + if String::from_utf16_lossy(&class[..length]) == "SysDragImage" { + *(context.0 as *mut usize) += 1; + } + } + BOOL(1) + } + let mut result = 0usize; + unsafe { + let mut message = MSG::default(); + while PeekMessageW(&mut message, None, 0, 0, PM_REMOVE).as_bool() { + let _ = TranslateMessage(&message); + DispatchMessageW(&message); + } + let _ = EnumThreadWindows( + GetWindowThreadProcessId(parent, None), + Some(count), + LPARAM(&mut result as *mut usize as isize), + ); + } + result + } + + #[test] + fn custom_preview_retires_shell_image_and_allows_next_target_to_restore_it() { + unsafe { OleInitialize(None) }.unwrap(); + let _ole = OleApartment; + let parent = TestWindow::new(); + let data: IDataObject = unsafe { SHCreateDataObject(None, None, None) }.unwrap(); + let source: IDragSourceHelper = + unsafe { CoCreateInstance(&CLSID_DragDropHelper, None, CLSCTX_INPROC_SERVER) } + .unwrap(); + let shell: IDropTargetHelper = source.cast().unwrap(); + let custom: IDropTargetHelper = + unsafe { CoCreateInstance(&CLSID_DragDropHelper, None, CLSCTX_INPROC_SERVER) } + .unwrap(); + // Transparent one-pixel fixture: real Shell rendering, no thumbnail + // of user files and no visible test window over the working desktop. + let pixel = 0u32; + let bitmap = unsafe { CreateBitmap(1, 1, 1, 32, Some((&pixel as *const u32).cast())) }; + assert!(!bitmap.is_invalid()); + let image = SHDRAGIMAGE { + sizeDragImage: SIZE { cx: 1, cy: 1 }, + hbmpDragImage: bitmap, + ..Default::default() + }; + let initialized = unsafe { source.InitializeFromBitmap(&image, &data) }; + if initialized.is_err() { + unsafe { + let _ = DeleteObject(bitmap.into()); + } + } + // The helper owns the bitmap after successful initialization. + initialized.unwrap(); + let point = POINT::default(); + for visit in 0..20 { + unsafe { shell.DragEnter(parent.0, &data, &point, DROPEFFECT_COPY) }.unwrap(); + assert_eq!( + shell_image_count(parent.0), + 1, + "Shell fixture must create a real image" + ); + // Include both a completed handoff and an image still held by + // the preceding target when our native receiver takes over. + if visit % 2 == 0 { + unsafe { shell.DragLeave() }.unwrap(); + } + dismiss_shell_drag_image(&custom, parent.0, &data, &POINTL::default()).unwrap(); + assert_eq!( + shell_image_count(parent.0), + 0, + "custom preview must be the only renderer" + ); + unsafe { shell.DragEnter(parent.0, &data, &point, DROPEFFECT_COPY) }.unwrap(); + assert_eq!( + shell_image_count(parent.0), + 1, + "leaving chat must preserve the source image" + ); + unsafe { + if visit % 2 == 0 { + shell.Drop(&data, &point, DROPEFFECT_COPY).unwrap(); + } else { + shell.DragLeave().unwrap(); + } + } + assert_eq!( + shell_image_count(parent.0), + 0, + "drop/cancel must leave no image window" + ); + } + } + + #[test] + fn creates_and_recreates_the_layered_receiver_window() { + // Keep the parent hidden: this exercises real Win32 creation without + // displaying a window or interfering with the user's active drag. + let parent = TestWindow::new(); + for _ in 0..50 { + let child = create_preview_window(parent.0).expect( + "layered child creation requires the desktop Windows compatibility manifest", + ); + unsafe { + let alpha = SetLayeredWindowAttributes(child, COLORREF(0), 1, LWA_ALPHA); + let valid = IsWindow(Some(child)).as_bool(); + DestroyWindow(child).unwrap(); + assert!(valid); + alpha.unwrap(); + assert!(!IsWindow(Some(child)).as_bool()); + } + } + } + + #[test] + fn encodes_a_bounded_thumbnail_for_large_images() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("photo.PNG"); + image::RgbaImage::from_pixel(800, 600, image::Rgba([20, 100, 180, 255])) + .save(&path) + .unwrap(); + let preview = thumbnail(path.to_str().unwrap()).unwrap(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(preview.strip_prefix("data:image/png;base64,").unwrap()) + .unwrap(); + let decoded = image::load_from_memory(&bytes).unwrap(); + assert_eq!((decoded.width(), decoded.height()), (200, 150)); + } + + #[test] + fn unreadable_or_non_image_files_keep_the_format_icon_path() { + let directory = tempfile::tempdir().unwrap(); + for name in ["broken.jpg", "report.pdf", "notes.docx"] { + let path = directory.path().join(name); + std::fs::write(&path, b"not an image").unwrap(); + assert!(thumbnail(path.to_str().unwrap()).is_none()); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn accepts_release_and_extra_fields_for_forward_compatibility() { + let request: SetFileDropPreviewTargetRequest = + serde_json::from_str(r#"{"targetId":"chat","bounds":null,"future":true}"#).unwrap(); + assert!(request.validate().is_ok()); + } + #[test] + fn rejects_invalid_preview_geometry() { + let request = SetFileDropPreviewTargetRequest { + target_id: "chat".into(), + bounds: Some(PreviewBounds { + x: 0.0, + y: 0.0, + width: 100.0, + height: 100.0, + scale: f64::NAN, + }), + }; + assert!(request.validate().is_err()); + } +} diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index e481bd4ebb..82a2b2d3a0 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -27,6 +27,7 @@ pub mod editor_ai_api; pub mod event_coalescer; pub mod external_hooks_api; pub mod external_sources_api; +pub mod file_drop_preview_api; pub mod git_agent_api; pub mod git_api; pub mod html_preview_api; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index d59bcb3493..9459d84a9e 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1425,6 +1425,7 @@ pub async fn run() { get_watched_paths, get_clipboard_files, api::browser_file_drop_api::resolve_browser_dropped_file_paths, + api::file_drop_preview_api::set_file_drop_preview_target, paste_files, get_config, get_configs, diff --git a/src/apps/desktop/windows-app.manifest b/src/apps/desktop/windows-app.manifest new file mode 100644 index 0000000000..7d4b945f2a --- /dev/null +++ b/src/apps/desktop/windows-app.manifest @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index 313d478b64..a43a6751e1 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:697e78e28549dbf0", + "digest": "fnv1a64:2851d2dc0482c8ac", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -6614,6 +6614,18 @@ "kind": "handled" } }, + { + "id": "set_file_drop_preview_target", + "surface": "tauri_command", + "remoteWorkspace": "LocalOnly", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "set_global_skill_disabled", "surface": "tauri_command", diff --git a/src/crates/contracts/product-domains/src/remote_surface/table.rs b/src/crates/contracts/product-domains/src/remote_surface/table.rs index 8ab3d1164c..1b95c261f0 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -640,6 +640,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("set_external_tool_conflict_choice_command", Unsupported, Proxied, HANDLED), op("set_external_tool_target_decision_command", Unsupported, Proxied, HANDLED), op("set_external_tool_targets_enabled_command", Unsupported, Proxied, HANDLED), + op("set_file_drop_preview_target", LocalOnly, ControllerLocal, REFUSED), op("set_global_skill_disabled", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("set_macos_edit_menu_mode", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED), op("set_main_window_transient_geometry", LocalOnly, ControllerLocal, REFUSED), diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index b7a6e5aa23..cc192e68ec 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -1537,6 +1537,7 @@ "evidence": [ "command:get_clipboard_files", "command:resolve_browser_dropped_file_paths", + "command:set_file_drop_preview_target", "command:upload_image_contexts", "source:src/web-ui/src/flow_chat/utils/externalFileIntake.ts#buildExternalFileContexts", "source:src/web-ui/src/flow_chat/utils/externalFileIntake.test.ts#buildExternalFileContexts", @@ -9537,6 +9538,9 @@ }, "account_identity": { "capabilityId": "feature.remote-connect" + }, + "file_drop_preview": { + "capabilityId": "feature.files-editor" } } } diff --git a/src/web-ui/src/app/scenes/session/ChatFileDropOverlay.tsx b/src/web-ui/src/app/scenes/session/ChatFileDropOverlay.tsx new file mode 100644 index 0000000000..2300d45aa6 --- /dev/null +++ b/src/web-ui/src/app/scenes/session/ChatFileDropOverlay.tsx @@ -0,0 +1,59 @@ +import { FileText } from 'lucide-react'; +import { useEffect, useRef, type RefObject } from 'react'; +import { useI18n } from '@/infrastructure/i18n'; +import type { FileDropPreview, FileDropPosition } from '@/shared/types/fileDropPreview'; +import { FileDropPreviewCards } from './FileDropPreviewCards'; + +/** Keep the stationary drop hint separate from the cursor's file previews and count. */ +export function ChatFileDropOverlay({ preview, positionRef }: { + preview?: FileDropPreview | null; + positionRef?: RefObject; +}) { + const { t } = useI18n('flow-chat'); + const overlayRef = useRef(null); + const cursorRef = useRef(null); + const showPreview = Boolean(preview?.files.length); + + useEffect(() => { + if (!showPreview || !positionRef) return; + let frame = 0; + let lastPosition: FileDropPosition | null = null; + const update = () => { + const position = positionRef.current; + const cursor = cursorRef.current; + const overlay = overlayRef.current; + if (position && cursor && overlay && position !== lastPosition) { + const bounds = overlay.getBoundingClientRect(); + const width = cursor.offsetWidth; + const height = cursor.offsetHeight; + const x = Math.max(12, Math.min(position.x - bounds.left + 20, bounds.width - width - 12)); + const y = Math.max(12, Math.min(position.y - bounds.top - height - 20, bounds.height - height - 12)); + cursor.style.transform = `translate3d(${x}px, ${y}px, 0)`; + cursor.style.visibility = 'visible'; + lastPosition = position; + } + frame = requestAnimationFrame(update); + }; + frame = requestAnimationFrame(update); + return () => cancelAnimationFrame(frame); + }, [positionRef, showPreview]); + + return ( +
+
+
+ {showPreview && preview &&
+ +
} +
+ ); +} diff --git a/src/web-ui/src/app/scenes/session/ChatPane.scss b/src/web-ui/src/app/scenes/session/ChatPane.scss index 39b17c8fcf..3f32dcc0fd 100644 --- a/src/web-ui/src/app/scenes/session/ChatPane.scss +++ b/src/web-ui/src/app/scenes/session/ChatPane.scss @@ -26,31 +26,145 @@ // A pane-sized target without intercepting drops or changing transcript geometry. .openbitfun-chat-pane__drop-overlay { position: absolute; - inset: var(--openbitfun-space-2); + inset: 0; z-index: var(--openbitfun-layer-overlay); pointer-events: none; display: grid; place-items: center; - border: 1px solid var(--openbitfun-color-accent-default); - border-radius: var(--openbitfun-radius-lg); - background: color-mix(in srgb, var(--openbitfun-color-accent-default) 9%, transparent); + background: color-mix(in srgb, var(--openbitfun-color-surface-scene) 68%, transparent); + backdrop-filter: var(--openbitfun-effect-blur-medium); + -webkit-backdrop-filter: var(--openbitfun-effect-blur-medium); } -.openbitfun-chat-pane__drop-hint { - display: inline-flex; +.openbitfun-chat-pane__drop-content { + display: flex; + flex-direction: column; align-items: center; + gap: var(--openbitfun-space-4); + width: min(100%, 420px); + padding: var(--openbitfun-space-6); + box-sizing: border-box; + text-align: center; + color: var(--openbitfun-color-content-primary); +} + +.openbitfun-chat-pane__drop-emblem { + flex-shrink: 0; + margin-bottom: var(--openbitfun-space-2); + color: var(--openbitfun-color-content-secondary); + opacity: 0.2; +} + +.openbitfun-chat-pane__drop-copy { + display: flex; + flex-direction: column; gap: var(--openbitfun-space-2); - padding: var(--openbitfun-space-3) var(--openbitfun-space-4); - margin: var(--openbitfun-space-4); - border: 1px solid var(--openbitfun-color-border-default); - border-radius: var(--openbitfun-radius-lg); - background: var(--openbitfun-color-surface-scene); +} + +.openbitfun-chat-pane__drop-title { color: var(--openbitfun-color-content-primary); - box-shadow: var(--openbitfun-shadow-sm); - font-family: var(--openbitfun-type-label-md-font-family); - font-size: var(--openbitfun-type-label-md-font-size); - font-weight: var(--openbitfun-type-label-md-font-weight); + font-size: var(--openbitfun-type-body-lg-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); line-height: var(--openbitfun-type-label-md-line-height); +} + +.openbitfun-chat-pane__drop-description { + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); +} + +.openbitfun-chat-pane__drop-cursor { + position: absolute; + top: 0; + left: 0; + visibility: hidden; + will-change: transform; +} + +.openbitfun-chat-pane__file-preview { + position: relative; + isolation: isolate; + width: 128px; + + &::before, &::after { + position: absolute; + inset: 0; + border-radius: var(--openbitfun-radius-lg); + background: color-mix(in srgb, var(--openbitfun-color-surface-raised) 88%, transparent); + box-shadow: var(--openbitfun-shadow-sm); + backdrop-filter: var(--openbitfun-effect-blur-medium); + } - .openbitfun-icon { color: var(--openbitfun-color-accent-default); } + &[data-multiple='true']::before { + content: ''; + z-index: -1; + transform: translate(7px, 4px) rotate(5deg); + } + + &[data-stack-depth='3']::after { + content: ''; + z-index: -2; + transform: translate(14px, 8px) rotate(10deg); + } +} + +.openbitfun-chat-pane__file-front { + position: relative; + padding: var(--openbitfun-space-2); + border-radius: var(--openbitfun-radius-lg); + background: color-mix(in srgb, var(--openbitfun-color-surface-raised) 92%, transparent); + box-shadow: var(--openbitfun-shadow-xl); + backdrop-filter: var(--openbitfun-effect-blur-medium); + +} + +.openbitfun-chat-pane__file-tile { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--openbitfun-space-1); + height: 92px; + border-radius: var(--openbitfun-radius-sm); + overflow: hidden; + background: var(--openbitfun-color-surface-subtle); + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-label-xs-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); + + &[data-kind='pdf'] { color: var(--openbitfun-color-status-danger-content); background: var(--openbitfun-color-status-danger-surface); } + &[data-kind='spreadsheet'] { color: var(--openbitfun-color-status-success-content); background: var(--openbitfun-color-status-success-surface); } + &[data-kind='document'], &[data-kind='code'] { color: var(--openbitfun-color-status-info-content); background: var(--openbitfun-color-status-info-surface); } + &[data-kind='archive'], &[data-kind='presentation'] { color: var(--openbitfun-color-status-warning-content); background: var(--openbitfun-color-status-warning-surface); } + + img { width: 100%; height: 100%; object-fit: cover; } +} + +.openbitfun-chat-pane__file-count { + position: absolute; + z-index: 1; + top: 0; + right: 0; + transform: translate(35%, -35%); + display: grid; + place-items: center; + min-width: 24px; + height: 24px; + padding-inline: var(--openbitfun-space-1); + border: 2px solid var(--openbitfun-color-surface-raised); + border-radius: var(--openbitfun-radius-pill); + background: var(--openbitfun-color-control-highlight-background); + color: var(--openbitfun-color-content-on-dark); + box-shadow: var(--openbitfun-shadow-sm); + font-size: var(--openbitfun-type-label-sm-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); +} + +.openbitfun-chat-pane__file-caption { + margin-top: var(--openbitfun-space-2); + max-width: 100%; + text-align: center; + color: var(--openbitfun-color-content-primary); + font-size: var(--openbitfun-type-label-xs-font-size); } diff --git a/src/web-ui/src/app/scenes/session/ChatPane.tsx b/src/web-ui/src/app/scenes/session/ChatPane.tsx index 792bc076bc..7fbf2bf9bf 100644 --- a/src/web-ui/src/app/scenes/session/ChatPane.tsx +++ b/src/web-ui/src/app/scenes/session/ChatPane.tsx @@ -6,8 +6,8 @@ */ import React, { useCallback, memo, useEffect, useRef, useState } from 'react'; -import { Icon } from '@openbitfun/ui'; -import { useI18n } from '@/infrastructure/i18n'; +import { ChatFileDropOverlay } from './ChatFileDropOverlay'; +import type { FileDropPreview, FileDropPosition } from '@/shared/types/fileDropPreview'; import { ModernFlowChatContainer as FlowChatContainer } from '../../../flow_chat/components/modern/ModernFlowChatContainer'; import { ChatInput } from '../../../flow_chat/components/ChatInput'; import type { ChatInputRegistration } from '../../../flow_chat/components/chatInputRegistration'; @@ -60,7 +60,11 @@ const ChatPaneInner: React.FC = ({ const addTab = useCanvasStore(state => state.addTab); const fileDropTargetRef = useRef(null); const [isFileDragOver, setIsFileDragOver] = useState(false); - const { t } = useI18n('flow-chat'); + const [filePreview, setFilePreview] = useState(null); + const fileDragPosition = useRef(null); + const updateFileDragPosition = useCallback((position: FileDropPosition | null) => { + fileDragPosition.current = position; + }, []); const deferredTaskDetailTimersRef = useRef([]); const deferredTaskDetailIdleCallbacksRef = useRef([]); @@ -184,18 +188,14 @@ const ChatPaneInner: React.FC = ({ )} {showChatInput && isSceneActive && isFileDragOver && ( -
- - - {t('context.dropToAdd')} - -
+ )} ); diff --git a/src/web-ui/src/app/scenes/session/FileDropPreviewCards.test.tsx b/src/web-ui/src/app/scenes/session/FileDropPreviewCards.test.tsx new file mode 100644 index 0000000000..c04201244e --- /dev/null +++ b/src/web-ui/src/app/scenes/session/FileDropPreviewCards.test.tsx @@ -0,0 +1,52 @@ +/** @vitest-environment jsdom */ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ + t: (_key: string, options?: { fileCount: string }) => `${options?.fileCount ?? ''} files`, + formatNumber: String, +}) })); +import { FileDropPreviewCards } from './FileDropPreviewCards'; +import { getFileDropPreviewKind } from './fileDropPreviewKind'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const container = document.createElement('div'); +document.body.appendChild(container); +let root: ReturnType | undefined; +afterEach(() => { act(() => root?.unmount()); root = undefined; }); + +describe('file drag previews', () => { + it('shows only the first thumbnail with stacked cards and the full selection count', () => { + root = createRoot(container); + act(() => root!.render( ({ + name: `photo-${i}.png`, thumbnail: `data:image/png;base64,${i}`, + })) }} />)); + expect(container.querySelectorAll('img')).toHaveLength(1); + expect(container.querySelector('img')?.alt).toBe('photo-0.png'); + expect(container.querySelector('.openbitfun-chat-pane__file-preview')?.getAttribute('data-stack-depth')).toBe('3'); + expect(container.querySelector('.openbitfun-chat-pane__file-count')?.textContent).toBe('12'); + }); + it('uses format-specific icons for documents and damaged image thumbnails', () => { + root = createRoot(container); + for (const [name, kind] of [['report.PDF', 'pdf'], ['budget.xlsx', 'spreadsheet'], ['source.rs', 'code']]) { + act(() => root!.render()); + expect(container.querySelector(`[data-kind="${kind}"] svg`)).not.toBeNull(); + expect(container.querySelectorAll('.openbitfun-chat-pane__file-tile')).toHaveLength(1); + } + act(() => root!.render()); + expect(container.querySelector('.openbitfun-chat-pane__file-count')).toBeNull(); + expect(container.querySelector('.openbitfun-chat-pane__file-preview')?.getAttribute('data-multiple')).toBe('false'); + act(() => container.querySelector('img')!.dispatchEvent(new Event('error'))); + expect(container.querySelector('[data-kind="image"] svg')).not.toBeNull(); + expect(container.querySelector('img')).toBeNull(); + }); + it('handles unknown formats, extensionless files, and case-insensitive suffixes', () => { + expect(getFileDropPreviewKind('README').kind).toBe('file'); + expect(getFileDropPreviewKind('data.unfamiliar').kind).toBe('file'); + expect(getFileDropPreviewKind('backup.TAR.GZ').kind).toBe('archive'); + expect(getFileDropPreviewKind('slide.PPTX').kind).toBe('presentation'); + expect(getFileDropPreviewKind('movie.MP4').kind).toBe('video'); + }); +}); diff --git a/src/web-ui/src/app/scenes/session/FileDropPreviewCards.tsx b/src/web-ui/src/app/scenes/session/FileDropPreviewCards.tsx new file mode 100644 index 0000000000..3a9f473ae0 --- /dev/null +++ b/src/web-ui/src/app/scenes/session/FileDropPreviewCards.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react'; +import { File, FileArchive, FileAudio, FileCode2, FileImage, FileSpreadsheet, FileText, FileVideo, Presentation } from 'lucide-react'; +import { OverflowText } from '@openbitfun/ui'; +import { useI18n } from '@/infrastructure/i18n'; +import type { FileDropPreview, FileDropPreviewItem } from '@/shared/types/fileDropPreview'; +import { getFileDropPreviewKind, type FilePreviewKind } from './fileDropPreviewKind'; + +const icons = { + image: FileImage, pdf: FileText, spreadsheet: FileSpreadsheet, + presentation: Presentation, document: FileText, code: FileCode2, + archive: FileArchive, audio: FileAudio, video: FileVideo, file: File, +} satisfies Record; + +function PreviewTile({ file }: { file: FileDropPreviewItem }) { + const [failedUrl, setFailedUrl] = useState(null); + const { kind, extension } = getFileDropPreviewKind(file.name); + const Icon = icons[kind]; + const image = file.thumbnail && file.thumbnail !== failedUrl ? file.thumbnail : null; + return ( +
+ {image ? {file.name} setFailedUrl(image)} /> : ( + <>{extension} + )} +
+ ); +} + +export function FileDropPreviewCards({ preview }: { preview: FileDropPreview }) { + const { t, formatNumber } = useI18n('flow-chat'); + const count = formatNumber(preview.count); + const firstFile = preview.files[0]; + return ( +
1} + data-stack-depth={Math.min(preview.count, 3)}> +
+ {firstFile && } +
+ {firstFile?.name} +
+
+ {preview.count > 1 && {count}} +
+ ); +} diff --git a/src/web-ui/src/app/scenes/session/fileDropPreviewKind.ts b/src/web-ui/src/app/scenes/session/fileDropPreviewKind.ts new file mode 100644 index 0000000000..fc73d3a1a5 --- /dev/null +++ b/src/web-ui/src/app/scenes/session/fileDropPreviewKind.ts @@ -0,0 +1,20 @@ +export type FilePreviewKind = 'image' | 'pdf' | 'spreadsheet' | 'presentation' | 'document' | 'code' | 'archive' | 'audio' | 'video' | 'file'; + +const extensions: Record = Object.fromEntries( + (Object.entries({ + image: 'png jpg jpeg gif webp bmp ico svg avif heic heif tif tiff', + pdf: 'pdf', + spreadsheet: 'xls xlsx xlsm csv tsv ods numbers', + presentation: 'ppt pptx odp key', + document: 'doc docx odt rtf txt md markdown pages', + code: 'js jsx ts tsx py rs go java c h cpp hpp cs rb php swift kt vue svelte html css scss json yaml yml toml xml sql sh ps1 ipynb', + archive: 'zip rar 7z tar gz bz2 xz tgz', + audio: 'mp3 wav flac aac m4a ogg opus', + video: 'mp4 mov mkv avi webm m4v wmv', + }) as [FilePreviewKind, string][]).flatMap(([kind, values]) => values.split(' ').map(extension => [extension, kind])), +); + +export function getFileDropPreviewKind(name: string): { kind: FilePreviewKind; extension: string } { + const extension = name.includes('.') ? name.split('.').pop()!.toLowerCase() : ''; + return { kind: extensions[extension] ?? 'file', extension: extension.length <= 8 ? extension.toUpperCase() : '' }; +} diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index e32bfb03e6..f4dddf8026 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -222,6 +222,8 @@ import type { SessionPermissionMode } from '@/infrastructure/api/service-api/Age import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag'; import { workspaceAPI } from '@/infrastructure/api/service-api/WorkspaceAPI'; import { useLocalFileDrop } from '@/infrastructure/files/useLocalFileDrop'; +import { useWindowsFileDropPreview } from '@/infrastructure/files/useWindowsFileDropPreview'; +import type { FileDropPreview, FileDropPosition } from '@/shared/types/fileDropPreview'; import { resolveBrowserDroppedFilePaths } from '@/infrastructure/files/resolveBrowserDroppedFilePaths'; import { buildExternalFileContexts, @@ -283,6 +285,8 @@ export interface ChatInputProps { /** The host conversation area that accepts files for this composer. */ fileDropTargetRef?: React.RefObject; onFileDragOverChange?: (isOver: boolean) => void; + onFileDragPreviewChange?: (preview: FileDropPreview | null) => void; + onFileDragPositionChange?: (position: FileDropPosition | null) => void; /** * Optional content and transport registration for hosts that embed the * standard composer. The registration never replaces ChatInput's UI. @@ -492,6 +496,8 @@ export const ChatInput: React.FC = ({ isSceneActive = true, fileDropTargetRef, onFileDragOverChange, + onFileDragPreviewChange, + onFileDragPositionChange, registration, }) => { const deviceSurfaceScope = getActiveSurfaceScope(); @@ -4816,6 +4822,16 @@ export const ChatInput: React.FC = ({ return () => inputElement.removeEventListener('imagePaste', handleImagePaste); }, [addClipboardImageFiles, captureExternalFileIntakeRequest, enqueueExternalFileIntake]); + useWindowsFileDropPreview({ + targetRef: fileDropTargetRef ?? externalFileDropTargetRef, + enabled: Boolean(fileDropTargetRef && onFileDragPreviewChange && onFileDragPositionChange) + && isSceneActive && !caps.transferInFlight && !isInterruptedTurnRecoveryInFlight, + onDragOver: setNativeFileDragOver, + onPreview: preview => onFileDragPreviewChange?.(preview), + onPosition: position => onFileDragPositionChange?.(position), + onDropPaths: paths => intakeExternalPaths('drop', paths), + }); + useLocalFileDrop({ targetRef: fileDropTargetRef ?? externalFileDropTargetRef, enabled: isSceneActive && !isWindowsDesktopRuntime() diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts index 6ab0f59721..506df8fb98 100644 --- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts +++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts @@ -1,6 +1,6 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. // Source: openbitfun_product_domains::remote_surface (Product Operation Registry). -export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:697e78e28549dbf0" as const; +export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:2851d2dc0482c8ac" as const; /** * Registered Tauri commands the Peer Device controller keeps on the controller @@ -129,6 +129,7 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "resolve_browser_dropped_file_paths", "restart_app", "rollback_frontend_update", + "set_file_drop_preview_target", "set_main_window_transient_geometry", "set_prevent_sleep_enabled", "set_tray_unread_count", diff --git a/src/web-ui/src/infrastructure/files/useWindowsFileDropPreview.test.tsx b/src/web-ui/src/infrastructure/files/useWindowsFileDropPreview.test.tsx new file mode 100644 index 0000000000..8110289b96 --- /dev/null +++ b/src/web-ui/src/infrastructure/files/useWindowsFileDropPreview.test.tsx @@ -0,0 +1,125 @@ +/** @vitest-environment jsdom */ +import React, { act, useRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +const mocks = vi.hoisted(() => ({ invoke: vi.fn().mockResolvedValue(undefined), + listener: null as null | ((event: { payload: Record }) => void), unlisten: vi.fn(), +})); +vi.mock('@/infrastructure/runtime', () => ({ isWindowsDesktopRuntime: () => true })); +vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ api: { invoke: mocks.invoke } })); +vi.mock('@tauri-apps/api/event', () => ({ listen: async (_name: string, listener: typeof mocks.listener) => { + mocks.listener = listener; return mocks.unlisten; +} })); +import { useWindowsFileDropPreview } from './useWindowsFileDropPreview'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const container = document.createElement('div'); document.body.appendChild(container); +let root: ReturnType; +afterEach(() => { act(() => root.unmount()); vi.clearAllMocks(); mocks.invoke.mockResolvedValue(undefined); }); + +async function setup() { + const onDragOver = vi.fn(), onPreview = vi.fn(), onPosition = vi.fn(), onDropPaths = vi.fn(); + function Pane() { + const ref = useRef(null); + useWindowsFileDropPreview({ targetRef: ref, enabled: true, onDragOver, onPreview, onPosition, onDropPaths }); + return
Chat
; + } + root = createRoot(container); + await act(async () => { root.render(); }); + container.firstElementChild!.getBoundingClientRect = () => new DOMRect(100, 100, 600, 500); + const drag = (types: string[]) => { + const event = new Event('dragenter', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { value: { types } }); + container.firstElementChild!.dispatchEvent(event); + return event; + }; + const emit = (targetId: string, kind: string, fields = {}) => { + act(() => mocks.listener?.({ payload: { targetId, kind, generation: 1, x: 100, y: 200, + count: 5, files: [{ name: 'photo.png' }], ...fields } })); + }; + return { onDragOver, onPreview, onPosition, onDropPaths, drag, emit }; +} + +describe('Windows enhanced file drop adapter', () => { + it('arms only external file drags without consuming HTML drag events', async () => { + const { drag } = await setup(); + expect(drag(['text/plain']).defaultPrevented).toBe(false); + expect(mocks.invoke).not.toHaveBeenCalled(); + await act(async () => { expect(drag(['Files']).defaultPrevented).toBe(false); }); + expect(mocks.invoke).toHaveBeenCalledWith('set_file_drop_preview_target', expect.objectContaining({ + request: expect.objectContaining({ bounds: expect.objectContaining({ scale: window.devicePixelRatio }) }), + })); + }); + it('accepts all paths once, isolates targets, and ignores stale thumbnails after drop', async () => { + const { drag, emit, onPreview, onDropPaths, onDragOver, onPosition } = await setup(); + await act(async () => { drag(['Files']); }); + const id = mocks.invoke.mock.calls[0][1].request.targetId; + emit('other-pane', 'enter'); expect(onPreview).not.toHaveBeenCalled(); + emit(id, 'enter'); expect(onDragOver).toHaveBeenLastCalledWith(true); + emit(id, 'over', { x: 120 }); expect(onPosition).toHaveBeenLastCalledWith({ x: 120, y: 200 }); + const paths = ['/a.png', '/b.png', '/c.png', '/d.png', '/report.pdf']; + emit(id, 'drop', { paths }); emit(id, 'drop', { paths }); + expect(onDropPaths).toHaveBeenCalledExactlyOnceWith(paths); + emit(id, 'thumbnails'); expect(onPreview).toHaveBeenLastCalledWith(null); + expect(onDragOver).toHaveBeenLastCalledWith(false); + }); + it('reports unsupported older hosts and preserves browser file intake', async () => { + const { drag, onPreview, onDropPaths } = await setup(); + mocks.invoke.mockRejectedValueOnce(new Error('Command unavailable')); + await act(async () => { expect(drag(['Files']).defaultPrevented).toBe(false); }); + expect(onPreview).toHaveBeenLastCalledWith({ count: 0, files: [], unavailable: true }); + expect(onDropPaths).not.toHaveBeenCalled(); + }); + it('returns to browser intake if native drag-image suppression fails', async () => { + const { drag, emit, onPreview, onDragOver } = await setup(); + await act(async () => { drag(['Files']); }); + const id = mocks.invoke.mock.calls[0][1].request.targetId; + emit(id, 'unavailable'); + expect(onPreview).toHaveBeenLastCalledWith({ count: 0, files: [], unavailable: true }); + expect(onDragOver).toHaveBeenLastCalledWith(false); + await act(async () => { drag(['Files']); }); + expect(mocks.invoke).toHaveBeenCalledTimes(1); + emit(id, 'thumbnails'); + expect(onPreview).toHaveBeenLastCalledWith({ count: 0, files: [], unavailable: true }); + }); + it('retries the next visit after a transient failure instead of disabling the pane', async () => { + const { drag, emit, onDragOver } = await setup(); + mocks.invoke.mockRejectedValueOnce(new Error('Native target creation failed')); + await act(async () => { drag(['Files']); }); + await act(async () => { drag(['Files']); }); + expect(mocks.invoke).toHaveBeenCalledTimes(1); + act(() => container.firstElementChild!.dispatchEvent(new MouseEvent('dragleave', { + bubbles: true, clientX: 20, clientY: 20, + }))); + await act(async () => { drag(['Files']); }); + expect(mocks.invoke).toHaveBeenCalledTimes(2); + emit(mocks.invoke.mock.calls[1][1].request.targetId, 'enter'); + expect(onDragOver).toHaveBeenLastCalledWith(true); + }); + it('recovers after a failed drag is dropped through the browser', async () => { + const { drag } = await setup(); + mocks.invoke.mockRejectedValueOnce(new Error('Native target unavailable')); + await act(async () => { drag(['Files']); }); + act(() => window.dispatchEvent(new Event('drop'))); + await act(async () => { drag(['Files']); }); + expect(mocks.invoke).toHaveBeenCalledTimes(2); + }); + it('does not re-arm or release during the browser-to-native receiver handoff', async () => { + const { drag } = await setup(); + await act(async () => { drag(['Files']); }); + act(() => container.firstElementChild!.dispatchEvent(new MouseEvent('dragleave', { bubbles: true }))); + await act(async () => { drag(['Files']); }); + expect(mocks.invoke).toHaveBeenCalledTimes(1); + }); + it('ignores a late failed activation after the drag has already ended', async () => { + const { drag, onPreview } = await setup(); + let reject!: (error: Error) => void; + mocks.invoke.mockImplementationOnce(() => new Promise((_resolve, failure) => { reject = failure; })); + await act(async () => { drag(['Files']); }); + act(() => window.dispatchEvent(new Event('drop'))); + await act(async () => { reject(new Error('Late native error')); }); + expect(onPreview).toHaveBeenLastCalledWith(null); + await act(async () => { drag(['Files']); }); + expect(mocks.invoke.mock.calls.filter(([, args]) => args.request.bounds !== null)).toHaveLength(2); + }); +}); diff --git a/src/web-ui/src/infrastructure/files/useWindowsFileDropPreview.ts b/src/web-ui/src/infrastructure/files/useWindowsFileDropPreview.ts new file mode 100644 index 0000000000..281bd4732a --- /dev/null +++ b/src/web-ui/src/infrastructure/files/useWindowsFileDropPreview.ts @@ -0,0 +1,147 @@ +import { useEffect, useRef, type RefObject } from 'react'; +import { isWindowsDesktopRuntime } from '@/infrastructure/runtime'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; +import { createLogger } from '@/shared/utils/logger'; +import type { FileDropPosition, FileDropPreview } from '@/shared/types/fileDropPreview'; + +const log = createLogger('WindowsFileDropPreview'); +interface PreviewEvent extends FileDropPreview, FileDropPosition { + targetId: string; + generation: number; + kind: 'enter' | 'over' | 'thumbnails' | 'leave' | 'drop' | 'unavailable'; + paths?: string[]; +} + +interface Options { + targetRef: RefObject; + enabled: boolean; + onDragOver: (over: boolean) => void; + onPreview: (preview: FileDropPreview | null) => void; + onPosition: (position: FileDropPosition | null) => void; + onDropPaths: (paths: string[]) => void | Promise; +} + +/** Controller-local OLE capture, armed only for external file drags over this target. */ +export function useWindowsFileDropPreview(options: Options): void { + const callbacks = useRef(options); + callbacks.current = options; + const { targetRef, enabled } = options; + + useEffect(() => { + if (!enabled || !isWindowsDesktopRuntime()) return; + const target = targetRef.current; + if (!target) return; + const targetId = crypto.randomUUID(); + let disposed = false; + let arming = false; + let requested = false; + let unavailable = false; + let attempt = 0; + let generation: number | null = null; + let unlisten: (() => void) | undefined; + + const clear = () => { + generation = null; + callbacks.current.onDragOver(false); + callbacks.current.onPreview(null); + callbacks.current.onPosition(null); + }; + const release = () => { + attempt += 1; + arming = false; + unavailable = false; + clear(); + if (!requested) return; + requested = false; + void api.invoke('set_file_drop_preview_target', { request: { targetId, bounds: null } }) + .catch(error => log.warn('Failed to release native file preview target', error)); + }; + const showUnavailable = (error?: unknown) => { + unavailable = true; + if (error) log.warn('Native file preview is unavailable; browser file intake remains active', error); + callbacks.current.onPreview({ count: 0, files: [], unavailable: true }); + }; + const enter = (event: DragEvent) => { + if (!event.dataTransfer?.types.includes('Files') || !callbacks.current.enabled) return; + if (unavailable) { showUnavailable(); return; } + if (arming || requested || generation !== null) return; + const rect = target.getBoundingClientRect(); + const currentAttempt = ++attempt; + arming = true; + requested = true; + void api.invoke('set_file_drop_preview_target', { request: { + targetId, + bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height, scale: window.devicePixelRatio }, + } }).catch(error => { + if (disposed || currentAttempt !== attempt) return; + requested = false; + showUnavailable(error); + }).finally(() => { if (currentAttempt === attempt) arming = false; }); + }; + const leave = (event: DragEvent) => { + if (!requested && !unavailable && generation === null && !arming) return; + // Native capture also causes a DOM dragleave. Only end this visit when + // browser dragover proves the pointer left, or a failed visit leaves. + if (event.type === 'dragleave' && !unavailable) return; + const rect = target.getBoundingClientRect(); + if (event.clientX < rect.left || event.clientX >= rect.right + || event.clientY < rect.top || event.clientY >= rect.bottom) release(); + }; + + const setup = async () => { + try { + // Use the native window event channel, never the peer runtime's transport. + const { listen } = await import('@tauri-apps/api/event'); + unlisten = await listen('openbitfun://file-drop-preview', ({ payload }) => { + if (disposed || payload.targetId !== targetId) return; + if (payload.kind === 'unavailable') { + if (!requested && generation === null) return; + requested = false; + clear(); + showUnavailable(); + } else if (payload.kind === 'enter') { + generation = payload.generation; + callbacks.current.onPosition({ x: payload.x, y: payload.y }); + callbacks.current.onPreview({ count: payload.count, files: payload.files }); + callbacks.current.onDragOver(true); + } else if (generation === payload.generation) { + if (payload.kind === 'over') { + callbacks.current.onPosition({ x: payload.x, y: payload.y }); + } else if (payload.kind === 'thumbnails') { + callbacks.current.onPreview({ count: payload.count, files: payload.files }); + } else { + requested = false; + unavailable = false; + clear(); + if (payload.kind === 'drop' && payload.paths?.length && callbacks.current.enabled) { + void Promise.resolve(callbacks.current.onDropPaths(payload.paths)) + .catch(error => log.error('Failed to accept native file drop', error)); + } + } + } + }); + if (disposed) { unlisten(); return; } + target.addEventListener('dragenter', enter, true); + target.addEventListener('dragleave', leave); + window.addEventListener('dragover', leave); + window.addEventListener('drop', release, true); + window.addEventListener('dragend', release); + window.addEventListener('blur', release); + } catch (error) { + if (!disposed) showUnavailable(error); + } + }; + void setup(); + return () => { + disposed = true; + target.removeEventListener('dragenter', enter, true); + target.removeEventListener('dragleave', leave); + window.removeEventListener('dragover', leave); + window.removeEventListener('drop', release, true); + window.removeEventListener('dragend', release); + window.removeEventListener('blur', release); + unlisten?.(); + release(); + }; + }, [targetRef, enabled]); +} diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index ac00142ebe..944412db3c 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -625,6 +625,9 @@ "image": "Image", "url": "URL", "dropToAdd": "Drop to add to message", + "dropFilesHint": "Add files and images to this conversation", + "dropFileCount": "{{fileCount}} files", + "dropPreviewUnavailable": "File preview is temporarily unavailable. Release to add files.", "dragHint": "Drop files here to add context" }, "agent": { diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 5b43e14ed9..559a32c875 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -625,6 +625,9 @@ "image": "图片", "url": "URL", "dropToAdd": "松开即可添加", + "dropFilesHint": "将文件和图片添加到当前对话", + "dropFileCount": "{{fileCount}} 个文件", + "dropPreviewUnavailable": "文件预览暂时不可用,仍可松开添加文件。", "dragHint": "拖放文件到此处添加上下文" }, "agent": { diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index d6c7410a51..9100c5e422 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -625,6 +625,9 @@ "image": "圖片", "url": "URL", "dropToAdd": "放開即可新增", + "dropFilesHint": "將檔案和圖片新增至目前對話", + "dropFileCount": "{{fileCount}} 個檔案", + "dropPreviewUnavailable": "檔案預覽暫時無法使用,仍可放開新增檔案。", "dragHint": "拖放檔案到此處新增上下文" }, "agent": { diff --git a/src/web-ui/src/shared/types/fileDropPreview.ts b/src/web-ui/src/shared/types/fileDropPreview.ts new file mode 100644 index 0000000000..35d2d01e0e --- /dev/null +++ b/src/web-ui/src/shared/types/fileDropPreview.ts @@ -0,0 +1,12 @@ +export interface FileDropPreviewItem { + name: string; + thumbnail?: string | null; +} + +export interface FileDropPreview { + count: number; + files: FileDropPreviewItem[]; + unavailable?: boolean; +} + +export interface FileDropPosition { x: number; y: number }