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
Browser WebGPU creates its public window handle before the internal GPUI window can accept updates. Calling focusElement from a React mount effect therefore threw GPUIX web window is not ready even though WEB_WINDOW was already present.
Store the latest browser focus target outside the window update path. Consume it during GpuixView render, after the window is active, and apply it after focus handles are synchronized. Explicit focus keeps precedence over autoFocus.
Use the same deferred focus state on desktop when a requested host element does not have a focus handle yet. Clear an older pending request whenever a newer request focuses an existing handle, so stale state cannot steal focus on a later render.
Document startup queuing and the latest-request-wins rule. Add a native patch changeset for the browser API behavior.
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 Holocron title, docs.json description, and README lead now say GPUI
and React for native apps, not only native React on the GPU. The hero
tagline is GPUI and React / for native apps. Keywords on the homepage
frontmatter are GPUI, GPUI React, GPUI TypeScript, and native GPUI app.
The shader overlay uses a 0.7 alpha multiplier instead of a dark/light
opacity class, so the flare stays visible behind the new copy.
Session: ses_f64f287b1ffeD0rCw6d52oXfHk
layout_text used window.line_height() after Taffy measure. Parent
with_text_style is gone by then, so the row was always 16×φ no matter
what React passed. A 28px font still sat in a 26px box. Issue 63.
Use the TextStyle captured during request_layout instead:
style.line_height_in_pixels(rem_size). Without an explicit lineHeight
that follows GPUI default leading, so a larger fontSize grows the box.
Pass lineHeight to set the row in pixels. minRows and maxRows still
multiply that height. An explicit height still wins.
Tests assert:
- textarea height equals lineHeight
- minRows=3 with lineHeight=30 is 90
- fontSize=28 with no lineHeight is 45 (28×φ)
- a single-line input honors lineHeight too
Session: ses_f79437cb5ffennyb8lTdU6KH7Q
The package used to export ./floating from dist/components/floating.js, which is the internal module for Select, Combobox, and Tooltip. That surface listed useControllableState and useFocusTrap as public helpers.
useFocusTrap is already gone from the implementation. It listened for Tab on a panel ref and forwarded it to focusNextWithin / focusPreviousWithin. Those renderer methods are the real scoped-tab API. A thin hook on top of them also fought a window-level Tab handler, because both moved focus and neither could stop the native key.
src/floating.ts is now the published entry. It re-exports FloatingLayer, renderSlot, and the style helpers libraries actually need. package.json points ./floating at dist/floating.js.
Docs and the pending Select changeset now match that:
- trap Tab by owning onKeyDown, then calling focusNextWithin / focusPreviousWithin
- do not also call focusNext from an element onKeyDown when a window Tab handler is already installed
- the floating table lists FloatingLayer and renderSlot only
Session: ses_f7fa3a82effee2tmI4o1tW6YzS
GPUI already turns an OS FileDropEvent into an ExternalPaths drag and
delivers on_drop to the hovered hitbox. Wire that onto host elements the
same way onClick works. Nested targets keep working because GPUI stops
propagation after a matching drop.
```tsx
<div
onFileDrop={(event) => openFiles(event.paths ?? [])}
style={{ width: 400, height: 300 }}
/>
```
event.paths is string[] of absolute Unicode filesystem paths. event.x and
event.y are the drop point in window pixels. An empty drop, or a drop that
contains a non-Unicode path, does not fire. to_string_lossy would invent a
path that does not exist on disk, so conversion uses Path::to_str instead.
Works on div, text, img, svg, input, textarea, code, markdown, diff, and
anchored. virtual-list is not a host div and does not take this event; wrap
it. Desktop only. Tests simulate Entered then Submit through GPUI.
Session: ses_f7f7c408bffe5d59D85FJmrsKT
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
The feed file was a workaround. GitHub already has tag_name, assets, and
a sibling .sig. checkUpdate now hits api.github.com:
GET https://api.github.com/repos/OWNER/REPO/releases/latest
Rewrite github.com/OWNER/REPO, /releases/latest, and
/releases/latest/download/... to that API URL. Match the host bundle
(*.app.tar.gz, *_{arch}.AppImage, *_x64-setup.exe / *_arm64-setup.exe)
and GET {name}.sig. Missing sibling signature is an error. Infer format
from the asset name.
Keep packager JSON as a silent fallback when the body already has
version plus url or platforms. Do not document that path.
CI uploads only what packager writes: the signed bundle and its .sig.
README and the changeset now show GitHub Actions plus
checkUpdate against https://github.com/OWNER/REPO/releases/latest.
Session: ses_f837ecfbeffeynBTQx5bKyvN9d
Port cargo-packager-updater 0.2.3 into packages/native/src/updater.rs
instead of depending on the crate. The original crate pulled crates.io
reqwest blocking, a second TLS stack next to Zed zed-reqwest. HTTP now
goes through reqwest_client, the same client <img> already uses.
JS:
import { checkUpdate } from '@gpuix/native'
const update = await checkUpdate('0.1.0', {
endpoints: ['https://github.com/OWNER/REPO/releases/latest/download/latest.json'],
pubkey,
})
if (update) await update.downloadAndInstall()
The feed is 204 or a latest.json with platforms keys like macos-aarch64.
downloadAndInstall() replaces packaged files and does not relaunch.
Desktop only. The browser wasm build does not export this.
Original: https://github.com/crabnebula-dev/cargo-packager/blob/v0.2.3/crates/updater/src/lib.rs
Session: ses_f83f922a9ffe9QbIBVQESIXTQM
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
Add a Holocron guide at website/src/guides/hermes.mdx and link it from the
README. The page covers rebuilding hermes-node on macOS (the published
tarball dead-strips napi_*), bundling React to CJS with bun build, and the
measured ship set: 12 MB executable plus a 22 MB native sidecar.
A hermes/ prototype sits next to the docs so the same path can be rerun.
Session: ses_f84b88b16ffePB3X7Fqzot47OY
GPUI already talks to the macOS AX tree, Windows UIA, and Linux AT-SPI
through AccessKit. GPUIX never set .role(), so VoiceOver saw an empty
window. A node is in the tree only with both an id (already set) and a
role.
Prop names match React DOM. Role values are ARIA tokens.
```tsx
<div
role="button"
aria-label="Delete note"
aria-id="notes.delete"
onClick={remove}
>
Delete
</div>
```
- `none` / `presentation` produce no node
- `<text>` defaults to Label, `<input>` to TextInput, `<textarea>` to
MultilineTextInput, `<img alt>` to Image
- onClick registers AccessKit Click, so VoiceOver Press fires the same
JS handler
- Tests dump GPUI debug_a11y_tree_json after paint
The zed submodule picks up two GPUI hooks: tests can activate the a11y
tree without a screen reader, and Img / Svg now report .role() instead
of dropping it.
Browser / wasm has no AccessKit adapter. These props are no-ops there.
Fixes#47
Session: ses_f89a78b8cffe4lrrAfJ8Uz4sH1
A throw used to keep the window alive but empty. render() now paints the
error message and stack, and Reload remounts the last tree.
Unhandled React render errors, uncaughtException, and unhandledRejection
all take this path. The overlay is scheduled on a microtask keyed to the
failing root, so a later remount does not paint a stale overlay. Process
handlers uninstall on resetRender.
Save under bun --hot still remounts too.
Session: ses_f941afdedffezh2lODSRNO04A8
Desktop GPUI defaults to NullHttpClient, so a remote URL never loaded.
Install Zed reqwest_client on native apps so img(SharedUri) can fetch in
the background. Wasm already has fetch.
A definite width and height now also set aspect_ratio. GPUI otherwise
fills that from the bitmap after decode and jumps the box. Tests use a
FakeHttpClient that returns the same SVG as the disk fixture, so the
dispatcher never parks on a real network.
Set both width and height. Failed loads still show the existing
fallback. A loading URL paints an empty box of the declared size.
Session: ses_f9810208bffeUeHRs8rtxe7KaD
A search pill with extra height used to paint text at the top of the box,
and rounded corners did not clip the glyphs. HTML inputs center and clip.
Single-line inputs now use items_center when given extra height, and
overflow_hidden when borderRadius is set. Document the no-default-padding
gotcha next to the other CSS-looking style traps.
The padding coverage test checks that paddingLeft actually insets painted
text, which is how a pill wrapper is supposed to work.
Session: ses_f9922154effe0jqPuJbXGeDNke
Plain Enter used to always fire onSubmit on both input and textarea, so a
normal multiline editor could not insert a newline, and Enter did nothing
when no onSubmit listener was set.
Textarea now uses a separate key context. Enter and Shift+Enter insert a
newline. When onSubmit is present, Enter submits and Shift+Enter still
inserts a newline. Input still submits on Enter.
Adding or removing onSubmit after mount swaps the key context, so a
composer that only submits after the user types still gets the right
Enter binding.
Fixes#46
Session: ses_f998cca76ffexsRUJHhI2AdZeA
GPUI already paints those decorations. This wires `"underline"`,
`"line-through"`, and `"none"` through StyleDesc, apply_styles, and the
React StyleDesc type so a React tree can request them.
Visual coverage compares decorated text against the same glyphs with no
decoration so a missing native apply cannot hide behind a type-only
change.
Session: ses_f9922154effe0jqPuJbXGeDNke
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
On macOS, JS owns the event loop. startFrameLoop calls renderer.tick()
about every 8ms, and that tick pumps AppKit. A throw that escaped the
timer stopped the next setTimeout, so the window froze. bun then often
exited, which looked like a crash.
startFrameLoop now catches errors from tick() and still schedules the
next pump. Native event callbacks catch throws from React handlers so
they do not become napi_fatal_exception. render() installs
uncaughtException and unhandledRejection listeners so bun stays alive,
and it starts the frame loop before the first React flush so a throw
during mount still leaves AppKit pumping. The loop is not restarted on
remount.
The error is logged. There is no auto-reload. Save under bun --hot to
remount.
Session: ses_f97da32b5ffejnj7pvF2xzbkXL
GpuixRenderer.tick() already drains only ready AppKit events. The README
now says that, and events.test.tsx checks that 30 idle ticks return in
under 200ms so Bun timers and sockets can run between frames.
Fixes#39
Session: ses_fa1101bf5ffexVlY2PD1MZjRSg
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
Persist renderer-owned containers, element ID allocators, and window event IDs across JavaScript module evaluation. The native renderer callback can now route into the active React root after bun --hot replaces the module graph.
Bind render-level onEvent callbacks to the owning root. Stale element and window events are rejected before they can reach either React handlers or the replacement root observer.
Extend the real bun --hot child-process regression to click both before and after reload and confirm that remounted roots keep unique IDs.
Issue: #37
Session: ses_fa1aaf89dffezIsneAEezbytzc
Document element and renderer keyboard callbacks, action dispatch order, and imperative focus traversal as part of the public API contract.
Update the documentation site to Holocron 0.33.0 and refresh its lockfile. The website typecheck and lint pass with the new dependency graph.
Session: ses_fa3b8830cffeGdTgarIef6r2Kn
Remove GPUIX's process-wide Tab and Shift+Tab bindings so focused editors and terminals receive the native key events directly.
Add renderer-level onKeyDown and onKeyUp callbacks, plus focusNext and focusPrevious wrappers for applications that want to install their own traversal policy. Keep browser focus on GPUI's hidden keyboard element after Tab without turning that platform safeguard into focus traversal.
Give each mounted root a key-event generation so queued callbacks cannot cross a remount. Disable native root listeners on unmount and preserve the active root when a second root is rejected.
Update the focus documentation, release note, native declarations, and end-to-end tests for element delivery, renderer delivery, explicit traversal, remount safety, and root ownership.
Session: ses_fa3b8830cffeGdTgarIef6r2Kn
Decode base64 and percent-encoded image data URLs when the src prop changes, then retain the decoded bytes through gpui::Image while preserving filesystem path loading and image element identity.
Reuse the data URL decoder for SVG sources, document the API, and cover the native render path with a screenshot comparison against the same filesystem image. Add a release changeset that references issue #35.
Keep a TODO at the JSON boundary for replacing base64 transport with binary mutations so large image payloads do not decode during paint.
Session: ses_fa3e66794ffeXNYejd15WllDnE
Expose two-stop linear gradients through the React style.background API with typed stops, CSS angle direction, and sRGB or Oklab interpolation.
Translate the structured value through the retained style pipeline into GPUI backgrounds. Reuse the same resolution for painting, mouse occlusion, and anchored overlay fill detection so transparent gradients keep the existing interaction rules.
Add native translation and transparency tests, an end-to-end GPU screenshot test for gradient direction, public documentation, and a minor changeset for @gpuix/native and @gpuix/react.
Session: ses_fae3bda32ffe3CrFWAX0OEwcI6
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
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
Publish a new @gpuix/cli package with a goke-powered `gpuix new` command. The command downloads the official example app, replaces its workspace React dependency with the latest npm release, and installs the generated project with Bun.
Update the quickstart, example documentation, workspace metadata, release notes, and CI publish order. Add a real GitHub-backed integration test and shell completion support.
Session: ses_fb87b7e28fferSvDT71z3OadR1
- `README.md` claimed **every element type** records painted bounds.
`<virtual-list>` records none: gpui's list is not an interactive element, so
there is nothing to record a box against. It is safe today only because
`VirtualListProps` does not extend `Props` and so takes no `testId`. The claim
is now scoped to elements that accept `testId`, and the exception is named
- `AGENTS.md` said the two id namespaces cannot collide, which is true only for
as long as nobody writes `.id(<index>)`. `impl From<usize> for ElementId`
makes that the idiomatic gpui row id, and it lands in the same `Integer`
namespace as a host id. Every per-row id in this crate is a formatted name for
that reason, and the rule is now written down
- the changeset is `minor`, not `patch`. `VirtualListShared.style` became
`Omit<StyleDesc, "hover" | "active">`, so an app passing either stops
compiling
- the changeset now records that a filled `<text>` takes mouse hits. Routing
`<text>` through the same builder as `<div>` means `should_occlude` applies to
it, so a label with an opaque background stops clicks reaching what is behind
it, like an HTML element with a background. The old text builder inserted no
hitbox at all. `element-identity.test.tsx` pins the new behaviour
- the `<img>` element-state test explained the offscreen window wrong. The
`focus: false` comes from gpui's `VisualTestContext::open_offscreen_window`,
not from anything in `test_renderer.rs`
Session: ses_fc10c354effe0X3SbzUKVPca44
`AGENTS.md` replaces the "custom elements are invisible to automation" section,
which is now wrong, with the two rules a new element has to follow:
- give it a host-derived GPUI id, or it silently loses hover, active, pointer
capture, implicit scroll, its accessibility node, and any state gpui keeps.
`<img>` had no id, which is why an animated GIF never left frame zero
- call `apply_interactive_styles`, never `apply_styles`, on a stateful root
Plus how bounds are recorded, and the trap behind it: containers add a
`bounds_tracker` child, leaves and `<anchored>` use gpui's `on_painted`, and
neither may move to prepaint because `gpui::list()` prepaints a speculative row
range and then rolls it back.
`README.md` drops the claim that `<img>`, `<svg>`, `<anchored>`, `<diff>` and
`<markdown>` have no painted bounds, says `hover` and `active` work everywhere
except `<virtual-list>`, and states the one-root-per-renderer rule.
Session: ses_fc10c354effe0X3SbzUKVPca44
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
`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
`render()` takes `focus` and `show`. `focus: false` opens the window behind
whatever app you were typing in, like `open -g`. `show: false` opens nothing
at all, so the process boots with a live React tree and an empty screen.
render(<App />, { title: 'Notes', focus: false })
**Two knobs, not one**
`gpui::WindowOptions` already had `focus` and `show`, and `to_gpui_window_options`
simply never set them. But mapping the fields alone does nothing, because
`init()` called `cx.activate(true)` unconditionally on both init paths.
On macOS the window flag only decides whether the window becomes *key inside*
the app; activation is what pulls the whole process forward. Both had to move
together, so `activate` is computed before the closure (the options are moved)
and the call is gated on it.
**Revealing it later**
`activateWindow()` brings the window forward and focuses it. It is the only way
to reveal a `show: false` window. macOS goes through `update_window`, the
threaded platforms through a new `UiCommand::ActivateWindow`. It runs
`cx.activate(true)` alongside `Window::activate_window()`, because the latter
only makes the window key within the app.
**Platform support**
Straight from GPUI's `WindowParams`:
| Platform | `focus: false` | `show: false` |
|----------|------------------------------------|---------------|
| macOS | orders in front without becoming key | honored |
| Windows | `SW_SHOWNOACTIVATE` | honored |
| Linux | ignored | ignored |
The macOS Dock icon still appears. `did_finish_launching` hardcodes
`NSApplicationActivationPolicyRegular`, so a menu-bar-agent app would need a
`remorses/zed` fork commit. Searched upstream first per AGENTS.md: no issue and
no PR asks for a configurable policy, and the only code hit is that hardcoded
call. Left for a follow-up.
**Verified**
Five new `window_options_tests` cover the mapping, including that an unset
option still defaults to true and that the existing fields keep mapping.
Focus stealing is not unit-testable, so I drove real windows and read the
frontmost process id and the AppKit window count through `osascript`:
default frontmost=app windows=1
focus: false frontmost=other windows=1
show: false frontmost=other windows=0
after activateWindow() frontmost=app windows=1
`cargo test --lib` 137 pass, `packages/react` 302 pass, `examples` 35 pass.
`x86_64-unknown-linux-musl` cross-builds, which type-checks the threaded
branch; Windows cannot cross-compile here because `onig_sys` needs Windows C
headers, and CI covers it.
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
Replace the stale pending-validation note with the result that CI now runs the full React and example suites through the DirectX-backed Windows test renderer.
This matches the successful Windows validation added for issue #26 and released in 0.5.1.
Session: ses_fc05758b6ffevGYCjaV7pxsfuO
Three changes to the release pipeline, all found while cutting 0.5.0.
**One target per OS.** The matrix built six targets; it now builds the
architecture each OS is mostly used on and nothing else:
| OS | Target | Renderer |
|---------|---------------------------|---------------|
| macOS | aarch64-apple-darwin | Metal |
| Linux | x86_64-unknown-linux-gnu | Vulkan / wgpu |
| Windows | x86_64-pc-windows-msvc | Direct3D |
Every extra target is a full gpui build, and gpui is most of the wall
clock of this workflow, so a release was paying twice over for platforms
almost nobody installs. Intel macOS, arm64 Linux and arm64 Windows lose
their prebuilt binary. `napi.targets` is trimmed to match; the two lists
must stay identical or the published loader looks for a platform package
CI never built. Both test jobs already pull targets that stay.
**The chat example gets an extension.** A GitHub release asset is served
as raw bytes, so a download lost the executable bit and, on macOS and
Linux, arrived with no extension at all. The build job now packs those
two into `example-chat-<target>.tar.gz`, which keeps the mode and names
itself. Windows keeps its `.exe`. Verified end to end with the real
0.5.0 macOS binary: pack, drop the mode to 644 like a browser download,
`tar -xzf`, and the extracted file is `-rwxr-xr-x` and opens its window.
The download also drops from 83 MB to 33 MB.
**Create the GitHub release before CI reaches the publish job.** The
upload step exits 0 when no release exists for the tag, so a release
created afterwards gets no binaries, and the only way to add them later
is to move ~100 MB through a laptop. There is no API that attaches an
artifact to a release: the assets endpoint wants the raw bytes in the
request body, and `gh release upload` cannot read stdin (cli/cli#5820).
AGENTS.md now spells out the order, and what to do about the tags when
CI fails and you push fixes.
Session: ses_fc073cf53ffem2NBtBXPFaCgyF
The appName doc comment claimed it was "the name macOS shows in the
application menu", which is exactly the thing it cannot do. macOS takes
that title from the executable, so a GPUIX app run as 'bun app.tsx' shows
'bun' no matter what appName says, and only a .app bundle changes it.
appName reaches the Hide and Quit items and nothing else.
README.md said the right thing in prose one paragraph later, so the file
contradicted itself, and the diagram in both README.md and the changeset
labelled the top-level column <appName>. Both now say <executable>.
index.d.ts is regenerated from the Rust doc comment.
Found by an oracle review of 7baa36f.
Session: ses_fc0edf605ffesXw3qtyEm0tZgG
GPUI never calls NSApplication.setMainMenu:, and GPUIX never called
cx.set_menus, so NSApp.mainMenu stayed nil. macOS painted nothing next to
the Apple menu. Verified on the running example app through the
accessibility tree: 'menu bars 0' before, 'Apple, bun, Window' after.
The empty bar was the smaller half of the bug. AppKit only provides the
standard shortcuts through menu items, so cmd-Q, cmd-H, opt-cmd-H, cmd-M
and cmd-W did not exist at all. There was no way to quit a GPUIX app from
the keyboard.
Activation policy was not the cause. did_finish_launching already sets
NSApplicationActivationPolicyRegular, embedded mode included.
Apple <appName> Window
|- Services |- (AppKit window tiling)
|- Hide <appName> cmd-H |- Minimize cmd-M
|- Hide Others o-cmd-H |- Zoom
|- Show All |- Close Window cmd-W
'- Quit <appName> cmd-Q '- (open windows)
crate::app_menu binds the keys, registers the handlers, then calls
set_menus, in that order: gpui::App::set_menus reads each key equivalent
out of the keymap, so a binding added afterwards would show no shortcut.
Quit, Hide, HideOthers and ShowAll are cx.on_action globals.
MinimizeWindow, ZoomWindow and CloseWindow go through
with_window_menu_actions on the root element in GpuixView::render,
because that is the only place in GPUIX where a Window exists.
New appName window option for the name inside 'Hide X' and 'Quit X'. It
defaults to title.
Two things AppKit decides, both found by reading back the live menu
rather than by trusting the code:
- the title of the application menu is the executable name, not the Menu
name passed to set_menus. 'bun app.tsx' shows 'bun', and only a .app
bundle changes it. The items inside do use appName
- the menu named Window is handed to setWindowsMenu:, which prepends
AppKit's own tiling items, 'Enter Full Screen' included. The first
version added that item too and produced two entries sharing a
shortcut, so it is gone
No Edit menu, on purpose. AppKit consumes a menu key equivalent before
the window sees the key event, so an Edit menu carrying cmd-C would take
the keystroke away from the selection listener in text::paint and from
the per-focus clipboard handling in custom_elements::input. An Edit menu
needs those handlers moved into GPUI actions first.
Tests: 198 Rust (2 new), 340 react, 36 examples, 6 example-app.
Session: ses_fc0edf605ffesXw3qtyEm0tZgG
The Scroll anchoring section said the list anchors on a **row**, which
implied identity. `gpui::ListState` anchors on a row **index**, and
`splice_focusable` shifts that number by however many rows were inserted
before it.
In children mode the two are the same thing, because React reconciles by
key and the index lands back on the same row. With `itemCount` there is no
key, so a prepend slides the content by exactly the number of inserted
rows.
Measured, not assumed. A 200-row windowed list anchored on index 50:
render(view(base, 50)) ► top row is row-50
render(view(['fresh', ...base], 50))
► top row is row-49
The section now says so and shows the correction the app has to make:
setRows((current) => [fresh, ...current])
setWindowStart((start) => (start === 0 ? 0 : start + 1))
`windowStart === 0` stays put, because the list is pinned to the top there
and the new row is supposed to be visible.
Found by an oracle review of 01f5788. It is a pre-existing behaviour; only
the sentence describing it was new and wrong.
Session: ses_fc16226ffffeOn4QTVIbw4K7Nz
Follow-up to an oracle review of 8aef438. The offset was correct but easy to
misuse in three distinct ways, all of which fail silently as a find cursor on
the wrong row.
Name it after what it counts
----------------------------
`indexOffset` -> `matchIndexOffset` on `HighlightSpec`, `index_offset` ->
`match_index_offset` in Rust.
It counts MATCHES above the window, never rows. `windowStart` is right next to
it in a `<virtual-list>` and is a row index, so the old name invited exactly the
wrong value. Rows hold different numbers of matches, so a row index can never
stand in for it.
Kill the invalid state in useTextSearch
---------------------------------------
`total` and `indexOffset` were two independent options and the doc claimed the
offset was "ignored when `total` is unset". It was not: the hook always passed
it, so this made EVERY active wash disappear.
useTextSearch({ query: 'fox', indexOffset: 50 })
Native numbers the visible matches from 50. The hook still takes its count from
`onHighlight`, say 4, and clamps `active` into `0..3`. No ordinal can ever match,
so nothing is ever active.
The two are one concept, so they are one object now:
matches?: { total: number; indexOffset: number }
Native counts and numbers the SAME window, so an app that overrides one must
override the other. Supplying one alone is now unrepresentable rather than
documented-as-ignored.
Refuse a malformed offset instead of reading it as zero
-------------------------------------------------------
`as_u64().unwrap_or(0)` turned a negative, fractional or string value into 0.
That is the worst possible outcome: every match renumbers and the only symptom
is the cursor landing somewhere else.
A present-but-malformed `matchIndexOffset` now logs a warning and rejects the
whole spec, so nothing paints and the reason is in the log. `null` and an absent
key still mean 0.
Prove the contract is a match count
-----------------------------------
The virtual-list test used one match per row, so the row index and the match
index happened to be equal and a row offset would have passed it. The new test
uses rows holding 2, 2, 1, 0 and 3 matches with the window starting at row 2,
where 4 matches sit above. Offset 4 and `windowStart` 2 are deliberately
different, and the test asserts that passing 2 marks nothing.
Two docs claims that were too strong
------------------------------------
The changeset said native and `findRanges` "can never disagree". They agree only
on the SAME string. They differ when a row holds several structural groups but
the app scans one concatenation, when the row is `<markdown>` whose painted runs
are not its source, and on lone UTF-16 surrogates that a Rust string cannot hold.
Both the README and the changeset now say the app must call it on the same
logical lines native paints.
Testing
-------
31 react highlight tests, 180 native, 36 examples, 5 example-app. Highlight perf
unchanged: keystroke p50 2.65ms, cursor p50 2.21ms.
`cargo test --lib` cannot run in this checkout right now; another session has
`renderer.rs`, `retained_tree.rs` and `test_renderer.rs` mid-edit and the lib
test target does not compile. All 8 errors are in those three files and none in
`search.rs`. The suite was green at 180 before that landed.
Session: ses_fc15ed901ffedvtR2Z3cCZPIAR
Both editors were unusable through the locator API. Every path threw.
await app.getByTestId('composer').click()
// AutomationError: Element has no painted bounds
await app.getByTestId('composer').fill('hello')
// AutomationError: GPUI browser input is unavailable
`automation::bounds_tracker` is what puts an element in the bounds registry,
and `build_element` only attaches it for `<div>` and `<text>`. A custom element
paints itself, so it has no bounds unless its own builder records them.
`<code>` did. The editor did not.
Fixed by attaching the tracker to the editor root, which is already
`relative()`, so the `absolute().size_full()` canvas measures the right box.
The only previous workaround was a hard-coded pixel coordinate, which breaks
the moment the layout moves. That is how I had to drive the chat composer while
testing browser HMR.
`<img>`, `<svg>`, `<anchored>`, `<diff>` and `<markdown>` still have no bounds.
`getByText` works inside them because text registers separately, but
`getByTestId(..).click()` does not. Recorded in the README and AGENTS.md.
`dispatchBrowserKeystroke` looked for `input[data-gpui-input]`. GPUI's IME
conduit is a `<textarea>` (`gpui_web/src/ime_mirror.rs`), because a single-line
input silently strips newlines from an assigned value and would desynchronise
the mirror from the document. The selector could therefore never match, and
every browser keystroke through the automation API failed.
It now matches on the attribute alone, exported as `IME_MIRROR_SELECTOR`, so a
future tag change cannot silently break it again.
`automation.test.tsx` gains a failing-first test that asserts an `<input>` and a
`<textarea>` both report non-zero bounds and that the textarea lays out below
the input. It failed with "Element has no painted bounds" before the Rust change.
`fill()` is browser-only, so it was verified in Chrome against the running dev
server: `bounds()`, `click()` and `fill('filled by automation')` all succeed and
the text appears in `getPaintedText()`.
AGENTS.md records that `bun run build` in `packages/react` must not run while
the web dev server is up. That script is `rm -rf dist && tsc`, and
`packages/react/dist` sits in the dev server's module graph, so deleting it
under a live server permanently corrupts the registry with `Failed to load
bundled module 'packages/react/dist/index.js'` until the server restarts. I hit
this twice.
Session: ses_fc233909fffeHxAGjrf2jkWSet
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
`activeIndex` promised "the nth match in the document" and could not keep that
promise inside a `<virtual-list>`. The list mounts a window of its rows, ordinals
are handed out during paint, and paint only ever sees that window, so the
sequence restarted at 0 on the first mounted row.
The documented pattern in the README was wrong because of it. It counted matches
across all rows with `findRanges`, then fed that document-wide cursor straight
back as `activeIndex`, which native read as an index over the painted rows:
hits [{"t":"fox 50","a":false},{"t":"fox 51","a":false},
{"t":"fox 52","a":false},{"t":"fox 53","a":false}]
`activeIndex: 52` over a window starting at row 50 marked nothing at all. Shift
the window and it marks an arbitrary visible row instead. There was no test for
`highlight` together with `<virtual-list>`, which is why it shipped.
indexOffset
-----------
The app owns the window, so the app says where it starts. `indexOffset` is the
number of matches above the mounted rows, and the per-frame ordinal cursor begins
there instead of at 0:
document mounted window paint
match 0..49 above the window, indexOffset = 50
match 50 ─────────────► row 50 ─────────► ordinal 50
match 51 ─────────────► row 51 ─────────► ordinal 51
match 52 ─────────────► row 52 ─────────► ordinal 52 <- active
match 53 ─────────────► row 53 ─────────► ordinal 53
match 54.. below, never built
This is the same contract `<virtual-list>` already has with `windowStart` and
`itemCount`: native cannot know what it never built, so the app tells it.
A cursor outside the window now marks nothing, which is correct. It used to mark
whatever row happened to be at that position in the viewport.
`index_offset` is excluded from `matcher_hash`, exactly like `active_index`, so
scrolling a find bar never rescans or re-folds any text.
`useTextSearch` takes it through as an option, next to the `total` it already
accepted for the same reason.
Testing
-------
The React test renders a 1000-item list with `windowStart={50}`, scrolls to row
50 so the rows really paint, and asserts `activeIndex: 52` lands on "fox 52".
Without the offset that assertion finds no active wash at all. A second case
asserts a cursor past the window marks nothing.
Three native tests cover the offset shifting the whole sequence, a cursor above
the window, and `matcher_hash` ignoring the offset.
The `highlight` prop has not been released, so this updates its pending changeset
rather than adding a fix entry for an API nobody has.
Session: ses_fc15ed901ffedvtR2Z3cCZPIAR
`gpui::ListState` anchors on a **logical item**, and `splice_focusable`
shifts that anchor by the number of rows inserted before it. A prepend
therefore keeps the rows already on screen and pushes the new one above
the viewport. Correct for a history pane, wrong for a feed.
A browser anchors the same way and suppresses it at `scrollTop: 0`.
`VirtualListEntry::sync` now copies that: it remembers a top-aligned,
non-`followTail` list whose `logical_scroll_top()` is `{0, 0}` and calls
`scroll_to(default)` after the splice. Scrolled anywhere else, the rows
under the pointer still do not move.
**How it looked**
`example-app` prepends the newest task. Adding through the composer:
add 1..10 top row = task N offset 0 looks fine
add 11 top row = task 11 offset -32 content now overflows
add 12 top row = task 11 offset -76 new row is off screen
add 20 top row = task 11 offset -428 list looks frozen
**Why nobody caught it**
While the content is shorter than the viewport, gpui's "does not fill"
branch walks back up to item 0 and re-anchors there on every layout, so
the drift is invisible. It appears on the frame the list first overflows.
The regression test grows a 160px list from 2 rows to 12 rather than
starting tall, and a second test asserts the anchor still holds below the
top.
Session: ses_fc16226ffffeOn4QTVIbw4K7Nz