Conversation
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>
|
|
🔗 Preview: https://blit-9c0hpircs-indent.vercel.app |
Coverage
|
This comment has been minimized.
This comment has been minimized.
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>
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>
This comment has been minimized.
This comment has been minimized.
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
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 previewsA view with no display size is inert — every input path is gated on one, and
scaledTarget()pins it to a 15 fps downscale.js/reactnever 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 asdriveSurfaceResize(Solid: 312 → 184 lines, its eleven zoom/resize tests untouched),resizabledefaults to true, and the three in-repo previews sayresizable={false}— a dock card drivingrequestResizefrom 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-framesyncImeTargetlayouts, double measurement per wheel event and connection-wide repaints are gone. And view tokens are prefixed with the connection id: a card carryings3onto 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 avhcap 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_ARGVblocks behindFEATURE_CREATE_EXEC, replacing an undocumented rule whereblit terminal start ls -laexec'd directly andblit terminal start htopwent through$SHELL -lic.blit terminal startis 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) andS2C_LISTshowed argv terminals blank. The exec plan is built before the fork — the old branch calledCString::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:
ItemsPropertiesUpdatedvoided 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_BYwith 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 fromscrollTop.Answer a window's plea for attention with a highlight, not the view
xdg_activation_v1tokens are cheap and unacknowledged, so clients repeat them several times a second — andactivateSurfaceraised 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.