FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

refactor(ui): item-owned reveal state machine with explicit selection identity by lstein · Pull Request #9520 · invoke-ai/InvokeAI · GitHub

refactor(ui): item-owned reveal state machine with explicit selection identity - #9520

Merged
lstein merged 12 commits into
invoke-ai:mainfrom
lstein:fix/viewer-reveal-item-owned
Aug 24, 2026
Merged

refactor(ui): item-owned reveal state machine with explicit selection identity#9520
lstein merged 12 commits into
invoke-ai:mainfrom
lstein:fix/viewer-reveal-item-owned

Conversation

lstein commented Aug 20, 2026
edited
Loading

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #9475 (merged), delivering the restructuring suggested across its review rounds: reveal coordination stops being shared mutable state plus timing guards, and becomes an item-owned state machine fed by explicit selection identity.

Selections carry identity

$gallerySelection publishes {name, generation, isAutoSwitch} from one store listener. The generation advances on every selection dispatch; the auto-switch mark is set immediately before the handoff dispatches its selection and is spent by that selection alone. The name-keyed marker module and its settle listener are deleted — a marker keyed by item name cannot distinguish "the auto-switch landed" from "the user picked that item later", which is where several review rounds' defects came from.

The reveal is a state machine

idle | deferred | awaiting-media | revealing, owned by the viewer context — one instance for both preview components, so a click that switches media type is just another selection. The shared previous-item ref and its cleared-selection sentinel are gone. Three behaviors fall out:

  • Re-picking the item already on screen reveals. It changes no state and no rendered name — only the generation — which the old name comparison structurally could not see.
  • A reveal owed during a resolve window is deferred, not dropped, and an unpainted item's claim is held (awaiting-media) until its frame exists, bounded by a 1 s grace.
  • The machine owns the flag and its timers exclusively. The provider settles selections that land while neither preview is mounted (comparison mode) and resets the machine on teardown, so nothing outlives its owner.

Coverage

The mounted happy-dom suite from #9475's final round carries over, re-pointed at the machine wiring: one reveal running across an image→video component swap on its original clock, attach making the provider's settle a no-op while mounted, the repeat-click reveal, readiness from a real <video> loadeddata event (reset on element swap), StrictMode double-invocation, and provider teardown leaving no timer. The machine's sequencing has its own unit suite. Every wiring guarantee is mutation-verified.

History note

This was stacked on #9475; the squash merge orphaned that history, so the branch is a single transplanted commit on main. The bounded output refetch this branch once introduced merged with #9475 and is inherited here, not re-introduced.

lstein commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto the consolidated #9475, which now contains #9434 as well — so this branch picks up
#9434's thumbnail-gated preload, its error-path overlay clear and its onInvocationComplete
coverage. This PR's own work is unchanged at ~690 lines in / 690 out: the item-owned state machine,
media-gated reveals, and the bounded output refetch.

Two test adjustments came with the merge, both in the wiring tests:

  • CurrentVideoPreview.test.ts gains the assertion that the reveal is actually connected to
    $isTemporarilyShowingSelectedImage. An adversarial review of the fix(ui): viewer progress-image handoff: slow reveal, overlay stuck after socket drop, stale-image flash on quick re-generate #9434 merge found that nothing
    covered it — replacing that wiring with a no-op left all 26 wiring assertions and the full suite
    green with the reveal dead. On this branch the machine is built once in context.tsx for both
    previews, so the check lives there and is made once.
  • CurrentImagePreview.test.ts arrived from the merge still describing the controller this branch
    replaces, and is rewritten against the machine: sync, the item-named readiness, attach, and
    negative assertions that none of the three superseded implementations survive beside it.

Ready for review after #9475.

…nvoke-ai#9475

invoke-ai#9475 was squash-merged (e431d24), orphaning this stacked branch's
history, so this is a transplant rather than a rebase: one commit that
re-applies the branch's semantic delta on top of main's merged state —
including the parts of invoke-ai#9475 this branch predated (session disposal, the
restored socket suite, and the hook + happy-dom test architecture from
its final round).

What this branch changes, restated against main:

- Selections carry identity instead of being inferred by comparing names.
  $gallerySelection publishes {name, generation, isAutoSwitch} from one
  store listener; the auto-switch mark is set immediately before the
  handoff dispatches its selection and spent by that selection alone.
  The name-keyed marker module and its settle listener are deleted.

- The reveal is a state machine (idle | deferred | awaiting-media |
  revealing) owned by the viewer context — one instance for both preview
  components, so a click that switches media type is just another
  selection. The shared previous-item ref and its cleared-selection
  sentinel are gone. Re-picking the item already on screen now reveals:
  it changes no state and no rendered name, only the generation, which
  the old comparison could not see.

- The machine owns the revealed flag and its timers exclusively; the
  provider settles selections that land while neither preview is mounted
  and resets the machine on teardown.

- useSelectedItemReveal keeps its mounted happy-dom coverage, now
  asserting the machine wiring: one reveal running across a component
  swap on its original clock, attach making the provider's settle a
  no-op while mounted, the repeat-click reveal, media readiness from a
  real <video> loadeddata, and provider teardown leaving no timer.

Every wiring guarantee above is mutation-verified. The bounded-refetch
work this branch once carried merged with invoke-ai#9475 and is inherited, not
re-introduced.
lstein force-pushed the fix/viewer-reveal-item-owned branch from de42aa1 to 08b0fdf Compare August 23, 2026 00:42
lstein changed the title refactor(ui): item-owned reveal state machine, media-gated reveals, bounded output refetch refactor(ui): item-owned reveal state machine with explicit selection identity Aug 23, 2026

JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

To examine:

  • invokeai/frontend/web/src/features/gallery/components/ImageViewer/selectedItemReveal.ts:125-134: Grace timer starts before selected media exists, then reveals unconditionally. Slow DTO/media load shows stale/blank content; if it exceeds 3s, late item stays covered. Effect: click becomes wrong or invisible. Likelihood: Medium. Test: delay selected render past 3000ms, then assert reveal still occurs.

  • invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/gallerySelectionSource.ts:20-22: Treats every selectionChanged as a viewer selection. Ctrl-click removing a non-active item leaves the displayed item unchanged but still bumps generation and reveals it. Effect: progress overlay flashes off for unrelated multi-select bookkeeping. Likelihood: Medium. Test: active b, selection [a,b], dispatch [b] and assert no reveal.

Suggestions:

  • Instead of starting the media grace timer on selection, start it only once the selected item is rendered; cancel when generation changes.

  • Consider separating active-item changes from multi-selection mutations.

…election mutations as picks

Two review findings from JPPhoto:

- The media grace deadline used to run from the moment the selection
  landed, so a slow DTO/render lifted the overlay onto whatever the
  component was still showing (the previous item, or nothing), and an
  item that took longer than the grace to arrive then stayed covered,
  its reveal already spent. The awaiting-media state now enters without
  a timer; the deadline is armed the first time the component actually
  renders the claimed item without a painted frame, so it bounds only
  the media wait it was designed for (failed loads, undecodable
  codecs). Until the item renders, the claim simply stays outstanding —
  cancelled as before by any newer selection or by the overlay going
  away.

- The gallery selection source counted every selectionChanged dispatch
  as the user picking something, so a multi-select mutation that leaves
  the active item in place — ctrl-clicking a non-active item off the
  selection — bumped the generation and flashed the overlay off for a
  gesture aimed at a different item. The two kinds of dispatch are
  indistinguishable at the listener (ctrl-deselecting `a` from [a, b]
  and plain-clicking `b` produce identical actions and transitions), so
  the separation happens where the gesture is known: plain clicks in
  the grid now dispatch imageSelected (reducer-equivalent for a single
  name), and selectionChanged is a pure mutation the listener no longer
  matches — its mutations count only when they move the active item.

Both behaviors are covered by new machine/listener tests, each verified
to fail against the code it fixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

lstein commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator Author

Both confirmed and fixed in a21dec2 — thank you, both were real.

Grace timer vs. slow render. Exactly as you described: the deadline was anchored to the selection, so a slow DTO/render meant the timeout lifted the overlay onto stale or blank content, and the reveal was already spent when the item finally arrived. I took your suggestion: awaiting-media now enters with no timer, and the grace is armed the first time the component actually renders the claimed item without a painted frame — so it bounds only the wait it was designed for (failed loads, undecodable codecs). Until the item renders, the claim just stays outstanding; it's still cancelled by any newer selection (every transition clears the timer) or by the overlay going away. Your test is in as does not run the grace against an item that has not rendered yet: item renders long after the deadline would have expired, and the reveal still occurs.

One deliberate wrinkle: the armed timer is not cancelled if a later sync disagrees on renderedItemName — during an image↔video swap both previews are briefly mounted and sync alternately, and the stale component must not kill the deadline the matching one armed.

selectionChanged as a pick. Also as described. The listener alone can't fully separate the two gestures — ctrl-clicking a off [a, b] and plain-clicking b to collapse the selection produce byte-identical actions and state transitions — so I separated them where the gesture is known, per your suggestion: plain clicks in the grid now dispatch imageSelected (reducer-equivalent for a single name), and selectionChanged is a pure multi-selection mutation that the listener no longer matches. A mutation that moves the active item (ctrl-click adding an item, shift-range) is still published by the change-of-active-item clause; one that leaves it in place (your ctrl-deselect case) no longer bumps the generation. Your test is in verbatim: seed [a, b], dispatch [b], generation unchanged.

Both new tests were verified to fail against the code they fix.

One residual worth naming so it doesn't look overlooked: ctrl-clicking the active item off a multi-selection ([a, b] with b active, ctrl-click b) still reveals — the active item moves to a, so the change-of-active-item clause publishes it. That's unchanged from before this round, and I've left it: the viewer's displayed item genuinely changes there, so showing the user what's now active seems right — but the deselect branch does know the gesture, so if you'd rather suppress that one too it's a small follow-up.

lstein requested a review from JPPhoto August 23, 2026 16:31

JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Needs addressing:

  • invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/gallerySelectionSource.ts:23-25: Delete-pruning dispatches imageSelected(lastSelected) even when active item stays unchanged (invokeai/frontend/web/src/features/deleteImageModal/store/state.ts:104-110). Listener records a new pick; machine reveals current item over live progress. Effect: unrelated multi-delete causes 2 s stale-result flash. Likelihood: Medium. Recovery: Automatic after timer. Test: Delete non-active item from [a,b] during progress; assert no reveal.

Corner/impossible cases that can safely be ignored:

  • invokeai/frontend/web/src/features/gallery/components/ImageViewer/selectedItemReveal.ts:165-167: Same-name video src refresh reuses paintedItemName (useSelectedItemReveal.ts:72-78, CurrentVideoPreview.tsx:392-403). Reselecting during media-cookie recovery skips grace and reveals before new loadeddata. Effect: stale/blank video briefly covers progress. Likelihood: Rare; requires same-video auth/media refresh. Recovery: Wait or select another item. Test: Change same-name src, omit loadeddata, reselect, assert reveal waits.

Suggestions:

  • Instead of treating all imageSelected actions as picks, exclude internal delete normalization or add explicit user-pick source.

  • Consider keying media readiness by src or DOM element generation, not filename.

lstein and others added 9 commits August 23, 2026 21:50
The delete flow's survivor branch — the displayed item outlived a deletion
that took other items out of the multi-selection — dispatched
`imageSelected(lastSelected)`. That leaves exactly the state it wants, but
`imageSelected` is the action that means "the user picked this", and the
viewer answers a pick by lifting a running generation's progress overlay off
the item for two seconds. Deleting some other item flashed a stale result
over a live render.

Dispatch `selectionChanged([lastSelected])` instead: same state, but the
mutation action, which the selection-source listener publishes only when it
moves the active item. The advance branch keeps `imageSelected` — there the
viewer genuinely moves to a different item, and revealing it is the point.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw
…er pick

Same class as the delete-flow pruning: the probe selects an item *for* the
user after a board or view change, but dispatched `imageSelected`, the action
that means "the user asked to see this". NoBoardBoard re-dispatches
boardIdSelected even when its board is already selected, so the probe can land
on the item already on screen — and as a pick that lifts a running
generation's progress overlay off it for two seconds.

`selectionChanged` leaves identical state and is published only when it moves
the active item, so a probe that genuinely changes what the viewer shows still
reveals; one that re-selects what is already displayed no longer does.

The test also covers the probe's success path, which had none.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw
Adversarial review of the previous commit: switching the survivor branch to
`selectionChanged` is necessary but not sufficient. The branch collapsed the
selection onto `lastSelected`, snapshotted before the delete request. If the
user selects something else while the request is in flight, that collapse
discards their pick *and* moves the active item back — which the listener
publishes under its change-of-active-item clause, so the overlay flashes
anyway, for a gesture aimed at a third item.

Filter the deleted names out of the live selection instead, falling back to
the surviving displayed item when everything selected since has been deleted.
The ordinary (non-racing) case is unchanged.

Also strengthens both delete suites, which after the previous commit pinned
only the item left displayed and passed against a survivor branch that pruned
nothing at all, and narrows the doc comment that overstated the action choice
as sufficient on its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw
Adversarial review of the board-probe commit: guarding the probe's *action*
only helps when it happens to re-select the item already displayed. The probe
selects the board's first item unconditionally, so re-running it for a
navigation that changed nothing throws the user's selection away and moves the
viewer — which publishes as a change of active item and reveals over a running
generation anyway.

Guard the two callers that could re-dispatch for no change: NoBoardBoard
(GalleryBoard and VirtualBoardItem have always guarded this; it never did) and
the gallery's view tabs. NoBoardBoard's click actions move to their own module
so they can be unit-tested — the component pulls in the dnd stack, which a
plain unit test cannot import.

Also from the review, on the previous commit:
- both delete suites now assert *every* selection write, not just the first;
  a stray trailing dispatch — what the user would actually end up looking at —
  passed unnoticed, as did dropping the fallback from the video modal.
- adds the two paths neither suite covered: the video fallback, and a
  non-racing three-item multi-selection.
- the survivor branch's fallback cannot honour "don't move the active item"
  and can still name an item removed by an overlapping delete; both are
  pre-existing, and the comments now say so rather than claiming otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw
…t probe

Replaces the click guards from the previous commit. Guarding each caller was
both too much and too little: too much, because it swallowed the click
outright, and clicking the board is exactly how a user recovers when the
selection is empty — after deleting the last item, after turning date boards
off (which resets the board and clears the selection without dispatching
boardIdSelected), or after the probe's own give-up. Too little, because the
guards read render-time snapshots, so a click racing a store update could be
dropped, and every future caller would have to remember the rule.

The probe itself knows the answer: if what the viewer is showing is still in
the list it just fetched, the board or view did not really change and there is
nothing to fix, so it writes nothing. If the selection is empty or gone from
the list, it selects as before. One place, live state, all callers.

Also fixes the give-up path this exposed: `condition` only re-evaluates its
predicate when an action is dispatched, so an already-fulfilled list with a
quiet store gave no wake-up at all — the probe sat through its 5 s deadline
and cleared a selection it should have kept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw
With the probe now skipping writes that change nothing, every write it does
make moves the displayed item and would publish under either action — so the
suite passed with the probe dispatching `imageSelected` again. An empty board
with nothing selected is the one write that changes nothing and would still
publish as a pick; that case is now covered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw
Adversarial review of the previous two commits found the "is the displayed
item in the new list?" test wrong in two ways.

It skips genuine board switches. A virtual date board's query args drop
`board_id` and filter on `created_date` alone, so its list is a superset of
every board's items for that day: switching from a real board to a date board
left the viewer on the old board's item, with the grid scrolled to it instead
of to the newest — and a cross-board multi-selection survived the switch, so
bulk actions then operated on items not visible in the grid.

And the short-circuit that skipped the query wait for an already-fulfilled
list made the effect run to completion synchronously inside the dispatch that
started it. That let the probe's write land between
`markNextSelectionAutoSwitched()` and the auto-switch's own `imageSelected`,
consuming the marker so the auto-switch registered as a user pick — the exact
flash the reveal machine exists to prevent. Only an incidental cache
invalidation kept it from firing today. Reverted; the pre-existing quiet-store
give-up it was fixing is reported to the reviewer instead.

The question the probe actually wants is whether the board or view changed at
all, which `getOriginalState()` answers exactly, before any await — so a click
that should do nothing cannot reach the give-up branch either. An empty
selection still probes, because that is the case where the user needs the
probe to pick for them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw
… a probe

Adversarial review of the previous commit: "the selection is non-empty" is not
"the user has something valid on screen". A search term — or starredFirst,
orderDir, the archived-boards toggle — narrows the list without starting a
probe, so the selection can be absent from what the grid shows. Clicking the
board, which is how the user gets out of that, was being swallowed. The same
gap stranded the viewer on an item from a board that had just been deleted:
the app dispatches `boardIdSelected` then `galleryViewChanged` back to back
there (and again when an upload targets another board), and the second of each
pair is a no-op, so it skipped while the first board change was still in
flight.

Ask the fuller question instead: did this navigation change the board or view,
and is the displayed item in the list that board is already showing? An
uncached list falls through and probes as before. Read synchronously off the
cache, so a click that should do nothing never enters the query wait, where a
quiet store can expire the 5s deadline and clear the selection.

The check also moves above `cancelActiveListeners()`. That is defensive rather
than load-bearing — with membership required, a skip can only happen when the
displayed item is valid for the list now on screen, so cancelling first would
not actually strand anything, and no test can tell the two orders apart — but
a navigation that changes nothing has no business cancelling the probe of one
that did.

Adds the coverage the review found missing: the cancel-then-return pair, the
view-tab half of the check (a mutation neutering it passed the whole suite),
the filtered-out-selection recovery, and a switch whose destination list is
cached *before* the click — the case that distinguishes comparing against the
previous state from a bare membership test. The empty-selection test no longer
leans on a synthetic tick to wake the probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw
Five attempts, four adversarial reviews, a new regression each time — the
auto-select probe's interaction with RTK Query's cache, `condition`'s
wake-on-dispatch semantics, and the action pairs the app dispatches around it
is subtler than it looks from the call site.

The last version could still be provoked into the opposite failure: a click
that changed nothing, on a selection the current list does not contain, fell
through to a query wait that nothing would wake, and the 5 s give-up cleared
the selection outright. A cold cache entry — which toggling starred-first or
order direction produces instantly — let the same click take a good selection
away and lift the progress overlay, the very flash this was meant to stop.

None of this is what the review that started this round was about, and all of
it predates the PR. Keeping only the part that does belong: the probe writes
with the mutation action, so a re-run that lands back on the item already
displayed no longer announces itself as a user pick. Filed the rest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw

lstein commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator Author

Both confirmed. Fixed, though the delete-flow one took two passes and the second finding sent me
down a hole I've since backed out of — details below.

Delete-pruning dispatches a pick it doesn't mean

Real, and it traces exactly as you described: selection [a, b] with b displayed, hover-delete
a, the survivor branch dispatches imageSelected(b), the listener matches the pick clause, and
because b is already rendered and painted the machine runs awaiting-media → revealing inside
the same sync, so the overlay comes off for the full 2 s.

The first fix used the round-1 vocabulary rather than a new mechanism: that branch prunes a
multi-selection, so it dispatches the mutation action. An adversarial pass then showed that was
necessary but not sufficient, because the branch also collapsed the selection onto
lastSelected, which is snapshotted before the request:

selection [a, b], b displayed, hover-delete a; while the request is in flight the user
ctrl-clicks c, so the active item is now c. The delete resolves, the branch collapses to
[b], the active item moves c → b, the change-of-active-item clause publishes it — same 2 s
flash, and their pick of c is silently discarded.

So it now filters the deleted names out of the live selection, falling back to the surviving
displayed item only when everything selected meanwhile was deleted:

const survivors = getState().gallery.selection.filter((name) => !deletedNames.has(name));
dispatch(selectionChanged(survivors.length > 0 ? survivors : [lastSelected]));

Correcting a claim I made in that commit message: this is not behaviour-neutral outside the
race. A selection of three or more where a non-active member is deleted used to lose the surviving
siblings as collateral ([a,b,c] with c displayed, delete a → [c]); it now gives [b,c],
which is what the branch's comment always said it did. There's a test for it now. deleteVideoModal
had the identical branch and got the identical treatment.

Two holes in that branch I did not close, both pre-existing and both now described in the
comments instead of papered over:

  1. The fallback fires only when everything in the live selection was deleted, which means
    lastSelected isn't in it — so that write always moves the active item and always reveals. It's
    the one path in the branch that can't honour the rule below.
  2. deletedNames only proves lastSelected outlived this request. Two overlapping deletes
    (shift-clicking the trash on a then b — the button doesn't await) can land the fallback on an
    item the other request already removed; the viewer then shows a name that no longer exists, and
    since it never renders, the machine's claim stays outstanding until the generation ends.

The generalisation, and the part I backed out

Sweeping every imageSelected dispatch for "rewrites the selection without the user having asked
for that item" turned up one more writer: the gallery's auto-select probe. What ships here is only
the vocabulary fix — the probe writes selectionChanged, so when it re-runs and lands back on the
item already displayed it no longer announces itself as a pick, and no longer flashes the overlay.

I also tried to fix the underlying behaviour, which is worse than the flash: the probe selects
item_names[0] unconditionally, so clicking the board you are already on (or the tab already
showing — NoBoardBoard and the view tabs dispatch regardless, unlike GalleryBoard and
VirtualBoardItem) throws your selection away. Five attempts, four adversarial reviews, a new
regression every time:

  • Guarding the click handlers swallows the click that is the only recovery when the selection is
    empty — reachable after deleting the last item, after showVirtualBoardsChanged(false) (which
    resets the board and clears the selection without dispatching boardIdSelected at all), and after
    the probe's own give-up.
  • Skipping when the displayed item is in the fetched list breaks real board switches: a virtual
    date board's args drop board_id and filter on created_date alone, so its list is a superset of
    every board's items that day. Switching to one then reads as a no-op and strands the viewer on the
    previous board's item, cross-board multi-selection and all.
  • Short-circuiting the query wait for a cached list removes the only await, so the effect
    completes synchronously inside the dispatch that started it — and its write lands between
    markNextSelectionAutoSwitched() and the auto-switch's imageSelected, eating the marker, so the
    auto-switch registers as a user pick and flashes the finished image over the next generation. Only
    an incidental cache invalidation keeps that from firing today.
  • Skipping when the selection is merely non-empty leaves it stale (a search term narrows the
    list without starting a probe) and, worse, let a no-op click fall through into a wait nothing
    would wake, so the 5 s give-up cleared the selection outright.

None of that is what you flagged and all of it predates this PR, so I've pulled it out to #9535
rather than keep churning an unrelated listener inside a PR under review. That issue carries the
repros and the four dead ends, plus a second pre-existing defect I hit on the way: with the
item-name list already cached and no other action dispatched, condition() never re-evaluates — it
wakes on a dispatch, never on its own timer — so the probe's 5 s deadline expires and clears a
perfectly good selection.

The rule itself is written into gallerySelectionSource's doc comment, including the half my first
attempt missed: choosing selectionChanged is necessary but not sufficient — such a writer must
also leave the active item where it is, and the comment names the two places that can't.

Same-name video src refresh

Confirmed real, and left alone — flagging why in case you disagree. It predates this PR:
usePaintedItemName and its name-keyed readiness are already on main from #9475, and this PR
touches neither. Reaching it needs notifyMediaCookieRefreshed() to fire (the only thing that
changes videoUrl for an unchanged videoName) while that video stays selected, then a
re-selection of it during a live generation; the cost is a blank element under the overlay for at
most the reveal duration, self-recovering. Your suggested shape is the right one — key readiness on
the resolved media URL rather than the name — and I'd rather do that against main than widen this
PR. Happy to file it too, or to fold it in here if you'd prefer it not ship separately.

Tests

Both delete suites now assert every selection write and its whole payload, not just the first.
That mattered: an earlier round of these assertions passed against a survivor branch that pruned
nothing at all, and against one that appended a second, garbage write — which is what the user would
actually have ended up looking at. New coverage for the in-flight-selection race in both modals, the
video modal's fallback (untested before, so removing it silently reintroduced the #9163 empty-viewer
bug), and the three-item multi-selection. Every new assertion was checked by reverting or mutating
its fix and confirming it fails.

Full frontend suite green (1990 tests), types/lint/prettier clean.

CI's `tsc --noEmit` covers the whole project; vitest's typecheck pass only
looks at `*.test-d.ts`, so this never showed up locally. The test store holds
just the two slices the listener needs, and the selector is typed against the
full RootState.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw
JPPhoto self-requested a review August 24, 2026 11:48

JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

LGTM!

lstein commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator Author

LGTM!

OMG, thank you! It's been a long road.

lstein merged commit 97844ed into invoke-ai:main Aug 24, 2026
17 checks passed
lstein deleted the fix/viewer-reveal-item-owned branch August 24, 2026 20:20
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

6.14.0 frontend PRs that change frontend files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants


Back | FazBrowse Home | New Git URL