⌘1…⌘9 resolves its tab strip through $activeTreeGroup (the interacted
zone), but ⌘W, ⌘T, ⌘⇧T and the strip's "+" all hardcoded the workspace
pane's group. In a layout with a second chat zone the number keys worked
and every other tab verb acted on main instead — and that zone's strip
had no "+" at all.
Adds focusedSessionGroup() to the tree store, resolving the same zone
$activeTreeGroup names when it hosts a chat strip and falling back to
the workspace otherwise, so focus parked in files/terminal can't make ⌘W
close the file tree. ⌘W closes through it, unanchored openSessionTile()
(⌘T / ⌘⇧T) docks into it, and the "+" renders on any chat strip, noting
its zone on pointerdown so the tab lands where it was clicked.
Chromium 121+ prefers scrollbar-width over ::-webkit-scrollbar and
ignores the latter when both are set, so our themed thumb never painted
and mac got the chunky platform "thin" bar. Gate standard scrollbar-*
for non-webkit engines only; ship a 4px webkit thumb on .scrollbar-dt
and portal menus.
The dock target is the docked composer at the bottom-center of its own
surface. In a split, the viewport's bottom-center is somewhere else, so
dragging onto the real dock never registered.
Pop-out was one flag and one position for the whole window, and every
keep-alive-mounted tab re-clamped that position against its own rect and
wrote it back. So floating the composer in one pane floated it in every
pane, and N surfaces raced for one value with the last writer winning — a
drag in one tab was lost in the next.
State is now keyed by layout group, the scope users actually mean: tabs in
a zone share a float, a split zone beside them keeps its own. Within a
zone the stored position is intent, and each surface derives what it
renders through the pure clampPopoutPosition against its own rect.
Re-placing is gated on pane visibility so a live drag can't force a reflow
in every background tab, and runs pre-paint so a revealed tab never shows
a stale frame. Storage is written on release rather than per drag frame,
dead zones are pruned against the live tree, and the pre-zone value seeds
one first read so an existing float survives the upgrade without leaking
into zones split later.
popoutAllowed was hardcoded false for session tiles, so only the primary
thread could pop out — a tab or a split had no way to undock its composer.
Secondary windows stay docked; that gate was the load-bearing one.
PaneGroupContext, alongside PaneVisibleContext: the zone hands each pane
the group id it's rendered in, so state that belongs to a stack of tabs
rather than to a pane can key off it — and follows a pane dragged between
zones, since the provider is whichever zone renders it.
Picking a model closed the menu and then flashed the pill's tooltip over
the fresh selection. The existing focus guard gated on `:focus-visible`
alone, which does not separate a mouse pick from a Tab: the dropdown
autofocuses its search field and keyboard-navigates its rows, so Chromium
is already in keyboard modality when the menu restores focus to the
trigger, and the guard never fired.
Track the device behind the last real interaction and use it to qualify
`:focus-visible`. A mouse pick reports `pointer` and stays silent; Tab
focus still opens the tip.
A repeated id in the transcript reached assistant-ui's MessageRepository,
which throws on the second link and takes the whole workspace pane down to
the contribution error boundary — the pane the user is actually working in.
Dedupe where the repository export is built, so no upstream transcript bug
can crash the pane, and close the journal merge path that produced one: a
resume that replays a still-journaled turn appended rows the base already
held by id.
The desktop's queue promises "run this AFTER the current turn", but the
promise broke on a race the user can't see: a drain that fired when the
client observed idle while the server was still unwinding the turn landed
in _handle_busy_submit, which applied busy_input_mode — redirecting or
interrupting the live turn with text the user explicitly queued. That's
why force-sending the queue felt like a dice roll: the same gesture
steered, interrupted, or queued depending on a millisecond settle race.
prompt.submit now carries queued:true on every fromQueue drain (composer
auto-drain, send-now, background drain), and the gateway's busy path
honors it by forcing queue semantics — never steer, never redirect,
never interrupt. Lose the race and the text simply waits its turn, which
is what queueing meant all along.
Covered on both sides: a gateway test proving a queued drain cannot touch
redirect/steer/interrupt on the live agent, and the desktop drain tests
now assert the flag rides every prompt.submit shape (direct, background,
resume-retry).
Filtering pins out of the project tree was deleting the lane along with
its last row, so pinning your only chat on a branch made the worktree
vanish from the sidebar — and a project whose sessions were all pinned
fell through to the "no sessions" empty state.
A lane is structure: it exists on disk and you can still start work in
it, which is why the git-worktree enhancer injects lanes that never had
a session at all. Keep emptied lanes, count a lane (not a row) as
project content, and teach the live overlay's prune to drop only the
lanes IT emptied.
A pin belongs to the Pinned section and nowhere else, but only the flat
recents list filtered them — the project overview, an entered project's
lanes, and the messaging platform sections all still rendered a pinned
row a second time.
Filter every unpinned group on a shared predicate that matches the live
id AND the durable lineage-root pin id, so a compression tip rotation
can't leak the row back in. A platform's "load more" count discounts its
pinned rows too, or it promises rows that never appear.
Notifications, ⌘K, switcher, session picker, cron/command-center, artifacts,
and @session refs all go through openSession. Plain click/Enter focuses the
existing tile or main; ⌘-click/⌘-Enter opens a new tab; ⇧⌘ still pops a window.
Focus layout keeps `files` in the workspace group's panes while chrome-
hidden. Slot keys walked that raw array, so ⌘2 landed on the first session
chip and every later number was off-by-one (especially after a ⌘W main-tab
shift). Index the same shown filter the strip paints, and cycle the same.
Adoption: a floating contribution stays out of the tree while a docked
sibling in the same pass is adopted, so the exclusion is specific rather
than a broken run.
Live DOM: mounts FloatingPanes and drives real pointer + resize events —
anchored spawn, drag, persistence across remount, edge-riding on resize,
the titlebar floor, collapse, and the no-drag button opt-out.
Plugins previously had to fake a floating pane by registering into a
chrome slot and rendering position:fixed themselves, which broke inside
any transformed ancestor and reimplemented drag, clamping, and
persistence each time.
Adds placement: 'floating' — the one non-tiling pane role. Adoption skips
those contributions so one can never become a track that steals width
from a zone; the tree renders them as fixed cards instead, draggable by
the header, with position and collapse persisted per pane id.
The card reuses HUD_SURFACE (command palette, session switcher) rather
than hand-rolling a second float surface.
Pure clamping/anchoring rules for a pane that lives outside the layout
tree: keep 48px grabbable at either horizontal edge, hard-bound the top
below the titlebar so the drag handle stays reachable, pin top-left
rather than invert when the card outgrows the viewport, and let an
edge-anchored card ride its edge as the window resizes.
No DOM, so the rules are testable directly.
A mid-stream steer persists an interrupted-turn checkpoint so the model knows
its reply was cut off. That scaffolding — "[This response was interrupted by a
user correction.]" and the "Visible response before the interruption:" header —
was written straight into message content, so every reload painted the raw
machinery as an assistant bubble (and merged it into the preceding tool-call
bubble). Steered transcripts became unreadable.
Reuse the existing display/replay split instead of inventing new surface:
- Carry the scaffolded form in the server-only api_content sidecar (the exact
bytes replayed to the provider), keep content the user's/agent's real words.
- When nothing reached the screen there is no clean form, so mark the row
display_kind=hidden — replayed to the model, dropped by every transcript
surface, exactly like compaction-reference rows.
- Honor display_kind=hidden in the gateway's _history_to_messages projection
(it only sniffed the [System: convention), so the checkpoint can't leak
through the live/resume path to the TUI/CLI either.
The model still receives the full interrupted context on the wire; the
transcript shows the partial reply and the user's correction.
Gating this behind an opt-in was the wrong call. A dev server already
executes arbitrary local JS — vite's module graph, every postinstall in
node_modules — so a loopback debugging port does not meaningfully widen
what a `npm run dev` session can already do, and `perf:serve` has opened
one unconditionally all along.
Requiring the variable also defeated the point: the tooling exists to be
reached for mid-task, and a capability you must remember to enable before
launching is one you don't have when you need it.
So the port opens on 9222 — the same port scripts/eval.mjs and
scripts/perf/lib/cdp.mjs already default to — for any dev-server run.
HERMES_DESKTOP_CDP_PORT stops being an on-switch and becomes an
override: a different port, or `off` to disable.
The hard gate is unchanged and still checked first: a packaged build
never opens the port, and no env value talks it into it. Neither does an
unpackaged `electron .` against dist/, which is how the packaged app
gets smoke tested.
Refusals only log when they contradict something the developer asked for
(a typo'd port, an explicit `off`). Packaged and dist runs are closed by
design and stay quiet.
A run of thinking headers is the one place the disclosure affordance isn't
otherwise discoverable — every other one sits in a row you're already
reaching for. The resting opacity becomes a token so only that surface opts
in; DisclosureRow is shared with tool rows and run headers, which keep the
invisible-until-hover default.
The turn block gap is the space between the reply and the work around it, so
a back-to-back run of thinking headers and tool rows spent the full gap
between every line and read as a stack of cards rather than one column.
Adjacent scaffolding now ticks at a third of it.
The hook is the block list the rhythm rule already enumerates, minus prose,
rather than `data-conversation-scaffold`: that marker is absent on a
multi-call tool group — most of a real run — and present on rows nested
inside one, so it both missed the lines that needed tightening and moved
ones that didn't.
An open file edit is the exception. A diff is the deliverable, read like a
PR, so it keeps the full block gap on both sides while the scaffolding
around it stays tight. A streaming turn seals blocks into separate bubbles
where the flex gap would restore the full gap, so that seal is corrected to
match — and skipped when either side carries a diff.
The renderer is a Chromium page, and apps/desktop already carries a whole
CDP toolkit for it — scripts/eval.mjs, scripts/perf/lib/cdp.mjs with its
shared SELECTORS map, and the diag-*/probe-* family. None of it can
attach to `hgui` or `npm run dev`, because neither passes
--remote-debugging-port. The only launcher that opens one is
`npm run perf:serve`, which is a separate isolated instance rather than
the app you're looking at.
Add HERMES_DESKTOP_CDP_PORT. When set, the shell opens a CDP port on
loopback so that existing tooling can read the live DOM: computed
styles, geometry, which rule actually won.
Three independent gates, all required, resolved by a pure function in
electron/dev-cdp.ts so the policy is testable without an Electron app:
1. not packaged — a shipped build never opens the port, and this is
checked first so no env combination can talk it into doing so;
2. HERMES_DESKTOP_DEV_SERVER present — an unpackaged `electron .`
against dist/ is how the packaged app gets smoke tested, so it
behaves like the packaged app here;
3. the port explicitly requested and a valid integer.
Default `npm run dev` is unchanged and silent: no port, no nag. An
opt-in that gets refused always logs why, so nobody loses an hour
wondering what isn't listening.
The address is pinned to 127.0.0.1 rather than left to Chromium's
default, and is deliberately not configurable — there's no reason to
expose a renderer debugger off-host and offering the knob invites
someone to try.
scripts/eval.mjs hardcoded :9222 and threw a raw ECONNREFUSED stack when
nothing was there. It now honours the same variable and explains itself.
Tab-descending into a folder from the @ popover re-types the token as a
bare `@apps/desktop/` so the next complete.path lists its children. That
is right while typing, but a bare token is not a reference —
REFERENCE_PATTERN only matches @kind:value — so a draft sent with one
rendered as plain text and attached nothing at all.
Promote it to @file:/@folder: on the way out, the same shape url-refs.ts
uses for bare links: on the committing space, on paste, and on submit for
a path that never got its space. A '/' is required so a handle like
@teknium1 is never mistaken for a path.
displayContentForMessage re-derived every ref from the attached context
block and re-emitted it as a detached list above the text. Now that the
token survives expansion, that list duplicated the inline chip. Hoist
only the refs the prose is missing, so turns persisted by an older
backend still render their chips.
Empty Enter while busy was a hard no-op, so a queued turn could only be
sent early via the panel's send arrow or Cmd+Shift+K. With prompts
queued, a second Enter now promotes the head and interrupts, mirroring
the idle empty-Enter drain. Nothing queued keeps the old no-op.
The panel's send-now row action switches from a bare up-arrow to the
return glyph, so the row states the keybind the double-send uses.