diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index d0282e8554..fdd26f2fe1 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -172,6 +172,7 @@ import { DeepResearchWebsiteAccessDialog, } from "@/features/chat/components/deep-research-composer-button"; import { + NativeAttachmentDragContext, type NativeIntent, useNativeAttachmentTargetKey, useNativeIntentStore, @@ -2782,6 +2783,7 @@ const Composer: FC<{ const aui = useAui(); const isDictating = useAuiState((s) => s.composer.dictation != null); const pageDragging = useContext(PageDragContext); + const nativeDragging = useContext(NativeAttachmentDragContext); const { overlay, closeOverlay } = useGeneratedImageOverlay(); const setImageToolsEnabled = useChatRuntimeStore( (s) => s.setImageToolsEnabled, @@ -5693,6 +5695,24 @@ const Composer: FC<{ ); + const dropOverlay = ( +
+ + + Drop files here + +
+ ); + return ( @@ -5733,10 +5753,9 @@ const Composer: FC<{ /> ) : null} {isTauri ? ( - // Phase 1 native model owns Tauri local-path drops. Restore browser - // attachment drops in Tauri once Phase 1d adds token bridging.
{composerContent} + {dropOverlay}
) : ( {composerContent} - {/* Gemini-style drop affordance, shown while a file is dragged over - the composer. Absolute + pointer-events-none so the outline adds - no layout shift and the drop still lands. */} -
- - - Drop files here - -
+ {dropOverlay}
)} diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 4c25c7fbc0..53e4a6ffd6 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -75,6 +75,7 @@ import { chatLocalModelOptions } from "./local-model-options"; import { type NativeIntent, NativeAttachmentTargetContext, + NativeAttachmentDragContext, NativeModelChip, NativeModelDropOverlay, useNativeIntentStore, @@ -3720,21 +3721,13 @@ export function ChatPage({ ), [hasActiveModel, loadNativeModelIntent], ); - // Dropped documents go to the thread bar, which owns the RAG upload and can materialize a thread - // id for a chat that has not been sent to yet. - const handleNativeAttachmentDrop = useCallback( - (intents: NativeIntent[]) => { - useNativeIntentStore.getState().addAttachments(artifactViewKey, intents); - }, - [artifactViewKey], - ); const handleNativeImageDrop = useCallback( (intents: NativeIntent[]) => { useNativeIntentStore.getState().addImageAttachments(artifactViewKey, intents); }, [artifactViewKey], ); - const handleNativeOpenDocumentDrop = useCallback( + const handleNativeDocumentDrop = useCallback( (intents: NativeIntent[]) => { useNativeIntentStore .getState() @@ -3768,9 +3761,8 @@ export function ChatPage({ hasActiveModel, isModelLoading: Boolean(loadingModel) || modelLoading, onAutoLoad: handleNativeModelDropAutoLoad, - onAttach: handleNativeAttachmentDrop, onAttachImages: handleNativeImageDrop, - onAttachOpenDocuments: handleNativeOpenDocumentDrop, + onAttachDocuments: handleNativeDocumentDrop, onAttachAudio: handleNativeAudioDrop, onAttachVideo: handleNativeVideoDrop, }); @@ -4966,12 +4958,16 @@ export function ChatPage({ runtimeReady={projectRuntimeReady} /> ) : ( - - - - + + + + + )} diff --git a/studio/frontend/src/features/native-intents/attachment-target.ts b/studio/frontend/src/features/native-intents/attachment-target.ts index ac451ae059..c9fe577595 100644 --- a/studio/frontend/src/features/native-intents/attachment-target.ts +++ b/studio/frontend/src/features/native-intents/attachment-target.ts @@ -3,6 +3,8 @@ import { createContext, useContext } from "react"; +export const NativeAttachmentDragContext = createContext(false); + export const NativeAttachmentTargetContext = createContext(null); export function useNativeAttachmentTargetKey(): string | null { diff --git a/studio/frontend/src/features/native-intents/components/native-model-drop-overlay.tsx b/studio/frontend/src/features/native-intents/components/native-model-drop-overlay.tsx index 4aa3c9b960..f465cc71b4 100644 --- a/studio/frontend/src/features/native-intents/components/native-model-drop-overlay.tsx +++ b/studio/frontend/src/features/native-intents/components/native-model-drop-overlay.tsx @@ -10,30 +10,6 @@ function overlayCopy(state: NativeModelDropState): { title: string; description: state.reason ?? "Drop a .gguf model, or documents to chat with.", }; } - if (state.status === "attach") { - // Only documents are indexed; images, audio and video ride the next message. - const description = - state.kind === "images" || state.kind === "audio" || state.kind === "video" - ? "Attached to your next message." - : state.kind === "mixed" - ? "Documents indexed, attachments sent with your next message." - : "Indexed for this chat."; - const noun = - state.kind === "images" - ? "image" - : state.kind === "audio" - ? "audio file" - : state.kind === "video" - ? "video" - : "file"; - return { - title: - state.count === 1 - ? `Drop to attach ${noun}` - : `Drop to attach ${state.count} ${noun}s`, - description, - }; - } if (state.status === "valid" && state.action === "replace") { return { title: "Drop to replace model", @@ -53,9 +29,10 @@ function overlayCopy(state: NativeModelDropState): { title: string; description: } export function NativeModelDropOverlay({ state }: { state: NativeModelDropState }) { + if (state.status === "attach") return null; const isIdle = state.status === "idle"; const isAutoLoad = - (state.status === "valid" && state.action !== "chip") || state.status === "attach"; + state.status === "valid" && state.action !== "chip"; const isInvalid = state.status === "invalid"; const { title, description } = overlayCopy(state); diff --git a/studio/frontend/src/features/native-intents/drop-paths.ts b/studio/frontend/src/features/native-intents/drop-paths.ts index 3cc714b694..17f07cb42b 100644 --- a/studio/frontend/src/features/native-intents/drop-paths.ts +++ b/studio/frontend/src/features/native-intents/drop-paths.ts @@ -14,24 +14,14 @@ import { TEXT_ATTACHMENT_EXTENSIONS, isTextAttachmentName, } from "../chat/text-attachment-accept.ts"; -import { - RAG_DOCUMENT_UPLOAD_ACCEPT, - RAG_UPLOAD_ACCEPT, -} from "../rag/types/rag.ts"; import { MAX_AUDIO_FILES } from "../../lib/audio-utils.ts"; -const DOC_EXTS = RAG_UPLOAD_ACCEPT.split(",").map((ext) => - ext.trim().toLowerCase(), -); +const DOC_EXTS = [".pdf", ".html", ".htm", ".docx", ".xlsx", ".xlsm", ".pptx"]; +const TEXT_EXTS = TEXT_ATTACHMENT_EXTENSIONS.map((ext) => ext.toLowerCase()); -// RAG types are filtered out, so a dropped .txt/.md keeps being indexed. -const TEXT_EXTS = TEXT_ATTACHMENT_EXTENSIONS.map((ext) => - ext.toLowerCase(), -).filter((ext) => !DOC_EXTS.includes(ext)); - -/** Dropped paths with an inline adapter but no RAG parser. */ export function isComposerAttachmentName(path: string): boolean { return ( + DOC_EXTS.some((ext) => path.toLowerCase().endsWith(ext)) || isOpenDocumentAttachmentName(path) || isRtfAttachmentName(path) || isToolOnlyAttachmentName(path) || @@ -39,13 +29,6 @@ export function isComposerAttachmentName(path: string): boolean { ); } -/** Documents only chat with files reads; composer-handled types stay there. */ -const OFFICE_DOC_EXTS = RAG_DOCUMENT_UPLOAD_ACCEPT.split(",").filter( - (ext) => !isComposerAttachmentName(`file${ext}`), -); - -const DROP_DOC_EXTS = [...DOC_EXTS, ...OFFICE_DOC_EXTS]; - function isTextDropName(path: string): boolean { const name = nativeFileName(path).toLowerCase(); if (!name.includes(".") && isTextAttachmentName(name)) { @@ -81,7 +64,7 @@ const VIDEO_EXTS = CHAT_VIDEO_DROP_ACCEPT.split(",").map((ext) => ); /** What the window actually takes, for the rejection toast and the overlay. */ -export const SUPPORTED_DROP_HINT = `Supported files: ${DROP_DOC_EXTS.join(",")}, ${OPEN_DOCUMENT_ATTACHMENT_EXTENSIONS}, ${RTF_ATTACHMENT_EXTENSIONS}, source and text files, ${TOOL_ONLY_ATTACHMENT_EXTENSIONS} with Code on, ${CHAT_IMAGE_DROP_ACCEPT}, up to ${MAX_AUDIO_FILES} of ${CHAT_AUDIO_DROP_ACCEPT}, one of ${CHAT_VIDEO_DROP_ACCEPT}, or a single .gguf model.`; +export const SUPPORTED_DROP_HINT = `Supported files: ${DOC_EXTS.join(",")}, ${OPEN_DOCUMENT_ATTACHMENT_EXTENSIONS}, ${RTF_ATTACHMENT_EXTENSIONS}, source and text files, ${TOOL_ONLY_ATTACHMENT_EXTENSIONS} with Code on, ${CHAT_IMAGE_DROP_ACCEPT}, up to ${MAX_AUDIO_FILES} of ${CHAT_AUDIO_DROP_ACCEPT}, one of ${CHAT_VIDEO_DROP_ACCEPT}, or a single .gguf model.`; /** Last path segment of a native path, for display and extension checks. */ export function nativeFileName(path: string): string { @@ -119,11 +102,7 @@ export function classifyDropPaths(paths: string[]): NativeDropClass { ? { kind: "model", path: ggufs[0] } : { kind: "unsupported" }; } - const docs = paths.filter( - (path) => - DROP_DOC_EXTS.some((ext) => hasExt(path, ext)) || - isComposerAttachmentName(path), - ); + const docs = paths.filter(isComposerAttachmentName); const images = paths.filter((path) => IMAGE_EXTS.some((ext) => hasExt(path, ext)), ); diff --git a/studio/frontend/src/features/native-intents/index.ts b/studio/frontend/src/features/native-intents/index.ts index 0aa8746010..c77c20e1f5 100644 --- a/studio/frontend/src/features/native-intents/index.ts +++ b/studio/frontend/src/features/native-intents/index.ts @@ -21,6 +21,7 @@ export { useNativeFileDrop } from "./use-native-file-drop"; export type { NativeFileDrop, NativeFileDropOptions } from "./use-native-file-drop"; export { NativeAttachmentTargetContext, + NativeAttachmentDragContext, useNativeAttachmentTargetKey, } from "./attachment-target"; export { useNativeIntentStore } from "./store"; diff --git a/studio/frontend/src/features/native-intents/native-drop-targets.ts b/studio/frontend/src/features/native-intents/native-drop-targets.ts index e0b51735e4..8f374d1719 100644 --- a/studio/frontend/src/features/native-intents/native-drop-targets.ts +++ b/studio/frontend/src/features/native-intents/native-drop-targets.ts @@ -47,10 +47,12 @@ function setHovered(next: HTMLElement | null): void { function listen(): void { if (listening || !isTauri) return; listening = true; - void import("@tauri-apps/api/window") - .then(async ({ getCurrentWindow }) => { - const currentWindow = getCurrentWindow(); - await currentWindow.onDragDropEvent(({ payload }) => { + void Promise.all([ + import("@tauri-apps/api/webview"), + import("@tauri-apps/api/window"), + ]) + .then(async ([{ getCurrentWebview }, { getCurrentWindow }]) => { + await getCurrentWebview().onDragDropEvent(({ payload }) => { if (payload.type === "leave") { setHovered(null); return; @@ -70,6 +72,7 @@ function listen(): void { // Scale is a refinement; the devicePixelRatio seed holds until it lands. // Failing here must not reset `listening`, or the next registration would // stack a second drop listener and double every drop. + const currentWindow = getCurrentWindow(); let scaleReported = false; await currentWindow .onScaleChanged(({ payload }) => { diff --git a/studio/frontend/src/features/native-intents/use-native-drop.ts b/studio/frontend/src/features/native-intents/use-native-drop.ts index 5f0c005ed2..bd0221e26f 100644 --- a/studio/frontend/src/features/native-intents/use-native-drop.ts +++ b/studio/frontend/src/features/native-intents/use-native-drop.ts @@ -5,7 +5,6 @@ import { registerNativeAttachmentPath, registerNativeModelPath } from "./api"; import { SUPPORTED_DROP_HINT, classifyDropPaths, - isComposerAttachmentName, } from "./drop-paths"; import { nativeDropTargetAt } from "./native-drop-targets"; import { useNativeIntentStore } from "./store"; @@ -14,11 +13,7 @@ import type { NativeIntent } from "./types"; export type NativeModelDropState = | { status: "idle" } | { status: "valid"; action: "load" | "replace" | "chip" } - | { - status: "attach"; - count: number; - kind: "docs" | "images" | "audio" | "video" | "mixed"; - } + | { status: "attach" } // `reason` explains a refusal the file types alone do not. Absent means the // files themselves are the problem. | { status: "invalid"; reason?: string }; @@ -35,36 +30,20 @@ interface NativeModelDropOptions { hasActiveModel: boolean; isModelLoading: boolean; onAutoLoad?: (intent: NativeIntent) => Promise | void; - onAttach?: (intents: NativeIntent[]) => Promise | void; onAttachImages?: (intents: NativeIntent[]) => Promise | void; - onAttachOpenDocuments?: (intents: NativeIntent[]) => Promise | void; + onAttachDocuments?: (intents: NativeIntent[]) => Promise | void; onAttachAudio?: (intents: NativeIntent[]) => Promise | void; onAttachVideo?: (intents: NativeIntent[]) => Promise | void; } function canAttachDocs(options: NativeModelDropOptions): boolean { - return options.nativePathLeasesSupported && Boolean(options.onAttach); + return Boolean(options.onAttachDocuments); } function canAttachImages(options: NativeModelDropOptions): boolean { return Boolean(options.onAttachImages); } -function canAttachOpenDocuments(options: NativeModelDropOptions): boolean { - return Boolean(options.onAttachOpenDocuments); -} - -function canAttachDocumentPaths( - paths: string[], - options: NativeModelDropOptions, -): boolean { - return paths.every((path) => - isComposerAttachmentName(path) - ? canAttachOpenDocuments(options) - : canAttachDocs(options), - ); -} - function canAttachAudio(options: NativeModelDropOptions): boolean { return Boolean(options.onAttachAudio); } @@ -81,28 +60,6 @@ function canAutoLoadModel(options: NativeModelDropOptions): boolean { ); } -function attachmentCount( - dropped: ReturnType, -): number { - if ( - dropped.kind === "docs" || - dropped.kind === "images" || - dropped.kind === "audio" || - dropped.kind === "video" - ) { - return dropped.paths.length; - } - if (dropped.kind === "attach") { - return ( - dropped.docs.length + - dropped.images.length + - dropped.audio.length + - dropped.video.length - ); - } - return 0; -} - /** Anything this handler would otherwise act on. "none" and "unsupported" * already have their own answers. */ function isActionableKind( @@ -121,27 +78,27 @@ function dropStateForPaths( return { status: "invalid", reason: options.dropsUnsupportedReason }; } if (dropped.kind === "docs") { - return canAttachDocumentPaths(dropped.paths, options) - ? { status: "attach", count: dropped.paths.length, kind: "docs" } + return canAttachDocs(options) + ? { status: "attach" } : { status: "invalid" }; } if (dropped.kind === "images") { return canAttachImages(options) - ? { status: "attach", count: dropped.paths.length, kind: "images" } + ? { status: "attach" } : { status: "invalid" }; } if (dropped.kind === "audio") { return canAttachAudio(options) - ? { status: "attach", count: dropped.paths.length, kind: "audio" } + ? { status: "attach" } : { status: "invalid" }; } if (dropped.kind === "video") { return canAttachVideo(options) - ? { status: "attach", count: dropped.paths.length, kind: "video" } + ? { status: "attach" } : { status: "invalid" }; } if (dropped.kind === "attach") { - const docsSupported = canAttachDocumentPaths(dropped.docs, options); + const docsSupported = dropped.docs.length === 0 || canAttachDocs(options); const imagesSupported = dropped.images.length === 0 || canAttachImages(options); const audioSupported = @@ -149,7 +106,7 @@ function dropStateForPaths( const videoSupported = dropped.video.length === 0 || canAttachVideo(options); return docsSupported && imagesSupported && audioSupported && videoSupported - ? { status: "attach", count: attachmentCount(dropped), kind: "mixed" } + ? { status: "attach" } : { status: "invalid" }; } if (dropped.kind === "unsupported") return { status: "invalid" }; @@ -164,7 +121,6 @@ function dropStateForPaths( interface RegisteredDrop { docs: NativeIntent[]; - composerDocuments: NativeIntent[]; images: NativeIntent[]; audio: NativeIntent[]; video: NativeIntent[]; @@ -209,10 +165,6 @@ async function registerDroppedAttachments( : dropped.kind === "attach" ? dropped.docs : []; - const composerDocumentPaths = docPaths.filter(isComposerAttachmentName); - const ragDocumentPaths = docPaths.filter( - (path) => !isComposerAttachmentName(path), - ); const imagePaths = dropped.kind === "images" ? dropped.paths @@ -231,26 +183,23 @@ async function registerDroppedAttachments( : dropped.kind === "attach" ? dropped.video : []; - const [docs, composerDocuments, images, audio, video] = await Promise.all([ - registerEach(ragDocumentPaths), - registerEach(composerDocumentPaths), + const [docs, images, audio, video] = await Promise.all([ + registerEach(docPaths), registerEach(imagePaths), registerEach(audioPaths), registerEach(videoPaths), ]); return { docs: docs.intents, - composerDocuments: composerDocuments.intents, images: images.intents, audio: audio.intents, video: video.intents, - docsFailed: docs.failed + composerDocuments.failed, + docsFailed: docs.failed, imagesFailed: images.failed, audioFailed: audio.failed, videoFailed: video.failed, error: docs.error ?? - composerDocuments.error ?? images.error ?? audio.error ?? video.error, @@ -264,8 +213,6 @@ function sameDropState( if (a.status !== b.status) return false; if (a.status === "valid" && b.status === "valid") return a.action === b.action; - if (a.status === "attach" && b.status === "attach") - return a.count === b.count && a.kind === b.kind; if (a.status === "invalid" && b.status === "invalid") return a.reason === b.reason; return true; @@ -297,9 +244,9 @@ export function useNativeModelDrop( const publish = (next: NativeModelDropState) => setDropState((prev) => (sameDropState(prev, next) ? prev : next)); - void import("@tauri-apps/api/window") - .then(({ getCurrentWindow }) => - getCurrentWindow().onDragDropEvent(async (event) => { + void import("@tauri-apps/api/webview") + .then(({ getCurrentWebview }) => + getCurrentWebview().onDragDropEvent(async (event) => { const currentOptions = optionsRef.current; if (event.payload.type === "leave") { draggedPaths = []; @@ -351,37 +298,21 @@ export function useNativeModelDrop( : dropped.kind === "attach" ? dropped.docs : []; - const composerDocumentPaths = documentPaths.filter( - isComposerAttachmentName, - ); - const needsRagDocuments = documentPaths.some( - (path) => !isComposerAttachmentName(path), - ); - const needsComposerDocuments = composerDocumentPaths.length > 0; - const needsComposerAttachments = - needsImages || needsComposerDocuments; + const needsDocuments = documentPaths.length > 0; + const needsComposerAttachments = needsImages || needsDocuments; const needsAudio = dropped.kind === "audio" || (dropped.kind === "attach" && dropped.audio.length > 0); const needsVideo = dropped.kind === "video" || (dropped.kind === "attach" && dropped.video.length > 0); - if (needsRagDocuments && !canAttachDocs(currentOptions)) { - toast.error("Attaching files needs the desktop backend", { - description: "Retry once Unsloth has finished starting up.", - }); - return; - } if (needsImages && !canAttachImages(currentOptions)) { toast.error("Attaching images is unavailable right now", { description: "Retry once this chat is ready for attachments.", }); return; } - if ( - needsComposerDocuments && - !canAttachOpenDocuments(currentOptions) - ) { + if (needsDocuments && !canAttachDocs(currentOptions)) { toast.error("Attaching files is unavailable right now", { description: "Retry once this chat is ready for attachments.", }); @@ -416,19 +347,14 @@ export function useNativeModelDrop( latestOptions.attachmentScope === currentOptions.attachmentScope ? latestOptions : currentOptions; - if (registered.docs.length > 0) { - await attachOptions.onAttach?.(registered.docs); - } // Documents first: a vision-less model throws on the image and // aborts the batch, which would discard them. const composerAttachments = [ - ...registered.composerDocuments, + ...registered.docs, ...registered.images, ]; - if (registered.composerDocuments.length > 0) { - await attachOptions.onAttachOpenDocuments?.( - composerAttachments, - ); + if (registered.docs.length > 0) { + await attachOptions.onAttachDocuments?.(composerAttachments); } else if (registered.images.length > 0) { await attachOptions.onAttachImages?.(registered.images); } diff --git a/studio/frontend/src/features/studio/sections/use-dataset-uploads.ts b/studio/frontend/src/features/studio/sections/use-dataset-uploads.ts index 7d7b6d1e83..5ed6cd0308 100644 --- a/studio/frontend/src/features/studio/sections/use-dataset-uploads.ts +++ b/studio/frontend/src/features/studio/sections/use-dataset-uploads.ts @@ -332,8 +332,11 @@ export function useDatasetUploads() { ); }; - void import("@tauri-apps/api/window") - .then(async ({ getCurrentWindow }) => { + void Promise.all([ + import("@tauri-apps/api/window"), + import("@tauri-apps/api/webview"), + ]) + .then(async ([{ getCurrentWindow }, { getCurrentWebview }]) => { const currentWindow = getCurrentWindow(); const stopScaleChanged = await currentWindow.onScaleChanged( ({ payload }) => { @@ -359,7 +362,7 @@ export function useDatasetUploads() { scaleFactor = currentScaleFactor; } - const stopDragDrop = await currentWindow.onDragDropEvent((event) => { + const stopDragDrop = await getCurrentWebview().onDragDropEvent((event) => { if (disposed) { return; } diff --git a/studio/frontend/tests/helpers/tauri-window-resolver.mjs b/studio/frontend/tests/helpers/tauri-window-resolver.mjs index 872635ccb2..5e4e61d24e 100644 --- a/studio/frontend/tests/helpers/tauri-window-resolver.mjs +++ b/studio/frontend/tests/helpers/tauri-window-resolver.mjs @@ -1,8 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -// bundler-resolver's two rules, plus "@tauri-apps/api/window" -> a local stub, -// because the real package only resolves inside a Tauri webview. +// Hold native drop registration pending without replacing the bundler resolution rules. import { existsSync } from "node:fs"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -17,7 +16,7 @@ function firstExisting(base) { } export function resolve(specifier, context, next) { - if (specifier === "@tauri-apps/api/window") { + if (specifier === "@tauri-apps/api/window" || specifier === "@tauri-apps/api/webview") { return next(WINDOW_STUB, context); } if (specifier.startsWith("@/")) { diff --git a/studio/frontend/tests/helpers/tauri-window-stub.mjs b/studio/frontend/tests/helpers/tauri-window-stub.mjs index 086ad825ad..79ced33a86 100644 --- a/studio/frontend/tests/helpers/tauri-window-stub.mjs +++ b/studio/frontend/tests/helpers/tauri-window-stub.mjs @@ -1,18 +1,21 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -// Stands in for @tauri-apps/api/window, which only resolves inside a Tauri -// webview. Control lives on globalThis so a test can hold the drag-drop install -// unresolved and inspect the window before it completes. +// Keep webview drop installation pending while window scale queries stay independent. const control = (globalThis.__TAURI_WINDOW_STUB__ ??= {}); -export function getCurrentWindow() { +export function getCurrentWebview() { return { onDragDropEvent: (handler) => new Promise((resolve) => { control.deliver = handler; control.installed = () => resolve(() => undefined); }), + }; +} + +export function getCurrentWindow() { + return { onScaleChanged: async () => () => undefined, scaleFactor: async () => 1, }; diff --git a/studio/frontend/tests/native-drop-paths.test.ts b/studio/frontend/tests/native-drop-paths.test.ts index 91d23e0e45..8c7f0264f5 100644 --- a/studio/frontend/tests/native-drop-paths.test.ts +++ b/studio/frontend/tests/native-drop-paths.test.ts @@ -114,15 +114,15 @@ const TOOL_ONLY_ADAPTER_RE = const OPEN_DOCUMENT_ADAPTER_ACCEPT_RE = /class OpenDocumentAttachmentAdapter[^{]*\{[\s\S]*?accept = OPEN_DOCUMENT_ATTACHMENT_ACCEPT;/; const OPEN_DOCUMENT_DROP_TO_COMPOSER_RE = - /const composerAttachments = \[[\s\S]*?\.\.\.registered\.composerDocuments,[\s\S]*?\.\.\.registered\.images,[\s\S]*?await attachOptions\.onAttachOpenDocuments\?\.\([\s\S]*?composerAttachments/; + /const composerAttachments = \[[\s\S]*?\.\.\.registered\.docs,[\s\S]*?\.\.\.registered\.images,[\s\S]*?await attachOptions\.onAttachDocuments\?\.\([\s\S]*?composerAttachments/; const OPEN_DOCUMENT_REGISTRATION_CLASS_RE = - /const composerDocumentPaths = docPaths\.filter\(isComposerAttachmentName\);[\s\S]*?const ragDocumentPaths = docPaths\.filter[\s\S]*?registerEach\(ragDocumentPaths\),[\s\S]*?registerEach\(composerDocumentPaths\),[\s\S]*?composerDocuments: composerDocuments\.intents/; + /registerEach\(docPaths\),[\s\S]*?docs: docs\.intents/; const OPEN_DOCUMENT_IMAGE_REGISTRATION_RE = - /const needsComposerDocuments = composerDocumentPaths\.length > 0;[\s\S]*?const needsComposerAttachments =\s*needsImages \|\| needsComposerDocuments;[\s\S]*?if \(needsComposerAttachments\) store\.beginImageDropRegistration\(\);[\s\S]*?finally \{[\s\S]*?if \(needsComposerAttachments\) store\.endImageDropRegistration\(\)/; + /const needsDocuments = documentPaths\.length > 0;[\s\S]*?const needsComposerAttachments = needsImages \|\| needsDocuments;[\s\S]*?if \(needsComposerAttachments\) store\.beginImageDropRegistration\(\);[\s\S]*?finally \{[\s\S]*?if \(needsComposerAttachments\) store\.endImageDropRegistration\(\)/; const OPEN_DOCUMENT_BACKEND_GATE_RE = - /function canAttachDocumentPaths[\s\S]*?isComposerAttachmentName\(path\)[\s\S]*?\? canAttachOpenDocuments\(options\)[\s\S]*?: canAttachDocs\(options\)[\s\S]*?const needsRagDocuments = documentPaths\.some[\s\S]*?if \(needsRagDocuments && !canAttachDocs\(currentOptions\)\)/; + /function canAttachDocs[\s\S]*?return Boolean\(options\.onAttachDocuments\);/; const OPEN_DOCUMENT_CHAT_QUEUE_RE = - /const handleNativeOpenDocumentDrop = useCallback\([\s\S]*?addOpenDocumentAttachments\(artifactViewKey, intents\)[\s\S]*?onAttachOpenDocuments: handleNativeOpenDocumentDrop/; + /const handleNativeDocumentDrop = useCallback\([\s\S]*?addOpenDocumentAttachments\(artifactViewKey, intents\)[\s\S]*?onAttachDocuments: handleNativeDocumentDrop/; const OPEN_DOCUMENT_DRAIN_RE = /takeOpenDocumentAttachments\(targetKey\)[\s\S]*?nativeAttachmentIntentToFile\(intent\)[\s\S]*?await aui\.composer\(\)\.addAttachment\(file\)/; @@ -220,16 +220,16 @@ for (const [format, extensions, adapter, rustList] of [ }); } -test("Office, Outlook and web page drops index in chat with files and match the native allowlist", () => { +test("native chat accepts Office types with a composer adapter; native intake retains explicit RAG formats", () => { const office = ".doc .xls .xlsx .xlsm .xltx .xltm .ppt .pptx .pptm .ppsx .ott .ots .otp .msg .mht .mhtml .xhtml .xht".split( " ", ); for (const extension of office) { const path = `/docs/Report${extension.toUpperCase()}`; - assert.equal(classifyDropPaths([path]).kind, "docs", path); - assert.ok(!isComposerAttachmentName(path), path); - assert.ok(SUPPORTED_DROP_HINT.includes(extension)); + const supported = [".xlsx", ".xlsm", ".pptx"].includes(extension); + assert.equal(classifyDropPaths([path]).kind, supported ? "docs" : "unsupported", path); + assert.equal(isComposerAttachmentName(path), supported, path); } // Types with a composer adapter keep going to the composer. for (const ext of [".odt", ".rtf", ".epub", ".eml", ".docm"]) { @@ -1558,10 +1558,10 @@ test("gettext catalogs use the charset declared by their header", async () => { ); }); -test("RAG documents keep their existing route", () => { +test("text and PDF drops attach to the composer", () => { for (const path of ["/docs/notes.txt", "/docs/notes.md", "/docs/paper.pdf"]) { assert.equal(classifyDropPaths([path]).kind, "docs", path); - assert.equal(isComposerAttachmentName(path), false, path); + assert.equal(isComposerAttachmentName(path), true, path); } }); diff --git a/studio/frontend/tests/native-drop-webview-routing.test.ts b/studio/frontend/tests/native-drop-webview-routing.test.ts new file mode 100644 index 0000000000..6767dc125a --- /dev/null +++ b/studio/frontend/tests/native-drop-webview-routing.test.ts @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import { mockIPC, mockWindows } from "@tauri-apps/api/mocks"; +import { readSrc, readText, registerBundlerResolver } from "./helpers/kit.ts"; + +registerBundlerResolver(); + +class ElementStub { + parentElement = null; +} +const zone = new ElementStub(); +Object.assign(globalThis, { + HTMLElement: ElementStub, + window: { crypto, devicePixelRatio: 1, location: { protocol: "tauri:" } }, + document: { elementFromPoint: () => zone }, +}); + +type Registration = { + event: string; + target: { kind: string; label: string }; + handler: number; +}; +const registrations: Registration[] = []; +mockIPC((command, args) => { + if (command === "plugin:event|listen") { + registrations.push(args as unknown as Registration); + return registrations.length; + } + if (command === "plugin:window|scale_factor") return 1; +}); +mockWindows("main"); + +const { nativeDropTargetAt, registerNativeDropTarget } = await import( + "../src/features/native-intents/native-drop-targets.ts" +); + +function emitFromWebview(label: string, event: string, payload: unknown) { + const internals = (window as unknown as { + __TAURI_INTERNALS__: { runCallback: (id: number, event: unknown) => void }; + }).__TAURI_INTERNALS__; + for (const listener of registrations) { + if ( + listener.event === event && + listener.target.kind === "Webview" && + listener.target.label === label + ) { + internals.runCallback(listener.handler, { event, id: listener.handler, payload }); + } + } +} + +test("native targets receive main-webview drops without claiming browser-page drops", async () => { + const delivered: string[][] = []; + const hovered: boolean[] = []; + const unregister = registerNativeDropTarget(zone as unknown as HTMLElement, { + onDrop: (paths) => delivered.push(paths), + onDragOver: (over) => hovered.push(over), + }); + try { + for (let i = 0; i < 100 && !nativeDropTargetAt({ x: 10, y: 10 }); i++) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.equal(nativeDropTargetAt({ x: 10, y: 10 }), zone); + const payload = { position: { x: 10, y: 10 }, paths: ["/tmp/probe.txt"] }; + emitFromWebview("browser-test", "tauri://drag-drop", payload); + assert.deepEqual(delivered, []); + emitFromWebview("main", "tauri://drag-enter", payload); + assert.deepEqual(hovered, [true]); + emitFromWebview("main", "tauri://drag-drop", payload); + assert.deepEqual(delivered, [["/tmp/probe.txt"]]); + assert.deepEqual(hovered, [true, false]); + } finally { + unregister(); + } +}); + +test("chat and training subscribe to the same webview event source", () => { + for (const path of [ + "features/native-intents/use-native-drop.ts", + "features/studio/sections/use-dataset-uploads.ts", + ]) { + const source = readSrc(path); + assert.match(source, /getCurrentWebview\(\)\.onDragDropEvent/); + assert.doesNotMatch(source, /(?:getCurrentWindow\(\)|currentWindow)\.onDragDropEvent/); + } +}); + +test("Rust authorizes only drops on the main webview", () => { + const main = readText("../../src-tauri/src/main.rs"); + const handler = main.slice( + main.indexOf(".on_webview_event("), + main.indexOf(".on_window_event("), + ); + assert.match(handler, /webview.label\(\) == "main"/); + assert.match(handler, /tauri::WebviewEvent::DragDrop\(tauri::DragDropEvent::Drop/); + assert.match(handler, /note_dropped_paths\(paths\)/); + assert.doesNotMatch(main, /tauri::WindowEvent::DragDrop/); +}); diff --git a/studio/src-tauri/src/main.rs b/studio/src-tauri/src/main.rs index f6949afd9a..febcfadc0b 100644 --- a/studio/src-tauri/src/main.rs +++ b/studio/src-tauri/src/main.rs @@ -2396,6 +2396,19 @@ fn main() { setup_unix_termination_signals(app)?; Ok(()) }) + .on_webview_event(|webview, event| { + // The multi-webview runtime delivers OS drops to the webview, not the window. + // Browser page drops must not authorize paths for Studio's native intake. + if webview.label() == "main" { + if let tauri::WebviewEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = + event + { + webview + .state::() + .note_dropped_paths(paths); + } + } + }) .on_window_event(|window, event| { if window.label() == "main" { match event { @@ -2408,13 +2421,6 @@ fn main() { _ => {} } } - // Record real drops here, in Rust, so the renderer can only register paths the - // OS actually handed to the native intake commands. - if let tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event { - window - .state::() - .note_dropped_paths(paths); - } if let tauri::WindowEvent::CloseRequested { api, .. } = event { // Never close directly: the only window, so closing exits before the reap. api.prevent_close(); diff --git a/studio/src-tauri/src/native_intents.rs b/studio/src-tauri/src/native_intents.rs index d1c5574d23..e78eea5905 100644 --- a/studio/src-tauri/src/native_intents.rs +++ b/studio/src-tauri/src/native_intents.rs @@ -615,7 +615,7 @@ const MAX_NATIVE_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024; // Matches the clipboard reader, so a dropped source file and a pasted one // accept the same sizes. const MAX_NATIVE_TEXT_BYTES: u64 = 20 * 1024 * 1024; -// OpenDocument archives use the composer's larger archive limit. +// Documents use the composer's larger archive and PDF limit. const MAX_NATIVE_OPEN_DOCUMENT_BYTES: u64 = 50 * 1024 * 1024; // Images stop lower: the composer throws over 20 MB without a toast and the // drain swallows it, so a larger read loses them silently. @@ -640,6 +640,14 @@ fn attachment_mime_type(path: &Path) -> Option<&'static str> { } let ext = path.extension()?.to_str()?.to_ascii_lowercase(); match ext.as_str() { + "txt" => Some("text/plain"), + "md" | "markdown" => Some("text/markdown"), + "html" | "htm" => Some("text/html"), + "pdf" => Some("application/pdf"), + "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + "xlsm" => Some("application/vnd.ms-excel.sheet.macroEnabled.12"), + "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"), "jpg" | "jpeg" => Some("image/jpeg"), "png" => Some("image/png"), "webp" => Some("image/webp"), @@ -770,13 +778,19 @@ fn read_attachment_payload(entry: &NativePathEntry) -> Result Result /// Document types the RAG ingest accepts; keep in sync with `config.SUPPORTED_UPLOAD_EXTS`. pub const ATTACHMENT_EXTS: &[&str] = &["pdf", "txt", "md", "markdown", "docx", "html", "htm"]; -/// RAG-only document types; keep in sync with `OFFICE_DOC_EXTS` in `drop-paths.ts`. +/// Office, Outlook and saved-page types RAG sources accept from a drop zone; chat drops take only the +/// ones with a composer adapter (`DOC_EXTS` in `drop-paths.ts`). pub const OFFICE_ATTACHMENT_EXTS: &[&str] = &[ "doc", "xls", "xlsx", "xlsm", "xltx", "xltm", "ppt", "pptx", "pptm", "ppsx", "ott", "ots", "otp", "msg", "mht", "mhtml", "xhtml", "xht",