fix(ams): close the slot popup before it covers the filament dialog (#2631)

Tapping Configure on an AMS slot left the slot popup standing on top of
the filament type/colour dialog it had just opened, so both layers were
on screen at once.

The popup is portaled at z-[60] so it can escape the stacking contexts
sibling printer cards create on the dashboard (#1336), which also puts
it above ConfigureAmsSlotModal and LinkSpoolModal at z-50. Nothing
dismissed it: it is hidden only by the pointer leaving it, and a touch
device never sends that after the tap that opened it. On desktop the
next mouse movement cleared it, which is why this is a tablet report.

FilamentHoverCard and EmptySlotHoverCard now dismiss themselves before
running any action that opens a dialog or navigates away - Configure,
Assign Spool, Unassign Spool, and both Open in Inventory links. The
dismissal clears the pending timer as well, so a queued open cannot
resurrect the card over the dialog.

Actions that report progress inside the popup are unchanged: RFID
re-read, Load and Unload render their spinner there, and Copy UUID its
confirmation tick.
This commit is contained in:
maziggy
2026-07-22 14:04:56 +02:00
parent 41ad1d65c7
commit 21c6182c8d
5 changed files with 159 additions and 8 deletions
+1
View File
@@ -13,6 +13,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Orca Cloud profile sync now connects by approving a code instead of the copy-paste sign-in** — Connecting Bambuddy to Orca Cloud used to mean opening an OAuth sign-in in a new tab, watching it redirect to a `localhost` URL that fails to load, then copying that dead URL out of the address bar and pasting it back into Bambuddy. That dance existed only because Orca's auth backend (Supabase) accepts no redirect target other than `localhost`, and the deliberately-broken redirect page confused nearly everyone who reached it. OrcaSlicer has since shipped a first-class external-app pairing API (the OAuth 2.0 Device Authorization Grant, RFC 8628), so the flow is now: click **Connect**, approve a short code on your Orca Cloud settings page, and Bambuddy pairs itself — no redirect, no paste, no client secret, and it behaves identically from a LAN IP, `localhost`, or behind a reverse proxy. Bambuddy requests **read-only** access (it only lists and views your Orca Cloud profiles), keeps the pairing alive with the API's rotating refresh tokens (validated end-to-end against Orca's staging and production servers), and stores nothing beyond the issued token pair. The profile list and detail views are unchanged, so nothing downstream of the connect step looks different. The old paste-based sign-in and the email/password fallback are removed. Points at production Orca Cloud by default; `ORCA_CLOUD_API_BASE` overrides the endpoint for testing.
### Fixed
- **The AMS slot popup stayed on screen and covered the filament dialog it had just opened (#2631, reporter @Jostxxl)** — Tapping **Configure** on an AMS slot left the slot's popup standing on top of the filament type/colour dialog, so the two layers overlapped: obscured content, competing backdrops, and controls of one layer sitting over the other. Most disruptive in the tablet operator workflow, where the printer is opened straight from a plate-clear scan and the next step is setting the loaded filament. **Root cause.** The slot popup is portaled to the page body at `z-[60]`, deliberately, so it can escape the stacking contexts that sibling printer cards create on the dashboard (#1336) — which also puts it *above* the Configure Slot and Link Spool dialogs at `z-50`. Nothing dismissed it: the popup is hidden only by the pointer leaving it, and a touch device never sends that event after the tap that opened it, so on a tablet it simply stayed up. On a desktop it self-cleared as soon as the mouse moved off the popup's bounds, which is why this only ever showed up on touch. **Fix.** The popup now closes itself before running any action that opens a dialog or navigates away — Configure, Assign Spool, Unassign Spool, and both Open in Inventory links, on loaded and empty slots alike — so exactly one dialog is ever on screen. Actions that report progress inside the popup (RFID re-read, Load, Unload, and Copy UUID with its confirmation tick) are deliberately unchanged, since none of them opens a dialog and closing would take their feedback with it. Covered by hover-card tests for loaded and empty slots: the popup is gone after each action, the action still fires, and it does not reappear once a pending open-timer elapses.
- **Slicing a single plate failed on a filament slot the plate doesn't even use, with no way to fix it from the UI (#2628, reporter @michaelklos)** — Slicing plate 2 of a local multi-plate 3MF for an A1 failed with **"filament preset (slot 1) is not compatible with printer Bambu Lab A1 0.4 nozzle"**. Slot 1 was labelled "Filament 1 (PLA) — not used by this plate", held `SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle`, and its dropdown was greyed out — so the slice was blocked by a slot the plate never touches and the user couldn't correct. Slicing **all** plates worked. **Root cause, two independent defects.** (1) The unused-slot substitution — which replaces the profile in every slot the plate doesn't paint with, so the slicer's validators don't judge the slice on slots its G-code never touches — always copied from **slot 1**. When slot 1 is itself the unused one, that's a no-op (the reporter's log even shows it: `Substituted slot-1 filament for unused slot(s) [1]`), and with several unused slots it actively spread slot 1's foreign profile across all of them — the same poisoning #1851 removed from the picker. (2) The dialog's printer-compatibility matcher only understood BambuStudio's short `@BBL <model>` tag. Profiles you save yourself carry the **full** printer name instead (`… @Bambu Lab H2D 0.4 nozzle`), which the matcher classified as "can't tell" — indistinguishable from compatible — so an H2D-scoped filament was offered in the main dropdown list, auto-picked for the slot on metadata score, and handed to the slicer. **Fix.** The substitution now copies from the plate's lowest **used** slot, so an unused slot can no longer block a slice regardless of what was baked into the source file; if a plate's used slots fall outside the submitted list, the picks are left untouched rather than substituted from a slot the plate doesn't use. And the matcher now reads both tag shapes — the same two forms the AMS slot dialog has parsed since #1623 — including a trailing `(Custom)` suffix and a stray earlier `@` in the name, so a profile scoped to another printer is never auto-picked and is grouped under **Other printers** where you can still choose it deliberately. A profile whose tag names no recognisable Bambu printer stays unclassified and keeps its place in the list, exactly as before. Covered by matcher tests (long-form mismatch and self-match, display-name-vs-short-code models, the nozzle filter, `(Custom)` suffix, the A1/A1 Mini alias, unrecognisable tags, stray `@`), picker tests (the reporter's registry: the H2D profile loses to the A1 one despite a better colour and tier score, but still wins for its own printer), and substitution tests (anchors on the first used slot, doesn't poison sibling unused slots, deterministic lowest-used anchor, support slots as anchor, and the out-of-range no-op).
- **A filament profile could be auto-picked for a printer it doesn't belong to when its name doesn't say which printer that is (#2628 follow-up)** — Slicing for a P2S failed with **"filament preset Bambu PLA Basic @BBL X1C 0.2 nozzle (slot 1) is not compatible with printer Bambu Lab P2S 0.4 nozzle"** — naming a profile that appeared nowhere in the slice dialog. The dialog showed `Overture PLA Matte @0.2` in every slot, including the one the plate actually uses; the slicer resolves that profile's inheritance chain and validates the X1C system profile at its root. **Root cause.** The dialog decides whether a profile fits the selected printer from the profile's own `compatible_printers` list, and falls back to reading the printer out of its NAME. This profile's name carries a nozzle size but no model, so the name fallback couldn't classify it — and the list, though present on the imported copy, is not shipped by every source: Bambu Cloud omits it from its listing on purpose (the per-profile endpoint is rate-limited), and Orca Cloud's listing carried it but Bambuddy only read the filament type and colour out of it. "Can't tell" is treated as usable, so the profile scored its way into the auto-pick for a printer it was never built for. **Fix.** Orca Cloud profiles now surface their own `compatible_printers` (it was already in the data Orca returns — no extra request), and the existing same-name bridge that lends Bambu Cloud entries their filament type and colour from another source now lends the compatible-printer list too, in both directions between the cloud sources. So whichever copy of a profile knows its printers teaches the ones that don't. As a last resort for profiles no source can classify, a bare `@<size>` tag in the name is now read as a nozzle size: it can rule a printer out (0.2 profile, 0.4 printer) but never rules one in, since a size says nothing about the model — and a number that can't be a nozzle (`@2026`) is ignored rather than guessed at. Profiles that still can't be classified keep their place in the list exactly as before; ones that can are grouped under **Other printers**, where you can still pick them deliberately. Covered by preset-listing tests (Orca list extraction incl. the bare-string form, malformed/empty lists staying unclassified, the bridge in both directions and for both process and filament, never overwriting a list an entry already has, no-donor staying unclassified, and the borrowed list being copied rather than shared across the per-user caches) and matcher tests (0.2-vs-0.4 rejection, matching size staying unclassified, the `0.2 nozzle` / `0.2mm` spellings, numeric `0.20` vs `0.2`, implausible sizes ignored, and model-bearing tags still taking the model path).
- **Switching off an accessory smart plug at the end of a print knocked the printer into "Unknown" and stalled the queue (#2629)** — With an end-of-print auto-off on a plug that powers a *filter fan* (not the printer), the printer flipped to **Unknown** the moment the plug switched off and the queue stopped dispatching to it until a manual **Force Refresh**. The printer itself never went anywhere — MQTT traffic continued a second later. **Root cause, two parts.** (1) Bambuddy treats *any* plug linked to a printer as that printer's power supply: every auto-off — time delay, temperature delay, the time-of-day schedule, a resumed-after-restart off, and a manual off from the plug card — immediately marked the printer offline, whether or not the plug feeds it. (2) That mark was **unrecoverable**. It forces `connected=False` and the state to `unknown`; `connected` heals on the very next MQTT message, but the state does not — the printer state is only rewritten when a status frame carries `gcode_state`, and the steady-state frames a P1S sends are partial. So `unknown` stuck until the next full status push, and the scheduler (which dispatches only to `IDLE`/`FINISH`/`FAILED`) treated the printer as permanently unavailable. **Fix.** The offline mark is now an explicitly *presumed* power cut: the state it overwrites is remembered, and the presumption is undone as soon as the printer sends another report on its own topic, since inbound traffic proves the power was never cut (a frame that does carry `gcode_state` still wins, and the recovery re-broadcasts so the UI un-greys). A printer whose power really was cut sends nothing, so it correctly stays offline — this also repairs the same stuck-`unknown` for a genuine printer plug whose MQTT resumes without a full push. On top of that, each plug now carries a **Powers the printer** toggle (shown when a printer is linked, in both the plug card and the add/edit dialog): leave it on for the plug that feeds the printer, turn it off for accessories — filter fan, chamber light, enclosure heater — and switching those off no longer touches the printer's state at all. Existing plugs are migrated as power plugs, so nothing changes until you say otherwise. The same flag also fixes the queue's power-on step, which used to pick whichever linked plug came first and could spend the whole power-on timeout waiting for a filter fan to boot a printer; it now picks the plug flagged as the power source. Covered by an end-to-end regression test that drives the real MQTT client, printer manager and scheduler through the reported sequence (accessory off → printer keeps talking → queue dispatches again; real power cut → stays offline), MQTT-client tests (presumed off remembers and restores the state, a partial frame recovers it, a real `gcode_state` overrides it, request-topic traffic does not count as proof of life, a reconnect discards the saved state, and a genuine second power cut is not undone), smart-plug tests (accessory plugs switch off without marking the printer offline on all four off-paths, power plugs keep the old behaviour), scheduler tests (power-plug selection), and migration tests on both SQLite and PostgreSQL.
@@ -326,6 +326,93 @@ describe('FilamentHoverCard', () => {
});
});
});
// The card is portaled at z-[60] — above ConfigureAmsSlotModal and
// LinkSpoolModal at z-50 — so a card left standing draws OVER the dialog its
// own button just opened. Mouseleave is the only thing that used to hide it,
// and a touch device never sends one after the tap that opened the card, so on
// a tablet it hung there indefinitely: two overlapping layers, competing focus.
describe('dismissal when an action opens a dialog (#2631)', () => {
it('closes the card when Configure is pressed, and still configures', async () => {
const onConfigure = vi.fn();
renderWithHover(
<FilamentHoverCard
data={baseFilamentData}
configureSlot={{ enabled: true, onConfigure }}
>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
fireEvent.click(screen.getByText(/configure/i));
expect(onConfigure).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
});
it('stays closed with no mouseleave, which is all a tablet ever gives us', async () => {
renderWithHover(
<FilamentHoverCard
data={baseFilamentData}
configureSlot={{ enabled: true, onConfigure: vi.fn() }}
>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
fireEvent.click(screen.getByText(/configure/i));
await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
// A pending show timer would resurrect the card on top of the dialog.
vi.advanceTimersByTime(1000);
expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument();
});
it('closes the card when Assign Spool is pressed', async () => {
const onAssignSpool = vi.fn();
renderWithHover(
<FilamentHoverCard
data={baseFilamentData}
inventory={{ assignedSpool: null, onAssignSpool }}
>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => expect(screen.getByText(/assign/i)).toBeInTheDocument());
fireEvent.click(screen.getByText(/assign/i));
expect(onAssignSpool).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
});
it('closes the card when Unassign Spool is pressed', async () => {
const onUnassignSpool = vi.fn();
renderWithHover(
<FilamentHoverCard
data={baseFilamentData}
inventory={{
assignedSpool: { id: 7, material: 'PLA', brand: 'eSun', color_name: 'Black' },
onUnassignSpool,
}}
>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => expect(screen.getByText(/unassign/i)).toBeInTheDocument());
fireEvent.click(screen.getByText(/unassign/i));
expect(onUnassignSpool).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
});
});
});
// EmptySlotHoverCard is the hover wrapper rendered for a physically empty
@@ -397,4 +484,41 @@ describe('EmptySlotHoverCard (#1133)', () => {
fireEvent.click(screen.getByText(/assign spool/i));
expect(onAssign).toHaveBeenCalledTimes(1);
});
// Same z-[60]-over-a-z-50-dialog problem as FilamentHoverCard (#2631).
describe('dismissal when an action opens a dialog (#2631)', () => {
it('closes the card when Configure is pressed, and still configures', async () => {
const onConfigure = vi.fn();
const result = render(
<EmptySlotHoverCard configureSlot={{ enabled: true, onConfigure }}>
<div>trigger</div>
</EmptySlotHoverCard>
);
fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
vi.advanceTimersByTime(100);
await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
fireEvent.click(screen.getByText(/configure/i));
expect(onConfigure).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.queryByText(/empty/i)).not.toBeInTheDocument());
});
it('closes the card when Assign Spool is pressed', async () => {
const onAssign = vi.fn();
const result = render(
<EmptySlotHoverCard onAssignSpool={onAssign}>
<div>trigger</div>
</EmptySlotHoverCard>
);
fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
vi.advanceTimersByTime(100);
await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
fireEvent.click(screen.getByText(/assign spool/i));
expect(onAssign).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.queryByText(/empty/i)).not.toBeInTheDocument());
});
});
});
+27 -1
View File
@@ -156,6 +156,19 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
timeoutRef.current = setTimeout(() => setIsVisible(false), 100);
};
// Dismiss the card immediately, for actions that open a dialog or navigate away.
//
// The card is portaled at z-[60] so it can escape sibling printer cards' stacking
// contexts, which puts it ABOVE ConfigureAmsSlotModal and LinkSpoolModal at z-50 —
// so a card left standing draws over the very dialog it just opened. Mouseleave is
// the only thing that normally hides it, and a touch device never sends one after
// the tap that opened the card, so on a tablet it stays up indefinitely (#2631).
// Clearing the timeout is not optional: a pending show timer would re-open it.
const dismiss = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setIsVisible(false);
};
// Cleanup timeout on unmount
useEffect(() => {
return () => {
@@ -328,6 +341,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
<button
onClick={(e) => {
e.stopPropagation();
dismiss();
navigate(`/inventory?spool=${spoolman.linkedSpoolId}`);
}}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
@@ -380,6 +394,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
<button
onClick={(e) => {
e.stopPropagation();
dismiss();
navigate(`/inventory?spool=${inventory.assignedSpool!.id}`);
}}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
@@ -393,6 +408,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
<button
onClick={(e) => {
e.stopPropagation();
dismiss();
inventory.onUnassignSpool?.();
}}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-red-100 dark:bg-red-500/20 hover:bg-red-200 dark:hover:bg-red-500/30 text-red-700 dark:text-red-400"
@@ -406,6 +422,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
<button
onClick={inventory.isAssigned ? undefined : (e) => {
e.stopPropagation();
dismiss();
inventory.onAssignSpool?.();
}}
disabled={!!inventory.isAssigned}
@@ -426,6 +443,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
<button
onClick={(e) => {
e.stopPropagation();
dismiss();
configureSlot.onConfigure?.();
}}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
@@ -534,6 +552,13 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
timeoutRef.current = setTimeout(() => setIsVisible(false), 100);
};
// See FilamentHoverCard.dismiss — same z-[60]-over-a-z-50-dialog problem, and the
// same missing mouseleave on touch (#2631).
const dismiss = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setIsVisible(false);
};
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
@@ -601,6 +626,7 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
<button
onClick={(e) => {
e.stopPropagation();
dismiss();
configureSlot.onConfigure?.();
}}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
@@ -612,7 +638,7 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
)}
{onAssignSpool && (
<button
onClick={(e) => { e.stopPropagation(); onAssignSpool(); }}
onClick={(e) => { e.stopPropagation(); dismiss(); onAssignSpool(); }}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
>
<Package className="w-3.5 h-3.5" />
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-ZS6qKcto.js"></script>
<script type="module" crossorigin src="/assets/index-tuTcZo3j.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-kl51qImb.css">
</head>
<body>