A committed test.only/describe.only shrinks the Playwright run to a
subset while the workflow still reports success — a silent partial
green. None of the three desktop Playwright configs set forbidOnly.
- Root config: forbidOnly: !!process.env.CI, mirroring the existing
retries: process.env.CI ? 1 : 0 CI switch — local devs keep the
ability to focus a test interactively.
- e2e/core and e2e/update configs: forbidOnly: true unconditionally.
These lanes already hard-set retries: 0 because a required job must
expose problems rather than hide them; the same contract forbids a
focused test quietly shrinking a required job.
Verified on base (red): CI=1 npx playwright test with a temporary
test.only probe passed and reported '1 passed'. After the fix (green):
the same probe fails with "item focused with '.only' is not allowed
due to the 'forbidOnly' option" under CI=1 for the root config and
without CI for the core/update lanes; without CI the root config still
runs the focused probe (local behavior unchanged).
Fixes#93142
The switch-back hydrate can be composed from the baseline that still held the
reply's streaming row, under the same id the store later settled. The overlay
replaced that row in place and never checked whether the page already had the
reply committed, so the answer painted twice until the next refresh (about one
run in three of the tool-turn race). The represented-on-page check now runs
before the in-place replace and drops the stale copy; a folded tool bubble is
also matched by its answer text when the completion carried no row receipt.
The tool-turn switch-back race still painted the answer twice, frozen on its
first chunk, in two places:
- appendLiveSessionProjection only stood the stale activate snapshot down for
a tool-free committed reply. A tool turn's snapshot carries the turn's whole
text and history folds narration, tools and answer into one bubble, so it
never matched. It now stands down when the committed bubble ends in a reply
that covers the snapshot; a mid-turn commit ends on a tool and still projects.
- overlayConcurrentMessageChanges only adds and replaces. A page composed from
the older baseline could carry a live row the store had already retired into
the committed reply. A baseline live row missing from the current store now
stays retired.
The steer matrix also covers the steered tool finishing in the background:
the oracle skips backend notice rows (process_complete and friends) the way
hydration draws them.
The new switch-back-race tool-turn case caught it: when message.complete
settles the live reply while the switch-back REST page is in flight, the
overlay kept the settled stream row unless the page carried an assistant
message with the same words. History folds a tool turn's narration, tool and
reply into one bubble, so its text never equals the reply and the answer
painted twice. The completion already binds the reply to its stored row;
match that row first and keep the text rule as the fallback.
The oracle now compares a steer stored at a tool boundary by the user's
words, not the out-of-band wrapper the model sees. The review-row scenario
does not reproduce #131626's duplicate on main, so it is a plain guard
instead of a KNOWN entry.
The in-page sampler already sees every frame; it now also fails a reply
rendered above its own prompt and two messages that swap places between
frames, so a wrong order that heals by the final state still fails.
reply-settle-paths.spec.ts forces the duplicate-reply shapes under the
transcript oracle: an answer the backend repeats as the final reply, with
a review.summary row delivered before that turn's message.complete
(#131626, KNOWN), a steer at each point of a tool turn, then reload and
switch-back over folded tool turns. switch-back-race.spec.ts runs both
orders with a tool turn too (#128809). The scripted provider can now
script marker-less traffic such as the background review fork.
A stale roster run finishing after a restart must not clear the new
run's rosterBusy flag. Dropping the generation check in
syncRelayRosters' finally left every existing test green, but let the
next 60s tick start a third concurrent roster writer while the
restarted run was still in flight (expected 4 profiles.list calls, got
6). Add a regression for that overlap, and apply eslint
padding-line-between-statements fixes to the lines the salvaged commit
added so the touched files stay at zero warnings.
Follow-up to #131028; mutation gap reported in review there.
On Windows, restore() or showInactive() on a window that the tray hid
paints the window, but it does not become the foreground window. The
window then drops all mouse and key input until the app restarts. A
relaunch from the taskbar or Start menu, a deep link, and a notification
click all restore the window this way. Only the tray menu and tray click
ran show() and focus().
The restore and show handlers now finish the activation for a window
that the tray hid: show(), then focus(). The call is deferred past the
native restore. Windows that the tray did not hide are not changed.
Fixes#127349
With shared metrics on, every provider stream runs through Relay's managed
pipeline. Relay pulls the next provider chunk before it delivers the current
one, and the provider read blocked the stream's private loop, so each chunk
was withheld until the next arrived. On Desktop the reply looked stopped while
the timer kept going and queued follow-ups waited on a turn that was still
running; a steer aborted text the user never saw, stacking user bubbles with
no output between them.
The off-loop read from the previous commit fixes it. Correct its comment to
the real cause, replace the two-loop starvation test (production gives every
stream its own loop, and it bounded wall-clock at 150ms) with a single-stream
contract that deadlocks on the old code, and add a core e2e spec that steers
and queues against a paused provider with Relay managed execution on.
The plugin deep link (hermes://plugin/install?catalog=) and the skill
deep link are separate features: one per kind. Reverting #119381's card
catalog should not leave skills without a link, so this restores the
skill route on its own:
- Desktop: skill-deeplink-install store, the skill route in
deeplink-routes, its use-desktop-integrations wiring, and the
confirm-store onConfirm/details/busy/done phases its dialog uses
(restored from main with their tests). Its nine strings move from the
deleted catalog.* namespace to skillDeepLink.* (en + the six locales
that translated them).
- Website: Skills Hub "Install in Hermes" button, plus the
source-qualified installIdentifier from extract-skills.py so the link,
the embedded picker and the CLI command all name the exact skill
(apps/shared/src/catalog-install.ts keeps only the skill helpers).
- Docs: skills.md "Install from the website" section, desktop.md pointer.
The card catalog UI stays reverted.
Reverts all seven commits of #119381 (59c54c78..476ff4dd), which
re-landed the native card-based Skills/Plugins catalog browser after it
was reverted twice (#113037 reverting #112669, #115787 reverting
#113128). Capabilities -> Skills and Plugins return to the list/detail
tabs with the embedded catalog picker they had before #119381.
Removed with it:
- Desktop catalog/ module (browser, cards, detail dialog, filters,
reels/masonry, install switch) and skill deep-link install
(hermes://skill/install); skills/embedded-hub-picker.tsx comes back.
- apps/shared catalog-browse.ts and catalog-install.ts.
- The `catalog` i18n namespace in every locale (+ regenerated
locales/_keys.desktop.json); the two i18n pack tests and the keys
test now exercise connectorsPage.searchPlaceholder instead.
- Website /skills "Open in Hermes Desktop" skill links and the
extract-skills install identifiers; docs return to the pre-#119381 text.
- Later fixes that only patched the reverted catalog code go with it:
5832a6eb93 + 7aba2cbbe9 (catalog download deadline),
9e63cdc4b8 (catalog lookalike rows), c756986db2 (catalog counts),
the catalog half of c13232e90c / f265273347.
Kept on purpose (later work that depends on code #119381 introduced):
- DialogContent `chrome` / `overlayClassName` / nested-dialog layering
(the image lightbox uses them).
- vitest.setup.ts / src/test/jsdom.ts stubs (other suites rely on them).
- Kanban toolset wiring on the Plugins Desktop switch (cd30fdadf6,
7ca2f5665f) and its tests, the `hermes_not_connected` server tone,
the SearchField flex-1 fix, skill provenance helpers. SkillDetail shows
a provenance pill only for agent/hub skills, since the pre-#119381
labels have no `external` entry.
The MSIX package carried a single Square44x44Logo.png, so every taskbar and
Start slot on a HiDPI Windows 11 machine was Windows upscaling a 44px bitmap,
and with no altform variants Windows plated the icon itself. The generator now
emits the qualified catalog Windows resolves by itself: <Logo>.scale-{125,150,
200,400} for all four manifest logos at Microsoft's pixel sizes, and
Square44x44Logo.targetsize-{16..256} in the three required theme forms —
unqualified and altform-lightunplated carry the light tile, altform-unplated
(dark theme) the dark tile, the same split macOS dark mode gets. before-build
stages the whole assets/appx directory; app-builder-lib runs makepri and
`makeappx /l` as soon as it sees a qualified asset, so nothing else changes.
The hand-listed *-dark.png logos, generated but never staged, are gone.
Exposed along the way: the commit badge sat outside the portrait's silhouette
clip, and at 16–30px its anti-aliased edge stacked coverage on the plate's
corner pixels and changed the alpha channel between build flavors. The badge
now shares the clip, is plain black, and is left off direct renders below
32px where no SHA is legible anyway.
The Desktop and TUI subcommand pickers offer /browser use (it is in the
registry's subcommands), but both handlers only knew connect, disconnect
and status, so picking it printed the usage line.
browser.manage gains a use action (enabled: bool) that writes the session
profile's browser.backend through the same set_browser_use_mode helper the
CLI now calls, and status/use report browser_use. The handler is profile
scoped, so a worker session writes its own profile. A live agent keeps its
tools (prompt cache), so Desktop and TUI say the switch applies to new
chats instead of swapping tools mid-conversation. use and status also run
on remote backends; connect/disconnect stay local-only.
Hovering a message reveals its time with the hover actions ("3:27 PM",
"Yesterday, 3:27 PM", "Sep 28, 3:27 PM"); the tip adds the age for same-day
messages and the full date. User bubbles show it beside Restore/Stop, replies
at the head of the action bar. It stands down while display.timestamps is on,
since the transcript already prints every stamp (#118405).
The Favorites section put a provider chip on every row beside the variant,
fast and effort chips, and with names as the only element allowed to shrink
they collapsed to a letter (#130349). A mixed Favorites section now names
each provider once over its rows; the variant tag stays a filled chip after
the name, while fast and effort move to outlined chips by the caret that
edits them. An inherited effort is no longer stamped on every row: it shows
on the active model and on rows whose preset chose one.
A packaged Windows build reported "Microsoft Store returned invalid
JSON" on every update check. The Store checker, the App Installer
checker and the relaunch waiter were resolved under the payload's repo
snapshot (<payload>/hermes-agent/apps/desktop/scripts/), but
INERT_SNAPSHOT_DIRS has excluded apps/ and scripts/ from that snapshot
since f6713f2762. The installed 2026.6523.3313.0 package has no apps/
directory at all. The payload python then failed to open the script,
exited 2 with empty stdout, and parseResult reported invalid JSON.
The two checkers are python that runs under the payload interpreter, so
they belong with the core code, like the other hermes_cli helpers the
desktop runs with -m:
- apps/desktop/scripts/check-store-update.py becomes
hermes_cli/windows_store_update.py and check-appinstaller-update.py
becomes hermes_cli/windows_appinstaller_update.py (git mv).
- The runner spawns `python -P -m <module>` with PYTHONPATH set to the
bundled core code, then the venv dependencies: the order the bin
launchers give sys.path. -P keeps the working directory off sys.path.
- The relaunch waiter is PowerShell run outside the package, not core
code. It ships as an app resource (win.extraResources) and resolves
from process.resourcesPath.
The Python tests now import the modules, and a real `python -P -m`
subprocess test proves a checker failure still reaches the desktop as
JSON. A vitest checks the packaging config ships the waiter.
The desktop DMG's volume icon defaulted to the packager's icns, which is
actool's 256px fallback whenever mac.icon is the Icon Composer package, and
dmgbuild's badge option can only paste onto the stock removable-drive icon.
Ship our own drive-with-the-girl artwork instead: assets/dmg-volume.png
(hand-made, one artwork since volume icons have no appearance variants) is
emitted as apps/desktop/packaging/dmg-volume.icns, beside the DMG background
so the `files` whitelist keeps it out of the app bundle, and dmg.icon points
at it.
Tauri always uses the app icns as the volume icon and offers no override, so
the bootstrap workflow runs scripts/bundles/set_dmg_volume_icon.sh on the
built DMG before signing (the same .VolumeIcon.icns + custom-icon flag
dmgbuild sets), and the verify step checks the icon is on the mounted image.
Verified on macOS 26.6.2: both DMGs render the identical volume icon.
Tauri's bundler (cli 2.11.4) compiles an Icon Composer package listed in
bundle.icon into Assets.car with actool >= 26 and sets CFBundleIconName, so
the bootstrap installer gets an unbranded twin of the desktop package under
src-tauri/icons/icon.icon (generator target; the bundle stays the plain
brand because flavours only apply under apps/desktop/). Tauri only logs when
actool is too old, so the macOS job selects Xcode 26 and the verify step
asserts Assets.car and the plist key are in the built .app.
The desktop DMG's volume icon defaulted to the packager's icns, which is
actool's 256px fallback whenever mac.icon is the .icon package; point
dmg.icon at the full-resolution assets/icon.icns. Tauri's DMG already takes
--volicon from the bundle's .icns, which the regenerated outputs cover.
Next to macOS 26's own icons the flat plate + girl reads better than the
ringed tile, so the ring is off in every output: the tiles' inward stroke,
the layered icon's border layer and its targets. BORDER_ENABLED keeps the
ring code one flag away.
Without a ring the girl's lowest nodes are dragged past the tile edge
(EDGE_OVERSHOOT) so the outline crops her instead of leaving a gap.
Clear light/dark and Tinted light/dark now come from one grayscale mono
layer with Liquid Glass on: the parts of the girl that are black in light
mode become a near-black frosted ink (0.13 @ 90%), her white parts stay
white, each pixel painted exactly once so materials never stack. The art
layer hides under "tinted" and the mono layer shows only there.
Mac girl at 1.06x (between main's 1.12x and the layers' 1.0x), for the icns
and the layered icon alike. Chosen from a six-appearance matrix of real
IconServices renders next to Finder.
main scaled the girl 1.12x for every mac target (a white tile with a ring
reads small beside full-colour peers), but the macOS 26 layers were still
registered on the full-bleed squircle box, so macOS 26 would have shown a
smaller girl than macOS 15.
The layered icon's canvas is the plate itself, so derive its portrait box
from the mac box in plate coordinates. Ink overlap with the icns girl goes
from 0.65 to 0.996 IoU; only the two art layers change.
Clear light/dark and Tinted light/dark all render from one "tinted"
specialization: the system reads the layer as luminance (white = visible)
and tints it. Without one they fall back to the light layers, whose black
girl and ring have no luminance, so those four modes showed an empty plate.
Point "tinted" at the white layers already used for dark mode; the manifest
test now requires it and pins it to the dark image. Verified in the Dock on
macOS 26.6.2 in Clear and Tinted, light and dark: white girl and ring as
glass / in the tint colour.
Each layer carried both a fixed "image-name" and per-appearance
"image-name-specializations". actool treats the fixed name as the image for
every appearance and drops the specializations, so Assets.car held only the
light PNGs and dark mode showed the black girl and black ring on the dark
fill: a black squircle. Icon Composer itself writes one or the other, never
both.
Drop the fixed name; the manifest test now rejects it and requires a dark
specialization on every layer. Verified on macOS 26.6.2 in dark mode: the
catalog gains art-dark/border-dark and the Dock shows the white girl.
electron-builder (app-builder-lib 27.0.0-alpha.6) compiles a `.icon` given as
`mac.icon` into Contents/Resources/Assets.car and sets CFBundleIconName, but
only with actool >= 26 (Xcode 26); on any other host it throws and the build
fails. scripts/mac-icon.cjs picks the Icon Composer package when that actool
is present and the legacy .icns otherwise, so a dev Mac on Xcode 16 keeps
building the app it always built.
The release runners keep Xcode 16.4 selected although 26.x is installed, so
the darwin job now selects the newest Xcode 26 via DEVELOPER_DIR and asserts
the actool version before packaging; a silent fallback cannot ship from CI.
electron-builder's `.icon` path also replaces the bundled icon.icns with
actool's 256px fallback. macOS <= 15 shows that file, so after-pack restores
our 16..1024 assets/icon.icns.
macOS 26 clips every app icon to its own squircle and shows legacy .icns art
inside a glass plate. Our border is a stroke on the tile's rounded rect, so on
26 the ring sits inside a grey rim and thins toward the corners where Apple's
curve and ours diverge.
Add an Icon Composer package (apps/desktop/assets/icon.icon) of canvas-filling
layers the system masks itself: a border band whose outer edge is the canvas
(the mask supplies the outline) and whose inner edge is Apple's mask offset
inward by the brand border thickness, so the band is the same width everywhere
along the visible outline; and the girl (+ commit badge) as a second layer.
Build flavours recolour the fill in icon.json only.
Apple's mask is a circular arc flanked by two cubic easing segments (the
Figma corner-smoothing construction). Fitted to macOS 26.6.2's own render of
a system icon on the 824-on-1024 grid: radius 214px, smoothing 0.645, rms
0.32px / worst 0.81px; a superellipse cannot get under 0.54px rms.
The .icns targets are unchanged: Apple's own 26-era .icns still sit on the
824 grid, and macOS <= 15 keeps showing that artwork.
The new contract test probes the border layer 640 points along the mask:
opaque at thickness-1.5px, transparent at thickness+1.5px. Rendering the
layers with the old r=185.4 corner violates it at 1448 probe points.
The off-screen hook only re-ran when hidden/parked changed, and a guest has
no webContents id until it attaches. A page whose session was parked between
mount and attach was never reported hidden to main (so mouse back/reload
could still target it) and never muted. The pane now feeds the guest's
dom-ready to the hook, which re-reports and re-mutes for the attached id.
preview.ts had grown to ~1450 lines. Who-owns-this-tab logic — compression
rotation aliases, the pending-runtime map, owner identity, ownsTab /
bucketTabsFor, the drawer visibility predicate — now lives in a topical
sibling. preview.ts keeps the tab list, its per-profile storage and the bucket
writers (adopt/rekey/prune), which need that storage. bucketTabsFor takes the
viewed bucket as a parameter instead of reading preview.ts's module state, so
the sibling has no import cycle back into preview.ts.
Behaviour-identical: importers of PreviewOwner and forgetPendingRuntimeTabs
now import from preview-ownership (no re-export shim).
The keep-alive test compared the pane registration object across a session
switch — an implementation detail. It now renders the layout tree and asserts
what the user gets: A's body element stays connected (hidden, inert) while B
is shown, is the same node with the same input value when A returns, mounted
once and never disconnected. Red on the pre-keep-alive head f5f54a2b6c9.
A hidden session's live Browser stays mounted so returning shows the same
page, but it kept playing audio into the chat the user had switched to. The
parked-pane set is now an atom; a preview pane whose tab is parked mutes its
guest (webview.setAudioMuted) and unmutes on return only if it was the one that
muted it, so a page muted beforehand stays muted. Inactive tabs in the session
on screen keep playing, like background tabs in a browser.
Timers/rendering: a CSS-hidden webview still reports visible to Chromium and
Electron has no public API to mark a guest hidden without detaching it (any
detach destroys the guest, losing its state), so throttling is left as is.
After a session switch, Chromium still tracked the hidden session's kept
Browser guest as the focused webContents, so main's focused-guest gestures
(mouse back/forward app-command, swipe, Ctrl/Cmd+R) navigated or reloaded a
page the user could not see. The keep-alive host also left focus on an inert
hidden guest, where keystrokes could still land.
- A keep-alive host going hidden blurs whatever inside it holds focus.
- The preview pane tells main when its guest leaves / returns to the screen
(hermes:preview-guest-hidden; only the embedding renderer is believed), and
commandFocusedGuest (moved to electron/preview-guest-offscreen.ts) skips a
hidden guest so the gesture falls through to the renderer's visible pane.
tabVisibleTo matched every ownerless tab when no session was focused, so a
runtime's pending tab (opened before its stored id bound) leaked into a fresh
draft's drawer and into any other runtime's. A pending tab is now visible only
while its own runtime is the one on screen; a true draft tab (no runtime) stays
in the draft.
Switching A -> B -> A unmounted A's Browser (its pane contribution was
disposed), so returning made a new Electron guest: unsaved input and page
JS state were lost, where main kept them. A preview tab whose session leaves
the screen now leaves the layout tree but keeps its contribution, so its
keep-alive host stays mounted (hidden, inert, aria-hidden, no pointer
events) and the same guest re-docks on return. Parked panes are excluded
from tree adoption (parked-panes.ts). Retention is capped at 8 hidden live
pages, longest-hidden first; past the cap a page unmounts and reloads from
its saved row when its session returns. Agent tools are unaffected: they
resolve tabs by owner, never by what is mounted.
Two ownership holes in the per-session preview scoping:
- Cross-profile pins (P1): previewTabsFor read the viewed profile's bucket
and admitted its pins unconditionally, and previewTabIdsVisibleTo
authorized them for the pop-out bridge, so profile X's tile could
preview.read profile Y's pinned page. The requester's owner now carries
the profile its runtime belongs to (previewScopeForRuntime, the same
resolution the rail scope uses); pins count only in that profile's
bucket, own tabs count wherever they live. Agent opens and closes use the
same identity, so X can neither navigate nor close Y's pinned Browser.
- Pending-runtime pool (P2): an ownerless tab was visible to every requester
with no stored id, so runtime B read, reused (navigated) and closed runtime
A's pre-stored-id Browser. Ownerless tabs are now either a runtime's pending
tab (that runtime only) or a true draft tab (the draft on screen only:
the primary's runtime, or a request naming no runtime) across reads,
reuse and closes.
Human UI paths ($visiblePreviewTabs, the focused drawer) are unchanged.
prunePreviewTabsForSession only filtered the viewed profile's tabs, so
deleting profile X's session while Y was on screen left X's unpinned tabs in
X's bucket and in storage. Prune every bucket (pins survive) and persist.
The zone menu's pin row captured `pinned` when the pane registered, and the
mirror keeps a same-title registration for the tab's whole life, so after the
first Pin the row kept offering Pin and kept setting true. Read the flag when
the menu renders and again on select.
- A popped-out Browser now answers a background session in another
profile: previewTabIdsVisibleTo reads every profile bucket, not only the
one in view. A background bucket counts only when the requester has a tab
of its own there, so one profile's pins never answer for another
profile's session.
- Agent preview tools act for the requesting runtime as well as its stored
id (PreviewOwner): a tab that runtime opened before its stored id bound
stays drivable/readable once the selection already names that id, locally
and through the pop-out bridge.
- A runtime whose session state is dropped before it binds a stored id
drops its pending notes (dropSessionState / clearAllSessionStates), so
the next draft's first send adopts those tabs instead of skipping them.
- The drawer's pending-tab clause skips a LISTED selection: a cold resume
selects an existing session a beat before it unbinds the old runtime, and
that runtime's pending tab no longer joins the resumed drawer. An unlisted
id (the runtime's own stored id arriving) still shows it.
- server-requests.test pop-out mocks forward the owner and assert it.
The preview store now derives visible tabs from session-focus's
$focusedSessionIsTile; the statusbar test's session-focus mock lacked it,
so the suite failed at import.
Second-pass review of the per-session preview tabs:
- drive_preview, tour, nav, input and the script runner resolve the
requester's tab (storedSessionIdForRuntimeId of the event's runtime) via
the shared resolveActivePreviewTab, not the focused session's; an id that
does not resolve reads the ownerless/draft view, never the focused one.
- Pre-stored-id agent tabs are handed over when THAT runtime's stored id
binds (handleTransition / ensureSessionState), and openPreview stamps the
event's runtime rather than whichever runtime is active.
- Requests forwarded to a popped-out Browser carry the requester's tab ids;
a pop-out showing another session's tab stays silent.
- Runtime notes are dropped with their tabs (close, prune, profile drop) and
draft adoption skips tabs pending for a live runtime.
- The rotation-alias walk is bounded by the map size.
With focus-driven adoption gone (F1), main's "keeps the tab when the
stored session id arrives afterwards" regressed: an agent's open_preview
right after a first send lands before the stored id, so the tab was
ownerless and vanished from the drawer once the id arrived.
openPreview now notes the runtime an ownerless tab was opened under
(memory-only; a fresh draft has no runtime, so its file-browser / "+"
Browser tabs carry no note). The tab is adopted only when THAT runtime's
stored id arrives, never when another session or tile takes the screen.
$focusedStoredSessionId / $focusedSessionIsTile moved to session-focus.ts
(to break the session-states <-> preview load cycle) but session-states
kept re-exporting them for its old importers - an internal-move compat
shim (independent review F5). Every importer now reads
'@/store/session-focus' and the re-export is gone.
Test plumbing follows the symbol: suites that stubbed the atom on the
session-states mock now stub it on session-focus (profile-routing) or use
the real derivation (open-session-refresh-in-place), and two
session-states assertions read the atom from the module that defines it.
session-sidebar-focus loads session-states for the primary-follow wiring
its last case asserts, as its old import did implicitly.
Also drops the dead $focusedStoredSessionId stub this PR had added to the
close-tab.test.ts mock (F8); the file is back to main.
resolveActivePreviewTab / readActivePreview, activePreviewInput and
activePreviewNav still read every session's tabs. With no visible
selection the tabs[0] fallback handed read_preview another session's
hidden tab, and the multi-tab meta listed hidden tabs' labels and URLs to
the model (independent review F3).
They now read previewTabsFor(session): the tabs that session can see (its
own plus pins). read_preview passes the requesting session's stored id;
drive_preview (active session only) uses the focused one. A popped-out
Browser renderer holds only its own tab, so it keeps reading that.
Independent review F2/F4/F9 on the session-scoped preview rail:
- F2 (hang): rekey(A,B) then a replayed rekey(B,A) put a cycle in the
memory-only alias map and the latestSessionId walk spun forever, freezing
$visiblePreviewTabs. Both ends of a rekey now resolve to their current
tips first and a rekey that resolves to the same tip is ignored, so the
map only ever links one tip to a different tip and stays acyclic.
- F4: a tab opened in the window between a rotation and focus following it
was stamped with the rotated-away id; the alias map does not survive a
relaunch, so the tab was orphaned. Opens, the strip "+" Browser and an
unpin now stamp the resolved tip, and a deleted conversation is pruned by
any of its ids.
- F9: close_preview with no url called closeRightRailTab per tab (N tab-list
writes, N persists, N selection fix-ups). It now closes the session's
unpinned tabs in one write and re-homes the selection once.
A fresh draft has no stored id, so the tabs it opens are ownerless. The
focus listener adopted every ownerless tab on ANY null -> id focus change,
so a side tile taking focus, or an existing session clicked in the
sidebar, swallowed the draft's file / Browser tabs and the draft's drawer
came back empty (independent review F1, same symptom as M1).
Adoption is now an explicit call made where the draft's first send
assigns its stored id, beside the composer draft's own hand-over
(announceNewSessionDraftKey). Focus changes never re-own a tab.