pin brace-expansion to 5.0.8
update concurrently to 10.0.4
update electron-builder to 26.15.3
update eslint to 10.8.0
update eslint-plugin-perfectionist to 5.10.0
update @assistant-ui/react to 0.15.0
update @assistant-ui/react-streamdown to 0.3.8
update radix-ui to 1.6.7
update react-router-dom to react-router@8.3.0 - react-router-dom is no longer a standalone package, it just reexports react-router
remove @radix-ui/react-slot: we import this from `radix-ui`
remove eslint-plugin-react: we imported it, but never actually used it!
The statusbar is now opt-in. Existing users with a stored preference
keep their choice; new users get a clean bottom edge. The way back is
the view.toggleStatusbar keybind or the ⌘K row, unchanged.
powerMonitor's AC/battery state is mirrored to the renderers
(store/power.ts) and visiblePoll quadruples its cadence on battery. Only
the safety-net refreshes slow down — event-driven refreshes and live
streaming are untouched.
It loads arbitrary user-linked pages offscreen; unthrottled, a heavy page
burns full CPU for the window's whole lifetime. Title resolution rides
load events and main-process timers, which throttling doesn't touch.
The process-wide disable-background-timer-throttling /
disable-backgrounding-occluded-windows switches plus a static
backgroundThrottling: false on every chat window pinned each renderer's
document.visibilityState to 'visible' for the life of the window. Every
visibility-gated backstop poll and clock tick in the renderer became an
always-on timer: an idle, minimized Hermes burned ~20% CPU around the
clock, on battery too.
Throttling is now a runtime dial. A small controller (stream-throttle.ts)
rides the merged hermes:active-work reports the quit guard already
receives: while any turn is in flight every chat window gets
setBackgroundThrottling(false) — a live answer keeps painting while
blurred, occluded, or minimized, exactly as before — and once all turns
settle (plus a 5s trailing window so the final flush lands at full
cadence) Chromium's default throttling returns and hidden windows go
quiet.
disable-renderer-backgrounding stays: process priority only, no timer
semantics, and it keeps hidden streaming fast.
Select-all + Cut emptied the text and left the composer blank — no draft,
no prompt. Delete had the same hole.
The placeholder is painted on `:empty`, and a cleared editor keeps a
scaffolding <br> so the contenteditable can't collapse to a sliver. Those
two facts collide: the moment the break lands the editor has a child,
`:empty` goes false, and the prompt never comes back.
CSS can't infer emptiness on its own either. A text node is invisible to
selectors, so `one<br>` and a lone `<br>` are the same shape — a structural
rule like `:has(> br:only-child)` paints the placeholder straight over the
user's text. The code that empties the editor is what knows, so it marks
the root and the condition reads `:is(:empty, [data-empty])`.
Both writers that reshape that root maintain the marker through one helper:
the normalizer, and renderComposerContents for a restored draft or an undo.
The message-edit composer shares the slot and the rule, so it takes the
same shared class instead of drifting on its own copy.
#74815 fixed the draft this stashed; the placeholder is a separate seam.
The tab strip decided the close gesture from the `uncloseable` flag, which the
workspace sets to keep its pane in the tree — so the one tab whose close now
does something couldn't be ⌘-clicked or middle-clicked, and its right-click
menu had no Close.
Read the gesture off the pane's registered closer instead, with the workspace
registering closeWorkspaceTab. An atom rather than a lookup, since that closer
comes from a wiring effect that lands after the strip's first paint.
The workspace pane can't leave the tree, so "close the main tab" only ever had
one answer wired: shift the next stacked session in. With main as the only tab
there was nothing to shift and ⌘W dead-ended on the tab the user was looking
at.
closeWorkspaceTab is now the one answer for every entry point — stacked
session still wins, and with nothing stacked main drops to a fresh New session
draft. A blank draft and a full-page view stay no-ops: a blank draft already
IS the post-close state.
Chromium on Windows and Linux answers a middle press inside a scroller by
starting the autoscroll pan, and the mouseup that ends the pan never becomes
an auxclick. Every surface carrying the gesture — tab strips, the session
list, the terminal rail — is a scroller, so middle-click only ever worked on
macOS, where autoscroll doesn't exist.
Arm on pointerdown, spend on the pointerup over the same element (press one
tab, release on another and nothing happens), and cancel the middle mousedown
on every press so the pan widget can't appear on a surface that owns the
button. One helper, four call sites.
⌥-drag is the app's force-selection gesture over mouse-mode TUIs, but
xterm's default alt-click-moves-cursor claims the same click and emits one
cursor left/right escape per column of travel. Shells that don't consume
them echo the raw `^[[D` burst into the buffer. One gesture, one meaning.
Both of xterm's link paths activate through `window.open()`, which the
window's setWindowOpenHandler denies, so ⌘-clicking a URL did nothing but
log "Opening link blocked as opener could not be cleared" — and the OSC 8
path fronted that dead end with a raw confirm() dialog. Route both through
the desktop bridge, the path every other external link in the app takes.
⌘-click on macOS, Ctrl-click elsewhere, matching VS Code's integrated
terminal, Terminal.app, and iTerm2. A bare click stays with the selection so
a misclick on a URL can't launch a browser.
ChatView migrated tip-keyed composer queue/draft entries onto queueSessionKey
whenever the two ids differed. queueSessionKey is route-driven and can flip to
Session B a frame before the store selection leaves Session A, so migrate
re-homed A queue entries onto B and the idle ChatBar auto-drained them into
the wrong chat.
Gate migrate on same-conversation lineage only (tip to root). Also honor
lineage when background queue drain decides selected/busy, so a root queue key
is not treated as idle/offscreen while the compression tip is still working.
The row fell back to the global $repoStatus whenever repoPath was blank,
painting the main pane's branch and ± onto a tile whose cwd hadn't
resolved yet. The fallback bought nothing — the primary computed is keyed
to $currentCwd, which is empty in exactly that case — and cost a rail
showing a tree the session was never in.
A tile and a branched session each live in their own worktree and render
from their own SessionView slice, so neither is the main pane's session.
Pass foreground: false at both call sites.
applyRuntimeInfo unconditionally mirrored a runtime's cwd, branch, model
and usage into the global composer atoms. Every tile create and session
branch called it, so opening a session in another worktree re-pointed the
MAIN pane's coding rail at that tile's repo — and persisted it, so the
wrong workspace cwd survived a restart.
Collect the patch first, then mirror it once behind a `foreground` gate.
Background callers still get the full patch for their own session state;
they just stop publishing into state they don't own.
main now labels each panel row's kebab with the row's name
(menuLabel={profile.name}), so the hardcoded "Actions" default this test
relied on no longer exists. The name alone is ambiguous — the row-select
button carries it too — so match the menu trigger via `expanded`.
Neither side conflicts textually, so this only surfaced once main merged in.
Addresses review on #73013.
1. Manage Profiles used a hand-rolled delete Dialog next to the shared
DeleteProfileDialog in the same folder. That copy missed the active-
profile re-home fix (f764b0400): deleting the profile the gateway is
on stranded it on a dead backend. Switch to the shared dialog, which
owns the deleteProfile call and re-homes to default. Drops
handleConfirmDelete, the deleting state, and the now-unused Dialog*
imports.
2. The name field regressed to a plain Input during the create-dialog
dedup, losing live slugging. Level both shared dialogs up to
SanitizedInput sanitize={slug} so every entry point gets the behavior
Manage Profiles had — the sanitize primitive means callers never
validate-then-reject.
3. Nothing rendered ProfilesView, which is how the drift got in. Add a
behavior test: create dialog exposes SOUL.md, deleting the active
profile re-homes to default, deleting a non-active one does not.
The Manage Profiles page had its own local CreateProfileDialog/
RenameProfileDialog copies that predated the shared dialogs in
create-profile-dialog.tsx / rename-profile-dialog.tsx. The local
create copy lacked the SOUL.md textarea, so New Profile from the
sidebar rail and New Profile from Manage Profiles rendered different
modals.
Delete both local duplicates and reuse the shared self-contained
dialogs (they own the createProfile/renameProfile/updateProfileSoul
calls), so both entry points show the same modal including SOUL.md.
Select-all + Delete cleared the composer; select-all + Cut left it holding
a draft. Both produce identical DOM, so the split was in the reader.
An emptied editor keeps a placeholder <br> \u2014 scaffolding
normalizeComposerEditorDom adds so the contenteditable doesn't collapse to
a sliver, not a line the user typed. composerPlainText read it as "\n",
and syncDraftFromEditor (session swap, pagehide) skipped the
normalize+sanitize its rAF twin runs. Cut's residue reached that reader;
Delete's went through the flush path and got cleaned.
Fixed where the two disagree rather than at each call site: an editor
holding nothing but its placeholder break reads as empty. A real
Shift+Enter break, a trailing break after text, and a nested lone <br>
are all unchanged \u2014 the exemption is scoped to the editor root by its slot
marker. syncDraftFromEditor now normalizes and sanitizes like the flush
path, so both readers see one truth.
main landed a lint rule banning refs mirrored from reactive values in an
effect — they lag a render and cause stale reads. The lane-collapse
override tracker did exactly that with a counts ref.
Hold the empty/non-empty signature in state instead. React bails out
when it's unchanged, so a poll where no lane's emptiness moved costs no
extra render, and the comparison always sees the current value.
Creating a task was mouse-only: the header button, the empty state, or a
per-column hover +. Kanban now ships a real command, wired the way any
plugin should wire one.
One action id (kanban.newTask) registered into two areas: KEYBINDS_AREA
gives it dispatch plus a rebindable row in the shortcuts panel, and a
palette row whose `action` field points back at the same id so the live
combo renders as its hotkey hint. The handler is route-independent — it
parks the lane in $newTaskLane and navigates, so the page picks the
request up whether it was already mounted or is mounting for the first
time, then clears it so a remount can't reopen a dismissed dialog.
Default is mod+alt+n (⌘⌥N). mod+n and mod+shift+n are core built-ins a
plugin can't shadow; core uses alt only for the mod+alt+1…9 profile
slots, never with a letter, which leaves ⌘⌥<letter> free as the natural
namespace for plugin commands.
Label ships in the plugin's own locale bundles (en/ja/zh/zh-hant) via
ctx.i18n, so it localizes without a core en.ts edit.
$bindings is seeded at module init from the actions known then, so an
action a plugin contributes later is absent from it. Both hotkey-hint
call sites did a raw bindings[id] lookup, so a plugin command rendered
with no combo in the palette and no hint on its tooltip even though the
dispatcher (which goes through $comboIndex → bindingsFor) fired it fine.
Route both through bindingsFor, the resolver that already falls back to
the stored override and the action's shipped defaults, and subscribe the
hint hook to the registry version so a late registration repaints.
Covered by behavior tests over the contributed-action contract: dispatch,
combo resolution, panel row, teardown, and the no-shadowing guard.
Now that #67303 shipped the plugin-scoped i18n door, the kanban plugin ships
its OWN locale bundles via ctx.i18n.register instead of a core t.kanban
namespace — nothing added to core en.ts/ja/zh/zh-hant/types.ts. useKanban()
binds usePluginI18n('kanban') to the message SHAPE (one tiny generic) so
components keep their typed k.newTask / k.moveTo(label) access unchanged.