One 0-100 lever beside Window Translucency: 0 keeps your bubble solid (the
default), 100 leaves only the outline. The store paints a single root var,
--user-bubble-keep, that --dt-user-bubble mixes in, so the read-only bubble
and the inline edit composer follow together and an untouched lever yields
byte-identical CSS. Reuses TranslucencySlider (with its overlay peek), has a
Cmd-K search entry, and copy in all six locales.
Under Window Translucency → Glass, the glass block sets the surface
tokens transparent so <body> is the sole painter. A tab's `--tab-face`
read those tokens, so the ✕ runway resolved to transparent→transparent
and the chip's `bg-(--tab-face)` painted nothing: hovering showed a bare
✕ over un-faded text, with no gradient at all.
Add a `data-glass-field` contract beside the block's existing
`data-glass-opaque` / `data-glass-raised`: a declarer receives
`--glass-field`, the same body mix glass already paints. The tab prefers
it and falls back to its own surface token (`--tab-surface`, which
TAB_ACTIVE / TAB_IDLE now set instead of `--tab-bg` so they feed the
chain rather than bypass it). Face, hover-darken, and the selection wash
all inherit the one base. Inert with glass off: the fallback resolves to
the exact opaque surface it did before.
Ignore-mouse cannot restore on X11, so a visible band that still has
pointer-events:none swallows clarify options and links. Held prompts
and solid-window bands now take the pointer without composer focus.
main's Bots-home flash fix (6f8be61) landed in plugin.js, which this branch
deletes. Both files stay deleted: the guard it adds (botOpenInFlight gating
botsHomeMayOpen) protects a surface this branch removes, and the two sites
where it renamed the generation bump to cancelBotOpen already bump here via
bumpBotOpenGeneration in shared.ts.
The default popover is glass over the app's own chrome, which is right for
something the user opened and wrong for something the app said. The accent
variant fills the same box — same arrow, same placement engine — with a solid
brand colour so an unprompted surface reads as the app speaking.
Filling it with `primary` directly doesn't work across themes: a pale accent is
a perfectly valid primary (imported VS Code themes love a pastel), and the
honest `primaryForeground` for one is near-black, so the loud surface comes out
a pastel card whispering. `--dt-primary-solid` deepens the hue until a light
foreground clears AA — a no-op on an accent that is already deep, and darkening
only, so the hue survives.
Core has one empty state, the intro splash, and it belongs to a fresh
draft with no session selected. A session that exists but has nothing in
it yet falls outside that, and whoever opened it is the only one who
knows what should stand in the gap — core has no business learning a
bot's face and name.
Add `chat.empty` as a contribution area, resolved by the transcript the
same way `transcript.directives` resolves an inline widget. A
contribution mounts for every empty session and renders nothing for the
ones it does not own, so it can subscribe to its own stores and appear
once they load.
Pull the splash's lettering out of `intro.tsx` into a `Wordmark`
primitive so a second caller cannot fork it, and move the typeface,
weight and tracking into `.wordmark` beside the `.fit-text` rules they
travel with — as utilities they were one class-merge or one missed scan
away from silently rendering the wordmark in body text. `Wordmark` takes
its width, because fit-text sizes to fill and a short name set at the
splash's full width comes out enormous.
Tailwind v4 wraps hover:/group-hover: in @media (hover: hover). Windows
hosts with a digitizer often answer false even with a mouse, so those
controls stay opacity-0 — clickable, invisible. Trust :hover itself.
Co-authored-by: xxxigm <54813621+xxxigm@users.noreply.github.com>
Wayland clients cannot place themselves, so the JS setBounds drag is a
no-op there. Make the composer bar a -webkit-app-region drag handle on
Linux (input carved out with no-drag) and let the compositor move it.
Co-authored-by: Tony Simons <214744153+asimons81@users.noreply.github.com>
While a fullscreen app owns the screen, the HUD becomes an in-game chat frame:
the idle bar steps back to a glanceable opacity, and the transcript is held
open for as long as the game is there rather than fading on a timer — you look
back at a chat log during a lull, not while the text happens to be fresh.
Detection is a pure pass over the same front-to-back window enumeration
read_window_below uses (electron/hud-game-overlay.ts); main polls it while the
HUD is open and pushes changes to the renderer, which owns the treatment. Two
details the enumeration forced:
- Hysteresis. Entering needs the game to be what the user is actually looking
at, so a windowed app on top vetoes it. Staying only needs the game to still
exist: clicking the HUD to type de-foregrounds the game and floats every
other open window above it, which otherwise dropped overlay mode at the
moment the user engaged with it.
- The last state is replayed on did-finish-load. The watch pushes only on
change and its first tick fires at window creation, before the renderer has
mounted its listener, so a HUD opened over an already-fullscreen game
consumed its only message and sat at 'no game' forever.
The band itself is reworked for living over someone else's window:
- Light-on-dark unconditionally. The theme's near-black body ink is unreadable
over a dark game, and every attempt to gate the light ink on some
condition — focus, then the game flag — produced a state where it evaluated
false and the words went black on black. The sheet is a dark scrim in every
theme so white is always right; anything that paints its own light surface
(a clarify question, an approval card, a code block, a form control) opts
back into theme ink by re-pointing the ink variable, matched on the fill it
paints rather than the feature it belongs to.
- Your own lines are gold rather than bubbled. With no card the log otherwise
reads as one voice; blue and purple are what most game UIs use for their own
text, so they disappear into the background.
- The scrollback ramps out at the top instead of being cut off, masked on the
scroller (the band is a static box — its rows overflow the thread viewport
nested inside it, so a mask on the band ramps over empty space).
- The sheet is inset under the bar, so its square top corners no longer poke
out past the bar's rounded ones.
Turn errors now carry a structured {layer, code, retryable} descriptor
(agent/error_surface.py) built from the same classifier the retry loop
uses. The tui_gateway stamps it on terminal error frames, retained
failed-turn snapshots, and resume replay; the Desktop error card renders
the layer title (provider / endpoint / streaming / auth / billing /
gateway / runtime / disk) plus matched actions: Retry, Switch provider,
Open logs, Copy diagnostics.
Older backends that omit the descriptor keep today's behavior (generic
title, string-sniff fallbacks) — the field is advisory on both sides.
Scheduler attribution on the incident trace (FINDINGS.md round-5-Opus receipt)
puts 133 of 138 wide document-scale recalcs on GlyphSpinner's ticker, and 0 of
254 cheap ones. Replacing glyph.textContent every interval is a structural
text-node mutation, so each tick scheduled a style recalculation that resolved
against the whole document -- with N spinners mounted in a streaming
transcript, that is the incident.
Every frame is now in the DOM from mount as a vertical strip, scrolled by a
transform translateY keyframes animation. Transform animations run on the
compositor: no JS timer, no text mutation, no per-frame style recalc, layout or
schedule.
The strip is N frames tall and each frame is exactly 1em, so translating -100%
travels N frames; steps(N) (jump-end) samples that at 0, 1/N .. (N-1)/N, i.e.
it parks on frame 0..N-1 for one interval each and wraps -- the same sequence
and cadence the setInterval produced. Frame count and duration (N x interval)
arrive as inline custom properties, so all 18 spinner names / 16 distinct
frame-interval shapes share one keyframes rule with no generated or colliding
per-variant CSS.
Sizing, colour and alignment are unchanged: the outer cell keeps its exact
classes, and the 1em clipping viewport is centred by the same items-center that
used to centre the single glyph -- so consumer classNames that set a box
(size-3, size-3.5) or a font-size still land the way they did.
Gating semantics preserved, per the original "N mounted tabs each ticking burns
CPU for pixels nobody can see":
- kept-alive hidden tab -> data-paused -> animation-play-state: paused. Kept
explicit rather than relying on the pane's content-visibility:hidden, since
that containment has a runtime kill switch and older pane layers only set
visibility:hidden, which does not stop an animation.
- window blur / minimize / document hidden -> the strip joins the existing
:root[data-renderer-animations-paused] allowlist in styles.css, driven by
main.tsx's installRendererAnimationPauseState(). That is the mechanism every
other continuous decorative animation here already uses, so the per-spinner
createRendererLoopPauseController goes away.
- reduced motion is now honoured, which the ticker never did: the blanket
@media (prefers-reduced-motion: reduce) rule freezes the animation. This
also makes E2E screenshots deterministic, which that rule exists for.
The frames are marked aria-hidden. role="status" is a live region and the old
implementation rewrote its text ~12x/second, which announced a new glyph on
every tick.
Tests rewritten, deliberately: all five previous cases asserted the ticker
itself (vi.getTimerCount(), per-tick textContent), which no longer exists. The
replacements pin what jsdom can see -- frame order, the custom properties
feeding steps()/duration per variant, zero timers ever created, the hidden-tab
gate, aria-hidden, and that the strip is still named in the global pause rule.
The blur/minimize/document-hidden contracts now belong to that global mechanism
and are covered by lib/renderer-loop-pause.test.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The arc-border running indicator animated background-position (repaint
every frame: ~3,600 main-thread style recalcs/min per arc, ~4,100ms/min
of renderer task time measured over 60s of true idle) and progress-slide
animated left (forced layout every frame). Both now travel via transform
on the compositor: same visuals, 3,617 -> 79 style recalcs/min and
4,108 -> 153 ms/min task time in the same harness (-96%).
With two sessions tiled side by side nothing said which one you were in —
both painted at full strength, both composers looked live. The unfocused
surface now fades and desaturates as one layer (thread, timeline rail,
composer, header together), so the focused conversation is the one with
colour in it. Light and dark carry their own opacity; a single pane never
dims, since focus falls back to the primary's selection.
The sidebar gains the matching half: every session open in a pane keeps the
active band, the unfocused ones at reduced strength through their own mixed
token — a colour rather than row opacity, which would have dimmed the title
and status dot with it.
The thumb mixed from --dt-midground, so every list had a small tinted bar in
its corner competing with real accent-coloured UI. A new --dt-scrollbar-thumb
derives from the same colour with chroma forced to zero, keeping each theme's
lightness — so the thumb still sits correctly against its own surfaces, just
without the hue. Alpha steps and the Firefox fallbacks are unchanged.
The exit chip floated over the composer in a 26px transparent strip reserved
for it (--hud-chip-strip), hidden until you hovered the bar. Under glass that
strip is bare untinted material across the top of the HUD — a band of chrome
above the surface, present in every state, holding a control you cannot see.
It rides the composer's controls row now, next to send. That costs no
reserved space and takes about 120 lines of CSS with it: the chip needed its
own placement, hover reveal, leave-hold, and an opaque card to stay legible
over an unknown desktop. None of that applies to a button on the bar, which
is already our surface — the problem was the placement, not the control.
Trade-off worth naming: the way out is now always visible in the HUD rather
than revealed on hover. It is one more permanent glyph on a Spotlight bar, in
exchange for an escape hatch that no longer depends on discovering it.
The band wore its own card tint at a hardcoded 80/92%, so a HUD beside the
docked window read as a lookalike rather than the same surface, and the Tint
slider moved one and not the other. It now paints --ui-bg-chrome at
--translucency-glass-keep: one painter, one token, one lever.
That needed the setting and the surface rewrite to stop being one flag.
data-hermes-glass means "this window's field surfaces may be rewritten" and
is deliberately false in the HUD, which owns its own backgrounds; the new
data-hermes-glass-on means "the user's Glass setting is live" and is
published everywhere, along with the tint number the band reads.
The 0.5rem side inset drops to zero while glass is on. It exists to keep an
opaque sheet clear of the bar's corner controls, but the frost is the whole
window — an inset sheet left a hairline of bare untinted material down both
sides.
An open completion drawer now drops the frost along with the band it belongs
to. The drawer takes the band to 25% and blurs it while the native material
stayed at full strength, which is the same bare slab in a different
disguise. It mounts without a focus change, so it is observed rather than
passed in, coalesced to a frame because the shell mutates with every
streamed token.
Glass was macOS-only because it rode setVibrancy. Windows 11 22H2 has a
first-party equivalent in setBackgroundMaterial, so the mode now resolves
its backing per platform instead of per-OS-check: macOS keeps vibrancy,
Windows 11 gets DWM acrylic / tabbed / mica, and everything older stays on
Clear. No third-party native addon.
Two Windows-specific details the mapping has to respect. DWM only paints
the client area of a transparent window (electron#49443), so glass-capable
Windows chat windows are born transparent with the opaque themed
backgroundColor covering them while glass is off — a live Clear/Glass
toggle then needs no window recreate. And Windows exposes three backdrops
for four frost rungs, so the two heaviest both resolve to mica; the mapping
stays total so a frost saved on a Mac still renders.
Glass support is computed once from os.release() and shared: main uses it
for the persisted default and every window, preload publishes it to the
renderer so the UI can't offer a mode the window can't back.
The transcript's activity row went silent for most of an agentic turn. Two
gates were too narrow. It mounted only while the tail bubble's own status was
`running`, so a turn that seals a bubble mid-flight (message.interim) or
finishes one while the agent keeps going left a settled message at the tail and
unmounted the row entirely. And its activity signature was part count plus text
length, which a landing tool result does not change — a result mutates the part
that was already there — so the pause after a call ended was read as more of
the same silence and dated from whenever the call started.
Between them, the app spent whole stretches with the composer's arc border lit
and Stop armed while the thread showed nothing and counted nothing.
The row now follows the same busy signal the composer does, is mounted by the
tail unconditionally, and decides for itself whether the turn owes the user a
line. A named wait (compaction, provider wait, a tool being drafted) says so
immediately; an unnamed gap earns two seconds of quiet first, so a run of quick
calls doesn't strobe. It still defers to the two waits already accounted for
elsewhere: a prompt the user is answering, and a tool call in flight carrying
its own row and timer — anywhere in the message now, not just the last part,
and excluding the silent tools that render nothing to defer to.
Renamed to TurnActivityIndicator / `aui_turn-activity`: it hasn't measured a
stall since it started measuring the whole turn.
Restores the four features SHL0MS built on #84329 that an earlier pass on
this branch had carved out, reconciled onto the shared translucency state
rather than the four-atom store they were written against.
- Frost picker. macOS exposes no blur-radius knob, so the vibrancy material
IS the frost control. The four in the ladder come from a pixel census on
macOS 26: the 14 Electron materials collapse to 9 distinct looks, and
these four are the widest separations that stay distinct in BOTH
appearances. sidebar/hud collapse into under-window when unfocused, which
is why they're deliberately absent -- normalizeMaterial rejects them, with
a test saying why.
- Sidebar-only glass, the Finder shape. <body> stays the single painter and
splits at the rail's live-measured edge with a hard gradient stop, so
there's no smear across the seam and no per-layer tint stacking. RTL
mirrors.
- Full-range tint. glassSurfaceKeep runs linear to zero, so the top of the
lever is bare untinted blur instead of stopping at a 30% wash. Text, cards
and the composer keep their own opaque tokens, which is what makes 100%
usable rather than unreadable.
- Peek. The settings overlay covers the very effect its slider controls, so
holding the slider ghosts the whole overlay layer and the live window
becomes the preview. A counter, not a boolean: a held drag and a timed
pulse from a picker click overlap, and the drag must not be cancelled by a
pulse expiring underneath it.
One change from the original: the peek's transition is scoped with :has() to
the overlay that arms it. The version on #84329 shipped a bare
`[data-overlay-surface] { transition: opacity 420ms }`, which gave every
overlay in the app -- command center, cron, agents, model picker -- a 420ms
opacity transition for the life of the process to serve one slider. Verified
by running the candidate rules through lightningcss: the scoped selectors
survive minification and no un-gated overlay transition remains.
visualEffectState is pinned to 'active' at each chat window, because several
materials collapse to a shared inactive look on blur -- without it the frost
choice silently erases itself whenever the user clicks another app.
Co-authored-by: SHL0MS <SHL0MS@users.noreply.github.com>
Glass thins the field tokens, so anything that needs a fill for a reason of
its own had to be exempted. Those exemptions were written as styles.css
reaching into other components by class name and slot -- .cursor-grabbing,
.composer-human-message-container, [data-slot='file-diff-panel'] -- which
puts the knowledge in the wrong file: rename the class, lose the fill, and
nothing fails until someone turns glass on over a diff.
There are only two roles. A surface that MASKS its siblings (the diff
gutter, a dragged row) must stay opaque or it reads as text through text. A
surface RAISED above the field (overlay cards, the inline edit box) stays
near-opaque and never thinner than the field behind it. Each one now says
which it is at its own call site, and styles.css styles the roles.
This also narrows the diff-panel rule to the sticky gutter that actually
masks, rather than the whole panel.
Two opaque layers sat between the transparent page and the vibrancy material, so glass read as a slight lightening instead of a blur. The contrib shell root and the SidebarProvider wrapper paint full-window opaque fills above body; both are cleared under glass so body's tint is the window's only field paint. And Chromium composites the page against the window backgroundColor before macOS composites the window, so chat windows now get an alpha-0 backing when glass is active, at creation for cold launches and via setBackgroundColor on runtime toggles, scoped to registered chat windows so the transparent special-purpose windows (HUD, pet, quick entry) are untouched.
Session panes stayed nearly opaque under glass while the landing page and overlay cards showed the effect: the field surfaces nest (body, pane container, chat section, transcript wrapper all wear the surface tokens), so a per-token tint stacked once per layer and compounded toward opaque exactly where the pane tree is deepest. Body now paints the glass tint once and the field tokens go fully transparent, so the field alpha is one number on every route.
Overlay cards on OverlayView are marked data-glass-raised and pinned near-opaque (never thinner than the field), inverting the hierarchy the first cut had backwards: glass field behind, solid card in front. Mask surfaces (diff gutter, dragged sidebar row, inline edit box) get opaque fills back, and in clear mode the overlay scrim darkens and widens its blur so two uniformly faded layers of text stop fighting.
The translucency slider maps to native window opacity, which fades the whole window including text; over a busy wallpaper even low settings get hard to read. This adds a second mode to the same lever: Glass keeps the window opaque at the native level and instead thins the renderer's field surfaces (chat surface + sidebar) over the macOS vibrancy material every chat window already carries, so the desktop shows through as a smooth matte blur while text keeps full contrast.
One lever, two modes: Clear stays the default and is byte-identical in behavior; Glass is macOS-only (other platforms normalize to clear on both sides of the IPC). Mode persists next to intensity in translucency.json and localStorage, applies live to all open windows, and survives cold launch. Raised surfaces (cards, popovers, composer, terminal) keep opaque fills; the terminal surface is pinned because xterm resolves its background to a concrete color for its canvas.
The bare dimmed glyph had nothing to read against. Over a light document in
a light theme it is a pale mark on white, and every rest opacity tried
(0.35, then 0.45, then 0.75 behind a text halo) came back reported as the
button being gone.
Give the control its own substrate, which is what every shipped overlay
does: Apple's HIG puts controls on a material rather than directly on
content, Firefox picture-in-picture draws close/unpip as opaque chips, and
Discord's overlay adds a contrast layer over the game. Deriving contrast
from the backdrop is not available to us either way -- mix-blend-difference
composites against the page, and behind a transparent Electron window that
is nothing.
The chip now wears the composer bar's own tokens (fill, hairline, radius,
bottom shadow), so it inverts with the theme and with the OS appearance
under mode 'system'. It rests hidden and fades in while the bar, the band,
or the chip itself is hovered, with a hold on the way out so it survives the
reach across the gap -- reaching for the HUD is the motion that means "I
want the app". Hover rather than focus: the caret gate behind #81893 broke
the escape hatch exactly when it was needed.
Hovering a truncated inbox-card title glides the clipped tail into view —
one direction at constant speed, a short hold at each end, then a snap back
to the start. Overflow is measured on pointerenter and the animation arms
only when the text actually clips, so short titles never wiggle. State lives
in DOM attributes and CSS variables, so a hover never re-renders the
memoized row, and the blanket reduced-motion override already disables it.
* fix(desktop): stop HUD window growing on drag; add corner resize handle
The HUD window is created frame:false + transparent:true + resizable:true.
On Windows, a transparent frameless window silently grows ~1px per
setPosition call (worse at >100% DPI scaling) — every drag of the composer
bar accumulated size drift, and the HUD could end up enormous (reported at
1385x1052 against a 620x320 default). Reading the size back mid-drag
compounds the drift because getSize() returns the already-drifted value.
Fix, mirroring the pet overlay's pattern:
- create the HUD window non-resizable (no system edge resize hot-zone)
- moveBy uses setBounds with a size snapshotted on the first move of each
drag, so the OS can never accumulate drift (verified: 500 moveBy calls
with zero size change on Electron 40 / Win11 / 175% DPI)
- add a bottom-right corner resize handle (resize-handle.ts) driving a new
hermes:hud:set-bounds IPC that flips resizable on for the call, restoring
the ability to resize a window that is otherwise non-resizable
* fix(desktop): pin HUD drag size in renderer, not main-process globals
The superseding pass drops hudDragWidth/hudDragHeight from main: composer
drag snapshots outerWidth/outerHeight when the hold arms (pet overlay
pattern) and passes them on every moveBy. Adds one test for that contract.
Supersedes #82455.
Co-authored-by: Ringo6107 <199014580+Ringo6107@users.noreply.github.com>
* fix(desktop): keep the HUD solid through a corner resize; drop dead handle state
The resize handle's `resizing` flag only fed a CSS rule that restated the
cursor it already had, so nothing pinned the window mid-gesture: click-through
hands the mouse away the moment the growing edge outruns the cursor. Raise the
composer drag's existing `data-hud-grabbing` instead — one flag for "a gesture
owns the window" — and cover it in click-through's tests.
Also drops the hook's always-true `enabled` param and routes teardown through a
`reset` callback, matching composer-drag.ts and clearing the atom-mirrored-ref
lint rule.
---------
Co-authored-by: Ringo6107 <199014580+Ringo6107@users.noreply.github.com>
Left cluster gets a macOS-only translate to sit on the traffic-light row.
All titlebar tools use 24×24 hit areas with 13.9px Codicons (inline size
beats unlayered codicon.css). Clusters share one flex shell with no gap —
buttons abut and the hit target is the spacing.
A thumb parked on a list you aren't touching is chrome, not information,
and the sidebar stacks several scrollers so it draws several of them at
once. Fade them in on hover instead, sharing the existing scrollbar
colors and the webkit/Firefox split rather than styling a second kind of
bar. Only the thumb's color changes, so the reserved gutter still keeps
rows from shifting sideways.
The chip was pointer-events: none until [data-slot='composer-rich-input']
had :focus, which made the only visible way out of HUD mode conditional
on the thing most likely to be broken when someone wants out. When focus
never lands (#81893 on macOS) you can neither type nor click your way
out: the HUD is a transparent always-on-top rectangle over the desktop
with no in-app dismiss.
It is now always clickable and dim (0.45) at rest, brightening on hover,
focus-visible, and composer focus. That keeps the original intent — not
a loud chip over the app behind — without gating the escape hatch on the
failure mode it exists for.
Salvaged from #82317 by @Ne0teric. The centering half of that PR is
dropped: #82233 already fixed the dock offset, and its 'translate: none'
is the exact literal Lightning CSS folds into 'transform', which is the
bug #82233 fixed.
Co-authored-by: Ne0teric <Ne0teric@users.noreply.github.com>
The band is a few lines of conversation floating over another app, not a
transcript. Tool blocks, file-diff panels, and background notices ("Self-
improvement review: patched ...") pushed the actual answer out of the
capped band and read as junk pinned over the window below.
A hairline shadow under the bar's bottom edge. Without it the HUD reads as
pasted onto the other app rather than floating above it; kept tight and faint
so it never becomes a glow around a card.
The composer's completion list — `/`, `@`, `:`, and the help hint — hangs off
the top of the bar. That is right everywhere the composer has a window above
it, and wrong in the one place the bar is parked against the screen's top edge:
the list rendered off screen, so `/` looked like it did nothing at all.
Flip it below the bar in that orientation and cap it to the room it actually
has, since the app's own cap assumes a full window.
The list hangs over the band, and both were half-lit over a third thing — the
app underneath. No pair of opacities reads well in that stack.
So the list goes fully opaque and the band falls back behind it: dimmer,
fractionally smaller, slightly out of focus, scaled from the bar's edge so it
reads as depth rather than as the panel shrinking.
Clicking away to another app is the commonest way the HUD gets let go of, and
it fires no focusout — the composer stays document.activeElement while the
window is inactive. Chrome stops matching `:focus` on an unfocused window all
the same, so the band lost its focus state with no hold running and snapped
shut instead of stepping down to the glanceable stage.
The sheet also goes heavier than the text in front of it. There is no blur to
separate the band from what it lies over, so it is the only thing keeping
half-opacity text off someone else's UI.
The band ran the full window and was clipped down for paint and hit-testing,
which was fine while its height was whatever the transcript measured. Capping
it broke that: the scroll container was still window-tall, so content shorter
than the window never overflowed and never scrolled, while the clip hid
everything past the cap. Half the transcript was unreachable.
Give the band the geometry it was only pretending to have — anchored to the
bar, as tall as --hud-band-height, inset at the sides — and drop the clip-path
along with the bar-height clearance that only existed to hold text out from
under a composer the box no longer runs beneath.
In HUD mode the bar is often the only thing on screen, so the states that
matter have to live on it.
Working gets the travelling arc the sidebar's active session already uses, at
2px and pill-rounded to match. Focus recolours the border the bar already
draws: the bar sits flush against the window, so a ring or a shadow is sawn off
by the window before any CSS can shape it, and buying the clearance moves the
bar.
The exit control moves off the band and into the open space above the composer,
right-aligned, appearing on focus only — anchored to the band it drifted to
wherever the transcript happened to end, and a turn landing is not a reason to
offer the window controls. Placed from --hud-bar-height rather than CSS
anchor(), because Lightning CSS drops an entire rule containing an `anchor()`
on the vertical axis: the flipped-edge override never reached the browser and
the control rendered off screen. In that orientation the bar hugs the window's
top edge, so the strip it sits in is reserved as dock padding.
Four separate ways the band claimed room it had nothing to put in:
The height was measured to the viewport's edge, and the scroll container is
`min-height: 100%`, so the whole window counted as transcript. Measure the
message rows instead, and treat zero-height rows as no transcript at all — a
fresh thread still renders scaffolding in the content box, which was enough to
buy the 12px overhang and leave a sliver of sheet hanging under the bar.
The band was uncapped, so a long thread turned a glance-over-your-work strip
into a second window. It now tops out at the smaller of 9.5rem and 42% of the
HUD.
The frost is native vibrancy — the OS content view, which fills the window
rectangle and cannot be clipped to the sheet from the page — so it is only ever
right when the sheet covers the window. With the band capped that is now
essentially never, and anything looser paints a grey slab across the whole HUD.
The composer's drop target is a full-window dashed sheet sized for the app's
chat column; in a bar there is nowhere to drop anything anyway.
Also insets the band 0.5rem each side so the bar's corner controls sit clear of
the sheet's edge rather than on top of it.
`-webkit-app-region: drag` and `useHudClickThrough` cannot share a window, and
every previous attempt at a HUD grab handle had them fighting. The window
manager takes a draggable region's mouse input whole, so the page never sees
the cursor arrive on the handle — and click-through, which decides whether the
window is solid from exactly those moves, has already handed the window to the
desktop by the time you press. The handle was unusable (the press fell through
to the app behind) and, having eaten the moves on the way out, it also left the
HUD solid over its own dead space so clicks meant for the app behind died in
empty window.
So no HUD surface declares a drag region any more, and dragging is a press and
hold on the bar: 140ms to arm, then the renderer moves the window through a new
`hud.moveBy`. Deltas are read in screen coordinates, because client coordinates
are relative to the window being moved and report zero once it keeps up with
the cursor. The pointer is captured and the window pinned solid for the
duration, so a fast drag cannot outrun the bar.
Removes the 2rem invisible drag strip along with it — dead window that only
ever swallowed clicks aimed at whatever was behind the HUD.
Follow-up to #81920, which bounded the frost to the sheet and left the surface
underneath it unbounded. Nothing paints in the empty space above a short
transcript now, and clicks still die there.
Two reasons, both in this stylesheet. The shell's scaffolding — the shell
itself, the chat surface, the wrapper between them — is full-window,
invisible and hit-testable, so the click-through hit test found something at
every point in the window. And the band's box is the whole window by design
(it is the scroll container), so engaging the HUD turned that entire rectangle
into a click target, which on a fresh thread is a window-sized hole over
whatever you were working in.
So: default the shell to `pointer-events: none` and let surfaces opt in, and
clip the band's box to the sheet, which hit-testing honours. Opting in rather
than listing the scaffolding to exclude, because the scaffolding is not a list
anyone maintains — one more wrapper and the dead rectangle is back, whereas a
control that forgets to opt in is visibly dead.
A fresh thread has nothing to show, but the HUD showed a slab of frosted
glass above the bar anyway. Vibrancy is the window's whole content view, so
it frosts the full rectangle — fine while the band always filled the window,
wrong the moment there is no transcript to fill it. It stays off until there
is something to back.
The sheet had a 12px floor for the same reason: the breathing room above the
first row was added in CSS, so a zero-row transcript still measured 12. It is
folded into the measured height now and only applies when there are rows.
The band is real macOS vibrancy now rather than backdrop-filter, which
reaches nothing in a transparent window — its backdrop root is the document,
and the desktop was never in it. Vibrancy composites below the web contents,
so it can see the desktop, and it can't be masked or clipped from the page.
That rules out the gradient the band used to carry and settles it as a flat
panel: uniform tint, uniform frost.
The fade does the work the gradient was doing. A landing turn brings the
transcript half way up to be glanced at, focus promotes it to properly
readable, and the hold takes it back down and then away — the panel sliding
behind the bar as the last of the text goes.
It only fades from an idle transcript. A running turn or a question waiting
on you holds it open, because a prompt that fades out is one you can neither
read nor answer, and the hold timer alone would expire through a long tool
call that prints nothing.
Both entry points read $selectedStoredSessionId, which is the WORKSPACE pane's
session — so whichever tile was fronted, the main tab went into the HUD. Tabs
exist precisely so those aren't the same question.
`getActiveComposer()` already answers it for the focus bus, healing to the
visible surface when its cached claim is buried, and a tile's routing key is its
stored session id. One resolver now, shared by the titlebar button and ⌘⇧H.
Coming back re-resumes through the tile delegate when the target is an open
tile. The ordinary resume path enforces "a session is either main or a tile,
never both" and would have closed the tile to take it into main, quietly
rearranging tabs the user opened on purpose.