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 }