Skip to content

feat(upload): take a file dropped anywhere on the schedule page - #3334

Open
vpetersson-bot wants to merge 10 commits into
masterfrom
agent/arm64-claude/b20f4cc44f6b
Open

vpetersson-bot wants to merge 10 commits into
masterfrom
agent/arm64-claude/b20f4cc44f6b

Conversation

@vpetersson-bot

Copy link
Copy Markdown
Contributor

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

  • Drag listeners are bound on window from the home container, so a drop on the table, the navbar or the margin beside it all count. 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 described above.
  • A full-page overlay ("Drop to upload") appears mid-drag. It is shown only when a drop would actually be taken, and it is pointer-events: none so it cannot become the drag's own target.
  • The drop runs through the same sequential 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.
  • 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().
  • Drive-by: the dropzone's hover/dragover fill becomes --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-dragleave floor, dragover recovery, 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 two tools/image_builder import-not-found errors are pre-existing and come from optional build deps absent in this environment).
  • Two Playwright cases are added to tests/test_app.py (a real file dropped on #asset-table persists as an asset; a file drag is cancelled on both dragenter and dragover). 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

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>
Copilot AI lite review requested due to automatic review settings September 15, 2026 13:51
@vpetersson-bot
vpetersson-bot requested a review from a team as a code owner September 15, 2026 13:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread src/anthias_server/app/static/src/home.ts
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>
Copilot AI review requested due to automatic review settings September 15, 2026 13:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 drop events 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>
Copilot AI review requested due to automatic review settings September 15, 2026 14:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 clears pageDragActive or pageDragDepth. If the add modal is hidden while a file is over its dropzone (for example, Esc/backdrop before the browser emits dragleave), 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/drop are dispatched in the iframe browsing context and never reach the parent window. pageDragActive therefore 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>
Copilot AI review requested due to automatic review settings September 15, 2026 14:15
@vpetersson-bot

Copy link
Copy Markdown
Contributor Author

Both points from the latest pass, addressed in bd357cd:

Preview iframe — the end-to-end case you asked for is now in tests/test_app.py (test_preview_iframe_is_shielded_from_a_file_drag): it opens a webpage preview, asserts the frame is interactive with no drag in flight, then dispatches a file drag on the overlay and waits for the frame to leave hit-testing.

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 padding: calc(var(--spacing) * 6) and the frame sits inside a centred .modal-card below its header. Every pixel between the viewport boundary and the frame belongs to the parent document, which is where pageDragActive is set. A parent-side capture listener would not help anyway: capture does not cross a browsing-context boundary either.

closeModal() drag reset — deliberate, and re-adding it would be the regression. The old dragActive was set by handlers on the dropzone element, so hiding the dropzone stranded it; that is exactly why the reset existed. pageDragActive is driven by listeners on window, which outlive any modal: closing the add modal mid-drag leaves a drag that is still genuinely in flight over the page, and the page-wide overlay (shown on !mode) correctly takes over. The depth counter sits at 1 in steady state — dragenter on the new target fires before dragleave on the old one, which is the transient the counter absorbs — so the next dragleave, whether the drag leaves the window or is cancelled with Esc, returns it to zero. Clearing it on close would instead tear the overlay down under a live drag. onPageDragOver re-raises a lost highlight, and onPageDragLeave floors at zero, so neither direction can strand it; both are covered in home.test.ts.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 leaves pageDragActive stuck 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 emits dragleave; then pageDragActive and pageDragDepth remain 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 previous dragActive reset did.
      this.mode = null
      this.editAsset = null
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread src/anthias_server/app/static/sass/_styles.scss
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>
Copilot AI review requested due to automatic review settings September 15, 2026 14:57
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread src/anthias_server/app/static/src/home.ts
Comment thread src/anthias_server/app/templates/_preview_modal.html
Copilot AI review requested due to automatic review settings September 15, 2026 15:02
@vpetersson-bot

Copy link
Copy Markdown
Contributor Author

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 allow-top-navigation, so the page behind is untouched and closing the preview restores it — cosmetic and self-healing.

Closing it completely costs one of:

  1. pointer-events: none on the frame unconditionally — no scrolling a previewed page.
  2. A click-to-interact shim: starts armed, disarms on the first real pointermove over the card, re-arms on pointerleave. Works (a native file drag dispatches no pointer events), but it's new state plus pointer plumbing with its own failure modes.

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 application/x-moz-file one is a false positive — "Files" in dataTransfer.types is normative in the HTML Standard and Firefox has always shipped it.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 clears pageDragActive/pageDragDepth. If a file drag is cancelled or the modal is hidden before the browser delivers dragleave (the stale-highlight case the removed dragActive = false handled), 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 subsequent dragover will 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>
Copilot AI review requested due to automatic review settings September 16, 2026 16:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 clear pageDragDepth; if Escape/cancellation closes one before its matching dragleave, the stale depth makes the next drag leave pageDragActive (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>
Copilot AI review requested due to automatic review settings September 16, 2026 16:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 pageDragDepth at zero. If the pointer then crosses a child, its matching dragenter/dragleave changes 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 next dragover. Seed the counter when rearming the state (at least 1) so the depth logic remains valid after a missed dragenter or 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>
Copilot AI review requested due to automatic review settings September 16, 2026 16:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 bulkDeleteOpen branch in acceptsPageDrop() (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>
Copilot AI review requested due to automatic review settings September 16, 2026 17:02
@sonarqubecloud

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants