Fix BL-16682 Save page before invoking Bloom AI Image Tools - #8191
Fix BL-16682 Save page before invoking Bloom AI Image Tools#8191andrew-polk wants to merge 2 commits into
Conversation
|
[Claude Opus 5 (1M context) from Andrew Polk's machine during preflight] Consulted Devin twice on 2026-08-10, up to commit First pass (
Second pass ( Two threads are deliberately still open for the developer: the one just described, and the question of what should happen when someone re-commits after a partially-applied commit (today's retry redoes the already-saved off-page slots and orphans their files). Both are in the preflight decision report. Devin also raised 6 informational observations, not mirrored here as threads. Two were worth a second look and neither needs action: Note that its second pass re-reported the arrow-const Bug and the silent-failure flag with their original pre-fix text (still describing CI: |
bcab9a5 to
f9cd8ba
Compare
|
[Claude Opus 5 (1M context)] This branch has been rewritten as a single self-contained commit ( |
0e31f6b to
704975a
Compare
|
[Claude Opus 5 (1M context)] Squashed to a single commit |
8c4c4b7 to
b3b9311
Compare
|
[Claude Opus 5 (1M context) from Andrew Polk's machine during preflight] Consulted Devin five times across this branch, most recently up to commit What it found that was real, and is fixed:
Two threads are deliberately left open for the developer, both design questions rather than defects, and both applying to #8193 equally now that the two approaches share the
A note for anyone reading Devin's own pages here: its later passes re-report findings that were already fixed, using their original pre-fix text — still describing helpers as arrow functions after they were converted, still saying no CI: |
andrew-polk
left a comment
There was a problem hiding this comment.
@andrew-polk partially reviewed 12 files and all commit messages, and resolved 4 discussions.
Reviewable status: all files reviewed, 2 unresolved discussions.
andrew-polk
left a comment
There was a problem hiding this comment.
@andrew-polk partially reviewed 17 files and all commit messages.
Reviewable status: all files reviewed, 2 unresolved discussions.
82372b4 to
8bcb678
Compare
andrew-polk
left a comment
There was a problem hiding this comment.
@andrew-polk reviewed 1 file and all commit messages.
Reviewable status: all files reviewed, 2 unresolved discussions.
8bcb678 to
2814f90
Compare
|
You probably want to review as two separate commits. The second one is just moving files. |
JohnThomson
left a comment
There was a problem hiding this comment.
@JohnThomson reviewed all commit messages, made 3 comments, and resolved 2 discussions.
Reviewable status: all files reviewed, 3 unresolved discussions (waiting on andrew-polk).
a discussion (no related file):
Previously, andrew-polk wrote…
You probably want to review as two separate commits. The second one is just moving files.
I found the control for requesting this, but couldn't make it work. What it showed me at first was just the changes to editing model. Reviewing as a whole makes it a bit overwhelming, but I've tried to focus on changes that looked significant. Just a couple of questions.
src/BloomExe/web/controllers/AiImageEditorApi.cs line 299 at r7 (raw file):
// Queue BEFORE the save: with no page loaded, SaveThen completes synchronously. model.RunAfterNextPageLoad(loadedPageId =>
Things are probably just being defensive here, but the comments seem confused. A few lines above, we aborted completely if there isn't a current page being edited. Now the comment immediately above (and I think I've seen this in a few places) claims we're trying to continue even if no page is loaded.
src/BloomExe/web/controllers/AiImageEditorApi.cs line 318 at r7 (raw file):
// save can fail still ends in EditingStateMachine navigating back to the page, so the // queued launch fires anyway. That the hook covers the failure paths for free is one of // the reasons for preferring it over doAfterSaveToDisk.
This is (hopefully) a very unlikely corner case, but are we sure we still WANT to open the AI image editor if something went so badly wrong that we can't even save the current page? I'm not sure what could cause such a failure, but things that come to mind are not enough disk space to update the HTML file, a badly corrupted image file, out of memory,...
2814f90 to
85b54db
Compare
|
[Claude Opus 5 (1M context) from Andrew Polk's machine during preflight] Thanks John — both of your questions were right, and the second one changed the behaviour. Fixed in On the confusing comment at On whether we should open the editor at all after a save that failed — you've convinced me, and for a reason that strengthens your case: if the page could not be saved then the saved book DOM is still stale, so the editor would open on exactly the out-of-date data this whole change exists to prevent, and a commit from there would call The earlier reasoning for opening anyway was to avoid a menu item that silently does nothing. That concern doesn't apply here, though: So the request to open is now made only from the two paths where the book DOM is sound:
Every failing path reaches neither, so the editor stays closed. Worth noting that this is broader than the obvious fix: my first attempt was to cancel via On reviewing as two commits — sorry that fought you. Noted that the tooling didn't cooperate; the split is still worth having in the history (the second commit is renames only, four files with no content change at all), but I won't claim it made your review easier this time. |
https://issues.bloomlibrary.org/youtrack/issue/BL-16682 One of two alternative implementations, for comparison; the other is the single commit on branch BL-16682 (PR #8193), which is smaller and more conservative. Since both now share the EditingModel hook, what distinguishes this one is the frame split and what that makes possible. THE BUG "Edit with AI..." opened the AI Image Tools with an empty "Image to Edit" slot when the user had just added the image. Everything Bloom tells the editor about the book -- the whole-book image list, and on commit each slot's current src -- is read from the SAVED book DOM, but an image the user just added lives only in the live page, because changeImage/changeImageByElement deliberately do not save (BL-16330). The clicked image therefore matched nothing in the list. Saving first also closes two latent hazards on the same path: a current-page commit result would describe an image the live page no longer showed, and DeleteSupersededAiImageFiles, which judges orphanhood from the saved DOM, could delete a file the live page still used. THE FIX The menu command posts aiImageEditor/saveThenLaunch; C# saves the page with EditingModel.SaveThen and then opens the editor. Because saving always ends in a navigation, two decisions follow, made for separate reasons. WHERE the overlay lives: the top window, not the page iframe that an ordinary page save replaces every time. This is what the three other image commands in the same menu already do -- the comment on missingMetadata in canvasControlRegistry.ts says so outright: "Launch via the workspace (top window) bundle, not this page iframe, so that saving the metadata ... doesn't tear the dialog down." So aiEditorLauncher.ts splits along the frame boundary: - aiEditorOverlay.ts (TOP window, on workspaceBundle): the overlay, the postMessage handshake with the editor's iframe, and the commit. - aiEditorPageCommands.ts (PAGE frame, on editablePageBundle): the only two things that must run where the live page is -- the menu command, and applying a commit's current-page swaps. - aiEditorShared.ts: the types, fileNameOf, and isCurrentPageSwap -- the predicate both halves must agree on. WHEN we open it: once the browser reports a page again, via EditingModel.RunAfterNextPageLoad. SaveThen's own doAfterSaveToDisk looks right -- the top window is alive at that moment -- but that moment is immediately before the navigation, and the navigation is not always confined to the page iframe. EditingView.StartNavigationToEditPage reloads the whole workspace root once MemoryUtils.SystemIsShortOfMemory(), which is Bloom's own private bytes past ~2GB: the ordinary state of a long editing session on a big book, and exactly what that full reload exists to recover from. Opening from doAfterSaveToDisk there meant the page saved correctly and the editor never appeared, with no message -- openAiImageEditor doesn't even build the overlay synchronously; it POSTs launch first and builds it in the reply, a whole round trip after the reload began, in a document being replaced. Waiting for the page load costs nothing and is immune to all three navigation routes. (To see that failure deliberately, temporarily make ShouldDoFullReload() return true, as its own comment invites; note that while it is forced on, every page click reloads the whole view.) CONSEQUENCES - The launch is asked for only from doAfterSaveToDisk (the save reached disk) or from doIfNotInRightStateToSave (no save was attempted, and a page load is coming anyway). A save that FAILS therefore leaves the editor closed: the book DOM is still stale, so we would be opening on exactly the out-of-date data this fixes, and a commit from there would call book.Save() on top of whatever went wrong. The user is not left guessing, since the state machine has already shown "Bloom had trouble saving a page...". This is also why failureAction is not used: it is not called when _saveBook() itself throws, which is the disk-full case. (Raised by JohnThomson in review.) - The request to open is queued up front, but only acted on if the save reached disk (doAfterSaveToDisk) or was never attempted (doIfNotInRightStateToSave). Queuing up front matters because OnTabAboutToChange discards the queue when the user leaves the Edit tab; queuing later, from the callback, let that discard run first on an in-flight save so the editor sprang open on return. Gating on the outcome is what keeps a FAILED save from opening the editor on a book DOM that is still stale. - Opening the overlay waits for window.workspaceBundle rather than assuming it. We are called the instant the page iframe reports loaded, and on the whole-workspace-reload route the root document loads in parallel, assigning workspaceBundle at the very end of workspaceRoot.ts. Calling straight in would throw inside a fire-and-forget script -- silently doing nothing on precisely the route this design exists to survive. - The deferred-save workaround in the commit handler is gone. It existed only because saving reloaded the frame that owned the overlay's own close button. That deferral also meant a second commit in one session read its oldSrc from a still-stale saved page and matched nothing, so this fixes that too. - The page frame is asked to apply current-page swaps only when the commit actually contains one. Asking unconditionally failed a wholly successful off-page commit whenever that frame happened to be mid-reload -- a window this feature's own post-commit save opens -- reporting an error for images C# had already replaced and saved, and inviting a retry that would redo them and orphan the files. - The current-page work is one named cross-frame call (applyAiImageEditorReplacements) rather than the top window reaching into another document's DOM, and it reports how many swaps landed so the caller saves exactly what needs saving. - The saveThenLaunch payload is parsed unguarded: the only caller is our own aiEditorPageCommands, so a parse failure means we broke our own contract and should hear the real exception (AGENTS.md: "Don't be overly defensive about error handling"). VERIFICATION - Front-end: full vitest run (--no-file-parallelism) -- 687 passed, 5 skipped, 62 files. The launcher's single suite became two, tracking the split: aiEditorPageCommands.test.ts (6) and aiEditorOverlay.test.ts (10), the latter needing no page DOM at all. - C#: full BloomTests suite -- 3063 passed, 0 failed, 12 skipped. - pnpm typecheck, eslint on the changed files, and the isolated production bundle build (build/agent-vite.sh) are all clean. - The premise was checked in code rather than assumed: switchContentPage sets only the page iframe's src, and the shell's URL change is a history.replaceState. Verified live in a running Bloom by Andrew, including the awkward case: with ShouldDoFullReload() forced true -- the state that made the first attempt at this fix save the page and then silently never show the editor -- the overlay appears. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pure relocation plus import paths; no behavior change. Review with `git show -M` and it is 11 renames, four of which have no content change at all. WHY `bookEdit/toolbox/canvas/AGENTS.md` opens with "This folder contains Canvas Tool UI and related Canvas utilities", and its README describes "the declarative system that controls what operations are offered for different kinds of canvas elements". The AI image editor is neither, and it had grown to 11 of the 51 files in there -- the largest non-canvas cluster in the folder. It is also not a toolbox tool: every other `toolbox/*` folder is an actual tool with a panel and a lifecycle, whereas this is a context-menu command plus a dialog-like overlay. That makes `bookEdit/copyrightAndLicense/` its closest structural neighbour, so it now sits beside it as `bookEdit/aiImageEditor/`. The parallel is exact rather than loose: both are image-related, both are launched from the same canvas context menu, and both render in the top window precisely so a page save cannot tear them down. The coupling to canvas is two imports, both from the menu machinery, which is the right relationship -- the canvas menu offers the feature, it does not implement it: canvasControlRegistry imports launchAiImageEditor, and buildCanvasElementControlRegistryContext imports isAiEditableImageSrc to decide whether the item is enabled. WHAT THE MOVE BUYS BEYOND TIDINESS A folder of its own is somewhere to write down the frame rule, which until now lived only in individual file headers: the overlay half must run in the top window and the live-page half in the page iframe, they must never import each other, and anything shared must stay pure. Bundling follows imports rather than folders, so nothing else enforces that. The new AGENTS.md states it, with the table of which file belongs to which frame, and points at HandleSaveThenLaunch for why waiting for the page load is also required. AiImageEditorApi.cs now names the folder so the C# side leads a reader to it. WHAT NEEDED NO CHANGE - Playwright: `playwright.bloom-exe.config.ts` uses testDir ".." with testMatch "**/bloom-exe*.uitest.ts" -- a glob, and its own comment says the setup exists "so a feature's e2e can live next to its code". - Vitest: its include is likewise a path-agnostic glob. - Every cross-reference in the C# and the CDP driving guide cites file *names*, not paths, and the one that mentions a path already elides it. VERIFICATION - typecheck, lint clean. The moved suites are found at the new path and pass: aiEditorOverlay (10), aiEditorPageCommands (6), aiEditorSlotMatching (5), aiEditorImageFormats (4) -- 35 with the canvas suites, same as before. - The isolated production bundle builds (build/agent-vite.sh). - BloomExe builds clean. A note on what was NOT verified, so nobody assumes otherwise: I tried to confirm at the bundle level that the two halves still land in the right places, and that check is inconclusive here -- this build funnels shared code into one 4.5MB chunk imported by eight entry points, so a grep cannot distinguish them. It is also unnecessary: the move changed no import edges, only paths, so bundle composition cannot have shifted. The guarantee that matters is the source import graph, which is still one-way, and which the new AGENTS.md now documents. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
85b54db to
5571a35
Compare
andrew-polk
left a comment
There was a problem hiding this comment.
[Claude Opus 5 (1M context)] Sorry the two-commit split fought you rather than helping. Noted that the control didn't work — the second commit is renames only (11 files, four with no content change), so git show -M on it is the quickest way to skip past it if you look again.
Also my fault that you had to read one long summary instead of answers under the lines you asked about: I replied with a single top-level comment because your comments have no GitHub mirror and I had wrongly ruled out using the Reviewable CLI. Both of your code comments now have their own replies in place, so this thread doesn't need the detail.
@andrew-polk+AI made 3 comments.
Reviewable status: 19 of 20 files reviewed, 3 unresolved discussions (waiting on andrew-polk and JohnThomson).
src/BloomExe/web/controllers/AiImageEditorApi.cs line 299 at r7 (raw file):
Previously, JohnThomson (John Thomson) wrote…
Things are probably just being defensive here, but the comments seem confused. A few lines above, we aborted completely if there isn't a current page being edited. Now the comment immediately above (and I think I've seen this in a few places) claims we're trying to continue even if no page is loaded.
[Claude Opus 5 (1M context)] You read it exactly as written, and it was wrong to write it that way. I had conflated two different things called "page": the guard a few lines above tests model.CurrentPage, the book page selection, whereas this comment meant EditingStateMachine's NoPage state — no page loaded in the browser, e.g. the Edit tab hidden. Those are independent: you can have a CurrentPage while the machine sits in NoPage, and in that state ToSavePending runs DoPostSaveAction synchronously, which was the real reason the request had to be made before SaveThen.
Rather than reword it, the restructuring for your other comment removed the ordering subtlety altogether, so this comment is simply gone in 140387d.
On your parenthetical — you wondered whether you'd seen the same thing in a few places. I grepped: that was the only occurrence in this change. The two other hits for similar wording are pre-existing and unrelated (EditingStateMachine.cs describing its own state, and a comment in ImageApi.cs).
src/BloomExe/web/controllers/AiImageEditorApi.cs line 318 at r7 (raw file):
Previously, JohnThomson (John Thomson) wrote…
This is (hopefully) a very unlikely corner case, but are we sure we still WANT to open the AI image editor if something went so badly wrong that we can't even save the current page? I'm not sure what could cause such a failure, but things that come to mind are not enough disk space to update the HTML file, a badly corrupted image file, out of memory,...
[Claude Opus 5 (1M context)] You've convinced me, and for a reason that strengthens your case: if the page could not be saved then the saved book DOM is still stale, so the editor would open on exactly the out-of-date data this whole change exists to prevent — and a commit from there would call book.Save() again on top of whatever went wrong. Your examples are apt.
The earlier reasoning for opening anyway was to avoid a menu item that silently does nothing. That doesn't apply here: EditingStateMachine has already shown the user "Bloom had trouble saving a page… Then quit Bloom, run it again" before this point, so they are told — they just shouldn't also get an image editor on top of it.
Fixed in 140387d. The request to open is now queued up front but acted on only if a captured bookDomIsSound flag was set, from either doAfterSaveToDisk (the save reached disk) or doIfNotInRightStateToSave (no save was attempted, nothing failed, and a page load is coming anyway). Every failing path reaches neither, so the editor stays closed.
Two things I got wrong on the way there, in case the shape looks roundabout:
- My first attempt was to cancel via
failureAction. Checking it against your disk-full example showed it wouldn't have covered it —failureActionisn't called when_saveBook()itself throws, nor on the deliberate_discardInFlightSavepath. Gating on "did the save land" covers all of them. - My second attempt only queued from those two callbacks rather than queueing up front and gating. Devin caught that this broke a different ordering:
OnTabAboutToChangediscards the queue when you leave the Edit tab, so on a save still in flight that discard ran first and the completing save re-armed behind it — the editor then sprang open when you returned to the page. Hence queue-early-but-gate.
Fixes BL-16682: "Edit with AI…" opened the AI Image Tools with an empty "Image to Edit" slot when the user had just added the image.
Ref: https://issues.bloomlibrary.org/youtrack/issue/BL-16682
Why it happened
Everything Bloom tells the AI editor about the book — the whole-book image list, and on commit each slot's current src — is read from the saved book DOM. An image the user has just added lives only in the live page, because
changeImage/changeImageByElementdeliberately don't save (BL-16330). So the clicked image matched nothing in the list and no edit target was sent.Saving first also closes two latent hazards on the same path: a current-page commit result would describe an image the live page no longer showed, and
DeleteSupersededAiImageFiles, which judges orphanhood from the saved DOM, could delete a file the live page still used.What this does
The menu command posts
aiImageEditor/saveThenLaunch; C# saves the page withEditingModel.SaveThenand then opens the editor. Because saving always ends in a navigation, two decisions follow, each for its own reason:Where the overlay lives — the top window. An ordinary page save replaces the page iframe, so an overlay hosted there would be torn down. This is what the three other image commands in the same menu already do; the comment on
missingMetadataincanvasControlRegistry.tssays so outright.aiEditorLauncher.tstherefore splits along the frame boundary:aiEditorOverlay.ts(top window, onworkspaceBundle) — the overlay, the postMessage handshake with the editor's iframe, and the commit.aiEditorPageCommands.ts(page frame, oneditablePageBundle) — the only two things that must run where the live page is: the menu command, and applying a commit's current-page swaps.aiEditorShared.ts— the types and the predicates both halves must agree on.When it opens — after the page has come back, via the new
EditingModel.RunAfterNextPageLoad.SaveThen's existingdoAfterSaveToDisklooks like the right hook, but it runs immediately before the navigation, and that navigation is not always confined to the page iframe:EditingView.StartNavigationToEditPagereloads the whole workspace root onceMemoryUtils.SystemIsShortOfMemory()— Bloom's own private bytes past ~2GB, i.e. the ordinary state of a long editing session on a big book. Opening fromdoAfterSaveToDiskthere meant the page saved correctly and the editor never appeared, with no message.Consequences worth knowing:
doAfterSaveToDisk) or was never attempted (doIfNotInRightStateToSave). Queuing up front matters becauseOnTabAboutToChangediscards the queue when the user leaves the Edit tab — queuing later let that discard run first on an in-flight save, so the editor sprang open on return. Gating on the outcome is what stops a failed save from opening the editor on a book DOM that is still stale;failureActionwould not do, since it isn't called when_saveBook()itself throws (the disk-full case) nor on the deliberate discard path.window.workspaceBundlerather than assuming it. C# is called the instant the page iframe reports loaded, and on the whole-workspace-reload route the root document loads in parallel, assigning that global at the very end ofworkspaceRoot.ts; calling straight in would throw inside a fire-and-forget script, i.e. silently do nothing on precisely the route this design exists to survive.oldSrcfrom a still-stale saved page and matched nothing — so this fixes that too.Commits
ca0f97d— the fix.2814f90— moves the AI editor front end out ofbookEdit/toolbox/canvas/(whose ownAGENTS.mddescribes it as canvas UI and utilities) intobookEdit/aiImageEditor/, besidecopyrightAndLicense/. Pure relocation plus import paths —git show -Mshows 11 renames, four with no content change — plus anAGENTS.mdrecording the frame rule, which bundling does not enforce on its own.A smaller alternative that kept the overlay in the page iframe was written and compared as #8193, and closed in favour of this one.
Verification
vitest run --no-file-parallelism— 687 passed, 5 skipped, 62 files. The launcher's single suite became two, tracking the split;aiEditorOverlay.test.tsneeds no page DOM at all.BloomTestssuite — 3063 passed, 0 failed, 12 skipped.pnpm typecheck, eslint, and the isolated production bundle build are clean.bloom-exeAI-editor Playwright tests pass against a running Bloom.ShouldDoFullReload()forced true — the state that made the first attempt at this fix silently show no editor — the overlay appears.Devin review
🤖 Generated with Claude Code
This change is
Devin review