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",