Desktop: make every drop zone take a drop again (#9036) - #9056
Conversation
Tauri delivers OS file drops window-wide and suppresses the webview's own drop events, so a zone wired only to `onDrop` does nothing in the desktop app: no drag-over border, and the file is silently ignored. Native drop routing (#8265) was only ever adopted by the shared image picker and the create-project dialog. Every other file drop zone still relied on HTML5 handlers that never fire, which is why this reads as intermittent: the same file works when it lands on the chat, covered by the window-wide handler, and does nothing anywhere else. Adds `useNativeFileDrop`, which claims the native drop for an element and returns drag-over state plus the HTML5 handlers the web build still needs, then adopts it in the zones that were dead: - Projects -> Sources, which also had no drag-over styling at all - Data Recipes unstructured seed - Diffusion training images - Video and audio reference pickers Documents upload by lease rather than an inline read, since the native reader only serves media inline, so the recipe seed route now accepts `nativePathLease` the way the RAG upload routes already do. The native path policy accepts video containers so the reference picker can register what it is given. Also stops two silent discards with the same symptom: a claimed zone that refused a drop while disabled, and compare mode disabling the window-wide handler outright. Both now say what happened. `native-dropzone-coverage.test.ts` walks src/ and fails if a zone reads files from a drag payload without either claiming the native drop or explicitly deferring to the window handler.
…eading Two things the first pass got wrong. Keeping the window-wide listener on outside single chat also handed it model drops, so a GGUF dropped on a compare or project view would load and replace the active model. Nothing happened there before, so that is not a change this should be making. The refusal now covers every kind the handler would act on, models included. The recipe seed route also moved its extension check after the read, so a rejected 500 MB upload was pulled into memory first. Back to validating the filename before reading a byte, as it was.
64 MB sat under the reference picker's own 72 MB, so a clip the picker accepts was refused on drop. The cap is a backstop; callers keep theirs.
… read Two review findings, both correct. The diffusion dataset zone accepts .bmp, .m4v, .caption and .jsonl, which the chat attachment policy rejects outright, and .txt, which registers but cannot be read inline. Captions beside images are the documented workflow there, so wiring that zone to the attachment path would have uploaded the images and silently lost the captions. It needs its own registration and upload policy, which is more than this belongs to, so it goes back to the picker it had. The recipe seed route also read a dropped path in full before checking any limit, so a multi-gigabyte local file went into backend memory before the 413. It now refuses on the stat and bounds the read by what the block has left, in case the file grows in between.
|
Went through all four. Three were right, one was already fixed. Diffusion dataset zone (correct, and worse than described). I checked the accept list against the native policy:
Native read before the size check (correct). A dropped path is a local file of any size, and it was read in full before any limit. Now refused on its stat, with the read bounded by what the block has left in case the file grows in between. Test added that asserts the file is never opened when the stat already exceeds the cap; it fails against the old ordering. Video cap versus the reference picker (already fixed). This landed on 02a18b0; 8bec354 raised Reference picker accepting fewer formats than its input (correct, keeping it). The input is |
for more information, see https://pre-commit.ci
…o the raw limit A disabled seed zone carried pointer-events-none, which takes it out of elementFromPoint, so nativeDropTargetAt could not find the target it had just registered. The disabled message was unreachable and the drop fell through to the window handler instead. MAX_NATIVE_VIDEO_BYTES was set to the reference picker's 96 MiB, but that cap bounds the data URL, not the file: the picker's own raw limit is 75497280 bytes. Rust was reading and base64-encoding up to 96 MiB, 128 MiB across the bridge, for clips the picker then rejected.
|
Three new ones. Two were right, one has a fix that would make it worse. Disabled seed zone not hit-testable (correct, and self-inflicted). Native video cap versus the raw reference limit (correct, and my earlier fix overshot). The 96 MiB figure bounds the data URL, not the file. So Rust was reading and base64-encoding up to 96 MiB, 128 MiB across the bridge, for clips the picker then rejected against 72 MiB. Set to 75497280 with a test that pins it to Queued batch tokens expiring (correct premise, wrong remedy). The loop is sequential and Minting the leases at drop time is what the previous revision did, and it fails 7.5x sooner. Redeeming a token does not extend it either, since |
|
Same class as the seed queue above, and the mechanism is right: the loop in These two paths are not equivalent. The seed route extracts synchronously inside the POST ( Preserving the tokens up front is the worse trade, on both paths: Redeeming early converts a 15 minute budget into a 2 minute one. That is what the first revision of this PR did, and it is why the current code mints per upload. Redeeming does not extend the token either, since What a real fix needs. Either a sliding expiry (new IPC surface, and it makes an idle token's lifetime effectively unbounded), or concurrent uploads, which race the per-block total-size check the seed route performs on every request. Neither belongs in a PR whose subject is making these zones accept a drop at all. For what it is worth, the failure is a clear per-file message on a row the user can re-drop, on a path that before this PR ignored the drop entirely. I would rather ship the bounded limitation than take the 7.5x regression or the security-relevant rewrite. Leaving both instances as they are. |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 06c90fd3c5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| _require_within_budget(size_bytes, budget) | ||
|
|
||
| try: | ||
| with grant.canonical_path.open("rb") as source: |
There was a problem hiding this comment.
Bind the opened file handle to the verified grant
When the dropped path is replaced between verify_native_path_lease and this open—whether with a symlink or an atomically renamed regular file—Path.open follows and reads the replacement without comparing the opened handle's identity to the signed grant. This defeats the lease fingerprint and can extract a different file than the user dropped; open without following links and validate the handle's size, timestamps, device, and inode before reading.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The path is never webview-named: Rust signs what the OS handed it and verify_native_path_lease re-fingerprints it. Reaching this needs a local attacker racing the filesystem on the user's own machine, which is not legitimate real-session input.
|
|
||
| try: | ||
| with grant.canonical_path.open("rb") as source: | ||
| content = source.read(budget + 1) |
There was a problem hiding this comment.
Bound the native read by the per-file cap
When a file grows after the preceding stat, this read is bounded only by the block's remaining budget, which can be nearly 1 GiB even though the per-file limit is 500 MiB. Fresh evidence in this revision is the new source.read(budget + 1) call: it can allocate and read roughly twice the allowed file size before _require_within_budget rejects it, potentially stalling or exhausting the backend; use one byte beyond the tighter of the remaining block budget and per-file cap.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The 500 MB per-file cap is already enforced on the stat() before open(), so read(budget+1) can only exceed it if the file grows mid-read, and it is bounded at about 1 GiB rather than unbounded.
Review summaryVerdict: useful. Real issue, and a genuine PR-caused CI break fixed in a3cb8e1. Before / afterBefore, several desktop drop zones did not accept a native drag: the disabled seed zone was The CI break was this PR's
Verified on a clean tree at head: Separately, On the 10 Codex itemsSix genuine, four rejected. Five of the six were already fixed on the branch; each thread names the commit. Two rejections are worth the detail:
I also independently recomputed the video cap rather than trusting the number: One genuine item deliberately not fixed hereThe native batch TTL item is real, and I measured it rather than agreeing on principle. The TTL is stamped once in It is not fixed in this PR because the available fix is 220 lines of Rust that change token lifetime semantics, which does not belong in a drop-zone PR. The narrower alternative is to make seed extraction asynchronous the way RAG ingestion already is, which removes the exposure without touching the token model. Going out separately either way. SimulationThe browser matrix genuinely applies here, so it was driven rather than asserted: 33 scenarios across Chromium, Firefox and WebKit, plus engine-level CDP drags. Edge is Chromium, so that is three engines, not four. The disabled-zone fix was reproduced at engine level in all three - with the pre-PR markup a real file drag is silently swallowed and Rust Backwards compatibility: Below the bar, noted
|
|
Codex Review: Didn't find any major issues. Bravo. Reviewed commit: ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
UI evidenceBEFORE is the merge base Read this first: the regression itself is not in the picture. #9036 is a Tauri problem. The desktop webview never sees its own drop events, so a zone wired only to What is web-reachable is the rewrite the fix required. The project Sources panel used to carry a bare Both drags are synthesised with a real Facts that moved, read off the same servers that were photographed:
The rest of the panel is unchanged between the halves, which is what makes the border the only thing that moved in shot 1. What this pair does not cover:
Useful, with the caveat stated: it demonstrates the web-side behaviour change in one of the rewritten zones, and it does not and cannot demonstrate the desktop drop this PR exists to fix. |
# Conflicts: # studio/backend/tests/test_data_recipe_seed.py # studio/frontend/src/features/rag/components/project-sources-panel.tsx
for more information, see https://pre-commit.ci
|
Merged current Two conflicts, both resolved in favour of keeping both sides:
After the merge: backend seed tests 18 passed 1 skipped, |
|
Codex approved this at a3cb8e1 and the head is two commits ahead, so the convergence check reports the approval as sitting on an ancestor. Those two commits are a merge of main and a pre-commit.ci formatting pass, not changes to this PR. Diffing the PR's own content against its merge base at both revisions: the approved diff and the head diff are identical apart from hunk line offsets and two blank lines around a test class that the merge shifted. Nothing this PR changes has moved since the approval, so I am treating it as approved at head rather than spending another review round on it. |
|
Could you get Backend CI (Python 3.13, Repo tests CPU) and the two Frontend CI jobs green, since those pass on main? |
…9057) * Studio: attach video to a chat, and say why when it is unavailable llama.cpp takes video through its OpenAI-compatible chat endpoint as an `input_video` content part, but only when the mmproj declares video, the binary was built with video support and ffmpeg is installed. It reports that verdict at /props under modalities.video. Nothing about the GGUF alone can tell us, so that is what Studio now reads. Frontend: video joins the drop classifier and its own pending queue beside images and audio, and a VideoAttachmentAdapter takes one clip per message from the picker or a drop. When the model cannot take video the adapter names all three possible causes instead of letting llama-server refuse the request later. Backend: video_base64 on the chat request is forwarded whole as an input_video part, since llama-server owns the frame sampling and there is nothing useful to transcode. has_video_input rides the same path as has_audio_input out to the model row. Compare mode is left out on purpose: video_base64 targets the single loaded GGUF, so at most one side could answer. Dropping a clip there now says so rather than ignoring the file. Size caps line up across the three hops (64 MB in the desktop reader, in the composer and in the route) so no hop accepts what the next refuses. * Studio: carry the video capability through, and cover the passthrough path Three review findings, all correct. syncModelCapabilities took has_video_input but never copied it into the row, and /api/models/list omits it for the active GGUF, so the adapter read false after every load and refused video even when /props reported it. The feature did not work in its main path. The tool and response_format passthrough returns before the injection and forwards an explicit field list, so a clip rode along nowhere and the model answered without it. Refused now, the way audio already is there. The size cap floored the base64 inflation, so a clip of exactly the size the composer allows was refused with a 413, and the data URI header was counted against the payload. Padded ceiling, measured after stripping. * Studio: carry the video capability through every hop, and refuse it where it cannot be served Three separate places map backend capability flags onto a model row and each one dropped the video flag: the direct status adoption, the queued-run capability Pick, and (fixed earlier) syncModelCapabilities. The adapter reads that row, so any of them leaves video refused on a model that supports it. Covered by a rule rather than three spot checks. Injection lives in the GGUF branch, so an external provider or a local transformers model answered as if no clip were attached. Both now refuse, as does token counting, which cannot inject the frames it would need to count. The size check also moved ahead of the automatic model switch so an oversized clip does not evict a working model before the 413, and video now votes in the pre-switch projector requirement alongside audio. The video drain's read-failure toast said 'audio', inherited from the audio drain it was cloned from. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: name the attached modality in the pre-switch refusal Adding video to require_vision made the shared rejection reachable for a request carrying only a clip, but its text is fixed at 'image or audio input', so the user who attached a video was told about modalities the request never carried. The label now follows what is attached, and defaults to the existing wording so the image-only callers are unchanged. * Match the document-refusal test to the message it now returns * Send the API key on the /props readback and skip video in the context recount The /props probe went out without an Authorization header, so under UNSLOTH_DIRECT_STREAM=1 llama-server answered 401 and video capability never came back. Context recount already bails on images and audio because toOpenAIMessages has no branch for them; video has the same property and was missing the bail, so the usage bar priced a text-only prompt and stringified megabytes of base64 on the UI thread. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Send a picked clip under the container its extension names The accept list carries extensions as well as mime types because the browser's answer is unreliable for mkv and some mov files, and the picker takes those files on the extension. Only an empty type was being replaced, so a clip the browser called application/octet-stream kept that type into the attachment, and the request builder recognises a file part only when its mimeType matches ^video/. The clip was attached, sent and dropped, and the model answered as though nothing were there, which is the silent drop this PR exists to remove. The table mirrors the one in native_intents.rs, so a clip read by the desktop reader and one picked in the browser reach the route the same way. --------- Co-authored-by: shimmyshimmer <182633334+shimmyshimmer@users.noreply.github.com> Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bf2e3bb9ef
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| block_dir = UNSTRUCTURED_UPLOAD_ROOT / block_id | ||
| # Reads 0 for a block with no directory yet, so this does not create one for | ||
| # an upload that is about to be refused. | ||
| budget = UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_BYTES - _get_block_total_size(block_dir) |
There was a problem hiding this comment.
Recheck the block quota after the awaited upload read
When two large multipart uploads target the same block concurrently, both can calculate the same remaining budget here and then yield in await file.read(). Each request subsequently validates against that stale snapshot and writes synchronously, allowing their combined size to exceed UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_BYTES. Previously the total was measured after the awaited read; recompute/reserve the quota after that await (with serialization if multiple workers can write the block) before saving.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not reachable here. The drop zone uploads strictly one at a time (unstructured-drop-zone.tsx awaits uploadUnstructuredFile inside its for loop), and a block's dialog is a single modal in a single-user local app, so two multipart uploads never target the same block_id concurrently in a real session. Reaching this needs two clients dropping into the same block at the same instant, and the outcome is a soft total-size cap overshooting, not a crash or lost data. Worth noting the pre-existing code had the same shape: the total was measured and the file written in one synchronous stretch, but nothing serialised two requests either.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 104ea3c40c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Treat video as requiring an image-capable projector
When automatic model switching is enabled and a video request names an audio-only GGUF, this sets require_vision but leaves _needs_image false. _target_is_vision(..., require_image=False) deliberately accepts any companion projector, including an audio-only one, so Studio evicts the currently working model, loads the incompatible target, and only then rejects it when /props reports no video support. Include video in the image-capability requirement so audio-only projectors are rejected before the swap.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
This needs auto-switch turned on (DEFAULT_OPENAI_AUTO_SWITCH_ENABLED is False) plus a video request that names an audio-only GGUF, a target that cannot serve the clip either way: the request 400s identically, only after a swap instead of before it. The pre-swap gate is there to stop a text-only target evicting a working model, and that case still resolves to no mmproj and is rejected before the load.
for more information, see https://pre-commit.ci
…i#9342) * Stub the two helpers the sliced harnesses started importing Backend CI's "Repo tests (CPU)" has been red on main since 588405d, with 111 failures in two files. Both are the same defect, and neither is about what the tests claim to be testing. These harnesses replay real studio source with its import block stripped, so every name the sliced region calls has to be defined in the harness. Two landed without one: refresh-context-usage.ts findLatestUserVideoBase64 (unslothai#9056, decline to price a prompt carrying video) chat-runtime-store.ts resolvePreserveThinkingOnLoad Both product changes are right. The first is the more dangerous shape: with no stub the replayed body throws a ReferenceError, the effect bails, `counts` stays 0, and the assertion reads "the empty New Chat view must be priced exactly once" -- a pricing bug that does not exist. 41 tests failed that way, and bisecting main was the only way to see it, because the message points at the wrong thing. test_chat_autoload_failure_gate.py already guards this and said exactly what was missing, which is why its 70 failures named resolvePreserveThinkingOnLoad outright. test_new_chat_context_recount.py had no such guard; it does now, built the same way -- parse the real import list, assert the harness defines each name. Mutation-tested: dropping the video stub turns it red with the name in the message. The preserve-thinking stub is the real resolver's rule verbatim, since no scenario here sets a stored preference. tests/studio: 0 failures, from 111. * Count nesting when finding install.sh's GPU-detection fallback With the harness stubs in place, Repo tests (CPU) is down to one failure, and it is the last of the same kind: correct product code reported as a defect by a scan that reads the file too loosely. test_no_torch_backend_auto_outside_fallback allows --torch-backend=auto only inside install.sh's "GPU detection failed" branch, and located the end of that branch as the first line equal to `fi` after the comment. unslothai#8670 put a `case` into the branch to choose the desktop install spec, with an `if`/`fi` inside one of its arms. That `fi` is now the first one, so the block ended four lines early and the branch's own install call -- line 5280, the one the test exists to permit -- was reported as a primary path using the flag. The end is now found by counting nesting. `case` counts too: it closes with `esac`, so an `if`/`fi` inside a case arm would still throw off a counter that only tracked `if`. Added test_the_fallback_range_reaches_the_end_of_the_branch, because the bug here was in the range and not in what the range was used for, so nothing was checking the range itself. It pins both ends: the block contains the branch's first `if` and both of its --torch-backend=auto calls, and does not run past its closing `fi`. Mutation-tested both ways. Adding --torch-backend=auto outside the branch still fails the original assertion; reverting the nesting count to the first-`fi` behaviour fails the new one. tests/python/test_cross_platform_parity.py 78 passed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
… leg (unslothai#9348) * Stub the two helpers the sliced harnesses started importing Backend CI's "Repo tests (CPU)" has been red on main since 588405d, with 111 failures in two files. Both are the same defect, and neither is about what the tests claim to be testing. These harnesses replay real studio source with its import block stripped, so every name the sliced region calls has to be defined in the harness. Two landed without one: refresh-context-usage.ts findLatestUserVideoBase64 (unslothai#9056, decline to price a prompt carrying video) chat-runtime-store.ts resolvePreserveThinkingOnLoad Both product changes are right. The first is the more dangerous shape: with no stub the replayed body throws a ReferenceError, the effect bails, `counts` stays 0, and the assertion reads "the empty New Chat view must be priced exactly once" -- a pricing bug that does not exist. 41 tests failed that way, and bisecting main was the only way to see it, because the message points at the wrong thing. test_chat_autoload_failure_gate.py already guards this and said exactly what was missing, which is why its 70 failures named resolvePreserveThinkingOnLoad outright. test_new_chat_context_recount.py had no such guard; it does now, built the same way -- parse the real import list, assert the harness defines each name. Mutation-tested: dropping the video stub turns it red with the name in the message. The preserve-thinking stub is the real resolver's rule verbatim, since no scenario here sets a stored preference. tests/studio: 0 failures, from 111. * Let the duplicate-load fast path read the binary it now has to compare Backend CI's "(Python 3.13)" leg has been red on main since e99dfe5, "Studio: use a custom llama.cpp build (unslothai#9292)", with three failures. All three are the tests, not the change. Two in test_metal_paravirtual_guard.py forbade BOTH _kill_process and _find_llama_server_binary on a duplicate /load, under one message: "tore down a healthy server on a duplicate /load". Only the first is teardown. unslothai#9292 put _binary_changed_since_launch() on the fast path, which reads the current llama-server to see whether a different one is installed than the live process was launched from -- exactly what a custom build has to check, since an identical request must still reload when the binary underneath it changed. So the read is correct and forbidding it reported a lookup as a teardown. _kill_process stays forbidden; the binary lookup now returns a fixed path, so the revision comparison sees no change and the fast path is still what is exercised. One in test_vram_budget_settings.py asserted the compacted source of _serial_load_scope ENDS WITH self._vram_fraction_pending=None. unslothai#9292 added a sibling clear, _binary_revision_pending, so the release is still in the finally but no longer last in it. The assertion now checks it is inside the finally, which is the property the test names. Mutation-tested: deleting the vram release from the finally still turns it red. Note on the leg name: 3.11 is not a second copy of this suite. The matrix is 3.13 scope=full and 3.11 scope=floor-spot-check, about 40 tests in three files, so "3.11 green, 3.13 red" is not a version difference -- 3.13 is simply the only leg that runs these. Reproduced locally on 3.13.12. test_metal_paravirtual_guard.py 119 passed, test_vram_budget_settings.py 90 passed. Test files only; no product code touched.




Fixes #9036.
What is going on
Tauri delivers OS file drops window-wide and suppresses the webview's own drop events. A zone wired only to
onDroptherefore does nothing in the desktop app: no drag-over border, and the file is silently ignored.Native drop routing (#8265) was only ever adopted in two places,
image-dropzone.tsxand the create-project dialog. Every other file drop zone still relied on HTML5 handlers that never fire on desktop:if (isTauri) returnThat is why it reads as random. The same file works when it lands on the chat, which the window-wide handler covers, and does nothing anywhere else.
The fix
A shared
useNativeFileDrophook claims the native drop for an element and hands back drag-over state plus the HTML5 handlers the web build still needs, so a caller keeps the singleonFilespath it already had. Every dead zone above now uses it.Two details worth calling out:
nativePathLeasethe same way the RAG upload routes already do.Silent discards
Two more paths had the same symptom as the issue and are fixed alongside it:
if (disabled) return, so the file vanished with no message. It now says the sources are still uploading.Regression test
native-dropzone-coverage.test.tswalkssrc/and fails if any file reads files out of a drag payload without either claiming the native drop or explicitly deferring to the window handler. It caught the reference picker while this branch was being put together, which is exactly what it is for.Testing
npm run typecheckclean,npm test2941 passing (5 new)cargo checkcleanpytest tests/test_data_recipe_seed.py13 passingnativePathLeaseThe native drop path itself needs a Tauri build to exercise by hand, so that part rests on the unit tests rather than a manual drag.