studio: restore file drops and composer drag feedback (#13387)
Some checks are pending
CodeQL / Languages to analyse (push) Waiting to run
CodeQL / Analyze () (push) Blocked by required conditions
Core / Resolve the unsloth_zoo revision (push) Waiting to run
Core / Core (HF=default + TRL=default) (push) Blocked by required conditions
Core / Core (HF=4.57.6 + TRL<1) (push) Blocked by required conditions
Core / Core (HF=latest + TRL=latest) (push) Blocked by required conditions
Core / Core zoo (HF=default + TRL=default) (push) Blocked by required conditions
Core / Core zoo (HF=4.57.6 + TRL<1) (push) Blocked by required conditions
Core / Core zoo (HF=latest + TRL=latest) (push) Blocked by required conditions
Core / llama.cpp build + smoke (push) Waiting to run
Cross-platform parity / parity (ubuntu-latest) (push) Waiting to run
Cross-platform parity / parity (windows-latest) (push) Waiting to run
Interrupted install recovery / select legs (push) Waiting to run
Interrupted install recovery / kill@ (push) Blocked by required conditions
Interrupted install recovery / windows kill@ (push) Blocked by required conditions
Kaggle T4 Notebook CI / gate (push) Waiting to run
Kaggle T4 Notebook CI / T4 smoke (push) Blocked by required conditions
Kaggle T4 Unsloth GPU CI / gate (push) Waiting to run
Kaggle T4 Unsloth GPU CI / Unsloth GPU smoke (push) Blocked by required conditions
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Waiting to run
Local Agent Guides CI / connection (claude, stable) (push) Waiting to run
Local Agent Guides CI / connection (codex, stable) (push) Waiting to run
Local Agent Guides CI / connection (dsh, stable) (push) Waiting to run
Local Agent Guides CI / connection (hermes, stable) (push) Waiting to run
Local Agent Guides CI / connection (openclaw, stable) (push) Waiting to run
Local Agent Guides CI / connection (opencode, stable) (push) Waiting to run
Local Agent Guides CI / connection (opencode, v2) (push) Waiting to run
Local Agent Guides CI / file-edit (claude) (push) Waiting to run
Local Agent Guides CI / file-edit (codex) (push) Waiting to run
Local Agent Guides CI / file-edit (dsh) (push) Waiting to run
Local Agent Guides CI / file-edit (hermes) (push) Waiting to run
Local Agent Guides CI / file-edit (vibe) (push) Waiting to run
Local Agent Guides CI / resume (claude) (push) Waiting to run
Local Agent Guides CI / resume (codex) (push) Waiting to run
Local Agent Guides CI / resume (opencode) (push) Waiting to run
Local Agent Guides CI / resume (pi) (push) Waiting to run
MLX CI on Mac M1 / dispatch (push) Waiting to run
Local Agent Guides CI / connection (pi, stable) (push) Waiting to run
Local Agent Guides CI / connection (vibe, stable) (push) Waiting to run
Local Agent Guides CI / file-edit (openclaw) (push) Waiting to run
Local Agent Guides CI / file-edit (opencode) (push) Waiting to run
Local Agent Guides CI / file-edit (pi) (push) Waiting to run
Local Agent Guides CI / prompt-cache (gemma-3-270m) (push) Waiting to run
Security audit / advisory audit (pip + npm + cargo) (push) Waiting to run
Security audit / pip scan-packages :: audio-torch280 (push) Waiting to run
Security audit / pip scan-packages :: audio-torch290 (push) Waiting to run
Security audit / pip scan-packages :: extras (push) Waiting to run
Security audit / pip scan-packages :: studio (push) Waiting to run
Security audit / pip scan-packages :: hf-stack (push) Waiting to run
Security audit / npm scan-packages (Unsloth frontend tarballs) (push) Waiting to run
Security audit / npm provenance + new install-script diff (push) Waiting to run
Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Backend CI / (Python 3.11, floor) (push) Waiting to run
Backend CI / (Python 3.13, a-k) (push) Waiting to run
Backend CI / (Python 3.13, l-r) (push) Waiting to run
Backend CI / (Python 3.13, rest) (push) Waiting to run
Backend CI / Repo tests (CPU, rest) (push) Waiting to run
Backend CI / Repo tests (CPU, studio) (push) Waiting to run
Backend CI / Repo tests (CPU, studio-install) (push) Waiting to run
Backend CI / Shell installer tests (push) Waiting to run
Unsloth export capability / capability (windows-latest) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Frontend CI / Frontend unit tests (Windows) (push) Waiting to run
Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Mac Unsloth Install Matrix CI / Install + load (macos-15) (push) Waiting to run
Mac Unsloth Install Matrix CI / Install + load (macos-26) (push) Waiting to run
Mac Unsloth Install Matrix CI / Install + load (macos-15-intel) (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (macos) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (extra) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (chat) (push) Waiting to run
Unsloth UI CI / Loaded-models indicator (cross-browser) (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Studio VAE seam guard / seam-guard (push) Waiting to run
Mac Unsloth Install Matrix CI / Install + load (macos-26-intel) (push) Waiting to run
Mac Unsloth UI + API + Update + Inference CI / Chat UI, API, Update and GGUF inference (push) Waiting to run
Unsloth Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (windows) (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (macOS) (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (Windows) (push) Waiting to run
Windows Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Windows Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Windows Unsloth GGUF CI / Unsloth install + inference without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2022) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection + VC++ round-trip (VS 2026) (push) Waiting to run
Windows Unsloth GGUF CI / setup.ps1 units, no-VS resolve, VC++ round-trip (windows-latest) (push) Waiting to run
Windows Unsloth UI CI / Chat UI Tests (push) Waiting to run
Windows Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
studiobench CI / Selftests, lint and the single-file build (push) Waiting to run
studiobench CI / Real-path session, every action ran (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
Windows Application Control CI / installer survives a denied unsloth.exe (push) Waiting to run
Windows ARM64 CI / woa (pwsh-7) (push) Waiting to run
Windows ARM64 CI / woa (windows-powershell-5.1) (push) Waiting to run
Windows No Compiler CI / the installer spawns no C# compiler (push) Waiting to run
Workflow trigger lint / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Waiting to run
Workflow trigger lint / security suites (torch-dependent) (push) Waiting to run

* studio: restore native file drops to the main webview

* studio: attach dropped documents to the composer and reuse drag feedback

* studio: remove unused attachment badge state

* studio: tidy native drag context import and stale drop comment

Import NativeAttachmentDragContext from the native-intents barrel like its siblings, indent the new provider in ChatPage, and update the OFFICE_ATTACHMENT_EXTS doc comment, which still pointed at the removed OFFICE_DOC_EXTS.

---------

Co-authored-by: mahiatlinux <mahiatlinux@users.noreply.github.com>
Co-authored-by: Michael Han <107991372+shimmyshimmer@users.noreply.github.com>
This commit is contained in:
Maheswar Kumar 2026-10-13 11:43:03 +13:00 • committed by GitHub
parent d8696f7573
commit 5f38afbc2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 282 additions and 228 deletions

View file

@ -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 = (
<div
className={cn(
"aui-composer-drop-overlay pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 overflow-hidden rounded-[inherit] bg-background/90 opacity-0 backdrop-blur-sm transition-opacity duration-150 group-data-[dragging=true]/dropzone:opacity-100 dark:bg-card/90",
(pageDragging || nativeDragging) && "opacity-100",
)}
>
<HugeiconsIcon
icon={AttachmentIcon}
strokeWidth={2}
className="size-6 text-primary"
/>
<span className="text-sm font-medium text-primary">
Drop files here
</span>
</div>
);
return (
<PromptQueueContext.Provider value={queueContextValue}>
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
@ -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.
<div className="aui-composer-attachment-dropzone unsloth-composer-surface relative z-10">
{composerContent}
{dropOverlay}
</div>
) : (
<ComposerPrimitive.AttachmentDropzone
@ -5746,24 +5765,7 @@ const Composer: FC<{
onDropCapture={claimPortaledDrop}
>
{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. */}
<div
className={cn(
"aui-composer-drop-overlay pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 overflow-hidden rounded-[inherit] bg-background/90 opacity-0 backdrop-blur-sm transition-opacity duration-150 group-data-[dragging=true]/dropzone:opacity-100 dark:bg-card/90",
pageDragging && "opacity-100",
)}
>
<HugeiconsIcon
icon={AttachmentIcon}
strokeWidth={2}
className="size-6 text-primary"
/>
<span className="text-sm font-medium text-primary">
Drop files here
</span>
</div>
{dropOverlay}
</ComposerPrimitive.AttachmentDropzone>
)}
</ComposerPrimitive.Root>

View file

@ -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}
/>
) : (
<NativeAttachmentTargetContext.Provider
value={baseAttachmentTargetKey}
<NativeAttachmentDragContext.Provider
value={nativeModelDropState.status === "attach"}
>
<TemporaryChatSaveBridge />
<SingleContent threadId={baseView.threadId} />
</NativeAttachmentTargetContext.Provider>
<NativeAttachmentTargetContext.Provider
value={baseAttachmentTargetKey}
>
<TemporaryChatSaveBridge />
<SingleContent threadId={baseView.threadId} />
</NativeAttachmentTargetContext.Provider>
</NativeAttachmentDragContext.Provider>
)}
</ChatRuntimeProvider>
</ChatActiveContext.Provider>

View file

@ -3,6 +3,8 @@
import { createContext, useContext } from "react";
export const NativeAttachmentDragContext = createContext(false);
export const NativeAttachmentTargetContext = createContext<string | null>(null);
export function useNativeAttachmentTargetKey(): string | null {

View file

@ -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);

View file

@ -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)),
);

View file

@ -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";

View file

@ -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 }) => {

View file

@ -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> | void;
onAttach?: (intents: NativeIntent[]) => Promise<void> | void;
onAttachImages?: (intents: NativeIntent[]) => Promise<void> | void;
onAttachOpenDocuments?: (intents: NativeIntent[]) => Promise<void> | void;
onAttachDocuments?: (intents: NativeIntent[]) => Promise<void> | void;
onAttachAudio?: (intents: NativeIntent[]) => Promise<void> | void;
onAttachVideo?: (intents: NativeIntent[]) => Promise<void> | 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<typeof classifyDropPaths>,
): 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);
}

View file

@ -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;
}

View file

@ -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("@/")) {

View file

@ -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,
};

View file

@ -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);
}
});

View file

@ -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/);
});

View file

@ -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::<native_intents::NativeIntakeState>()
.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::<native_intents::NativeIntakeState>()
.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();

View file

@ -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<NativeAttachmentFi
// reapplied below once the bytes say it is TypeScript.
let max_bytes = if has_transport_stream_extension(path) {
MAX_NATIVE_VIDEO_BYTES
} else if is_text_attachment {
} else if is_text_attachment || mime_type.starts_with("text/") {
MAX_NATIVE_TEXT_BYTES
} else if mime_type.starts_with("image/") {
MAX_NATIVE_IMAGE_BYTES
} else if mime_type.starts_with("video/") {
MAX_NATIVE_VIDEO_BYTES
} else if mime_type.starts_with("application/vnd.oasis.opendocument.") {
} else if mime_type.starts_with("application/vnd.oasis.opendocument.")
|| mime_type.starts_with("application/vnd.openxmlformats-officedocument.")
|| matches!(
mime_type,
"application/pdf" | "application/vnd.ms-excel.sheet.macroEnabled.12"
)
{
MAX_NATIVE_OPEN_DOCUMENT_BYTES
} else {
MAX_NATIVE_ATTACHMENT_BYTES
@ -1099,20 +1113,43 @@ mod tests {
}
#[test]
fn document_token_is_refused_by_the_image_reader() {
let path = temp_path("note").with_extension("pdf");
fs::write(&path, b"%PDF-1.4").unwrap();
let (_state, entry) = attachment_entry(&path);
let Err(err) = read_attachment_payload(&entry) else {
panic!("expected the read to be refused");
};
assert!(err.contains("Only chat attachments can be read inline"));
let _ = fs::remove_file(path);
fn composer_documents_read_inline_with_adapter_mime_types() {
for (ext, mime) in [
("txt", "text/plain"),
("md", "text/markdown"),
("markdown", "text/markdown"),
("html", "text/html"),
("htm", "text/html"),
("pdf", "application/pdf"),
(
"docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
),
(
"xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
),
("xlsm", "application/vnd.ms-excel.sheet.macroEnabled.12"),
(
"pptx",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
),
] {
let path = temp_path("composer-document").with_extension(ext);
fs::write(&path, b"document bytes").unwrap();
let (_state, entry) = attachment_entry(&path);
let payload = read_attachment_payload(&entry).unwrap();
assert_eq!(payload.mime_type, mime, "{ext}");
assert_eq!(BASE64.decode(payload.base64).unwrap(), b"document bytes");
let _ = fs::remove_file(path);
}
}
#[test]
fn text_attachments_read_inline_with_their_own_cap() {
for (ext, mime) in [
("txt", "text/plain"),
("md", "text/markdown"),
("cs", "text/plain"),
("php", "text/plain"),
("js", "text/plain"),
@ -1213,6 +1250,24 @@ mod tests {
let _ = fs::remove_file(path);
}
#[test]
fn composer_documents_use_the_document_cap() {
let path = temp_path("document-cap").with_extension("pdf");
fs::write(&path, vec![0u8; MAX_NATIVE_ATTACHMENT_BYTES as usize + 1]).unwrap();
let (_state, entry) = attachment_entry(&path);
assert!(read_attachment_payload(&entry).is_ok());
fs::File::create(&path)
.unwrap()
.set_len(MAX_NATIVE_OPEN_DOCUMENT_BYTES + 1)
.unwrap();
let (_state, entry) = attachment_entry(&path);
let Err(error) = read_attachment_payload(&entry) else {
panic!("expected the document cap to reject the read");
};
assert!(error.contains("too large"));
let _ = fs::remove_file(path);
}
#[test]
fn model_token_issues_distinct_validate_and_load_grants() {
let state = new_native_intake_state();

View file

@ -48,7 +48,8 @@ pub fn classify_native_model_path(path: &Path) -> Result<ClassifiedPath, String>
/// 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",