Skip to content

Draw toasts above live web pages instead of freezing the page under them - #419

Merged
benfriebe merged 2 commits into
mainfrom
steal-focus
Oct 16, 2026
Merged

benfriebe merged 2 commits into
mainfrom
steal-focus

Conversation

@benfriebe

@benfriebe benfriebe commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Problem

A toast in the bottom right (a workspace deleted, "Removed worktree", Zen Mode) took a web pane away while it was up. You could not scroll the page, and the keyboard left it.

The toast stack was drawn in the client's document, which sits under a web pane's native page view. So it registered its box (useOverlayPresence) and every page it overlapped was parked: swapped for a still poster, which a wheel cannot scroll. The park also handed the keyboard to the client (releaseKeyboardIfHeld) and nothing gave it back when the toast went.

Change

The Electron shell now draws the toast stack itself, in a see-through WebContentsView (the corner layer) placed over the stack's box, above every page view. The page under a toast stays live, scrolls, and keeps the keyboard.

  • Client (client/src/chrome/corner-layer.ts, ToastStack.tsx): the stack moved out of App.tsx into ToastStack.tsx. Inside the shell (?shellWindow=) the page exposes globalThis.__kelpiCorner. While the shell is attached, the page still lays the stack out (it is the measurement) but at opacity 0, marks it data-drawn-by="shell", and does not register its box, so nothing parks. If the shell stops taking frames for 1.5 s (UNTAKEN_FRAME_MS) the page detaches it and draws the stack itself again.
  • Overlay page (client/overlay.html, client/src/overlay/CornerOverlay.tsx): a new Vite entry that renders the same ToastCards on a transparent document and reports clicks back.
  • Shell (shell/src/corner-layer.ts, corner-layer-view.ts): a pure controller (probe, frame loop, click loop, placement with a 12 px margin, up to 3 overlay restarts, then it gives the stack back to the page) and its Electron view (transparent background, own partition, no background throttling). Both directions are executeJavaScript long-polls, as quit-prompt.ts already does: no preload and no daemon protocol change.
  • Keyboard: the layer samples who holds the keyboard as the pointer enters it, and when a click focuses the layer it hands the keyboard straight back (webHost.keyboardOwner() / restoreKeyboard(), newly exposed).
  • Stacking: adding a page view puts it on top, so the web host now calls onViewAttached and the layer re-raises itself.
  • Fallback: wherever the layer is not drawing (a browser, the phone, an older shell, a layer that failed) the stack is drawn in the page and parks the panes it covers, poster and all, as before.

docs/shell-ui.md §4.2 gains a "Toast-over-a-web-page requirement" paragraph.

Reviewer notes

  • Plugin notifications (BundledPrompts Notifications) share the corner but were not moved: they are still drawn in the page and still park. The toast layer draws above them where the two overlap.
  • The scenario web-pane-poster-under-worktree-toast (Show a web page's still frame under a toast it arrives beneath, instead of a gray pane #358) is renamed to web-pane-live-under-toast and rewritten: it used to assert the poster under the toast, and now asserts the page stays live, the card is drawn by the layer, and a click on the layer's card dismisses the toast. It keeps Show a web page's still frame under a toast it arrives beneath, instead of a gray pane #358's "arrive at the pane while the toast is up" round.
  • Every call into the page or the overlay is guarded (cornerCallScript). A call sent while the client window reloads runs in the new document, sometimes before its bridge is installed; it answers a sentinel instead of throwing an uncaught error in the page, and the controller hands the stack back to the page until the next load. The first battery run caught this (plugin-browser-features, plugin-terminal-features); fixed in 8419e92.
  • Keyboard hand-back and on-screen compositing can't be checked from the scenario lane (lane windows are keyless). The owner tested them by hand in a dev instance.

Test plan

  • Typecheck clean (client and shell).
  • Unit tests: root 11,550 passed, shell 1,084 passed, 43 of them new (client/src/chrome/corner-layer.test.tsx, client/src/overlay/CornerOverlay.test.tsx, shell/src/corner-layer.test.ts).
  • node scripts/scenario.mjs --window offscreen web-pane-live-under-toast: 25/25.
  • Also green alone: web-pane-middle-click-and-modal-poster, plugin-backend-prompts, cmd-click-codex-links, terminal-copy-paste-chords, recover-interface-keeps-web-panes, web-pane-comes-back-after-hide, workspace-agent-delete. plugin-interaction-presenters fails only in its cleanup step, the pre-existing plugin-interaction-presenters fails its cleanup on main: Settings does not open to restore the bundled placements #396.
  • By hand in a dev instance: a web pane on a long page, a toast raised over it. The page scrolls and keeps typing under the toast, and after a click on the toast the keyboard is still in the page.
  • node scripts/verify.mjs --since origin/main full battery on 8419e92: typecheck, root tests (11,550), shell tests (1,084), bundles, full audit and packaged smoke (75/75) passed. Scenario lane 62/68; every red is also red on main or a known issue (details in the comment below).

🤖 Generated with Claude Code

https://claude.ai/code/session_01DMT4Nhd3hzhXa2EEaHi4u7

benfriebe and others added 2 commits October 16, 2026 11:14
A toast in the bottom right (a workspace deleted, a worktree removed, Zen
Mode) parked every web pane it covered: the page was swapped for a still
poster, so it could not scroll, and the park handed the keyboard to the
client and never gave it back.

The Electron shell now draws the toast stack itself, in a see-through
WebContentsView (the "corner layer") placed over the stack's box above
every page view. The client still lays the stack out, at opacity 0, but
no longer registers its box, so nothing parks. A click on the layer acts
on the card under it, and focus on the layer goes straight back to its
previous owner. Both directions are executeJavaScript long-polls: no
preload, no daemon protocol change. Where the layer is not drawing (a
browser, a phone, an older shell, a failed layer) the old in-page stack
and poster still apply. Plugin notifications are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMT4Nhd3hzhXa2EEaHi4u7
The battery's plugin-browser-features and plugin-terminal-features went
red on "the renderer threw nothing": TypeError reading 'next' of
undefined. A call the frame loop sent while the client window reloaded
ran in the new document before its bridge was installed, and threw
there.

A call into a document without the global now answers a sentinel
instead of throwing, and the controller turns that into the rejection
its loops already handle (the stack goes back to the page until the
next load).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMT4Nhd3hzhXa2EEaHi4u7
@benfriebe

benfriebe commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner Author

Battery results

node scripts/verify.mjs --since origin/main, full tier, run twice.

Run 1 (7524b74) caught a real bug: plugin-browser-features and plugin-terminal-features were red, both alone and in the lane, on "the renderer threw nothing" (TypeError: Cannot read properties of undefined (reading 'next')). The shell's frame loop sent a next call while the client window reloaded (Page.reload), and it ran in the new document before the bridge was installed. Fixed in 8419e92: page and overlay calls answer a sentinel instead of throwing. After the fix, both are green alone (67/67, 59/59), and so is web-pane-live-under-toast (25/25).

Run 2 (8419e92): typecheck, root tests (11,550), shell tests (1,084), bundles, full audit and packaged smoke (75/75) passed. Scenario lane: 62 of 68 green. The six reds:

Scenario Result Also red without this PR?
plugin-interaction-presenters red alone, cleanup step yes, #396
plugin-settings-presenter red alone, cleanup leaves Settings open yes, #373
plugin-pane-chrome red in the lane, green alone yes, #348 (daemon-restart click race)
plugin-pane-search red alone, "a daemon restart stands the placement down" (selection null) yes, the same #348 race; red on unmodified main in #374's battery, flips run to run (green alone in run 1)
cmd-backspace-line-kill red after clear-screen, green alone yes: clear-screen cmd-backspace-line-kill image-pane kitty-keeps-system-chords fails the same three checks on unmodified main (0124d1b)
kitty-keeps-system-chords red after image-pane, green alone yes: same sequence, same failing check on main

The last two fail after clear-screen on main too: filed as #420 (clear-screen leaves the shared pane running zsh).

🤖 Generated with Claude Code

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.

1 participant