feat(upload): take a file dropped anywhere on the schedule page - #3334
vpetersson-bot wants to merge 10 commits into
Conversation
Dragging an image or video onto the asset list did nothing useful: the only drop target was the dashed zone inside the add-asset modal, so a drop on the page itself fell through to the browser, which navigated the tab to the local file and took the page with it. The drag listeners now live on the window, bound from the home container. A drag carrying files is claimed on dragenter/dragover (a drop only fires on a target that cancelled the preceding dragover, and an unclaimed drag is the navigation above), a full-page overlay says the page will take it, and the drop runs through the same sequential uploadFiles() batch path the file picker uses. The add modal opens on its upload pane so a dropped file gets the same "File 2 of 5" progress a picked one does. Drops released over the edit, preview, bulk-edit or delete overlays are claimed but ignored -- a file dropped over one of those is not aimed at the asset list, and opening the add modal underneath would bury the form in progress. Which pane the add modal shows moves from a nested x-data onto homeApp as addTab, because a page-level drop has to open the modal on the upload pane and the parent cannot reach into a child scope. The modal dropzone keeps no drop handler of its own -- the window listeners own every drop on the page, so there is exactly one path into uploadFiles() -- and its hover/dragover fill becomes --color-link-wash; the literal it replaces was a light lavender that stayed light in the dark theme. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
There was a problem hiding this comment.
🟡 Changes recommended
An active upload can still show the drop overlay while new drops are ignored.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds page-wide drag-and-drop uploads to the Schedule Overview.
Changes:
- Routes page drops through the existing sequential upload flow.
- Adds drag feedback, shared modal tab state, and styling updates.
- Adds unit and browser-level coverage.
File summaries
| File | Summary |
|---|---|
tests/test_app.py |
Adds browser-level drag-and-drop coverage. |
src/anthias_server/app/templates/home.html |
Adds window-level listeners and upload overlay. |
src/anthias_server/app/templates/_asset_modal.html |
Uses shared upload-tab state. |
src/anthias_server/app/static/src/home.ts |
Implements drag routing and upload handling. |
src/anthias_server/app/static/src/home.test.ts |
Tests drag-and-drop behavior. |
src/anthias_server/app/static/sass/_styles.scss |
Styles drop feedback and dropzone states. |
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Hiding the add modal during an upload leaves uploadState set and mode null, so the page keeps listening for drops -- but dropFiles() will not start a second batch over a running one. The overlay still read "Drop to upload", promising something that would not happen. It now reads the upload state itself and says the batch is already running. The drop is still taken far enough to reopen the modal onto that batch, which is the answer to "why was my file ignored", so refusing the drop outright would have been the worse fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
There was a problem hiding this comment.
🔵 Needs a closer look
A moderate issue remains with drops over the preview iframe bypassing the window handler.
Review details
Suppressed comments (1)
src/anthias_server/app/static/src/home.ts:709
- The window-level handler cannot receive
dropevents whose target is the sandboxed<iframe>used by the preview modal: drag events do not bubble across a browsing-context boundary. Consequently, dropping a file over a webpage/streaming preview can navigate the iframe instead of being claimed and ignored as this path intends. Add a drag/drop shield or handlers for the preview surface (including the iframe) so the parent page prevents that default before the drop reaches the frame.
onPageDrop(this: HomeAppData, event: DragEvent) {
if (!dragCarriesFiles(event)) return
// Claimed even when the drop is refused below: whatever is on
// screen, letting the browser navigate to the dropped file would
// throw the page away.
event.preventDefault()
- Files reviewed: 6/6 changed files
- Comments generated: 0 new
- Review effort level: Lite
Drag events do not cross a browsing-context boundary, so the page-wide drop handlers never see a file released over the preview modal's sandboxed iframe. The frame fell back to the browser default and navigated away from the asset being previewed to the dropped file -- the one surface on the page the window listeners cannot claim. The frame drops out of hit-testing for the length of a drag, handing those events back to the page underneath, which refuses the drop without navigating anything. Only while a drag is in flight; the pointer cannot reach the frame without first crossing the modal overlay, which is the page's own document, so pageDragActive is already set by then. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
There was a problem hiding this comment.
🔵 Needs a closer look
Address iframe drop protection and reset page drag state when closing the modal.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
src/anthias_server/app/static/src/home.ts:456
closeModal()no longer clearspageDragActiveorpageDragDepth. If the add modal is hidden while a file is over its dropzone (for example, Esc/backdrop before the browser emitsdragleave), the page-wide state can remain active with a positive depth after the modal disappears, leaving the upload overlay stuck and affecting the next drag. Reset both drag fields when closing the modal, independently of the upload state.
src/anthias_server/app/static/sass/_styles.scss:1114
- A file drag can enter this iframe directly from the desktop, in which case
dragenter/dragover/dropare dispatched in the iframe browsing context and never reach the parentwindow.pageDragActivetherefore stays false, this class is never applied, and the iframe's default drop behavior can navigate its preview instead of the page claiming and ignoring the file as intended. The protection needs a parent-side capture/shield that is active before entering the iframe, plus an end-to-end regression case for this path.
// Only while a drag is in flight; the frame has to stay interactive
// the rest of the time. The pointer cannot reach the frame without
// first crossing the modal overlay, which is the page's own
// document, so pageDragActive is already set by then.
&.is-drag-shielded { pointer-events: none; }
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
The shield is a computed style that only exists mid-drag, so nothing in the unit suite can see it. Opens a webpage preview, checks the frame is interactive with no drag in flight, then dispatches a file drag on the modal overlay the pointer has to cross to reach the frame and waits for the frame to leave hit-testing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
|
Both points from the latest pass, addressed in bd357cd: Preview iframe — the end-to-end case you asked for is now in On the "enter the iframe directly from the desktop" path: the frame cannot be reached without the parent document seeing the drag first. A drag from the desktop starts outside the browser, so the pointer has to cross the content-area edge, and the frame never touches that edge — the nested overlay carries
|
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved iframe drag-navigation risk and stale drag state when leaving the document or closing the modal.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
src/anthias_server/app/static/src/home.ts:701
- The depth counter is not cleared when the drag leaves the top-level document. A file drag can enter a nested child, incrementing this counter more than once, and then leave the browser (or be cancelled) with only an outside
dragleave; decrementing once leavespageDragActivestuck true and can also leave the iframe shielded for subsequent interactions. Reset the drag state when the leave is outside the document (and/or on the appropriate window-cancel signal), rather than only flooring the counter at zero.
onPageDragLeave(this: HomeAppData, event: DragEvent) {
if (!dragCarriesFiles(event)) return
// Floor at zero: a dragleave with no matching dragenter (the
// recovery case above) would otherwise push the counter negative
// and leave the overlay stuck on for the rest of the session.
this.pageDragDepth = Math.max(0, this.pageDragDepth - 1)
if (this.pageDragDepth === 0) this.pageDragActive = false
src/anthias_server/app/static/src/home.ts:456
- Unlike the old
closeModal()path, this no longer clears the page drag state. Esc/backdrop/Cancel can close the modal before the browser emitsdragleave; thenpageDragActiveandpageDragDepthremain set, leaving the full-page feedback visible and making the next drag start with a stale depth. Reset both fields when closing the modal, as the previousdragActivereset did.
this.mode = null
this.editAsset = null
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
Both branches hoisted the Add modal's pane selection out of its nested x-data and onto homeApp -- master (#3329) so openAdd() can reset the pane between opens, this branch so a file dropped on the page can open the modal straight onto the upload pane. Same move, two names. Takes master's `tab` and drops this branch's `addTab`. The drop path now goes through openAdd() and overrides the tab afterwards, so a dropped file gets the same fresh pane every other way in does -- no stale URL in the From-URL box, no Apps pane parked on the last config form -- rather than reaching past the reset that #3329 just added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
The shield arms off a parent-document dragenter, and the frame never touches the viewport edge, so a drag from the desktop always crosses the page first. Drag events are sampled, though, so a fast enough flick can put its first sampled position inside the frame and skip the parent. The frame then takes the drop and navigates itself. It is sandboxed without allow-top-navigation, so the page behind is untouched and reopening the preview restores it. Closing the gap costs either preview interactivity or a click-to-interact shim with its own pointer plumbing, both of which cost more than the glitch. Recorded here so it is a known limit rather than an oversight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
There was a problem hiding this comment.
🟡 Changes recommended
Critical issues remain with Firefox file drags and direct drops over the preview iframe.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Lite
|
Rebased onto master and all review threads are answered — but one of them is a judgement call I'd rather you made than have buried in a resolved thread, so restating it here. Accepted limitation: the preview modal's iframe. Drag events don't cross a browsing-context boundary, so the page-wide drop handlers can't see a file released over a webpage preview. The frame drops out of hit-testing for the length of a drag, which covers the ordinary case (the frame never touches the viewport edge, so a drag from the desktop crosses the page first and arms the shield). The gap is that drag events are sampled: a fast enough flick can land its first sampled position inside the frame and skip the parent. When that happens the sandboxed frame navigates itself to the dropped file. No Closing it completely costs one of:
I judged both more expensive than the glitch and recorded it as a known limit in the SCSS comment. Say the word if you'd rather have either one. The other two findings from the last pass are settled in-thread: the mid-batch overlay wording was real and is fixed; the Firefox |
There was a problem hiding this comment.
🔵 Needs a closer look
closeModal() must reset page drag state to avoid stale drag UI and shielding.
Review details
Suppressed comments (1)
src/anthias_server/app/static/src/home.ts:474
closeModal()no longer clearspageDragActive/pageDragDepth. If a file drag is cancelled or the modal is hidden before the browser deliversdragleave(the stale-highlight case the removeddragActive = falsehandled), the page remains in the drag state: reopening Add highlights its dropzone, and the page overlay/iframe shield can stay armed indefinitely. Reset both fields here; a subsequentdragoverwill re-arm them if the drag is genuinely still active.
this.mode = null
this.editAsset = null
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
closeModal() reset the modal's own fields but left pageDragActive and pageDragDepth set. A drag cancelled with Escape, or a modal closed before the browser delivers the matching dragleave — the stale highlight case the page-level handler exists for — then leaves the page believing a drag is still in progress: reopening Add shows its dropzone lit, and the overlay/iframe shield can stay armed indefinitely. Resetting costs nothing, since a genuinely ongoing drag re-arms both on the next dragover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
There was a problem hiding this comment.
🔵 Needs a closer look
Resolve drag-state cleanup across all overlay close paths and fix the Playwright shield-state predicate.
Review details
Suppressed comments (2)
src/anthias_server/app/static/src/home.ts:479
- This reset only runs when the Add/Edit modal calls
closeModal(), but the new drag state is also active while preview, bulk-edit, and bulk-delete overlays are open. Those overlays close through other paths that do not clearpageDragDepth; if Escape/cancellation closes one before its matchingdragleave, the stale depth makes the next drag leavepageDragActive(and the overlay/iframe shield) stuck on. Put this reset in a shared helper and invoke it from every overlay close path.
// Clear the page-level drag state too. A drag cancelled with
// Escape, or a modal closed before the browser delivers the
// matching dragleave, otherwise leaves the page believing a drag
// is still in progress: reopening Add shows its dropzone lit,
// and the overlay/iframe shield can stay armed indefinitely. A
tests/test_app.py:1257
- This interpolates the arrow-function source without invoking it, so Playwright evaluates a function object (truthy) and returns immediately; the test never verifies that the iframe actually switches to
pointer-events: none. Invoke the predicate in the expression so this regression test waits for the shield state.
f'{pointer_events.strip()} === "none"', timeout=DEFAULT_TIMEOUT_MS
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
closeModal() was the only path that reset pageDragActive/pageDragDepth, but the page-level dragenter fires while the preview, bulk-edit, bulk-delete and delete-confirm overlays are open too. Closing one of those mid-drag (Escape before the browser delivers the matching dragleave) stranded the counter: the page overlay and the preview iframe's pointer-events shield stayed armed for the rest of the session, and the next Add opened with its dropzone lit. The reset moves into resetPageDrag(), called from every close path. The two overlays that closed by assigning their flag inline from the template get closeDelete() / closeBulkDelete() methods so they can. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
There was a problem hiding this comment.
🔵 Needs a closer look
Drag-state recovery can leave the depth counter inconsistent and hide the overlay during an active file drag.
Review details
Suppressed comments (1)
src/anthias_server/app/static/src/home.ts:740
- The recovery path reactivates the overlay while leaving
pageDragDepthat zero. If the pointer then crosses a child, its matchingdragenter/dragleavechanges the counter from 1 back to 0 and hides the overlay even though the file is still over the page; it will only reappear on the nextdragover. Seed the counter when rearming the state (at least 1) so the depth logic remains valid after a misseddragenteror an overlay reset.
if (!this.pageDragActive) this.pageDragActive = true
- Files reviewed: 8/8 changed files
- Comments generated: 0 new
- Review effort level: Lite
onPageDragOver re-arms pageDragActive after a dragenter that never arrived, but left the counter at 0. The next enter/leave pair — the pointer crossing one table row — then ran 0 -> 1 -> 0 and hid the overlay while the file was still over the page, flickering for the rest of the drag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
There was a problem hiding this comment.
🔵 Needs a closer look
Add coverage for the bulkDeleteOpen refusal path before approval.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/anthias_server/app/static/src/home.test.ts:930
- The new refusal matrix covers edit, preview, bulk-edit, and the single-asset delete prompt, but not the
bulkDeleteOpenbranch inacceptsPageDrop()(home.ts:717-719). Since this PR promises that drops over each competing overlay are claimed and ignored, add the bulk-delete case here so that branch cannot regress without a test.
- Files reviewed: 8/8 changed files
- Comments generated: 0 new
- Review effort level: Lite
The refusal matrix covered every competing overlay except bulkDeleteOpen, so that branch could regress untested. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
|



What
Dragging an image or video onto the Schedule Overview now uploads it. Previously the only drop target was the dashed zone inside the add-asset modal, so a file dropped on the page itself fell through to the browser, which navigated the tab to the local file and took the page with it.
How
windowfrom the home container, so a drop on the table, the navbar or the margin beside it all count. A drag carrying files is claimed ondragenter/dragover— adroponly fires on a target that cancelled the precedingdragover, and an unclaimed drag is the navigation described above.pointer-events: noneso it cannot become the drag's own target.uploadFiles()batch path the file picker uses, and opens the add modal on its upload pane so a dropped file reports the same "File 2 of 5 · 40%" progress a picked one does.x-dataontohomeAppasaddTab, because a page-level drop has to open the modal on the upload pane and the parent cannot reach into a child scope.uploadFiles().--color-link-wash. The literal it replaces was a light lavender that stayed light in the dark theme, making the hovered zone the brightest thing on a dark page.Testing
bun test— 144 pass. Nine new cases cover the routing: which drags are claimed from the browser, the depth counter that keeps the overlay from flickering between table rows, the stray-dragleavefloor,dragoverrecovery, a page drop reaching the upload path, and a drop over each competing overlay being refused but never navigated.uv run pytest -m "not integration"— 2055 pass, 3 skipped.uvx ruff check .,ruff format --check,mypy .clean (the twotools/image_builderimport-not-found errors are pre-existing and come from optional build deps absent in this environment).tests/test_app.py(a real file dropped on#asset-tablepersists as an asset; a file drag is cancelled on bothdragenteranddragover). They need the Docker test stack, which this runtime could not build — the host is out of disk — so they have not been executed here and CI is the first run.🤖 Generated with Claude Code