Skip to content

Manage a remote in a pane, and what a pane costs - #296

Merged
pcarrier merged 6 commits into
mainfrom
pane
Aug 19, 2026
Merged

pcarrier merged 6 commits into
mainfrom
pane

Conversation

@pcarrier

@pcarrier pcarrier commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Six themes, one branch. Everything here started from the same place: Manage was a modal on top of a modal, and pulling it into a pane exposed what a pane costs — surface views that were inert or repainting per frame, dock cards that fought the encoder, a scroll path racing itself, and an app able to take the view away from the viewer.

surface: give every binding a real pane, and stop paying for previews

A view with no display size is inert — every input path is gated on one, and scaledTarget() pins it to a 15 fps downscale. js/react never set one, so EMBEDDING.md's full-window React pane was a blurry stream nobody could click. Solid's ~110 lines of resize policy move into core as driveSurfaceResize (Solid: 312 → 184 lines, its eleven zoom/resize tests untouched), resizable defaults to true, and the three in-repo previews say resizable={false} — a dock card driving requestResize from a box derived from the surface it resizes is the loop that once took the server down. Cards follow the logical window size, not the mediated composite. Per-frame syncImeTarget layouts, double measurement per wheel event and connection-wide repaints are gone. And view tokens are prefixed with the connection id: a card carrying s3 onto another server used to take over the live pane's subscription.

Manage a remote in a pane, not in a dialog

manage:<conn>: is a BSP tile: it splits next to what it manages, survives focus going elsewhere, restores from the hash, and parks in the dock. Panel lists are bounded by the pane instead of a vh cap inherited from the old overlay, so each tab has exactly one scroller. The parked card is a title — "dev:manage › Session", address dim — rather than a live catalog rendered at 60% of unreadable; the tab comes from a per-connection map, because parking unmounts the panels. A focused manage pane names itself in the status bar like every other tile, and a surface a tile covers is offered as a dock card instead of vanishing from the workspace.

Start a process in a terminal the way we start one out of a terminal

CREATE2 gains negotiated HAS_ENV/HAS_ARGV blocks behind FEATURE_CREATE_EXEC, replacing an undocumented rule where blit terminal start ls -la exec'd directly and blit terminal start htop went through $SHELL -lic. blit terminal start is exec-by-default with --shell, --cwd, --env. Two bugs fall out: restart replayed only the legacy fields (an argv terminal came back as a bare login shell) and S2C_LIST showed argv terminals blank. The exec plan is built before the fork — the old branch called CString::new(..).unwrap() on the far side of a fork in a multi-threaded process. Windows parses the blocks and refuses them, and does not advertise the feature. Real TS-encoder frames are pinned as hex in blit-remote so a Rust-side layout change JS did not make fails in CI rather than in a tab.

Let a tray menu click survive the app repainting its own menu

Exit in Zoom's tray menu did nothing: ItemsPropertiesUpdated voided the whole menu and the click gate refuses a stale revision. A property change now invalidates only the named ids. A long press was making it likelier — it left a trailing click that read as a mouse click and sent Activate.

Scroll a terminal on its own row grid, and stop fighting the client

The server answered every C2S_SCROLL_BY with an absolute offset, so each answer landed a round trip late and the client adopted it: twelve rows of wheel went out as 2, 2, 4, 4, 2 and landed on fourteen, worst on a remote iPad. Only clamping is answered now (no wire change — an older client is fixed by the absence of a message). A wheel notch travels a whole number of rows, so nothing is left to write back and one move goes out per notch instead of six; the sync's echo is claimed by position rather than by a frame, so a quantised notch is no longer swallowed; and sub-row drift is never squared up, since nothing renders from scrollTop.

Answer a window's plea for attention with a highlight, not the view

xdg_activation_v1 tokens are cheap and unacknowledged, so clients repeat them several times a second — and activateSurface raised the surface for each one, dragging every dock click back within ~1.4s. An activation now lights the surface where it already is: its card's header bar, or a ring on its pane. The raise-and-restore machinery behind it (activationStack.ts, restoreMainView, lowerFocusedSurface) was unreachable and is gone. The compositor still forwards every request, which is safe because the answer is no longer the view.

A `BlitSurfaceView` with no display size is inert: every input path in
BlitSurfaceCanvas is gated on one — keyboard, pointer, wheel, touch, IME,
clipboard, drag — and `scaledTarget()` pins it to a downscale capped at
THUMBNAIL_MAX_FPS. `js/react` never called `setDisplaySize` and had no
`resizable` prop to make it, so the full-window React pane in EMBEDDING.md
rendered a blurry 15 fps stream that could not be clicked. The policy Solid
had — ~110 lines of ResizeObserver, even-extent rounding, leading and
trailing debounce edges, zoom arithmetic — moves into core as
`driveSurfaceResize`, and both bindings wire it to their own lifecycle and
nothing more. Solid drops from 312 to 184 lines with its eleven zoom/resize
tests untouched and passing, which is the evidence the port is faithful.
`resizable` now defaults to true in both: a view that takes no input is the
special case, so it should be the thing you ask for.

Which made the three in-repo previews that never passed the prop start
owning their surface's size. The visible symptom was the dock thumbnails
losing their height; the unseen half was a card driving `requestResize` from
a box whose own height is derived from the surface it was resizing — the
loop whose last incarnation retired and rebuilt a hardware encoder per turn
until libnvcuvid took the server down. They are explicit now.

A card's shape follows the window, not the composite. The composite is the
logical size times whatever scale the highest-DPI viewer asked for, floored
onto the even 4:2:0 grid, so it moved when somebody else's display DPI did,
for a window that never changed shape — and a card's box moving is not free:
it re-derives the view's scaled request, which costs the server an encoder
rebuild and this client a keyframe. The logical size was already on the wire.
The subtle half is the per-surface signature: `SurfaceStore` mutates
dimensions in place and Solid does not track property access on plain
objects, so a card only sees a new value when its item gets a fresh object
reference. Both pairs are in the signature now, and the doc comment says why.

Three costs paid per presented frame or per wheel event are gone.
`syncImeTarget` stopped early-outing the moment a guest reported a caret —
GTK/Qt do so on every cursor move — so every frame did a
`getBoundingClientRect`, four visual-viewport reads and a style write, at
60-240 Hz, inside the decoder's present path, and the write invalidated
layout for the wheel handler's own reads. It is now gated on this view's
caret or box, or on one refcounted shared `layoutEpoch`. `handleWheel` and
`sendPointerAt` measured twice per event where one reading threads through.
And every mounted view repainted on any surface's title change, since
`onChange` is connection-wide — eighteen repaints for a dock of fifteen
cards and three panes; object identity is exactly the "did mine change" test.

Finally, a view's subscription token is prefixed with its connection id.
Canvases change connection in place — `setConnectionId` re-points a canvas
without re-minting — and the token keys `SurfaceSub.views` and
`surfaceViewSizes`, both by that string alone. A card carrying `s3` onto
another server took over the pane's registration: the live pane decoded a
512x256, 15 fps stream it could not take back, or froze on its last frame
when the card scrolled out of the dock and unsubscribed on its behalf. The
token never reaches the wire, so this costs nothing but the string.

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

indent Bot commented Aug 19, 2026

Copy link
Copy Markdown
PR Summary

Six themed commits pulling "Manage" from a modal into a BSP pane, plus the surface/pane fixes that move exposed along the way.

  • CREATE2 exec/env negotiation: C2S_CREATE2 gains HAS_ENV/HAS_ARGV blocks behind FEATURE_CREATE_EXEC, encoded/decoded through a new shared Create2Request/parse_create2 in blit_remote. blit terminal start is exec-by-default with --shell/--cwd/--env; restart now replays the full ChildSpec (argv terminals no longer come back as bare shells) and S2C_LIST renders argv. The execve plan is built before fork(), moving every allocation and NUL-check off the child's side of the fork. Windows parses but refuses the exec blocks and doesn't advertise the feature.
  • Surface/resize: every binding gets a real display size (js/react's full-window pane is interactive again); Solid's resize policy moves into core driveSurfaceResize, resizable defaults to true, and in-repo previews pass resizable={false}. View tokens are prefixed with the connection id so a dock card can't hijack a live pane's subscription on another server.
  • Manage in a pane: manage:<conn>: is a BSP tile that restores from the hash, parks in the dock with a per-connection tab label, and names itself in the status bar.
  • Scroll: the server answers only clamps (no more stale absolute-offset echoes); a wheel notch travels a whole number of rows and the sync echo is claimed by position.
  • Tray + attention: an ItemsPropertiesUpdated invalidates only the named ids so a menu click survives a repaint; an xdg_activation token now highlights the surface (card header / pane ring) instead of raising it, and the now-unreachable raise/restore machinery is deleted.

Issues

All clear! No issues remaining. 🎉

1 issue already resolved
  • In pty_unix.rs, the doc comment on ExecPlan::new describes an argv0_override parameter that the function does not accept; its signature is new(program, args). Reword to reflect that argv0 comes from callers passing the client-written name in args. (fixed by commit 6591fa0)

CI Checks

All CI checks pass on 6591fa0. The earlier e2e failure was flaky CI infra (FlakeHub auth / Nix cache throttling), not a code regression, and cleared on re-run.

View session

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

🔗 Preview: https://blit-9c0hpircs-indent.vercel.app

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

Coverage

Crate Lines Functions Regions
alacritty-driver 75.7% (934/1234) 78.7% (74/94) 79.0% (1521/1926)
browser 0.0% (0/825) 0.0% (0/69) 0.0% (0/1404)
cli 39.9% (5920/14836) 46.5% (596/1282) 40.8% (9238/22637)
compositor 35.1% (6038/17209) 49.6% (500/1009) 34.9% (8302/23786)
desktop 78.5% (4298/5478) 71.3% (376/527) 74.9% (5943/7933)
fonts 85.3% (756/886) 89.9% (71/79) 86.4% (1485/1719)
fssync 92.5% (5516/5961) 94.4% (501/531) 92.7% (10181/10981)
gateway 34.0% (669/1966) 40.2% (68/169) 31.4% (1029/3279)
git 87.7% (4643/5295) 90.2% (378/419) 87.5% (7409/8463)
guest 82.8% (2286/2760) 80.4% (259/322) 81.5% (3813/4677)
lsp 77.2% (2688/3483) 79.4% (262/330) 74.9% (4210/5619)
proxy 19.2% (172/898) 20.5% (26/127) 21.0% (293/1392)
remote 91.7% (17408/18989) 94.5% (1223/1294) 89.3% (28252/31648)
sd-notify 73.9% (68/92) 100.0% (6/6) 83.2% (109/131)
server 56.5% (32403/57319) 63.7% (2787/4374) 57.8% (49081/84889)
ssh 32.2% (165/512) 48.2% (27/56) 31.4% (261/830)
upsidedown 31.4% (391/1247) 27.8% (55/198) 34.8% (797/2287)
webrtc-forwarder 8.5% (238/2805) 10.7% (22/205) 6.3% (289/4595)
webserver 64.4% (1250/1941) 67.6% (173/256) 66.6% (2099/3151)
Total 59.7% (85843/143736) 65.3% (7404/11347) 60.7% (134312/221347)

@blacksmith-sh

This comment has been minimized.

Comment thread crates/server/src/pty/pty_unix.rs Outdated
The panels behind Manage — a server's applications, its connected clients,
its units, its extensions — were a modal on top of a modal, so they were the
least durable thing on the screen. Anything that closed an overlay closed
them; one of the things that closes an overlay is a window asking to be
raised; and enabling an application in the Session tab starts an application
that does exactly that. The panel dismissed itself one second after being
used, and the way back in was two dialogs.

They are now a BSP tile like any other: `manage:<conn>:`. It splits next to
whatever it manages, it survives focus going elsewhere, the URL hash and the
tab registry restore it, and it parks in the dock as a card. The trailing
colon in the assignment is load-bearing: parseTileAssignment splits on the
first ":" after the prefix, so "manage:hound" parses as nothing at all, and a
tile that fails to parse renders an empty pane. ConnectionControl (the nested
overlay) goes away with its only caller, and RemotesOverlay loses the props
that fed it; what it gains is `onManage`, so a shell with nowhere to put a
tile shows no button.

A panel's list is bounded by its pane instead of by the viewport. The Session
tab scrolled twice — the tile scrolled and the catalog inside it scrolled —
so the wheel did one or the other depending on where the pointer was, and the
"Add an application" search box could be pushed out of sight by the thing it
filters. Both scrollers were right in a dialog capped at 80% of the screen;
in a pane a `vh` cap is a number unrelated to the box it is in. The height
now flows down — tile to chrome to panel — and the panel bounds its own long
list with `flex: 1; min-height: 0`, at every level, or a thousand-row unit
table sizes the box that is supposed to be bounding it. Same fix in the three
other panels that grew up in the same dialog: units, journal (which pages off
its own scroll position, so a second scroller under it was worse than
cosmetic) and extensions.

The parked card is a title, not a picture. It was the server's name over a
frame of shrunk-down panels — a client catalog running every second behind
text at 60% of a size nobody reads — and it dropped the one thing a viewer
picks the card by: which tab it was left on. So it says "dev:manage › Session"
and stops, with the address dim and the same › the terminal and surface cards
use, drawn only when there is a name to separate. The tab has to come from
outside the panels, because parking a pane unmounts them, so the pick and the
tab it resolves to live in a map keyed by connection (connectionTab.ts). Two
values, not one: the pick is what the viewer clicked and may name a tab an
extension has since taken away, and only the mounted panels know the set to
resolve it against. The tile is restored on the tab it left, out of the same
map.

A focused manage pane names itself in the status bar, like every other tile —
an editor its path, a commit its oid, a terminal "dev:1 › zsh" — on the same
focus contract, with an empty actions slot because every control it has is
inside the pane.

And a surface a tile covers is offered in the dock rather than hidden twice.
Tapping a tile's card while a surface held the main view made that surface
disappear entirely: the tile draws ahead of it, and the panel's filter dropped
it because focusedSurfaceId still named it. The sessions memo has had the rule
all along — a terminal is off-screen once a surface *or a tile* is up — and
the surfaces memo never got the tile half. The slot stays set on purpose: it
is what puts the surface back when the tile closes.

e2e: remote-panels asserts pane content and a hash that survives a reload
instead of a second dialog, and every Manage locator is tightened to
/^Manage$/ — a parked manage card is a button whose name ends in the same
word. Measured in a browser at 900px and 460px of pane: exactly one scrolling
element per tab.

Every spec that opens one also closes it, because the dock is the *host's*
open-tab list rather than the page's: a tile left open is registered under
`tabs/` on the server, so it came back as the first parked card in every later
spec — whose own localStorage.clear() cannot reach it — and a card whose body
is a title rather than a preview is not what a suite full of terminals expects
to find there. It also pins the preview panel open, which moves every geometry
the pane specs measure. The cleanup runs in afterEach, so a spec that fails
half way through still hands the next one a clean host.

Co-Authored-By: Claude <noreply@anthropic.com>
pcarrier and others added 4 commits August 19, 2026 16:41
blit could already spawn a child with an exact argv, an explicit cwd, and
explicit environment variables — but only outside a terminal, through the
process family. Inside one, CREATE2 carried a single unstructured command
string that the server handed to `$SHELL -lic`, the child's environment
was built entirely server-side with no client input, and cwd was
reachable on the wire but not from the CLI.

A raw-exec path did exist, undocumented: the server split the command
payload into argv if and only if it contained a NUL, and the CLI joined
its positional arguments with NUL. So `blit terminal start ls -la`
exec'd directly while `blit terminal start htop` went through the login
shell — the same command, two execution models, decided by how many words
you typed.

CREATE2 gains two length-prefixed blocks, HAS_ENV (bit 5) and HAS_ARGV
(bit 6), after the deadline and before the unprefixed command, behind
FEATURE_CREATE_EXEC (bit 29). Neither is probeable: an older server does
not refuse an unknown feature bit, it ignores the bit and reads the bytes
behind it as command text, or finds no command and spawns a plain
interactive shell. So both are negotiated, and every client refuses to
ask for what the server did not advertise. The legacy NUL spelling still
parses and is what the CLI falls back to — with a trailing NUL, which is
what makes a one-word argv take an old server's argv branch instead of
its shell branch.

The open-coded parse in the server's CREATE2 arm moves into
`blit_remote::parse_create2`, so the layout has one authority instead of
one per reader. The legacy fields keep their historical leniency — an
undecodable cwd is dropped rather than refused — and the exec block is
strict, because nothing depends on it being forgiving and silently
exec'ing something other than what was asked is the one outcome worth a
refusal.

Two bugs fall out of the old NUL path:

  - C2S_RESTART replayed only `command`/`cwd`, both None on the argv
    path, so restarting an argv terminal silently gave a bare login
    shell. A Pty now stores the whole ChildSpec and restart replays it.

  - S2C_LIST's COMMAND column reads the same field, so argv terminals
    listed blank. It now carries a shell-quoted rendering of the argv,
    computed once in the create arm and handed to `list_refusal` as well
    as stored. A guard that weighs one string while `push_list_entry`
    writes another truncates a u16 length prefix and desynchronizes every
    client's catalog — and `pty_list_bytes` reads the same field as the
    encoder, so the debug_assert in `pty_list_msg` would stay quiet
    throughout. The rendering is display-only; restart reads the spec.

The exec plan — resolved program, argv CStrings, pointer array — is built
before the fork. The old child branch called `CString::new(arg).unwrap()`
on the far side of a fork in a multi-threaded process: unreachable while
argv came from a NUL split, live the moment it carries arbitrary bytes,
and an abort holding whatever allocator mutexes the dead threads left.
PATH lookup reads the child's own environment, so an overridden PATH
changes which binary runs, and argv[0] is what the client wrote rather
than the resolved path.

A working directory that could not be entered used to be ignored, leaving
the child in the server's directory with nothing said. It now writes one
line to the pty and exits.

Windows parses both blocks and refuses them: its pseudoconsole takes a
command line, not an argv, and has nowhere to put an override. It has to
parse them anyway — a cursor that skipped the blocks would read the rest
of the frame as a command — so FEATURE_CREATE_EXEC is simply not
advertised there.

`blit terminal start` is exec-by-default, with `--shell` for a string
that needs a shell and `--cwd`/`--env` for the other two knobs. The
positional gains trailing_var_arg, without which a command carrying its
own flags never parsed at all: `blit terminal start brave
--ozone-platform=wayland`, the example in learn.md, was a clap error. A
lone word containing shell syntax is refused with a pointer to --shell
rather than exec'd as a filename.

crates/guest gains argv/env on CreateRequest, and js/core encodes both —
plus HAS_DEADLINE, which the TypeScript encoder never implemented, so no
browser client could arm a create-time deadline.

That leaves two encoders and one parser, with asymmetric risk: this crate
ships with the server, the TypeScript one ships in every browser tab and
every embedder pinned to an older release, and each test suite only ever
reads back its own bytes. Frames from the real TS encoder are pinned as
hex in blit-remote, so a Rust-side layout change that JS did not make
fails here rather than in someone's tab.

Verified against a private server: all three knobs at once, sleep as a
direct child with no shell in the tree, restart replaying argv and env,
the catalog naming argv terminals, a bad cwd reported instead of
ignored, and both cross-version directions.

Co-Authored-By: Claude <noreply@anthropic.com>
Selecting Exit in Zoom's tray menu did nothing. The click reached the
server and was thrown away there: monitor_menu voided the whole menu —
zeroing menu_revision, clearing menu_items — on ItemsPropertiesUpdated
just as it does on LayoutUpdated, and the click gate refuses anything
whose revision no longer matches. Zoom emits two property updates on
every AboutToShow, re-syncing its language checkmark, and a logged-in
session repaints whenever its own state moves; each one made every item
in the menu the user was looking at unclickable.

The two signals do not mean the same thing. A layout change can renumber
or repurpose every id, so nothing the client is holding can be acted on.
A property change repaints named items and leaves the rest of the menu
exactly as the user is reading it, so now only those ids stop being
clickable and the menu keeps its revision.

Nothing told the user either way: the TRAY_MENU_STALE a refusal answers
with is coalesced away by the event queue's per-key latest-wins dedup, so
the client only ever sees a refreshed menu — the menu blinks and the app
stays.

A long press was making it more likely. It fires contextmenu and then a
trailing click on the same press, and onContextMenu cleared
primaryPointerType, so that click read as a mouse click and sent
Activate: the app's window came up behind the menu being read, and the
repaint that followed could take the menu with it. trayPrimaryGesture()
now names the three outcomes, and the click a long press leaves behind is
ignored.

Co-Authored-By: Claude <noreply@anthropic.com>
Scrolling a terminal was jumpy with a wheel and outright janky with an iPad's
momentum. Four things were moving the view that the viewer's hand was not.

The server was answering moves it did not need to. Scrollback navigation goes
out as relative moves — `C2S_SCROLL_BY`, one per `scroll` event — precisely so
a gesture and the server's re-anchoring compose instead of racing, and the
server answered *every* one of them with an absolute `S2C_SCROLL_OFFSET`. A
gesture is many events: Chromium animates a notch over several frames, an
iPad's momentum tail is dozens. Each answer landed a round trip after the
position it described had been left behind, the client adopted it as
authority, and the next delta — measured from where it was dragged back to —
came out too big. Twelve rows of wheel went out as 2, 2, 4, 4, 2 and landed on
fourteen. The amplification grows with latency and event rate, which is why a
remote iPad had it worst. The client already applies the same delta to the
same offset before sending, and a re-anchor crossing on the wire shifts offset
and depth together, so only clamping — which the client cannot predict, its
idea of the scrollback's depth being a frame old — is still worth a round
trip. No wire change: an older client is fixed by the absence of a message.

A wheel notch now travels a whole number of rows. A notch is 120 CSS px
whatever the font is, so left to native scroll it lands mid-row, and the
offset it maps to is rounded. At a 19px cell, twelve notches moved 6 or 7 rows
apiece and were pulled back by +6, -7, -1, +5, -8, -2, +4, -9, -3, +3, +9, -4
px. It also collapses the notch from a burst of animated scroll events into
one, so the wire carries one relative move per notch instead of six.
Pixel-precise devices keep the native path deliberately: a trackpad means the
fraction it reports, and macOS varies a notch's size with its own scroll
acceleration, so a wheel there is not recognisable by size and falls here too.

The sync's own echo is claimed by name rather than by the frame it lands in.
`syncScrollSurface` writes `scrollTop` and the scroll listener has to ignore
the event that write produces, which it did by suppressing itself for a frame
— discarding whatever genuine scroll landed inside it. Survivable while a
notch was six events; once quantising made it one, a notch that landed in the
window was the entire gesture and went nowhere. The write knows which position
it asked for, so it claims that instead of a span of time. The frame survives
as a backstop, because a write the browser clamps produces no echo at all. The
resize path keeps its timed suppression on a separate flag: there the thing
being ignored is a reflow re-clamping across two frames, with no single
position to name.

And nothing squares a scroll surface up inside a row any more. The re-align
held off only while a gesture was in flight — one gesture for a flick, one per
notch for a wheel, each settling well under `SCROLL_SETTLE_MS`. There is
nothing to square up: the canvas draws rows from `scrollOffset`, our scrollbar
beside it too, and the surface's own is hidden. A position inside a row is
invisible until the write makes it visible, and the write rides the render
loop — an idle shell only renders on the cursor blink, so the correction
arrived up to half a second after the wheel stopped, reading as the terminal
moving on its own. Whole-row moves — Shift+PageUp, a paste, a server
re-anchor — still land immediately, since those change the offset without
touching the surface. This last part is device-agnostic in a way the rest is
not: it does not care what number the browser called a notch.

Tested at both ends: on the server, the notch that used to be answered six
times is answered none and the two ends of the scrollback still are; on the
client, a rig that plays a gesture against a server holding its own offset and
answering N frames late, where a notch lands on twelve and a flick on
thirty-six for every wire length. The answer-everything variant is kept as a
test so the 2, 2, 4, 4, 2 stays ruled out rather than merely absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking a terminal in the dock next to a talkative Wayland client did not
work: the terminal appeared for an instant and the surface came back. Clicking
another surface behaved the same way, and clicking repeatedly worked only
sometimes. Under a layout it was worse — each attempt re-focused a pane out
from under you.

The click was never the problem. `xdg_activation_v1` was: an activation token
is cheap and its delivery unacknowledged, so a client repeats the request
several times a second, and `activateSurface` honoured every one of them by
raising the surface. Each repeat therefore landed *after* whatever had just
been picked and undid it; "insisting" only worked when a click happened to fall
in a gap between requests. The one guard it had — skip if the surface is
already on top — suppresses repeats only while the app is foreground, which is
exactly the state a click on something else has just ended.

So an activation now buys a highlight and nothing else. The surface that asked
is lit for 1.4s where it already is: its dock card's header bar, or a ring
around its pane — red, fading out, one colour and one direction. The view belongs to whoever is looking at it, and an app that
wants it can only ask to be looked at.

surfaceAttention.ts keeps the one subtle part, pure and tested: a repeat
arriving inside an open window must leave that window alone. Re-arming on each
request would restart the animation from the top ten times a second — a strobe
rather than a pulse — and the window would never close while the client kept
asking. Both helpers return their input by identity when nothing changed, since
the caller holds it in a signal and a fresh-but-equal Map would re-render and
restart the very animation the debounce protects.

Nothing raises any more, so the raise-and-restore machinery behind it was
unreachable: activationStack.ts, `activatedAssignment`, `restoreMainView` and
`lowerFocusedSurface` are gone, and the two callers that reported a dead
surface now just clear the slot. The compositor is unchanged — it still
forwards every request, which is safe *because* the answer is no longer the
view. Validating tokens against a real input serial is still worth doing (they
are a guessable counter with no registry, so one client can forge another's),
but it is a separate concern now that a granted activation cannot take
anything.

Verified against the reproduction: a Chromium surface self-activating every 2s
while dock cards are clicked. Before, every pick was dragged back within ~1.4s.
After, the pick stays and the card pulses once per window — and under
`l=line(a,b)` the pane assignments are untouched across the poke, with the ring
on the unfocused pane that holds the surface.

Co-Authored-By: Claude <noreply@anthropic.com>
@blacksmith-sh

This comment has been minimized.

@pcarrier
pcarrier merged commit 04d5054 into main Aug 19, 2026
19 of 20 checks passed
@pcarrier
pcarrier deleted the pane branch August 19, 2026 17:13
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