GPUI uses a flat painted hit list and does not bubble clicks. A custom row
with its own background can therefore cover SelectItem or ComboboxItem and
prevent selection, hover state, and popup close behavior.
Add asChild to both item primitives through the existing renderSlot path.
The custom row becomes the host item itself, so it owns one native hit target,
one event id, the item ref, composed handlers, state styles, and disabled
selection behavior.
Migrate every chat picker to asChild. MenuRow now forwards its ref and host
props, while keeping its own row padding. Add end-to-end tests for filled
Select and Combobox rows and a chat regression assertion for menu row height.
Document when to use asChild, including the ref and host-prop forwarding
contract. Add a React patch changeset with the new public API.
Session: ses_f61007e8fffe0tRyMx0uRPPeCZ
Select rows stole the click. GPUI has no bubbling, so a filled MenuRow
sat on top of SelectItem and the pick never ran. The fill now lives on
the item. Inner icons and labels set pointerEvents none.
Search stayed at 420px and closed on an outside press. The dim layer
used to take the hit, so onMouseDownOutside never fired. The listener
is on the card. Opening search autoFocuses the filter field.
The composer stays mounted across threads, so autoFocus only ran once.
New Task, a thread switch, and history now call focusElement.
The demo reply no longer bolds the last word, so markdown cannot put a
period on the next line.
Session: ses_f61007e8fffe0tRyMx0uRPPeCZ
Each sidebar row now owns its own transcript. Clicking a thread paints
that conversation. New Task prepends a blank Today thread and clears
the composer. Sends append the user turn, then a demo assistant reply
that says this is the GPUIX chat demo. Retry rebuilds that last reply.
History arrows walk a stack of thread ids. Search filters titles.
Settings shows a short note and can cycle the frame overlay. The
inspector lists the current thread, model, reasoning, access, and
mode. The list filter keeps only threads for the selected project.
Tests cover thread switch, new task, demo reply, inspector, and
history. The live launch wait looks for GPUIX chat demo so the quoted
user text cannot match twice.
Session: ses_f61007e8fffe0tRyMx0uRPPeCZ
The previous array form could not grow. Extra fields such as scroll
offsets would have to sit at magic indexes. Native napi, wasm, the test
renderer, automation, and every call site now use named fields.
Session: ses_f83f90209ffeVmkdjhaBehDTP7
Select no longer walks children by `child.type === SelectItem`. Root `items`
is optional and only a label lookup for SelectValue while the popup is
closed. Keyboard nav and clicks read mounted SelectItem children through
registerItem. Disabled lives on Item. A styled wrapper around Item works.
Without items, SelectValue shows the raw value.
Register items in useLayoutEffect, not a callback ref, so a middle item
can re-render without jumping to the end of the list. selectValue rejects
unmounted and root-disabled items. A late-mounted selected item becomes
the highlight.
Native:
- getFocusedElementId, focusNextWithin, focusPreviousWithin walk GPUI's
painted TabStopMap via focus_next_among / focus_prev_among
- visibility: hidden maps to GPUI invisible()
- getElementBounds is typed on NativeRenderer
JS:
- export Props, GpuixTheme, and host prop types from @gpuix/react
- export FloatingLayer, useFocusTrap, and friends from @gpuix/react/floating
AGENTS.md: built-in headless controls follow Base UI's Root-data vs
children split.
Fixes#59
Session: ses_f83f90209ffeVmkdjhaBehDTP7
A raw Mach-O has no icon. cargo-packager wraps the binary in a .app.
Pass an .icns, not a 1024 PNG. cargo-packager rejected a 1024 PNG with
No matching IconType.
examples/packager.json packs the Bun chat compile as one main binary.
hermes/packager.json lists gpuix-hermes as main and the .node as a
second binary, not a resource. Resources land in Contents/Resources.
Hermes dlopen looks next to the exe, which is Contents/MacOS.
README documents the flow: install cargo-packager, build AppIcon.icns
with sips + iconutil, then cargo packager --release --config packager.json.
formats is host OS only (app/dmg, nsis, appimage). On this machine the
Bun chat .app is 82 MB and the Hermes counter .app is 34 MB.
Ignore packager output: examples/bundle/, examples/AppIcon.icns,
hermes/bundle/, hermes/AppIcon.icns, and tmp-icons/. Do not track
generated hermes/chat.cjs.
Session: ses_f84b88b16ffePB3X7Fqzot47OY
The homepage README now opens on the Superhuman-style mail client
sitting on the desktop wallpaper, not the chat window. Alt text is
`mail.tax example`. The launch snippet is `bun --hot mail.tsx`.
The committed JPEG is a window capture composited onto the Tahoe
wallpaper so the lake shows around the rounded window. Full-screen
capture cannot see other apps on this machine.
Also land the mail example itself: three panes, thread list, reading
pane, and the Framer newsletter assets.
Session: ses_f92fa1015ffeSFgm8TqqfEZnVb
Dispatch click, hover, wheel, and drag input through AnyWindowHandle on desktop and browser hosts. Mouse listeners can now update GpuixView during normal GPUI event delivery without triggering a nested entity lease and aborting the process.
Cover the production renderer with a child-process automation test that clicks the live chat sidebar, waits for the state change, then drives keyboard input through the composer.
Build and verify both the release native addon and the WebAssembly renderer so the ownership fix stays consistent across every live host.
Issue: #38
Session: ses_fa1aaf89dffezIsneAEezbytzc
Add a macOS frosted-glass dashboard that demonstrates GPUI's native blurred window backdrop, transparent titlebar, and translucent React surfaces.
Keep the root tint and glass cards low-opacity so the system vibrancy remains visible. Add a dedicated example command, document it in the examples table, and include a release changeset.
Validation:
- Type-check the example with TypeScript
- Run lintcn in examples
- Launch the native window and inspect a macOS window screenshot
Session: ses_fae3bcd26ffe4af01dEaLO6DG9
Consume the pending changesets into a minor release. Users get
stable GPUI identities on every host surface, background window
launch, live desktop fill/press, pixel-stable virtual-list
anchors, a single applyBatch transport, one prebuilt target per
OS, and CRLF-safe <code> rows.
CI publishes with bun, not npm, so workspace:^ never ships as a
literal string. React still rewrites @gpuix/native to the current
native version before publish, because bun pack otherwise keeps
the previous lockfile caret (^0.5.1 does not include 0.6.0).
The infinite-chat Windows assertion no longer requires the
viewport to be empty of message ids. On Windows the short window
still showed three ids after wheeling into the void; the test now
checks that the list anchor is on the trailing edge instead.
Fixes#25
Session: ses_fb1f5cd3fffeAe9VxtTeynTdjA
Make every native example honor GPUIX_BACKGROUND and make every current real-window automation example pass that flag. Human launches still focus normally, while agent-driven launches avoid taking the user keyboard on macOS and Windows.
Explain why live input, clicks, and screenshots do not require activation. Correct stale guidance that claimed fill and press were unavailable, and document that Linux currently ignores the focus option.
Validated with the examples and example-app test suites, type checking, lintcn, a live screenshot, and a macOS foreground-process check.
Session: ses_fb208083cffewN8NfeygDAdslz
Reduce the renderer mutation contract to one applyBatch call per React
commit across napi, wasm-bindgen, and the GPU-backed test renderer.
- replace the dynamic batching Proxy with an explicit commit-phase facade
- keep application commands on the original renderer instance
- remove direct create, append, style, text, prop, and commit entry points
- remove the unused removeChild and setCustomPropValue wire operations
- require raw JSON style and custom-prop values and boolean event flags
- regenerate native declarations and update architecture documentation
- update serialization benchmarks for the smaller protocol
- add a minor changeset for @gpuix/native and @gpuix/react
Validated with 204 Rust tests, 359 React tests, 47 example tests,
6 starter-app tests, release native and Wasm builds, lintcn, and both
serialization benchmark stages.
Session: ses_fb7d2b638ffen9PHc0FVU3DpAv
`bun scripts/web.ts` now registers one bundled document per example:
`/` keeps the chat and `/infinite` serves the infinite history. The
shared cross-origin isolation headers wrap each document the same way.
The inline page styles move from web.html into a shared web.css that
both documents import, and web-infinite-chat.{html,tsx} are the browser
entry pair for the example. bun.lock picks up string-dedent for the
example's seeded message generator.
Session: ses_fbc66676dffeMDwZ3jJhimdkw6
An older page landing while the reader waited in the loading row moved the
screen. Three stacked causes, three layers of fix:
1. gpui anchors a list on the item at the viewport top. While the reader
waits in the void that item IS the void, so the splice replaced the
screen under it. The old restore (`scrollToItem(listId, index)`) snapped
the anchor message to the viewport top with offset 0, itself a jump of
up to EDGE_HEIGHT pixels. No non-negative ListOffset can express "keep
this row where it is": the rows above it are freshly inserted and
unmeasured. The gpui fork (submodule commit 8b94defe56) now accepts a
negative `offset_in_item` in `ListState::scroll_to`; `layout_items`
resolves it by walking upward with real measured heights, which is what
makes the restore exact rather than estimate-based.
2. On the live renderer `scrollToItem` applied `scroll_to` immediately,
but the just-committed prepend only reaches the ListState at the next
`GpuixView::render`, so `splice_focusable` shifted the freshly restored
anchor a second time. The test renderer hid this because its wrapper
flushes first. Virtual-list scrolls are now queued in
`PENDING_VIRTUAL_LIST_SCROLLS` and applied right after that frame's
splice, then the queue is cleared (one-shot).
3. Scrolling DOWN jumped for a different reason: a reader resting at the
bottom sits on gpui's at-end sentinel (`logical_scroll_top` is None,
reported as itemIndex == item count), where any offset-in-void math is
meaningless. `getListScrollTop` returns
`[itemIndex, offsetInItemPx, viewportHeightPx]`; the app converts the
sentinel with `EDGE_HEIGHT - viewportHeight` so the void's old top edge
stays put and the new page fills the blank space below the text.
New API on all four surfaces (napi, UiCommand, wasm, test renderer):
- `scrollToItem(listId, index, offsetInItem?)` — px, may be negative
- `getListScrollTop(listId)` — logical anchor plus viewport height
`examples/infinite-chat.tsx` drops the snap-to-top workaround and restores
both directions. The loading row shrank from 560px to 160px.
Tests, each verified to fail against the previous behaviour:
- examples: "keeps the message under the reader at the same pixel when an
older page lands", "keeps the trailing messages at the same pixel when a
newer page lands", "lands a newer page inside the void the reader is
staring at"
- packages/react: scrollToItem offset roundtrip, negative-offset
normalization ([50,-100] over 40px rows resolves to [47,20]), top clamp
Session: ses_fbc66676dffeMDwZ3jJhimdkw6
Export the public host instance type for typed element refs.
Add a bounded virtual-list chat example with cursor pagination in both directions, stable prepends, variable-height Safe MDX rows, route links, loading states, and retained-page limits.
Include focused coverage for initial rendering, route navigation, paging to both ends, stable retained rows, and request termination at history boundaries.
Session: ses_fbcc7a504ffe9Ff4lP0KDJLp3B
`focus: false` shipped in the previous commit with no guidance on when to
reach for it. The answer is: whenever an agent starts the app. An agent that
launches the window to check its own work takes the keyboard away from
whoever is typing, mid-sentence, once per iteration, and it gains nothing by
doing so.
**README**
The `Background launch` section now leads with that rule, and a new
`Let an agent drive the app` section documents the environment-variable
pattern so a human run is unchanged and an agent run stays out of the way:
render(<App />, { focus: process.env.GPUIX_BACKGROUND !== '1' })
bun app.tsx # you: window comes to the front
GPUIX_BACKGROUND=1 bun app.tsx # agent: opens behind your editor
**AGENTS.md**
The same rule as an instruction, in `Drive the live window`, so an agent
follows it without being asked.
**examples/chat.tsx**
The AGENTS.md snippet launches `chat.tsx` with `GPUIX_BACKGROUND=1`, but
`chat.tsx` read no such variable, so the documented example would have
silently kept stealing focus. Its `render()` now honours it. This is the app
AGENTS.md tells agents to launch, so it is the one that most needs the flag.
**A claim I had to retract**
The first draft said typing works on a background window. It does not.
`fill()` and `press()` throw `keystrokes are not live yet`, because the live
renderer implements no `simulateKeystrokes`. That is a pre-existing `launch()`
gap and is unrelated to focus; it fails on a focused window too. All three
documents now say so and point at `createTestRoot()` for anything that types.
**Verified rather than assumed**
Every claim was run against a real launched window, then the probe deleted:
click() on an unfocused window works, count=2 after two clicks
screenshot() on an unfocused window works
both on a show:false window works, never on screen
focus not stolen Chrome stayed frontmost
fill() / press() on a live process throws
The exact AGENTS.md snippet was then run end to end: it launched `chat.tsx`,
clicked `sidebar-collapse`, wrote a screenshot of the fully painted app, and
left Chrome frontmost throughout.
`examples` 36 tests pass.
Session: ses_fc17399a8ffeTt72KZetsU15ty
Dispatch synthetic key events through the focused GPUI window input pipeline on macOS and threaded desktop platforms. Expose the generated napi methods needed by launch automation.
Add live composer integration coverage and document fill and press behavior. Replace active Waku branding with GPUIX content and accurate React-to-GPUI architecture details.
Session: ses_fc015b322ffedMaROvgdRpVsuI
Build the browser chat entry as part of the documentation website's production
build. The web build now emits a minified `chat.js` bundle and its Wasm asset
into the website's ignored public directory, after rebuilding the native Wasm
and React package so deployed output cannot use stale generated code.
Keep the production destination fixed at `website/public/chat-example` rather
than accepting a caller-controlled directory. The build clears that generated
directory before bundling without allowing a malformed command to remove an
arbitrary repository path.
Serve the shared example HTML at `/chat-example/`, redirect the non-trailing
slash form, and set COOP and COEP on the document. Those headers make the page
cross-origin isolated, which the shared WebAssembly memory requires. Static
JavaScript and Wasm stay same-origin under the same route prefix.
Add an accessible loading screen while the browser downloads and initializes
the renderer, then remove it after GPUIX mounts. The same shell remains usable
for local browser development, while the production route swaps its TypeScript
entry for the generated JavaScript asset.
Ignore the generated website bundle and add a minor changeset for the new live
example at https://gpuix.dev/chat-example/.
Session: ses_fc04fcb1dffeooxK1KAopXSTAY
Two more assumptions that only held on a developer machine, both found
by the new Windows job and by a macOS runner that shrank to 1024x768.
The timeline test computed every hit point from a hardcoded 1280x800.
The window is a real platform window, so a smaller display clamped it to
1024x642 and the ruler, the playhead and the marquee were all clicked
below the bottom edge. It now opens one probe window, reads the size the
platform granted, and derives the viewport props and the geometry from
that. Verified by pinning the probe to 1024x642 locally: all 19 pass.
The font test asserted that `fontFamily: "Menlo"` changes the picture.
Menlo is macOS only, so on Windows it fell back to the default and the
two screenshots were byte-identical, which is the test doing its job on
a family the host does not have. `MONO_FAMILY` names a real monospace
per platform instead.
Session: ses_fc0b7cd62ffe3aBw0pyYBOBDjf
`/tmp` does not exist on Windows, and native `save()` never creates the
parent directory, so every capture in styles, events, img and the diff
example failed the whole test there with
Failed to save screenshot: The system cannot find the path
specified. (os error 3)
That is 34 of the 41 failures in the new Windows test job.
`SHOTS_DIR` already pointed at packages/react/screenshots for exactly
this reason, so the fix is to use it, and to create it where it is
defined rather than in a `beforeAll` each file had to remember. The
examples diff test gets the same treatment against
examples/screenshots.
Session: ses_fc0b7cd62ffe3aBw0pyYBOBDjf
An edit to `examples/chat.tsx` now hot-updates the running page instead of
reloading it. Components swap in place and `useState` survives, so the GPUI
canvas, the composer text and the scroll position all stay where they were.
A reload re-downloads a ~19 MB Wasm module; this avoids that entirely.
`scripts/web.ts` drops the `Bun.build` step and the hand-written static file
map. It now registers `examples/web.html` as a Bun HTML route with
`development: { hmr: true }`, so Bun bundles the page, watches the graph and
applies the React Fast Refresh transform.
Fast Refresh never talks to a renderer directly. It goes through the React
DevTools global hook:
Bun HMR runtime
-> refreshRuntime.injectIntoGlobalHook(window)
@gpuix/react reconciler.ts
-> reconciler.injectIntoDevTools()
-> hands the hook scheduleRefresh + setRefreshHandler
react-refresh
-> performReactRefresh() -> scheduleRefresh(root)
That injection already existed. What was missing was a bundler that runs the
transform, and a test proving the wiring holds.
`examples/web-chat.tsx` must NOT call `import.meta.hot.accept("./chat", ...)`.
Bun runs an importer's dependency-accept callback even when the imported module
already self-accepted for Fast Refresh, so the callback remounted the tree on
top of a successful refresh and wiped every hook. That looks exactly like Fast
Refresh being broken, and it is not.
The Wasm half is a singleton: `WebGpuixRenderer::init` fails with "GPUIX web is
already running" once its thread-local app exists, and gpui_web appends its own
canvas to <body>. Living in node_modules is not what protects it, because Bun
bundles it into the same client registry. What protects it is that Bun re-runs
only the changed module and then walks upward through its importers, so an
unchanged dependency stays evaluated and cached.
packages/native/.cargo/config.toml links with `--shared-memory`, so the
generated `WebAssembly.Memory` is `shared: true` and the document must be
cross-origin isolated. `Bun.serve` has no way to add headers to an HTML route
(oven-sh/bun#16873), so the bundled document is registered on a private path and
re-sent from `/` with COOP + COEP. Only the top-level document needs them:
`require-corp` constrains cross-origin subresources, and every asset the dev
server emits is same-origin.
`packages/react/src/__tests__/fast-refresh.test.tsx` mounts through the GPU test
renderer, clicks to set state, swaps the component family, and asserts the new
output keeps the old state. It fails when `injectIntoDevTools()` is removed.
This matters because the failure mode is silent. Without the injection there is
no error and no page reload: Bun still accepts the update and still calls
`performReactRefresh()`, which iterates zero mounted roots and schedules
nothing. The bundle changes and the painted UI stays stale.
Do not assert on the return value of `injectIntoDevTools()`. It ends in
`hook.checkDCE ? true : false` and react-refresh installs no `checkDCE`, so a
working injection still reports `false`.
- `--build-only` / `--rebuild` / auto-build-if-missing in scripts/web.ts, and
the missing check tests both wasm-bindgen outputs, not just the `.wasm`
- `examples/tsconfig.json` gets `noEmit: true`; nothing there is ever compiled
by tsc and a stray `tsc -p .` littered a `.js` next to every `.tsx`
- `@types/bun` as a root devDependency plus a `typeRoots` entry so
`scripts/tsconfig.json` types `Bun.serve` and the `*.html` bundler import
- README documents that automation locator bounds are canvas pixels, not CSS
pixels. Do not use `window.devicePixelRatio`; a tool can override the viewport
scale after GPUI has sized its canvas. Use `canvas.width / canvas.clientWidth`
Validated in Chrome with playwriter: text edits and a module-level colour
constant edit both repaint the canvas with state preserved, and a revert
restores a byte-identical screenshot.
Session: ses_fc233909fffeHxAGjrf2jkWSet
A `highlight` prop on any element paints a background wash behind matched or
explicitly given text ranges. It scopes to the declaring element's subtree, so
the root searches the window and a container searches that container. Nothing
resolves and nothing paints unless an element declares one.
<div highlight={{ query: 'fox' }}>
<text>the quick brown fox</text>
<code code={source} language="ts" />
</div>
Every string GPUIX paints already funnels through `crate::text`, so one hook
there reaches `<text>`, `<code>`, `<markdown>` and `<diff>` with no per-element
props. `useTextSearch` drives a find bar; `findRanges` is the same matcher in JS
for row data a virtual list never mounted.
There is deliberately NO joined document for a query
--------------------------------------------------
`shouldSetTextContent` is false, so React makes a separate host text node for
every interpolated string. `<text>Hello {name}!</text>` is three painted runs of
one logical line, and a per-node matcher cannot find `Hello Tommy`. So text has
to be merged before matching.
Merging the WHOLE subtree into one string was the obvious approach and it is
wrong: a 5k-row chat becomes one megabyte string, and the query is part of the
cache key, so a find-bar keystroke rebuilds it. Instead `GroupList::collect`
merges only what React split: adjacent primitive text children of one parent.
A match never crosses a group, which is what a browser does across a paragraph
boundary. A joined document is built only when a spec supplies explicit
`ranges`, because only then does anyone index into it.
Grouping is structural. It never reads `display`, which only knows `flex` and
`grid` here, and every text node already sits inside a `div`.
Two caches, and why search_revision exists
------------------------------------------
GroupList keyed by search_revision (walk + fold)
MatchSet keyed by search_revision + matcher (scan)
washes derived colorize() (O(matches))
`highlight` is itself a custom prop, so `subtree_revision` moves on every
keystroke. Keying the group list on it makes the cache do nothing for the one
case it exists for. `search_revision` moves on text, structure, and a nested
declaration appearing or disappearing; not on style, not on a native element's
own props, not on the query.
`matcher_hash` excludes `activeIndex` and the colours, so moving a find cursor
only re-colours matches that were already located.
A timing budget cannot police this. On the 1000-turn chat the broken version is
2.7ms against 1.9ms, because most of that text lives in native element props
rather than retained `<text>`. `highlight_cache_tests` in `renderer.rs` compares
`Arc` identity instead and fails outright.
Native elements match their own painted string
----------------------------------------------
`<code>`, `<markdown>` and `<diff>` build text inside `render()`, so the
build-time resolver never sees it. Rather than a second traversal that
re-derives their text and `sub` values, which would drift from markdown's
render-time counter, they match the exact string they are about to paint
through `washes_for_native_run`.
Their matches share one ordinal sequence per declaration, continuing after the
retained matches via `NativeHighlight::offsets` and memoised per run so a row
gpui paints twice keeps its numbers. Numbering each run from zero made
`activeIndex: 1` mark the second match of every code line active.
Copy grouping, and destroy_element
----------------------------------
Two pre-existing bugs surfaced while building this, each with its own changeset.
`join_spans` put a newline between every span, so copying the interpolated line
above yielded `"Hello\nTommy\n!"`. Runs now carry a group and same-group runs
join with nothing. Copy and search share `search::group_id`, because deriving
one from `element.parent` and the other from adjacency silently diverges when a
non-text sibling sits between two leaves.
`destroy_element` never unlinked the child from its parent's `children` and
never called `mark_changed`. React sends `removeChild` first so it was hidden,
but the batch and napi APIs both allow a direct destroy.
Contract
--------
Matches are non-overlapping and leftmost-first. Case-insensitive matching uses
Unicode lowercasing, not full case folding, so `ff` does not match `ff`; `fold`
carries an offset map because lowercasing changes byte length. Explicit ranges
are UTF-16 code units and a boundary inside a surrogate pair is rejected, never
snapped. A word boundary is any code point that is not a letter, a digit, or
`_`. `userSelect: "none"` does not opt out of search, because a browser still
finds that text; only real chrome does.
`onHighlight` is queued during build and flushed after the root build returns,
keyed on `MatchSet::identity()`. Emitting inline lets a `setState` in the
handler re-enter the build; keying on the count alone misses a query swap that
finds the same number of hits.
Testing
-------
A wash is a quad, so `getPaintedText()` cannot see it. `getPaintedHighlights()`
reports the matched range in UTF-16 units plus the boxes drawn, one per visual
row, on all three renderers including the browser build.
167 native tests, 305 react, 17 examples.
Known limits, documented rather than hidden: `onHighlight` counts retained text
only, and a `<virtual-list>` row that was never built cannot be matched,
counted, or scrolled to. `findRanges` exists for exactly that.
Session: ses_fc21bea16ffe1AkeGWr9j3cQTa
The pan perf test never called `flush()`, so it timed the React update and
none of the GPUI build, layout and paint that follows. That hid the single
most important fact about a pannable surface.
| Wheel pan, 3,259 clips over 26 tracks | p50 |
|---|---|
| Culled | **7.7 ms** |
| `memo(ClipLayer)` only, no culling | **92 ms** |
| `memo` only, as the old test reported it | 0.6 ms |
`<virtual-list>` is the only thing that virtualizes. A surface that owns its
own offset places children absolutely, so GPUI lays out every retained child
every frame. `memo` and culling fix different halves:
```
memo(Layer) ► cuts React work and the applyBatch mutations
cull in JS ► cuts GPUI build, Taffy layout, and paint
```
The cull-off case now has a budget too, so the culled path cannot quietly
drift into it, and the drag samples use the new unflushed
`dispatchMouseMove` so one sample is one frame, like the pan.
Three correctness fixes in the example:
- a clip could be dragged past the last frame of the project, where nothing
could reach it again. Both the wanted position and the snapped position
clamp now, and a snap the clamp overrode no longer paints a guide
- the playhead could scrub past both ends
- collapsing a track only hid its clips and left an empty band. The row now
shrinks to an 18px header strip and everything below moves up
Documentation corrections, all from the same review:
- `AGENTS.md` still said to keep `occlude()` for absolute overlays, which
contradicts the rule three sections down
- `BlockMouseExceptScroll` is not DOM bubbling. GPUI hit-tests one flat list
of painted boxes, so the wheel reaches **any** scroller behind the
element, not only an ancestor. A new test covers an absolute card over an
unrelated sibling scroller
- `pointerEvents: "none"` stops the element blocking hitboxes behind it. It
does not disable that element's own listeners
New README material worth keeping: the culling section above, the
left-button capture rule, and the readout pattern. Every timeline test
paints its state into a `<text testId>` and asserts an exact number, which
survives a layout tweak far better than a screenshot diff.
Session: ses_fc1c6df4bffewVGSCAfO0JZ4nT
```bash
cd examples && bun --hot timeline.tsx
```
The question was whether GPUIX can carry a real editing surface, so the
example drags clips between tracks, trims both edges with snapping,
scrubs a playhead, marquee-selects, zooms under the pointer, and pans on
both axes with a frozen ruler and a frozen track column. Data is mocked
from a seeded generator, so every run paints the same project.
```
┌──────────────────────┬─────────────────────────────────────────────┐
│ tools 00:03:50 │ Ruler 0 5 10 15 20 25 │ ◄ pans X
├──────────────────────┼─────────────────────────────────────────────┤
│ Caption │ ▓▓▓▓ ▓▓▓ ▓▓▓▓▓▓▓ │
│ ▸ Track 10 │ █████████████ ▎playhead │ ◄ pans X and Y
│ ▸ Track 9 │ ▓▓▓▓ ▓▓ │
│ Audio 1 │ ∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿ │
│ ▲ pans Y │ │
└──────────────────────┴─────────────────────────────────────────────┘
```
Two patterns in it are worth copying.
**React owns the scroll offset.** A native `overflow: "scroll"` grid
cannot drive a frozen header: GPUI moves the grid on the wheel frame,
and the `onScroll` callback that would move the ruler lands a frame
later, so the two tear apart during a fast pan. One `onScroll` listener
on a non-scrolling parent keeps `scrollX` and `scrollY` in state, and
all three panes translate from the same numbers. Zed does the same. A
media-bin panel beside the preview stays on the native two-axis path for
comparison.
Each translating wrapper sets `pointerEvents: "none"`, because a
positioned box takes hits with no fill and would otherwise swallow every
press meant for the ruler or the empty grid behind it.
**A drag needs no overlay.** Every clip, trim handle, the ruler, the grid
and the zoom slider carry `onMouseDown`, `onMouseMove` and `onMouseUp`,
which arms GPUI's pointer capture, so a release past the window edge
still ends the gesture. A full-window overlay mounted on the press cannot
do that: capture is armed by the press, and the overlay does not exist
yet at that moment. I built the overlay first and it failed exactly that
test.
`timeline.test.tsx` drives all of it through the automation API and
asserts exact numbers read from three `<text testId>` readouts, not
screenshots. `timeline.perf.test.tsx` times mount, pan and drag on a
3,259-clip, 26-track project:
| Sample | p95 |
|---|---|
| mount | 131ms |
| pan, culling on | 1.90ms |
| pan, culling off | 0.64ms |
| drag move | 11.7ms |
Drag samples are not comparable to pan samples.
`nativeSimulateMouseMove` flushes before and after the event, so each one
contains two complete GPUI paints.
Session: ses_fc1c6df4bffewVGSCAfO0JZ4nT
The `applyBatch` path looked like the obvious place to speed up a large
mount, so measure both halves on the real `ChatApp` queue instead of
guessing. `examples/bench-serialization.ts` times the JS encode, and
`packages/native/examples/bench_serde.rs` times the Rust decode and the
resident tree.
The answer is that the codec is the **smallest** lever:
| Finding | Number |
|---|---|
| Allocations during decode | 1.76M for 221k ops |
| `StyleDesc` size | 1392 B, so a typed `Vec<Op>` gets 1408 B wide |
| `RetainedElement` resident | 3102 B, 5.4x smaller behind an `Arc` |
| Ops that actually parse a style | 90 of 221k |
`batch_payload` deep-clones every style payload and then `from_value`
parses it again. Interning styles in the protocol beats any codec swap,
and a hand-packed compact style buys nothing once they are interned.
No mainstream JS-Rust codec deduplicates repeated string *values*.
msgpackr `useRecords` deduplicates keys and emits a format `rmp_serde`
cannot read, so value dedup has to happen in the protocol.
`retained_tree` and `style` become `pub` so the bench measures the real
types instead of a copy that silently drifts. `rmp-serde` is a
dev-dependency only; the shipped addon still speaks JSON.
The same `Cargo.toml` and `lib.rs` hunks also carry the
`gpui_platform/test-support` feature and the Windows `cfg` for the test
renderer, because those edits sit inside the same hunks.
Session: ses_fc1c6df4bffewVGSCAfO0JZ4nT
`<code>` no longer paints a fill, border, radius, padding or language header, so
the two examples that showed a card now build one.
**chat.tsx** gets a `CodeBlock` wrapper used by both call sites, the transcript
rows and the safe-mdx `renderNode` override, so a fenced block in a markdown
turn and a raw code turn look the same. It drops `codeRadius` and
`codeHeaderTextSize` from `CHAT_THEME.metrics`: those fields are `mdCode*` now
and only reach the `<markdown>` fenced block, which the chat renders through
safe-mdx instead.
**native-text.tsx** gets the same wrapper for its CODE tab.
The mount budget in `chat.perf.test.tsx` goes 150ms to 170ms. The card is three
extra host nodes per code turn now, about 6ms over 1000 turns. Chrome costs
React nodes instead of native paint, and that is the point of the change.
Session: ses_fc1eac9e3ffewHyLo8c2O8kvvc
A long press in the mobile web build highlighted the whole screen and popped
the iOS callout. Two browser behaviours, both on the host page:
- **Selection.** The app draws every glyph on the canvas, so a long press
found no text and selected the whole `body` instead.
- **Tap highlight.** `-webkit-tap-highlight-color` paints a box over the
tapped element, and the canvas fills the window.
`contextmenu` was already prevented in `gpui_web/src/events.rs`, so only the
CSS side was missing. Long press now belongs to GPUIX, which handles
selection itself.
`[data-gpui-input]` keeps `user-select: text`: `gpui_web/src/ime_mirror.rs`
writes a real selection range into that hidden textarea for composition,
backspace context, and autocorrect. A blanket `user-select: none` would break
IME on iOS and Android.
Verified in Chrome against the running example:
| Element | user-select | tap highlight |
|---|---|---|
| `body` | none | rgba(0,0,0,0) |
| `canvas` | none | rgba(0,0,0,0) |
| `[data-gpui-input]` | text | rgba(0,0,0,0) |
This fixes the example page only. The stronger place is `prepare_canvas` in
`zed/crates/gpui_web/src/window.rs`, next to the `touch-action: none` GPUI
already sets, so every host page inherits it.
Recommitted after an accidental reset in a parallel session; content is
identical to the original f99ad81.
Session: ses_fc26d592affeZYo4Ozf6yC9ilK
An iOS keyboard covers the bottom of the window, so the chat composer
sat behind it with no way for an app to know. GPUI reports the geometry
through WindowInsets; nothing surfaced it to JS.
- getWindowInsets() on both the napi and Wasm renderers, returning
safeArea, ime, and effective edges.
- useWindowInsets() samples that every 100ms and only rerenders when
the numbers change. Safari fires visualViewport events in bursts
while the keyboard animates and is documented to report stale values
on some of them, so polling is steadier than subscribing. Pass
intervalMs to slow it down, or false to read once.
- The hook also derives keyboardTop, keyboardVisible, and visibleHeight.
- examples/chat.tsx pads its content column by ime.bottom, so the
composer and workspace footer stay above the keyboard.
index.d.ts is regenerated by napi from the new #[napi] types.
Session: ses_fd13bc1a1ffecH1QUVugD0YnOj
render({ debugFrameOverlay }) only worked on desktop. The Wasm renderer
had no binding at all, and the React renderer silently skipped the call
through optional chaining, so the browser had no way to see draw time.
- Turn on gpui's profiler feature. DebugFrameOverlayMode and
set_debug_frame_overlay_mode sit behind it, and hdrhistogram builds
for wasm32-unknown-unknown.
- Export setDebugFrameOverlay and getDebugFrameOverlay from
WebGpuixRenderer.
- Queue the mode when the GPUI window does not exist yet. Browser
graphics init is async, so render() sets the overlay before
WEB_WINDOW is populated; open_window applies the pending mode on the
first paint. Without this the setter threw during ChatApp render and
the React tree never mounted, leaving a black page.
- Share the mode parser between napi and wasm instead of duplicating
the string match.
examples/web-chat.tsx turns full mode on.
Session: ses_fc2a313e9ffebyZow7iy5ZvYDR
GPUI does not dispatch TouchEvent into a gesture arena yet, so gpui_web
forwarded every pointer as a mouse event. Combined with the canvas
touch-action: none, an iOS swipe became a text selection and nothing
scrolled. A composer tap opened the keyboard and lost it immediately.
The gpui_web side of this lives in the zed submodule, which this commit
moves to 773208d. It maps touch pans to ScrollWheelEvent, holds
mouse-down until a long press, tracks one active touch pointer, and
handles pointercancel and lostpointercapture.
Native side:
- A tap only arms the selection. It no longer selects or blurs, so a
swipe scrolls and a composer tap keeps focus. The first dragging move
promotes the armed press into a real drag and blurs then, which keeps
Cmd+C copying painted text (see 95545aa).
- <input> and <textarea> call window.request_text_input() from their
mouse-down handler. gpui_web reads that synchronous intent instead of
the previous frame's input handler, so the keyboard opens during the
browser user gesture.
- web.html no longer pins the hidden IME field to (0,0). Rust parks it
on the caret; a clipped 1px box at the origin makes iOS drop the
keyboard.
Verified in the browser build with Playwriter, driving synthetic touch
pointer events against the wasm renderer:
| Gesture | Result |
| ----------------- | ----------------------------------------- |
| pan | scrolls both ways, selection stays null |
| composer tap | readOnly false and focused, keyboard opens|
| tap elsewhere | readOnly true, keyboard closes |
| long press | selects the word under the finger |
| long press + drag | extends the selection |
Not verified on real hardware. Touch pans still have no momentum;
GPUI's touch arena (zed-industries/zed#63068) excludes it.
Session: ses_fc2a313e9ffebyZow7iy5ZvYDR
GPUI Web keeps a focused DOM input for clipboard and composition. It only
used a 1px opacity-0 box at top:0, so host `input` CSS made a native text
field show at the top of the page.
Point zed at the gpuix fork commit that forces the hide with important
inline styles, clip-path, and autocomplete=off. The browser example CSS
matches that so a refresh hides it without a Wasm rebuild.
Bridge GPUI events back to React through a queued Wasm callback. Event payloads keep the desktop camelCase shape, and microtask delivery prevents React mutations from re-entering GPUI during native event dispatch.
Point the Zed submodule at the browser-safe GPUI gesture clock so precise wheel input no longer panics on wasm32-unknown-unknown.
Restore the complete web chat transcript with React-composed Markdown, tables, code, and native diffs. Sidebar controls, selects, comboboxes, inputs, and scrolling now work in the browser.
Validation: Wasm release build, 108 native tests, 258 React tests, 17 functional example tests, six GPUI gesture tests, rustfmt, lintcn, and Playwriter clicks and scrolling through BrowserWebGPU.
Session: ses_fd23d320cffeTXMP6XvLBmNC2a
Follow the virtual-list children contract introduced on main and keep the six-turn browser transcript regression check focused on the retained child count.
Validation: chat and diff example tests pass; the full Wasm build, native build, Rust tests, React build, and React tests also pass.
Session: ses_fd23d320cffeTXMP6XvLBmNC2a
Resolve the duplicate wasm dependency table introduced by the rebase and use web-time for automation and motion clocks so wasm32-unknown-unknown compiles cleanly.
Guard Node stdin automation in browser bundles, render a bounded six-turn web transcript, and validate the small-turn transcript contract with the native renderer.
Validation includes the release native addon, full Wasm and browser bundle, BrowserWebGPU runtime loading in Chrome, 108 Rust tests, 258 React tests, 21 example tests, rustfmt, and lintcn.
Session: ses_fd23d320cffeTXMP6XvLBmNC2a
Compile the retained GPUIX renderer for wasm32-unknown-unknown and expose the React mutation interface through wasm-bindgen while keeping napi-rs as the desktop host.
Publish the browser loader and Wasm asset, add the ChatGPT browser example and isolated development server, and prepare the release workflow with the required Rust and wasm-bindgen toolchains.
Use GPUI Web bundled IBM Plex Sans and Lilex fonts, add portable raw SVG source rendering, and embed the chat icons so the browser canvas matches the native layout.
Validation: 86 Rust tests, 12 example tests, 3 native image/SVG tests, React builds and package lints, Bun package dry-run, and Playwriter WebGPU visual checks.
Session: ses_fd2303a27ffeNl7JAmV7905R7z
CI already built @gpuix/native for every napi target. Each of those jobs now
also compiles examples/chat.tsx with bun build --compile against that target's
.node, and uploads example-chat-<rust-target>. On a main push, publish attaches
those files to the existing @gpuix/react@x.y.z GitHub release.
compile-chat.ts now accepts CI env:
- COMPILE_OUT names the binary (Windows still gets .exe)
- COMPILE_TARGET is the bun compile target, e.g. bun-darwin-arm64
- COMPILE_SKIP_ICONS and COMPILE_SKIP_APP skip rsvg/magick and the macOS .app
wrap, which CI runners do not have
Icons are also skipped when rsvg-convert or magick is missing. The Windows
icon is optional. A compiled binary starts because chat.tsx treats
Bun.isStandaloneExecutable as an entry point, not only Bun.main.
The publish job downloads only bindings-* for npm, then example-chat-* for
the release upload. Missing release tags or empty artifacts skip instead of
failing the publish.
README and AGENTS document the download names. The changeset is a react minor.
Session: ses_fcb34222fffeedDTVhecHbRYvw
The windowed VirtualList helper only mounts the visible React window. That hid
most transcript rows from the retained tree, so tests had to guess last-item
indexes and could not assert a full child list. The chat example now maps every
turn into <virtual-list> children again.
Markdown turns go through SafeMdxContent instead of the native <markdown>
element. The painted overview text still comes from that React markdown tree,
so the old findByType('markdown') check is gone.
Tests follow the tree instead of hardcoded indexes:
- scrollToItem uses transcript.children.length - 1
- an 80-turn mount now keeps 80 retained children across a sidebar collapse
- chat.perf cases get a 60s timeout, because mount and chrome updates pay the
full React child list again
Session: ses_fcb34222fffeedDTVhecHbRYvw
Prose already wrapped when the box had a definite width. A markdown node in a
flex row kept its max-content width, so a long paragraph or list item could
leave the column. The markdown root and each text block now shrink with
min-width: 0. Chat Safe-MDX does the same: a single-string paragraph is a
full-width text node, and list or quote columns set minWidth: 0.
Fenced code inside markdown, and the host code element, now use a flex-row
overflow-x viewport with a flex_none content column. A flex_col scroller
stretched nowrap rows to the card, so a horizontal wheel did nothing. The
vertical wheel still stays on the parent via restrict_scroll_to_axis.
getElementBounds and automation locators work on markdown, code, and diff.
Those elements never painted a bounds tracker, so a testId returned null.
Empty markdown no longer early-returns, so it records a box too. Tests
assert wrap geometry from painted bounds, and screenshot-diff a horizontal
pan on a wide fence.
TestRenderer.findByTestId looks that testId up from the retained tree.
Session: ses_fccafff19ffeBvtYVPjN9ok24s
The suite was logging React production vs development, implied FPS, and
mean times. Those numbers did not change the budgets. Overlay stats
include the setup flush after reset, so they are logged only. A
throttled run still skips the default budgets.
Also
- readMacCpuThrottle() only reads THROTTLE. GPUIX_THROTTLE is gone.
- profile-chat-scroll.tsx times dispatchScrollWheel(), not a later
flush(). The wheel already draws.
- Drop the NODE_ENV=production notes from AGENTS.md. Development React
stays the default so warnings remain.
Session: ses_fccd34c30ffejLDt6w20KI87xR
The host <virtual-list> still retains every child. That is fine for a few
hundred turns. A 10k-row chat paid the full createInstance and applyBatch
cost at mount, then again on every chrome setState that remapped the list.
VirtualList takes itemCount and renderItem. Native ListState keeps the
logical length for the scrollbar. React only mounts the visible window
plus overdraw.
JS VirtualList native <virtual-list>
itemCount + renderItem itemCount + windowStart
| |
+-- children[window] ------------->+
^ |
+------- onVisibleRange -----------+
Native changes
- EventPayload grows startIndex and endIndex for visibleRange.
- VirtualListConfig reads itemCount. logical_count() is that value, or
the child length when the host still maps every row.
- VirtualListEntry stores window_start. child_at() and logical_index_of()
translate between the mounted slice and the gpui list index.
- A sliding window is not a splice of items 0..N. Only the unwindowed
children API still uses prefix/suffix splice.
- scrollToItem emits visibleRange so the React window can catch up.
TestRenderer.dispatchNativeEvents() now runs after scrollToItem for
the same reason.
React changes
- VirtualList computes an initial window from alignment and followTail.
- onVisibleRange pads by viewport + overdraw and skips identical ranges.
- Chat Transcript uses VirtualList. Markdown turns go through <markdown>
instead of SafeMdxContent so a windowed row stays one native node.
Tests and docs
- virtual-list.test.tsx keeps an 8-row window over 1000 logical rows.
- chat.test.tsx scrolls by logical index, not children.length, and
expects fewer retained rows than turnCount.
- README and AGENTS.md document the windowed path and the chrome
setState remount trap.
Session: ses_fccd34c30ffejLDt6w20KI87xR
The debug overlay already stored the last 1000 draw samples. JS could
not read CUR / 1% / 10% / MAX. Tests could not fail on a slow frame.
GPUI now exposes Window::debug_frame_overlay_stats(). Native and
TestRenderer wrap that as getDebugFrameOverlayStats(). p90Ms is the
overlay 10% line. p99Ms is the overlay 1% line.
examples/chat.perf.test.tsx times three paths through createTestRoot:
- mount of 1000 turns
- wheel draw via dispatchScrollWheel, not a second flush
- sidebar click
It asserts p95 and max, not every-frame FPS. Budgets are catastrophic
caps for an unclamped M-series CPU.
THROTTLE=utility|background|maintenance re-execs under taskpolicy -c.
taskpolicy -c only works at launch. examples/vitest.config.ts wraps
the main vitest process so workers inherit the clamp. A throttled run
logs numbers and skips the default budgets. GPU and RAM stay on the
host. This is an E-core / QoS clamp, not Chrome 6x.
NODE_ENV=production is about 10% faster on mount and sidebar. Wheel
draw does not change. Do not force production on the whole suite.
CI on macOS now runs bun run test in packages/react and the chat
perf file after building @gpuix/react.
Session: ses_fcd37c2c6ffeCEJRtM5YPhq0WN
A 5,000-row chat hitch on every sidebar click and composer keystroke.
ChatApp owned chrome state and remapped every Transcript row on each
setState. Collapse measured 254ms. Scroll was already fine at ~5ms.
memo(Transcript) so collapsed, draft, model, and footer state do not
rebuild the list. Turns stay in ChatApp. A new message still updates
the list. Sidebar motion and typing no longer walk 5k fibers.
VirtualListEntry::sync also paid for every GPUI frame. Sidebar motion
and the focused caret requested frames, and each one built a HashMap of
all row ids plus cloned focus handles. Return early when config, child
ids, revisions, and focusability match.
getAutomationTree() serialized style, events, and custom props for every
retained node. Locators only match id, type, testId, and text, then read
bounds. That JSON was 110ms on a 5k-row tree, so every
getByTestId().click() was dominated by unused style maps.
to_automation_json() drops those fields. get_tree_json() still uses the
full to_json() path so TestRenderer style inspection is unchanged.
After the change, collapse is 66ms and tree JSON is 22ms. Mount of 5k
rows is still ~220ms in one applyBatch. That is the remaining first-paint
cost, not the interaction hitch.
Also replace the AGENTS.md screencapture recipe with the automation API.
Drive live windows with launch() / connectTest() and testId locators.
Do not use usecomputer or desktop clicks.
```
chrome setState
│
▼
ChatApp re-render
│
├── memo(Transcript) ► skip 5k row map
└── Header / Composer ► cheap chrome only
```
```
GpuixView::render
│
▼
VirtualListEntry::sync
│
├── ids/revs/focus same ► return
└── otherwise ► splice / remeasure
```
INTERACT=1 on examples/profile-chat-scroll.tsx prints mount, getTree,
idle flush, collapse, and expand timings.
Session: ses_fd0cc2171ffedb9Cl9KHr9g62w
Wrap the rewritten 252px sidebar and its 1px divider in one native motion.div clipping row. Keep the content mounted, expose stable collapse and expand locators, and preserve the current Waku layout while the main pane expands.
Drive the real collapse through the automation locator and freeze the native clock to capture exact 100ms and 200ms frames without timer sleeps. Keep composer and send locators aligned with the automation guide.
Use the direct mdast Root type for the safe-mdx cache and declare @types/mdast in the examples package. Update the README sidebar pattern to the exact 253px animated width.
Session: ses_fd5faf7f1ffezwFhkdKQa42frN
Composer submit used to only clear the draft. Enter and the send
button now append a user turn and scroll the virtual-list to that
row. The test types hello, submits, and asserts the last row.
Markdown turns now go through SafeMdxContent. Parse results are
cached by source so a 5k remount does not re-parse the same
blocks. The live window starts at 5,000 turns instead of 10,000.
Also add a wheel-flush profiler for bun --cpu-prof, retitle the
compiled binary as Waku-style, refresh chat-app.png, and drop the
stale chat-diff curated screenshot.
Session: ses_fd1333811ffefCKd7t9qN7SLgI
The Waku-style chrome already referenced compose, folder, settings,
git-branch, lock, zap, and the rest. Those files were never in the
tree, so bun --hot chat.tsx could not resolve the file imports.
Each file uses fill or stroke #000 so native <svg> can tint from
style.color.
Session: ses_fd1333811ffefCKd7t9qN7SLgI
Two cases stayed blank. GPUI external_path is async, so the first
paint had no image. Bun and Vitest import … with { type: file }
pass a data:image/svg+xml URL, and fs::read cannot open that.
Load src when the prop is set. File paths come from disk. Data
URLs are percent-decoded. The icon paints with svg().data(...)
and tints from style.color.
currentColor in the file is not style.color. Existing chat icons
now use fill or stroke #000 so the tint applies.
Session: ses_fd1333811ffefCKd7t9qN7SLgI
A backgroundColor used occlude() (BlockMouse). That ended the hit
test, so the transcript list never saw the wheel when the pointer
was over text. Empty padding still scrolled. Content felt stuck.
In-flow fills now use block_mouse_except_scroll(). Absolute, fixed,
and pointerEvents: auto still occlude.
Also ship the table work that made this visible:
- display: grid with gridTemplateColumns and max-content tracks
- SafeMdx tables flatten into one grid
- overflow-x is a flex viewport (min_w_0 + flex_none child) so a
wide table can pan on X without remapping the vertical wheel
- the live chat example repeats the thread to 10,000 rows
Session: ses_fd15f94abffe0KCQKm2WAXHew8