A back-to-back run of 3+ adjacent tool calls now collapses into a
fixed-height window that pins the newest call to the bottom and fades
older ones up under a top gradient, so a long run no longer shoves the
reply off screen. Shorter runs are byte-identical to before, and the DOM
shape is the same in both modes (only classes flip) so crossing the
threshold mid-stream never remounts a row. Expanding any row breaks the
window out to full height via a `:has([data-tool-open])` rule.
Let users click the status bar context indicator to see how tokens are
split across system prompt, tools, rules, skills, MCP, and conversation.
Co-authored-by: Cursor <cursoragent@cursor.com>
Extract the agents/trace overlay chrome into overlays/panel.tsx and adopt it
across the Cron, Profiles, and Agents overlays so they share one layout
(centered card, header, master/detail list with built-in search, kebab row
actions, big "+" footer, empty state) instead of three ad-hoc split layouts.
Also in this pass:
- OverlayView insets equidistantly on every side (was top/left-only, which
left a large left gutter on narrow windows).
- Form-control chrome: input border/background/recessed-inset are now
per-mode theme-var knobs (--dt-input-border/-bg/-inset) — resting borders
blend in, strengthen on hover, and go solid on focus / while a Select is open.
- Thread-timeline popover reuses the shared dropdown surface (1:1 with the
kebab menus) and scrolls the hovered prompt into view.
When idle with a top-level delegate_task still in flight, render a static,
shimmering system-note at the transcript tail instead of a spinner (which
reads as "stuck"). Reuses the shared steer / slash-status chrome (centered,
0.6875rem, muted, Codicon) so it sits in the thread like every other meta
line, and mirrors the primary child's latest stream line, falling back to
generic copy. i18n across en/ja/zh/zh-hant; markdown prose/heading rhythm
tuned so a re-entered turn breathes.
Ship the final pet-generation UX polish (provider picker behavior, step-2 cancel flow, banner integration, and visual consistency) and make saturated-chroma background removal C-op driven so hatch processing no longer hammers the machine during long runs.
Share one SHIKI_THEME (github-dark-dimmed) across code blocks and inline
diffs so they can't drift, and pull token saturation/brightness back via a
`.shiki` dark-mode filter. The dimmed theme alone only changes the
background — which both surfaces strip — so the bright foregrounds needed
the filter to actually calm down.
Unify the diff renderer onto the same Shiki path as code blocks: highlight
the marker-stripped change content in the file's language, then a per-line
transformer layers the add/remove tint + gutter accent on top. Falls back
to the plain color-only renderer when the language is unknown, over budget,
or while Shiki loads.
- shikiLanguageForFilename(): extension → bundled-language id (shared
filename-token helper with codiconForFilename).
- code display:grid so full-width line tints don't double with newline
nodes; theme surface stripped so context lines stay transparent.
Code/diff blocks preferred a system Cascadia Code before the bundled
JetBrains Mono, so they drifted from the terminal (which leads with
JetBrains Mono) on machines where Cascadia is installed. Reorder so every
mono surface uses the face we actually ship.
Render write_file/edit_file/patch as a reviewable diff instead of raw
result JSON, closer to a Cursor/T3 per-edit review.
- Unified diff via FileDiffPanel: strip git file-header + @@ hunk noise,
drop the +/- gutter, color by line with a 2px gutter accent, full-bleed
to the card, transparent context lines, compact scroll height.
- Header shows filename + language icon + +N/-N stats; full path moves to
a hover tooltip (no Edited verb, no ms).
- Treat the three file-edit tools uniformly (isFileEditTool); read diff
from inline_diff or patch's diff field; suppress raw-arg detail.
- Reusable FileTypeIcon primitive sharing the code-block icon mapping
(codiconForFilename), codicon fallback.
- Per-row scaffolding fade (not the group wrapper, which trapped child
opacity); expanded edits stay full, collapsed fade; keyboard-only focus
lift. Hide diff-less rehydrated creates that read as dupes.
- Peel-off undock drops the floating composer under the cursor (centered
horizontally, preserving the vertical grab offset) instead of snapping to
the docked corner.
- Unify the / · @ · ? completion drawer and the attach (+) menu onto one
shared glassy panel primitive (composerPanelCard): smallest theme font,
hairline border, nous shadow; floats off the composer, inset from the left.
- Directive chips: Backspace removes the chip + its auto-inserted trailing
space atomically (no orphaned space), and a phantom trailing block left by
contenteditable no longer falsely expands the composer to two rows.
- Model picker: scroll area capped at max(150px, 30dvh); footer rows aligned
(matching icons, dropped a redundant margin).
- Composer focus shifts the border ~15% toward foreground (no fill change);
input is cursor-text; trimmed control icon/button sizes.
Gesture-driven: drag the docked composer up to peel it out, drag it back to
the bottom-center dock zone (radial glow ramps with proximity) to redock, and
double-click the grab area to toggle. Floating composer is compact, grows
upward as it wraps, and can be moved by its 5px transparent grab platform
(diagonal hatch on hover). Position + popped state persist; secondary windows
always start docked. rAF-coalesced drag, persisted only on release.
The image-generate tool showed a placeholder, then the model echoed a
(often different) image inline in its prose — a second, jarring copy in
the wrong place, dimmed as tool scaffolding, with a misplaced download
button.
Now the generated image lives only in the tool slot:
- Strip every embedded image/media link from the assistant prose of a
message that produced an image (the model frequently restates the
remote URL while the result holds the local path), preserving the
agent's words. Applied on hydration, live deltas, and completion.
- One stable frame sized from the aspect_ratio arg up front, so the
diffusion placeholder and the decoded image share the same box and
crossfade with no layout shift; the box derives its height from the
true ratio on load (no letterboxing).
- Exempt generated images from the tool-block dim-until-hover rule.
- Extract a shared useImageDownload hook + ImageLightbox so the tool
image and markdown images share one implementation.
Streaming auto-follow chased content growth while parked at the bottom,
which rubber-banded — the tail pin and the virtualizer's own measurement
adjustments fought for scrollTop. Drop it; the one-time new-turn jump
already lands a fresh message in view and the viewport stays put after.
Attachments rendered inside the editable user bubble and were collapsed
via an IntersectionObserver + [data-stuck] CSS hack while the bubble was
pinned. Render them as a flow sibling BELOW the sticky bubble instead, so
they scroll away behind it naturally — no observer, no collapse. Image
refs still render as thumbnails, file refs as chips; no border. Removes
the now-unused useStuckToTop hook and its CSS.
The terminal looked soft/heavy on every platform because the xterm
Terminal was built with allowTransparency: true, which drops the WebGL
renderer's opaque fast-path and bakes glyphs as grayscale-alpha coverage
for compositing over a see-through canvas. Our surface (--ui-bg-chrome)
is opaque and withSurface already paints it, so transparency was pure
blur for no benefit — VS Code keeps it off too. Also drop the Medium
(500) base weight for normal/bold (400/700) to match VS Code's metrics,
and remove the now-unused JetBrains Mono Medium face + woff2.
Group recents as parent-repo → worktree → sessions using local git
metadata (probed over IPC, with a path-name heuristic fallback for
remote backends). Single-worktree repos collapse to one level. Sessions
order by creation time and never reshuffle on new messages.
Also: fuse the status stack to the composer border, restore icon actions
in the queue panel, fix sidebar label truncation and drag styling, hide
sticky-message attachments while pinned, and bump the terminal font.
Strict sticky-bottom autoscroll for the chat thread: while the viewport is
parked at the bottom, the tail follows content growth (streaming tokens, late
measurement, Shiki re-highlight) via a useLayoutEffect keyed on the
virtualizer's own size signal, pinned in the same pre-paint pass as its
scrollToFn so the two never rubber-band. The gate is a single boolean — one
upward pixel (scroll/wheel/touch) disarms follow until the user returns to the
bottom.
Adds a floating jump-to-bottom control that appears once scrolled ~10px away
(above the dim threshold so a sub-pixel settle never flashes it), positioned
above the composer with respect to the status stack, with a subtle
scale + slide in/out animation that honours prefers-reduced-motion. The button
bridges to the virtualizer's re-arm + pin path through a small nanostore
emitter.
Supersedes #43624.
* feat(desktop): session-scoped status stack + kill new-window theme flash
Stack subagents, background tasks, and the queue into one collapsible
"sink" above the composer, reusing the queue's chrome so every status
reads as one piece. Extracts shared StatusSection / StatusRow /
TerminalOutput primitives and a unified $statusItemsBySession store
(subagents mirrored, background owned here, merged + grouped for render).
Renames BrailleSpinner → GlyphSpinner now that it drives more than braille.
Separately, fix the white flash on every new/cmd-clicked window: macOS
`vibrancy` paints an NSVisualEffectView that follows the OS appearance and
ignores `backgroundColor`, so a dark app on a light-mode Mac flashed white
until the renderer painted over it. Pin `nativeTheme.themeSource` to the
app theme (persisted to userData so cold launches paint right before the
renderer loads), hold windows with `show:false` until `ready-to-show`, and
pre-paint the themed background via an inline script before the bundle runs.
* feat(desktop): dock the slash popover to the composer via one shared fill var
The slash·@ popover (and ? help) now docks onto the composer's edge with the
same chrome as the queue/status stack — rounded outer corners, fused borderless
edge, no shadow — but keeps its own narrow width.
Surface + drawer paint a single --composer-fill var; the state ladder
(rest / scrolled / focused / drawer-open) lives once in styles.css on
[data-slot='composer-root']. The :has() drawer-open rule is last and forces an
opaque fill, since translucent glass sampling different backdrops (thread vs
fade gradient) can never match. This replaces the focus-within !important
override that repainted the surface behind every previous matching attempt.
Also drop the chevron column from the project file tree — the folder open/closed
icon already carries the expand state.
* feat(desktop): base inset for file tree rows (post-chevron alignment)
* feat(desktop): wire the status stack's background tasks to the real process registry
The background group was UI-only (dev-mock seeded). Now it's live e2e:
- tui_gateway: new session-scoped `process.list` (registry snapshot filtered
by the session's session_key, plus a 4KB output tail for the inline
terminal viewer) and `process.kill` (single process, ownership-checked —
unlike process.stop's kill_all).
- Renderer: `reconcileBackgroundProcesses` syncs snapshots into the store
layout-stably — rows keep their position when state flips (never re-sort),
new processes append, unchanged rows keep object identity so memoised rows
skip re-rendering, and a dismissed-set stops the registry's retained
finished procs from resurrecting X-ed rows.
- Refresh triggers: session open, terminal/process tool.complete,
status.update(kind=process) from the gateway's notification poller, and a
5s poll armed only while a running row is visible (catches silent exits).
- Stop = real `process.kill` + optimistic dismiss; Dismiss = client-side
with resurrection guard.
- Re-keyed the stack to the RUNTIME session id: it was keyed by the stored
session id, where neither subagent events nor process.list would ever land.
- Deleted dev-status-mocks.ts (__hermesStatusMocks) — no more seed shit.
Reconcile invariants covered in store/composer-status.test.ts.
* feat(desktop): todos + openable subagents in the status stack, self-healing file tree
- todo lists move out of the inline chat panel into the composer status stack
(checklist icon, dashed ring = pending, spinner = in progress, check = done),
fed live from todo tool events and seeded from history on session open
- subagent rows carry the child's real session id end-to-end
(delegate_tool → gateway → renderer) so clicking one opens ITS session window
- status stack publishes its measured height so the thread's bottom clearance
grows with it; card paints the shared --composer-fill so focused/scrolled
states match the composer exactly
- file tree self-heals: ENOENT roots retry on a 3s cadence + Try again button,
and the main process expands ~ in IPC paths (gateway cwds arrive as ~/...)
- composer drag-drop of tree entries inserts inline refs instead of attachments
* fix(desktop): file tree falls back to the workspace dir when a session's cwd is gone
Sessions record their launch cwd; deleted worktrees leave that path dead,
so opening such a session swapped the tree from the default workspace to a
directory that ENOENTs forever — the 3s retry just spun on it. On a root
read error the tree now asks main to sanitize the cwd (prefers the
configured default project dir), displays that fallback, and quietly
re-probes the original path so it switches back if the dir reappears.
* feat(desktop): working restore-checkpoint button on past user prompts
The discard icon on hover of a past user bubble was decorative — clicking
did nothing. It's now a real control: a confirmation dialog explains that
everything after the prompt is removed, then the session rewinds to that
turn and reruns the same prompt (prompt.submit with
truncate_before_user_ordinal, the same mechanism the edit composer uses).
Failures rethrow into the dialog's inline error instead of toasting.
* fix(desktop): show the restore-checkpoint button on the latest user prompt too
Restoring the most recent prompt is just 'retry this turn' — no reason to
exclude it. Stop still takes the slot while the turn is running.
* fix(desktop): finished todo lists clear themselves out of the status stack
A list whose every item is completed/cancelled lingers ~4s so the final
checkmark is visible, then the todo group drops out of the stack. A fresh
active list arriving within the linger cancels the scheduled clear.
* chore(desktop): drop dead editableCheckpoint copy, terser restore confirm
* fix(desktop): rewind clears the abandoned timeline's todos + background
Restoring to (or editing) an earlier prompt rewinds the conversation, but
the todos and background processes spawned by the now-discarded turns kept
showing in the status stack — and the real background processes kept
running. Both rewind paths now clear the session's todo rows and kill +
drop its background processes before the fresh run repopulates them. Also
drops the click-to-edit clamp transition, which flashed a half-expanded
bubble on the way into the edit composer.
* feat(desktop): user messages are always editable; edit/restore revert mid-stream
The bubble is now always click-to-edit — even while a turn streams — instead
of going inert during a run. Sending an edit acts like restore: it rewinds to
that prompt and re-runs with the new text. Both edit and restore can fire
mid-stream now; the gateway refuses prompt.submit while a turn runs (4009
"session busy"), so they interrupt the live turn first and retry the submit
until the cooperative interrupt winds it down. Restore (re-run as-is) shows on
every prompt except the latest running one, which keeps the Stop button.
* fix(desktop): label preview-pane ⌘L selections with the filename, not "zsh"
The terminal owns a global ⌘/Ctrl+L "send selection to composer" shortcut, so
selecting text in the file preview pane and hitting it fell through to the
terminal handler — which imported the right text but labelled the composer ref
"zsh:N lines" off the shell name. When the selection isn't an xterm selection,
label it with the previewed file instead.
* fix(desktop): ⌘L on a preview line selection inserts the @line ref, like dragging
The source preview lets you select lines in the gutter and drag them into the
composer as an @line:path:start-end ref. ⌘/Ctrl+L now does the same when a line
selection is active — it drops the identical ref instead of falling through to
the terminal's global handler (which grabbed the native text selection and sent
a bogus terminal block). Capture-phase + stopPropagation so it wins; with a line
selection there's no native selection, so the terminal handler stays out of it.
* chore: gitignore apps/desktop/demo/ scratch output
The desktop demo prompt writes demo/*.txt during recorded walkthroughs; it's
throwaway, never part of the app. Ignore it so it stops cluttering git status.
* feat(desktop): subagent watch windows, hard stop, sidebar hygiene
Child-session mirror for live subagent windows, delegate sessions tagged
and excluded from the sidebar, composer focus/stop polish, and WS stall
resilience on the gateway transport.
* refactor: DRY delegate SQL + trim status-stack noise
Extract shared listable-child and delegate-delete helpers in hermes_state,
collapse cancelRun busy release, and cut comment bloat in resume/status paths.
* fix(desktop): hide orphaned subagent sessions in sidebar
Cascade-delete all ephemeral children on parent delete (not just tagged rows),
run v16 backfill to tag legacy orphans, and record new delegates as source=subagent.
* fix: restore orphan contract for untagged children + lazy session eviction
Cascade-delete only _delegate_from-tagged rows (v16 backfill covers legacy),
walk marker chains recursively with FK-safe orphaning, gate lazy watch
sessions out of the still-starting eviction exemption via an explicit flag,
pass session_id to _make_agent only when resuming, and hide source=subagent
from session search.
* fix(gateway): gate child mirror off upgraded sessions + age out stale run entries
Review findings: the mirror could interleave synthetic events with a real
native stream once a watch window upgrades (prompt.submit builds an agent),
and a lost subagent.complete left _active_child_runs pinning running=true
forever. Mirror now stops when the live session owns an agent; liveness
reads ignore entries older than an hour.
* fix(gateway): reject prompt.submit into a watch session while its child runs
A lazy watch session's running flag is False (the run lives in the parent
turn), so typing mid-run sailed past the busy guard and built a second agent
racing the in-flight child on the same stored session. Busy error until the
run completes; afterwards the submit upgrades into a normal conversation.
* refactor(gateway): DRY watch-resume payload + compose listable-child SQL
Fold the duplicated child-run busy overlay into one _reuse_live_payload
helper across both resume reuse paths, collapse the twin mirror early-returns,
and build _LISTABLE_CHILD_SQL from _BRANCH_CHILD_SQL instead of restating it.
* fix(desktop): clip horizontal overflow on sidebar scroll areas
Add overflow-x-hidden alongside overflow-y-auto on session list scrollers
and the shared SidebarContent primitive — vertical scroll unchanged.
The terminal listed JetBrains Mono only as a late fallback and shipped no
webfont, so on machines without SF Mono/Menlo xterm measured the grid on the
regular system face while styled SGR spans fell back to a font with different
advances — glyphs squeezed and overlapped.
Bundle the regular/bold/italic woff2 (Apache-2.0, the faces the dashboard
already ships), put the family first in the xterm stack, pin the weights, and
warm every face before mount (fonts.ready only settles already-requested
faces; bold/italic aren't asked for until styled output paints, past atlas
init). Vite emits them as hashed assets under dist/** with base './', so the
fonts ship in the asar and every install path inherits them.
Arabic/Hebrew/Persian/Urdu chat text rendered left-to-right and
left-aligned, and mixed RTL/English technical messages (the common case)
read backwards. Resolve each chat block's base direction from its own
first strong character (UAX#9) with pure CSS, scoped to the chat
surfaces only:
- `unicode-bidi: plaintext` + `text-align: start` on assistant prose
blocks (p, h1-h6, li, blockquote), the user bubble's text lines, and
both composers (main + edit share the composer-rich-input slot). RTL
blocks read and right-align RTL; English stays LTR; mixed
conversations resolve per block. `text-align: start` is required
because the user bubble hardcodes `text-left`.
- Inline `code` and KaTeX are pinned `direction: ltr; unicode-bidi:
isolate`, so the bidi first-strong heuristic skips them: a sentence
that *starts* with a command (`./run.sh ...`) followed by Arabic
still resolves RTL, and the command's own neutrals keep their order.
- Fenced code surfaces (code-card, user fences) are pinned LTR so they
never mirror or right-align inside an RTL list item or blockquote.
`direction` is never forced, so app chrome, layout, and list indent
stay LTR per the issue's request not to flip the whole UI. English-only
content is byte-for-byte unchanged.
Salvaged and unified from #44065 and #44169; verified in Chromium that
isolate removes inline code from the paragraph direction vote (the
code-first case), making the JS dir-resolution in #44065 unnecessary.
Fixes#44150
Co-authored-by: Adolanium <Adolanium@users.noreply.github.com>
Co-authored-by: Adalsteinn Helgason <AIalliAI@users.noreply.github.com>
* fix(desktop): prevent sidebar section overlap
Use a shared sidebar section scroller only on short windows so sections do not overlap, while preserving per-section scrolling on taller layouts.
* fix(desktop): measure section stack for compact sidebar mode
Window-height media query kept big windows in compact mode whenever the OS chrome ate into 830px; observe the section stack element instead so compact only engages when the stack is actually short.
* refactor(desktop): drive sidebar compact mode with CSS, not JS
Replace the matchMedia hook with a `short` (max-height: 830px) Tailwind
variant so the per-section scrollers flatten into one shared scroll stack on
short windows purely in CSS. Taller windows keep their per-group scrollers and
recents virtualization unchanged.
* refactor(desktop): pure-CSS two-mode sidebar scroll + collapse/cap groups
Drop the JS-measured compaction in favour of a single `compact` height
variant (max-height: 768px):
- tall: every section is its own capped, independent scroller; Sessions
is the lone flex-1 scroller.
- short: sections flatten and the stack scrolls as one.
Every section is now `shrink-0`, so nothing is squeezed below its
content and bled onto a sibling — the root cause of the header overlap
(flexbox implied min-size). Sessions keeps its virtualized scroller in
short mode only when it's the long list.
Non-session groups (messaging, cron) collapse by default — expanded ids
persist per platform — and render 3 rows, revealing 10 more on demand.
Extract the shared SidebarLoadMoreRow. Stress harness seeds 50 recents
to mirror the real first page.
* chore(desktop): trim sidebar comments, unify "compact" naming
Self-review polish: condense the over-long mode comments, use "compact"
consistently (matching the variant) instead of mixing "short", and drop a
no-op useCallback around revealMoreMessaging.
* chore(desktop): drop dev sidebar stress harness from the PR
Remove stress-probe.ts and its main.tsx import — it was a throwaway
testing aid, not something to ship.
* feat(desktop): hover-reveal collapsed chat sidebar as a fixed overlay
When the sessions sidebar is collapsed, hovering the left edge now floats
it back in as a fixed overlay over the main content instead of just being
hidden. The collapsed grid track stays at 0px so the panel never reserves
space — it slides over whatever's underneath and retracts on pointer-leave.
- PaneShell: new hoverReveal prop. When a pane is collapsed + hoverReveal,
render an edge hot-zone + a side-anchored floating panel (absolute, full
height, honors any persisted resize width) that slides in on hover/focus.
- ChatSidebar: force the (otherwise opacity-0 when collapsed) sidebar fully
visible + interactive while the overlay is revealed, via an
in-data-[pane-hover-reveal=open] variant.
- desktop-controller: opt the chat-sidebar pane into hoverReveal.
* feat(desktop): lower window minWidth 900→400
Lets the window shrink to a narrow rail (e.g. for the collapsed
hover-reveal sidebar) instead of being floored at 900px.
* fix(desktop): render full sidebar content in hover-reveal overlay
The hover-reveal overlay showed only the nav rail — session rows, search,
pinned/recents were gated behind `sidebarOpen` (false while collapsed), so
they never mounted in the floated panel.
Add a $sidebarRevealed store the PaneShell overlay drives via a new
onHoverRevealChange callback, and gate ChatSidebar's content on
`sidebarOpen || sidebarRevealed` (contentVisible) instead of raw open
state. The overlay now shows the complete sidebar.
* fix(desktop): drop shadow on hover-reveal sidebar overlay
* feat(desktop): hover-reveal the file-browser sidebar too
The reveal mechanism already lives in the shared Pane primitive — the
right rail just opts in with hoverReveal. Its content renders
unconditionally, so (unlike the chat sidebar) it needs no extra
content-visibility gating.
* clean(desktop): tighten hover-reveal pane code
KISS pass — flatten the translate ternary, derive a single `revealed`,
inline the edge style, drop the redundant set-guard, and trim comments to
the house one-liner style. No behavior change.
* fix(desktop): stop hiding sidebar nav labels on narrow windows
The nav labels (New session, Skills, …) and the ⌘N hint were gated on a
viewport breakpoint (max-[46.25rem]:hidden), so shrinking the window hid
them even when the sidebar itself was wide — including in the hover-reveal
overlay. Drop the gate; the label already truncates (min-w-0 flex-1) so it
ellipsizes gracefully in a narrow rail, and contentVisible already hides it
when collapsed to the icon rail.
* feat(desktop): auto-collapse both sidebars below 600px into hover-reveal
Add a Pane `forceCollapsed` prop — collapses the track without writing to
the store (so the saved open state restores when the window widens) while
keeping hoverReveal alive (unlike `disabled`, which suppresses it).
desktop-controller watches (max-width: 600px) and force-collapses the chat
sidebar + file browser, so on a narrow window both rails get out of the way
and the hover-reveal overlay becomes the way in.
* feat(desktop): hover-intent + refined easing for sidebar reveal
- Gate the reveal on pointer velocity: the full-height edge hot-zone now
only arms on a slow, deliberate pass (<=0.55 px/ms). Fast sweeps toward
the titlebar/statusbar — or off the window — blow past the threshold and
never trigger, so the wide hit area stops being a nuisance.
- Swap the slide easing to cubic-bezier(0.32,0.72,0,1) at 260ms (snappy-out,
soft-land) for a more serious-app feel.
* fix(desktop): don't reveal sidebar during window resize
Resizing the window parks the cursor on the screen edge and fires slow
pointermoves over the hot-zone, reading as deliberate intent. Guard the
reveal on (a) e.buttons !== 0 — any button-held drag, incl. edge-resize —
and (b) a 250ms cooldown after any window resize event.
* feat(desktop): hoverIntent-style poll gate + inert contents during slide
Replace the single-sample velocity check (too eager — fired on any one slow
move, incl. resize drift) with a port of Brian Cherne's hoverIntent: poll
the pointer every 90ms and only arm once it has *settled* (moved <5px between
two consecutive polls inside the edge zone). Fly-bys, pass-throughs, and
resize drift never produce two close samples in a row, so they don't trigger.
Also keep the revealed panel's CONTENTS pointer-events-none until the slide-in
transition finishes (onTransitionEnd → settled), so you can't misclick a
session row mid-animation. Resets on retract.
* fix(desktop): no cursor/hit-test leak before reveal settles
The edge hot-zone showed cursor:pointer the instant the pointer touched it —
before the panel was armed or in view. And contents were inert but the panel
itself still hit-tested, so the cursor could flip mid-slide. Fix: hot-zone is
cursor-default (it's invisible), and the whole panel is pointer-events-none
until revealed && settled, so the cursor never changes or lands on a row
before the slide-in finishes.
* fix(desktop): geometry-driven close so revealed panel always retracts
The revealed panel relied on its own onPointerLeave to close — but a panel
that slid in under a still cursor (or whose contents were inert during the
slide) never fires enter/leave, so it got stuck open (esp. the file browser).
onTransitionEnd also bubbled from the file-tree's own row transitions,
tripping the settled flag wrongly.
Replace with a document-level pointermove watcher that closes once the cursor
leaves the panel's bounding rect + a 24px grace — independent of pointer-events
state or what the contents do. Gate interactivity on a simple slide-duration
timer (interactive) instead of the fragile transitionEnd, so the cursor still
can't flip or land on a row before the panel is in view.
* feat(desktop): make sidebar toggle shortcuts reveal when force-collapsed
mod+b / mod+j were no-ops on a narrow (force-collapsed) window — they
flipped the store but the pane ignores it. Now the toggle handlers also
dispatch PANE_TOGGLE_REVEAL_EVENT; a force-collapsed Pane listens (only while
overlayActive) and flips its hover-reveal, so the shortcut floats the rail in
(and back out) at this responsive breakpoint.
* refactor(desktop): name the 600px sidebar collapse breakpoint
Hoist the inline '(max-width: 600px)' literal into
SIDEBAR_COLLAPSE_BREAKPOINT_PX + SIDEBAR_COLLAPSE_MEDIA_QUERY in
layout-constants, so the responsive collapse point is a single named source
of truth instead of a magic string in the controller.
* tweak(desktop): sidebar auto-collapse breakpoint 600px -> 768px
768 is the standard md breakpoint and a more honest 'no room to dock' point.
* tweak(desktop): halve sidebar reveal slide duration 260ms -> 130ms
* Revert "tweak(desktop): halve sidebar reveal slide duration 260ms -> 130ms"
This reverts commit 6009a13200.
* perf(desktop): pre-mount hover-reveal contents to kill slide-in stall
The reveal mounted the (heavy, virtualized) sidebar contents in the same
frame the slide started, so the browser stalled painting the transform until
the mount finished — a ~100-200ms beat before the panel moved, very visible
on the instant keyboard toggle (hover masked it via the 90ms intent poll).
Report overlayActive (collapsed-overlay mode) rather than the live reveal
state to the mount consumer, so contents stay mounted off-screen while
collapsed and reveal is a pure transform. Visibility is still driven
separately by the data-pane-hover-reveal attr + the slide transform.
* fix(desktop): make reveal hotkey spammable
Two throttles on the reveal toggle:
- The handler fired both the reveal event AND toggleSidebarOpen() per press;
the store write hits localStorage synchronously every keystroke + recomputes
the grid, janking rapid presses. When collapsed, only dispatch the reveal
event (the store toggle was a no-op anyway).
- The geometry close-watcher slammed a keyboard-opened panel shut on the first
stray pointermove (trackpad jitter), fighting hotkey spam. Keyboard reveals
now ignore geometry until the cursor actually enters the panel, then the
mouse takes over.
* fix(desktop): inset reveal hot-zone past the OS window-resize gutter
The hot-zone sat flush at the window edge (left-0/right-0), overlapping the
OS resize grab strip — reaching to drag-resize naturally slows the cursor
there, which hoverIntent reads as settled and reveals before the resize drag
even starts. Inset the hot-zone 8px so the outermost edge stays a pure
resize/drag region and only an intentful move just inside it arms a reveal.
* fix(desktop): keep reveal hot-zone at edge, gate arming past resize gutter
Insetting the hot-zone made it unreachable when moving fast. Instead, anchor
the zone flush at the edge (w-4, always captures the pointer) but only ARM the
reveal when the cursor settles >=8px in from the edge — so a resize-reach that
parks on the outermost OS grab strip never triggers, while a deliberate move
into the zone still does. Keeps polling while in the gutter so moving inward
still arms.
* refactor(desktop): rebuild hover-reveal as pure CSS, delete the JS state machine
The hand-rolled pointer state machine (hoverIntent poll, refs, timers, document
pointermove geometry-close, interactive gate, resize cooldowns, keyboard-held
suppression) was fragile and side/instance-specific — hover broke on the right
rail, keyboard toggles triggered phantom animations, resize popped it open.
Replace all of it with the native primitive: CSS group-hover drives the slide
transform; a transition-delay on enter (instant on leave) is the hover-intent
gate (a fast pass-by doesn't dwell long enough to open); a thin edge trigger
inset past the OS resize grab strip arms it; and a single `forced` bool
(data-forced, toggled by the keyboard event) pins it open. Side-agnostic by
construction — group-hover doesn't care which edge or which pane.
Net: ~200 lines of imperative pointer logic → ~40 lines of declarative CSS.
* fix(desktop): don't animate hover-reveal panel across viewport on side flip
Flipping panes changed the off-screen transform from -translateX (off the
left) to +translateX (off the right). transition-transform interpolated
between them, passing through translate-x-0 (fully on-screen) mid-way — so the
hidden panel visibly slid across the window to reach its new hiding spot.
Key the panel on side so it remounts off-screen on the new edge with no
transition to play.
* clean(desktop): tighten hover-reveal markup
KISS pass on the CSS-driven reveal: reuse the existing `side` instead of a
local `left`, move the static duration/ease to inline style (drop two
single-use CSS vars + their arbitrary-value classes, keep only the
state-dependent enter-delay var), and trim comments to the house one-liner
density. No behavior change.
* fix(desktop): inset titlebar past traffic lights when sidebar is force-collapsed
The titlebar content inset (clearing the macOS traffic lights) keyed off the
stored sidebarOpen/fileBrowserOpen, but below the collapse breakpoint both
rails are force-collapsed so the left edge is uncovered while the store still
says open — content (the intro wordmark) overflowed under the lights. Gate
leftEdgePaneOpen on !narrowViewport using the shared SIDEBAR_COLLAPSE_MEDIA_QUERY.
Also rename the now-misleading reveal plumbing to match what it actually does:
onHoverRevealChange -> onOverlayActiveChange, $sidebarRevealed ->
$sidebarOverlayMounted (+ setter/consumer). It reports/stores collapsed-overlay
mode (mount gate), not live reveal state.
* feat(desktop): small --nous-shadow lift on revealed hover-reveal panels
Add a --nous-shadow token (white-based on light, black-based on dark) and apply
it to the floating sidebar panel only while revealed (group-hover / data-forced)
so it reads as lifted off the surface. No shadow on the off-screen panel.
* feat(desktop): shadow-reveal lift on revealed hover-reveal panels
Mirror the --shadow-nous layered falloff into a new --shadow-reveal token whose
drop color flips per mode (white on light, black on dark) via --shadow-reveal-raw
set in :root / :root.dark. Apply the generated shadow-reveal utility to the
floated panel only while revealed (group-hover / data-forced). Leaves the shared
--shadow-nous untouched.
* feat(desktop): use tuned reveal shadow, drop per-mode token
Replace the --shadow-reveal token machinery with Brooklyn's tuned literal
(0 -18px 18px -5px #0000003b) inline per-panel via --reveal-shadow, y-offset
sign flipped for the right side. Same color both modes. Reverts styles.css to
pristine (token removed).
* fix(desktop): use the reveal shadow verbatim, don't invert it per side
Flipping the y-offset sign for the right side inverted the shadow's direction
(cast-up -> cast-down), making it read heavier — not a mirror. The mirror axis
for a left/right panel is offset-x, which is 0 here, so both sides take the
tuned value as-is: 0 -18px 18px -5px #0000003b.
* clean(desktop): hoist reveal shadow to a named const
Move the inline reveal-shadow literal to HOVER_REVEAL_SHADOW alongside the
other HOVER_REVEAL_* tuning consts; drop the now-stale per-side comment.
* fix(desktop): truncate titlebar title before the right tool cluster
The session title used a hardcoded max-w-[52vw] that's blind to where the
right-side tools start, so it ran under them at narrow widths / with pane
tools present. Bound the title container by the same vars the titlebar drag
region uses (--titlebar-content-inset + --titlebar-tools-right +
--titlebar-tools-width) so it truncates exactly at the cluster's left edge.
* fix(desktop): responsive markdown tables — floor width + nowrap headers
The wrapper had overflow-x-auto but the table was w-full with auto layout, so
instead of scrolling it crushed columns until even header words broke mid-word
(Tim/e, Nig/ht). Add a min-w-[18rem] floor so it scrolls horizontally when the
column is narrower than readable, and whitespace-nowrap on th so headers never
break mid-word. Above the floor it still wraps cells naturally.
* fix intro
None of the UI sans/mono font stacks (themes/presets.ts, styles.css) carry
emoji glyphs, so on platforms whose default text font lacks them (e.g. Linux)
emoji rendered as tofu boxes in the composer and chat.
Append a color-emoji fallback — Apple Color Emoji / Segoe UI Emoji / Segoe UI
Symbol / Noto Color Emoji / the `emoji` generic — to every font stack
(SYSTEM_SANS, SYSTEM_MONO, the Courier theme, and the CSS --dt-font-* defaults).
Text still uses the primary fonts; the browser only falls back for emoji
codepoints. Custom themes build on SYSTEM_* so they inherit it automatically.
* fix(desktop): unify dialog/overlay buttons on shared Button component
Replace raw <button> action/text controls across the modal layer (boot
failure, install, update, onboarding, clarify, model-visibility,
notifications, gateway menu) with the shared Button + its variants
(text / ghost / icon-xs). Drops the bespoke square-cornered styling so
every dialog matches the app's slightly-rounded button system, and
swaps clarify-tool's hardcoded "Skip" for the existing i18n string.
* feat(desktop): add dev-only dialog gallery for auditing overlays
A code-split, DEV-gated harness (toggle ⌘/Ctrl+Alt+Shift+D) that triggers
every dialog/overlay so their buttons can be eyeballed in one place:
store-driven overlays (boot failure, updates, notifications, sudo/secret)
plus in-place dialogs (confirm, profile create/rename, attach-url, model
picker/visibility, clarify, tool approval). Never ships to production.
* fix(desktop): use Ctrl+Shift+D for dialog gallery (mac-friendly)
The Cmd/Ctrl+Alt+Shift+D chord is impractical on macOS (Option mangles
the keypress). Ctrl+Shift+D is the same chord on every platform and uses
neither Cmd nor Option.
* fix(desktop): stop overriding button icon size to size-4
Action buttons hardcoded size-4 icons, overriding the Button component's
built-in size-3.5. That extra 2px is why boot-failure / onboarding / gateway
buttons looked chunkier than the settings "Apply" (size-3.5 spinner) despite
being the same component+size. Drop the overrides so icons inherit 3.5.
* feat(desktop): add BrandMark, use it in the updates overlay hero
New BrandMark renders the white logo.png on a hardcoded brand-blue tile
(#0000F2 light / #222 dark), replacing the generic Sparkles hero glyph in
the "update available" overlay. Trying it here first to iterate on the look.
NOTE: apps/desktop/public/logo.png is currently a 1x1 placeholder — the tile
renders now; the glyph appears once the real white logo art is dropped in.
* feat(desktop): add real logo.png asset, render it white in BrandMark
logo.png is blue line-art on transparent, so force it white via filter to
read on both the brand-blue (#0000F2) and near-black (#222) tiles. Bump the
glyph to 62% of the tile for the portrait aspect.
* fix(desktop): BrandMark renders logo as-is, no light bg/radius/padding
Drop the white filter, the hardcoded light-mode blue tile, the radius, and
the inner padding. Logo now fills the tile over a transparent surface in
light mode; dark keeps the #222 tile.
* fix(desktop): bump updates-overlay BrandMark to size-16
* feat(desktop): use downscaled karb.webp in BrandMark
Swap the BrandMark glyph to karb.webp, downscaled from 1129x1418/888KB to
254x320/81KB for the hero badge.
* feat(desktop): use nous-girl mark in BrandMark, invert in dark
Key the white background to transparent so only the black line-art remains
(384px/20KB webp). Light mode shows black art; dark mode flips it white via
dark:invert on the #222 tile. Drop the now-unused karb.webp and logo.png.
* fix(desktop): BrandMark uses nous-girl as-is (no transparent/invert)
The dark-mode invert read as a creepy negative. Use the opaque black-on-white
mark unchanged in both themes; drop the white-key, dark:invert, and #222 tile.
* fix(desktop): give BrandMark an explicit white bg tile
* fix(desktop): use nous-girl.jpg directly in BrandMark
* perf(desktop): downscale nous-girl.jpg to 256x256 (466KB -> 19KB)
* style(desktop): bump nous light --theme-secondary to 14% blue
* fix(desktop): outline button is transparent, not chrome-filled
The outline variant used bg-background (the chrome color), so on cards/overlays
with a different surface it rendered as an odd gray-blue fill (visible on the
boot overlay's Repair install / Use local gateway). Make it bg-transparent so
it inherits the surface like a real outline. Reverts the unrelated
--theme-secondary tweak.
* fix(desktop): clean outline button — thin border, no shadow/fill
Drop shadow-xs and the resting fills (light chrome bg, dark bg-input/30) so
outline is just a thin clean border with a subtle hover, in both themes.
* fix(desktop): stop forcing tertiary bg on outline buttons
A global [data-variant='outline'] rule set background: var(--ui-bg-tertiary),
which (attribute-selector specificity) overrode the cva bg-transparent — so
outline buttons always showed the pale tertiary fill on cards/overlays
regardless of the variant classes. Scope that fill to secondary only; outline
is now a true transparent border.
* style(desktop): unified overlay design system + restore #38631 flat-UI
Overlays/dialogs/toasts share a custom shadow-nous (downward-weighted) and
--stroke-nous hairline instead of hard borders: boot-failure, install,
notifications, model-picker, onboarding, prompt-overlays, updates, Dialog.
- button: outline is a 1px inset ring (no fill/shadow); chrome lives in Button
- BrandMark: 256px nous-girl mark replaces sparkle glyphs (updates/onboarding/about)
- onboarding: conditional header, lemniscate-bloom loaders, OTP device-code boxes,
NOUS CONNECTED hero (ascii decode) + cuneiform easter egg, "Begin" matrix exit
- shared LogView + ErrorState; math/ascii loaders over "Loading..." text
- appearance-settings flattened to SegmentedControl/ListRow; keybind-panel on
shadow-nous + text-variant reset
- restore flat-UI clobbered by #38631's stale-squash (4a1907bd1): command-center,
profiles, skills, messaging, cron de-boxed; shared SearchField + PAGE_INSET_X;
profiles back on OverlaySplitLayout; skills tabs+search one row, no row dividers
* refactor(desktop): clean pass — drop dead code, dedupe, fix stale docs
- log-view: drop unused `bare` prop + forwardRef (no caller uses ref)
- install-overlay: drop `stateOverride` (only the removed dev gallery used it)
- profiles: ProfilesViewProps down to { onClose } (drop vestigial section/titlebar)
- onboarding: hoist shared PROVIDER_ROW_CLASS (was duplicated 2x)
- brand-mark / error-state: tighten comments, fix stale AlertCircle reference
Add a central keybind registry + nanostore so desktop hotkeys are
discoverable and user-rebindable. A titlebar ⌨ button (and ⌘/) opens a
collapsible map grouped by Composer (read-only) / Profiles / Session /
Navigation / View; click any chip to capture a new combo. Overrides
persist to localStorage as a delta against shipped defaults, so future
default changes aren't shadowed by a stored snapshot.
Migrates the previously scattered inline listeners (palette, command
center, new session, sidebar, theme) into the registry, and adds profile
switch/cycle/create + default-profile hotkeys.
Conversation rhythm:
- Single `--paragraph-gap` knob drives paragraph spacing both inside a
markdown block and between consecutive prose parts, out-specifying Tailwind
Typography's prose margins. Code cards carry the same gap themselves so it
holds at any Streamdown nesting depth.
- Two-tier vertical rhythm: `--turn-block-gap` separates scaffolding (tools /
thinking) from the reply; `--tool-row-gap` keeps a tool run tight.
- Drop the prose indent so prose, tools, todos, and thinking share one left
edge. `---` renders as quiet spacing, not a heavy rule.
Flat tool list:
- Tools always render as a standalone-row stack, never a "Tool actions · N
steps" group. assistant-ui slices the tool range unstably (interleaved live
vs. reconstructed-consecutive when settled), so grouping reshuffled the whole
turn the instant it settled. Flat rows are pixel-identical either way.
- Inline approvals can no longer be buried in a collapsed group body.
- Remove the now-dead grouping helpers from tool-fallback-model.
Empty thinking:
- Suppress reasoning disclosures with no visible text (encrypted / spinner-
coerced reasoning) instead of leaving an empty "Thinking" header.
- Tail stall indicator returns "thinking" when a running turn goes quiet.
Streaming cadence:
- Smooth character-reveal decouples visible cadence from bursty arrival.
- Flush queued text deltas before applying tool events so a tool row can't
jump ahead of its preceding text.
- Disable Nagle on the GUI WebSocket so per-token frames aren't coalesced.
Polish: clarify/patch/vision_analyze tool meta, queue-panel + diff-lines
spacing, sticky human bubble expands on focus (not hover).
Strip shadow-composer (and its focus/open-state variants) from the
composer surface, composer fallback surface, and the shared user-bubble
base class. Also drop the !important box-shadow override on
[data-slot=composer-surface] that re-applied the shadow regardless of
the utility class, so the flatter look actually takes effect.
* feat(desktop): dedicated Providers settings with Accounts/API-keys subnav
Rework provider configuration in the desktop app into its own Providers
page that mirrors the first-run onboarding picker, instead of burying
provider keys in the generic Tools & Keys list.
- Add a Providers settings page (providers-settings.tsx) reusing the
onboarding picker cards/ApiKeyForm so the two surfaces stay identical
- Add a sidebar subnav (Accounts vs API keys) backed by a deep-linkable
`pview` URL param; nested OverlayNavItem variant for a lighter active
state so children don't compete with the parent item
- Scope provider search to the active sub-view in its native card format
(no more accordion fallback); collapse the API-key grid to the top
providers behind a "Show all" toggle to cut scrolling
- Launch real in-app OAuth from settings via startManualProviderOAuth;
fix the misleading red "reason" banner that showed during an active
connect (neutral style, hidden during a flow, omitted for direct
per-provider launches)
- Expand PROVIDER_GROUPS and add longest-prefix matching so providers
like xAI/Ollama group correctly instead of landing under "Other"
- Drop redundant messaging API keys from Tools & Keys (channel_managed)
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(desktop): Cursor-style provider key list with inline inputs
Replace the card-grid API-key form on the Providers page with a
per-provider list (mirrors Cursor's API keys section):
- One row per vendor with its primary key input inline; rows with extra
vars (base URL, region, alt tokens) expand to reveal those on focus
- Set keys show their redacted value as the placeholder; Save appears on
edit, Remove on a set key
- Hide redundant alias key fields (e.g. ANTHROPIC_TOKEN vs
ANTHROPIC_API_KEY) unless already set, and label set aliases by env var
name so they're unambiguous
- Smaller mono input text + compact height
Co-authored-by: Cursor <cursoragent@cursor.com>
* style(desktop): flatten providers settings UI chrome
Tighten the providers settings surface to match the newer desktop style:
remove extra card rails/borders in API-key rows, reduce visual noise in the
providers subnav, replace bespoke link-like controls with shared text-button
variants, and improve key input readability.
* feat(desktop): rework providers settings UI
- Flatten the shared OAuth picker rows (accounts + onboarding): drop the
rounded-2xl/border cards for flat hover-bg rows; Nous hero keeps a subtle
tint plus an animated blue→purple arc border.
- Key fields collapse to a single input: a set key reads read-only (redacted)
and edits in place on focus/click — no Replace/Cancel chrome. Save on type,
Esc cancels (without closing the overlay), "Remove or esc to cancel" hint.
- Non-key overrides render boxless, content-sized (field-sizing) and
right-anchored; advanced fields align under the primary key column.
- Add `xs` control size; size fields via padding (no fixed heights).
- Cards expand on key-input focus; chevron shows on hover/expanded; expanded
state uses a ring + softer bg tier so hover ≠ focus.
- Relocate "Get a key" to the bottom-right of the expanded panel; drop the
redundant provider description.
- Cmd+K: add Providers (accounts) and Provider API keys deep-links.
* fix(desktop): flatten provider fields, drop input shadows, fix Cmd+K provider rank
- KeyField: collapse to one stacked label-above-input form field (drop the
bespoke `naked`/inline/column branches); empty advanced overrides fade until
hover/focus/set
- styles: kill the resting + focus drop shadow on shared input chrome so form
inputs sit flat (composer keeps its own shadow)
- Cmd+K: drop stray `providers` keyword from Skills & Tools so the Providers
settings entry ranks first for "provider"
* fix(desktop): nous portal arc blue → orange
* fix(desktop): rank appearance above settings in Cmd+K
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
Tab/focus showed Tailwind's `focus-visible:ring-*` (a box-shadow) plus the
native outline. Drop both via an unlayered reset that nulls --tw-ring-*;
the composer / input soft-glow is untouched (those use direct box-shadows).
Builds on the clarify/needs-input work with a cross-cutting pass to make
the desktop surfaces feel like one app.
- Global Cmd+K command palette (cmdk): nav, settings deep-links, async
API-key / MCP-server / archived-session groups, reusable theme sub-page
(light/dark groups, stays open on pick), loop nav, fuzzy match. Replaces
per-page settings search.
- Shared SearchField: borderless, underline-on-focus, `field-sizing`
auto-width. Unifies sessions sidebar, pages, overlays, command center,
cron; drops bespoke OverlaySearchInput.
- Cron & Profiles converted to OverlayView; flat token-driven panels
(no card-in-card / divider borders) matching command center.
- `r` refresh hotkey via useRefreshHotkey; drop the visible refresh buttons.
- Button text/textStrong link variants applied across settings & views;
shared PAGE_INSET_X content gutters.
- Math/ascii loaders replace "Loading…" text placeholders; x-icon close
over text "Close"; cursor-pointer at the dropdown/select primitive level.
Add a base-layer rule giving every interactive control (button, select,
menu item, switch, tab, summary) cursor:pointer, and strip the now-redundant
hardcoded cursor-pointer from those elements (plain clickable divs/labels
keep theirs). Remove the divide-y separators from settings list sections so
they breathe.
Replace the background-clarify toast (expired on alt-tab, easy to miss) with a
persistent, glowing amber "needs input" dot on the session's sidebar row,
driven off a new ClientSessionState.needsInput flag mirrored into a
$attentionSessionIds store. The flag is set on clarify.request and cleared the
moment the turn resumes (tool.complete) or ends.
Also: redesign the clarify tool UI (borderless choices, pseudo-radio dots,
right-aligned checkmark, arc border, tighter padding), make Button the single
source of icon-button styling (4px radius, new icon-titlebar variant, titlebar
buttons rendered polymorphically via asChild, Codicons throughout), put the
file-tree refresh action first, and .trim() pasted composer text.
* fix(desktop): prevent IME Enter from splitting messages and viewport resize from disarming scroll anchor
Two fixes for the Hermes Desktop composer:
1. IME composition Enter was treated as message submission. When a Korean/
Japanese/Chinese IME is composing text and the user presses Enter to
finalise the preedit, handleEditorKeyDown fired submitDraft() because it
did not check event.nativeEvent.isComposing. The assistant-ui hidden
textarea already guards this correctly; the custom contentEditable
handler was missing it. Added an early return when isComposing is true.
2. Viewport resize (composer expand/collapse, window resize) was disarming
the scroll sticky-bottom anchor. When the composer grows, the thread
viewport shrinks, the browser adjusts scrollTop down to keep content
visible, and the onScroll handler misread this as a user scroll-up.
Added lastClientHeightRef tracking so the disarm condition now requires
BOTH stable scrollHeight AND stable clientHeight before treating a
scrollTop decrease as user intent.
Fixes: random mid-message sends during IME typing; scroll jumps when the
composer resizes or the window changes size.
* fix(desktop): prevent virtualizer measurement adjustments from fighting scroll anchoring
The virtualizer's measureElement callbacks trigger scroll adjustments when
item sizes differ from estimates. These fight our ResizeObserver +
pinToBottom loop, creating visible rubber-banding (view snaps to composer
then jumps back up), even during idle.
Three changes:
1. React.memo on VirtualizedThread to stop parent re-renders cascading
2. Shared stickyBottomRef so scrollToFn can check bottom state
3. scrollToFn override: skip adjustments when user is at bottom
* fix(desktop): use stable useCallback ref instead of inline arrow for onBranchInNewChat
The inline arrow `messageId => void branchInNewChat(messageId)` created a
new function reference on every render. This cascaded through:
desktop-controller → ChatView → Thread → useMemo([...onBranchInNewChat])
→ new messageComponents object → VirtualizedThread receives new prop
→ React.memo overridden → virtualizer recalculates → measurement
adjustments trigger scroll jumps at the 15-second useStatusSnapshot
interval.
Pass the already-useCallback'd branchInNewChat directly.
* fix(desktop): use ctrlEnter submitMode on hidden textarea + gate ResizeObserver on isRunning
Two root-cause fixes:
1. IME message splitting: The hidden ComposerPrimitive.Input textarea had
submitMode='enter' (default), so any Enter keydown it received — even
during IME composition — triggered form.requestSubmit(). Changed to
submitMode='ctrlEnter' so only the contentEditable div (which correctly
checks isComposing) handles plain-Enter submission.
2. Scroll jumps during idle: The ResizeObserver auto-follow loop was
active even when the thread wasn't running, causing spurious
pinToBottom calls whenever any layout shift occurred (browser reflow,
font load, GPU cache eviction). Gated the ResizeObserver on
thread.isRunning so auto-scroll only follows during active streaming.
User messages still pin via useLayoutEffect, and thread.runStart still
calls jumpToBottom.
* fix(desktop): keep chat bottom anchor stable through idle layout shifts
* fix(desktop): prevent code block shrink scroll bounce
* fix(desktop): release bottom height lock on run completion
* fix(desktop): keep streaming code blocks rendered
* fix(desktop): keep bottom anchored through final render
* fix(desktop): render streaming reasoning code blocks
* feat(desktop): add subtle streaming block animations
Pin user bubbles 0.75rem below the scroll top via a single token instead of
flush top-0, so the sticky header doesn't sit hard against the thread edge.
Long user prompts stick to the top of the thread while the response streams
beneath them, so a multi-line prompt could eat most of the viewport. Clamp the
read-only human bubble's text to ~2 lines with a soft bottom fade; the clamp
lifts on hover or keyboard focus, and clicking the bubble still opens the edit
composer (which shows the full text). Short messages are untouched — no clamp,
no fade.
Overflow is measured on an unclamped inner wrapper so the ResizeObserver only
fires on real content/width changes, not every frame while the outer
max-height animates open; the measured height feeds --human-msg-full so
expand/collapse animate to the true height instead of overshooting the cap.