Completes group/1:1 attachment parity (#88983). PR #89486 covered images;
this adds the remaining half:
- The composer picker accepts any file type; kind (image/pdf/file) decides
the staging RPC. Paste handlers accept non-image files too.
- Drag & drop anywhere on the room drops into the active composer (open
reply box, else main), with a drop overlay naming the target.
- Member turns stage PDFs via pdf.attach (rendered per-page into vision
tiles by the gateway) and other files via file.attach; each returned
@file: ref is appended to that member's turn prompt so file tools can
read the artifact. Failed attaches still degrade to text-only.
- Transcript markers distinguish [attached PDF: x] / [attached file: x] /
[attached image: x]; room log renders non-image attachments as named
chips, pending chips show type icons.
Tests: 3 new vm-harness tests (per-kind RPC routing across members,
@file: ref injection into turn prompts, transcript labels); 279 total pass.
New agents now get a blobatar — a deterministic soft-body face generated
from the bot's name (same name, same face, forever) — as the default
shapes mode, with full manual control:
- Face follows the name live while typing in New Agent
- Randomize re-rolls the seed; Lock face pins the current one so a later
rename can't change it (Unlock returns to name-following)
- Any of the six silhouettes (round/organic/boxy/nub/cloud/sun) can be
pinned via frozen-per-major trait positions while the rest stays
name-derived
- Classic geometric shapes remain one click away, and existing bots keep
their stored looks untouched
Wiring: blobatar@0.2.0 (zero deps, ~3.7KB) exported through the plugin
SDK (blobatarSvg / Blobatar), feature-detected in plugin.js with a
legacy-shape fallback for older desktops. Blob shape strings are
'blobatar[:seed[:kind]]' inside the existing meta.shape field, so
persistence, cross-machine ui_meta sync, and the roster's PNG backfill
(data-bot-face tag preserved) all work unchanged.
Second layer of the "make hydration feel instant" work (on top of the
paint-first wait): persist a bounded tail (40 msgs / 256KB / 50-session
LRU) of every reconciled transcript in localStorage, keyed by durable
stored-session id.
- Cold resume paints the cached tail immediately — the wake is visually
complete before any network I/O; the REST prefetch / runtime resume
reconcile the authoritative transcript over it when they land.
- The cached paint is DISPLAY-ONLY: reconciliation treats the view as
empty (viewMessagesForReconcile), so authoritative content replaces the
provisional paint wholesale — no grafting onto stale rows. Failure
latches also treat it as empty, so a cached paint can never mask a
genuinely stranded resume; a resume that proves the session empty rolls
the paint back and drops the poisoned entry.
- Saves happen only post-reconcile (cold path + warm activate path);
deletes drop the entry; a gateway/mode re-home wipes the cache (another
backend can recycle stored ids).
Sabotage-proven: disabling the cache load fails 5/7 cache tests; the
display-only contract is covered by the existing resume reconciliation
suite (663 tests green).
wake instead of the full runtime boot (#89206 class)
zero trust's third bundle (on ae6578af, both prior fixes present) showed the
remaining failure: cold profile backends on slower Windows machines take
47-120s to fully boot, while the wake path's fixed budgets (20s hydration,
~15s resume retries) raced the whole boot and lost — "errors waking up BOTS"
while the backend came up healthy moments later.
Rather than raising timeouts, make the wake cheap:
- waitForFocusedSessionHydration: a history-bearing chat is hydrated when
the persisted transcript is PAINTED on the right session. The REST
prefetch delivers that seconds after the backend's HTTP is up; the full
runtime resume (agent build, MCP discovery, 114-skill load) keeps warming
in the background and binds the composer when it lands. Only an
expected-empty chat still waits for the runtime (nothing to paint).
- On hydration timeout, log a [bot-wake] phase breakdown (activation ms,
hydration ms, which conditions were unmet) to the renderer console so the
next support bundle pinpoints the slow phase directly.
- web_server: flush the headless "listening" line — block-buffered on the
Desktop's piped stdout, it surfaced minutes late and made boots look far
slower than they were in support bundles (the 120s "gap" in this bundle
was partly this artifact).
Sabotage-proven: restoring the runtime-gated wait fails the new paint-first
test by timing out — the exact field shape.
Group rooms were text-only on the ingest side: the composer and
runGroupChatMemberTurn submitted prompt.submit {text} with no attach step,
so a user screenshot could never reach the members' models (community
report from Osiris). The gateway already ships the staging pipeline
(image.attach_bytes -> attached_images -> next prompt.submit) and the 1:1
canonical chat uses it — this wires the group surface to the same path.
- Composer + thread reply boxes: attach button, Ctrl/Cmd-V paste, pending
chips with preview/remove, image-only sends allowed.
- Attachments are downscaled (long edge 1568px) and stored on the room-log
entry, so reloads keep showing what members were shown.
- Turn drive stages the delta's images into EVERY responding member's own
per-group session via image.attach_bytes before its prompt.submit —
works cross-connection through requestForBot; a failed attach degrades
that member to text-only rather than failing the turn. Watermarks
guarantee an image is staged at most once per member.
- formatGroupChatLine names attachments ([attached image: name]) so the
transcript delta and the staged pixels line up for every viewer.
- Room log renders attached images on user entries.
Tests: 6 new vm-harness tests (fan-out staging order, mention-scoped
attachment routing, image-only sends, no re-attach across turns,
transcript naming, invalid-attachment degradation); 276 total pass.
The comment above ensureGatewayAgent carried both the old and the new
contract on consecutive lines: "a local/null connectionId falls through
to the profile path verbatim", immediately contradicted by "only a null
connectionId falls through, explicit local is a registry identity".
Dropped the stale line.
Same wording above prepareGatewayForAgent in gateway.ts, tightened to
match what the code actually does: registryBackendScopeKey only collapses
to the bare profile key for a null or empty id, so an explicit local id
scopes to conn:local::<profile> and stays on the registry route.
Comments only, no behavior change. tsc --noEmit, eslint and the three
affected suites (43 passed) re-verified.
Refs #82140
The agent path already declined to publish when applyActive() rejected its
activation, but the profile path discarded the same boolean and published
unconditionally. applyActive() returns false when its captured epoch has
been superseded, which happens whenever a newer switch or a teardown lands
while this preparation is still awaiting its route or socket.
The result was not a torn publication. batch() makes those writes
observer-atomic either way. It was something subtler: ONE complete,
internally inconsistent tuple, the CURRENT gateway paired with the stale
target's profile pointer and descriptor. Atomicity cannot make a rejected
activation correct, so the caller has to decline to publish at all.
prepareGatewayForProfile now returns Promise<() => boolean> like its agent
counterpart. The primary and shared-primary thunks return applyActive()
directly; the secondary thunk reports whether the prepared entry was still
current AND the epoch was accepted, keeping the descriptor publish
conditional on having a cached connection so an accepted activation with no
descriptor still moves the companions.
prepareGatewayForAgent's genuinely-local fallthrough now returns the profile
thunk unchanged instead of wrapping it to return an unconditional true,
which had been reporting a rejected activation to the agent caller as a
successful one.
Two regressions on the profile door: a superseded activation leaves all
three stores on the existing complete route with no subscriber notified at
all, and an accepted one still publishes, so a thunk that always reported
false could not pass. The mock thunks in profile.test.ts now return true,
since a bare vi.fn() returns undefined and would read as "superseded".
The prepare/publish seam removed the *await* between activating the
gateway and setting the profile pointer and connection descriptor, but
not the *notification* gap. Nanostores drains a store's listeners
synchronously inside .set(), so three sequential sets still let a
$gateway listener run while $activeGatewayProfile and $connection named
the previous backend. That is the same mixed state the seam exists to
prevent, just narrowed from an async window to a synchronous one, and it
is worse to debug because it is invisible in an await-shaped reading of
the code.
batch() defers every notification to the end of the callback, so the
three become one observable transition on both the profile path and the
agent path.
Pinned with a test that attaches a real $gateway listener and asserts the
companions are already current in the first callback; the mock thunks now
publish distinct gateway identities so an out-of-order publication cannot
pass unnoticed, and three existing tests assert $gateway is still the
ORIGINAL object (by identity) on every path that must publish nothing.
Review caught that the agent path fixed the pending-descriptor race but not
the failure path. `resolveConnectionForActiveAgent` caught a
`getConnectionFor` rejection and returned null, so `Promise.all` resolved as
`[null, activate]` and the switch published anyway: the activation thunk ran
and `$activeGatewayProfile` advanced, while only `setConnection` was skipped.
That is the same mixed state this PR exists to remove, except it does not
close on its own. The pending-descriptor window ends when the descriptor
arrives; a failed lookup never arrives, so `$gateway` named the new backend
while `$connection` described the old one until an unrelated reconnect or
switch happened to repair it. Anything branching on connection mode in
between (plugins, `MEDIA:`, `/api/fs/*`, `/api/media`, image attach) saw the
pair disagree.
Let the rejection propagate, matching `resolveConnectionForProfile`, whose
contract is already exactly this: null means "no desktop bridge" and nothing
else, and a bridge rejection aborts the whole switch before anything is
published. Both doors now fail closed identically, and the caller can retry.
The existing "leaves the prior connection intact when the descriptor fetch
fails" test asserted the old best-effort behaviour, so it pinned the defect
rather than a contract worth keeping. Replaced with a rejected-descriptor
test that asserts none of the three atoms moved and the activation thunk was
never called. The pending-descriptor case keeps its own separate test, so the
success and failure contracts are pinned independently.
Also reworded the publication comments: these are sequential atom writes with
no asynchronous gap between them, not a transaction, and describing them as
one "frame" overstated the guarantee.
`ensureGatewayAgent` is the (connectionId, profile) door the SDK's `ensureAgent`
goes through, and it landed on main after the profile path was made atomic. It
published in the order the profile path used to:
await ensureGatewayForAgent(connection, target) // $gateway flips here
$activeGatewayProfile.set(target)
await syncConnectionToActiveAgent(connection, target) // $connection here
The trailing await is the same mixed-state window: $gateway and
$activeGatewayProfile already name the agent's backend while $connection still
describes the previous one, so any request or plugin mode-listener firing in
that window announces the wrong mode to the new backend.
Both doors now share one seam:
* `prepareGatewayForAgent` mirrors `prepareGatewayForProfile`: dial the socket,
publish nothing, return the synchronous activation thunk. A local/null
connection falls through to the profile seam, so the two paths cannot drift.
`ensureGatewayForAgent` becomes `(await prepareGatewayForAgent(...))()`,
exactly how `ensureGatewayForProfile` relates to its own prepare.
* `syncConnectionToActiveAgent` splits into `resolveConnectionForActiveAgent`,
which resolves only. `ensureGatewayAgent` resolves the descriptor and dials
the socket concurrently, then activates, moves the profile pointer and sets
the descriptor with no awaits between them.
The best-effort contract on this path is unchanged on purpose: a descriptor
lookup that fails still leaves the previous `$connection` in place rather than
aborting the switch, which is what the profile path does instead. That
difference is deliberate and called out for review rather than quietly
harmonised.
Tests: `profile-agent-activation.test.ts` gains
`never publishes the agent gateway before its connection descriptor`, the mirror
of the profile-path test, asserting a pending `getConnectionFor` leaves all
three atoms on the old backend and that they flip together once it resolves. The
existing mutex and resync tests move onto the prepare/publish mocks, which also
repairs them: that file mocked `@/store/gateway` without `prepareGatewayForProfile`,
so its profile-path cases called an undefined mock after the rebase.
ensureGatewayProfile used to activate the target gateway and set
$activeGatewayProfile while the connection descriptor fetch was still
in flight, so during that window $gateway already targeted the new
backend while $connection still described the previous one, and any
request or plugin mode-listener firing then announced the wrong mode to
the new backend. A failed descriptor fetch made the mismatch permanent.
prepareGatewayForProfile (new gateway-store seam) opens the socket and
returns a synchronous activation thunk without publishing anything;
ensureGatewayForProfile now delegates to it. The switch resolves the
descriptor and opens the socket first, then flips the active gateway,
the profile atom, and $connection in one synchronous frame. A
descriptor failure aborts the switch as a unit: nothing is published and
every atom still consistently describes the previous profile.
The deferred-descriptor test holds the fetch open and asserts the public
atoms never disagree, then releases it and asserts all three flipped
together; the failure test asserts no partial publication.
Adds a reviewable in-app install path for Hermes plugins:
hermes://plugin/install?repo=owner/repo (and Settings -> Plugins ->
Install from Git) opens a confirmation modal showing the repo identity
and source links, shallow-clones to probe for agent and/or desktop
plugin artifacts, lets the user pick components, then installs — agent
side through the gateway's new plugins.manage `install` action (wrapping
the existing dashboard_install_plugin), desktop side through a new
Electron git-install module with subdir-escape guards, a 60s clone
timeout, non-interactive git env, and insecure-scheme warnings. Never
auto-installs; hybrid repos get one dialog. Legacy plugin-agent /
plugin-desktop deeplinks route into the same modal.
Salvaged from PR #82735 by @serefyarar (net diff applied onto current
main as a single authored commit; the branch carried merge commits).
The preview screenshot PNG from the original branch was intentionally
not carried over — images live in PR bodies, not the repo.
The revert to the committed theme lagged behind Escape. The palette
body stays mounted through the whole exit animation, and the preview
was cleared at unmount. So the repaint waited for the fade.
Subscribe to the palette open store in the body and clear the preview
the moment the store flips to closed. The unmount clear stays as the
backstop for a body that dies without a close.
The highlight preview did not fire. cmdk calls the root onValueChange
only in controlled mode, when the value prop is set. The palette is
uncontrolled, so the preview callback never ran.
Add a HighlightWatcher child that subscribes to the cmdk store with
useCommandState. The store reports the highlight in both modes. The
watcher replaces the dead root prop.
The new test renders a real uncontrolled cmdk root. It proves that
the watcher fires and that the root prop stays silent. If cmdk later
fires the prop in uncontrolled mode, the second assertion fails, and
the watcher becomes removable.
The theme rows in the Cmd-K picker applied a theme only on select.
Now the highlighted row paints its theme immediately.
cmdk reports the highlighted row through onValueChange on the root.
A new optional onHighlight callback on PaletteItem receives it. The
theme rows preview through a new previewTheme function on the theme
context. The preview is not persisted. A highlight on a row without
onHighlight, a page change, a palette close, or a commit clears the
preview. Then the committed appearance returns.
The global SESSIONS sidebar only aggregated local profiles and v1 per-profile
remote overrides. Sessions living on v2 registry connections (remote/cloud/ssh
gateways) never appeared — the remote API returned the rows, but the renderer's
Sessions component received an empty array (#88880).
- electron/profile-session-routing.ts: fetchRegistrySessionRows reads each
CONNECTED registry gateway's session list (ssh backends natively, shared
remote/cloud hosts via one cross-profile aggregate with a legacy flat-list
fallback), tagging rows with connection_id + owning profile.
spliceRegistrySessionRows dedupes them into the unified list and extends
per-profile totals. Reads never pass include_hidden, so Bot Mode's hidden
canonical chats stay OUT of the global list, same as local sessions.
- electron/main.ts: mergeRemoteProfileSessions splices registry rows; the
/api/profiles/sessions[+/sidebar] intercepts also fire when registry
gateways are pooled (previously only v1 remote overrides). Only
already-pooled backends are read — a sidebar refresh never dials or spawns
a backend (the roster-respawn trap), and a dead gateway contributes nothing.
- types/hermes.ts + use-session-actions: SessionInfo carries connection_id;
resuming a registry-owned row activates its connection-scoped gateway
(ensureGatewayAgent) instead of a same-named local profile.
- store/gateway.ts: ensureActiveGatewayOpen rides out an in-flight secondary
activation (bounded 8s) instead of failing instantly — the Sessions "+"
during remote wake no longer errors "Hermes gateway is not connected".
Tests: 5 new registry-source/splice unit tests (tagging, shared-host
aggregate + legacy fallback, dead-gateway isolation, hidden-flag contract,
dedupe/totals) and a sabotage-verified activation-wait regression test.
Two hardening follow-ups on the salvaged #84192 work:
- dispatchNativeNotification now reports whether the notification actually
reached the OS bridge, and dispatchPluginNativeNotification registers its
onActivate/onAction closures only on true. Previously a throttled,
disabled, or baseline-suppressed notification registered handlers that no
click could ever clear, leaking them for the window's lifetime.
- The renderer's onNotificationActivate handler re-resolves the activate
payload through resolveHermesOpenPath instead of trusting the pre-IPC
validation, keeping path validation in one funnel for any future
hermesDesktop.notify caller.
Adds a regression test covering the throttled and suppressed cases.
Extends ctx.os.notify (the curated plugin OS door from #78685) with icon,
action buttons, and a serializable `activate` target. Body/action clicks
focus the window and navigate to the plugin's screen; activation paths
share one resolver (hermes-open-target.ts) with hermes:// OS deep links,
so `hermes://index-network/intent/1`, `/index-network/intent/1`, and
{ path, params } all land on the same hash-router route. Approval
notifications keep their existing session-scoped channel.
Salvaged from PR #84192 by @serefyarar (net diff of the PR branch applied
onto current main; branch carried merge commits so a single authored
commit preserves attribution).
The inline branch of MATH_SPAN_SPLIT_RE excluded `$` from the body outright,
so a `\$` inside inline math — a literal dollar sign, valid TeX — broke the
span match and the shield silently didn't apply. `$\sqrt[3]{8} + \$5$` still
lost its index.
Step over escape pairs instead, matching the escaped-delimiter rule
findClosingSingleDollar already applies via isEscapedAt. The two body
alternatives are disjoint on their first character, so the added quantifier
can't backtrack ambiguously.
Reported by Copilot in review.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SPGjEQ2yrS4nWiooUEdYti
`$\sqrt[3]{8}$` renders as a plain square root — the index is gone. It is
not a KaTeX layout problem: the index never reaches KaTeX. CITATION_MARKER_RE
strips `[3]` as a citation marker, because its lookbehind accepts any letter
and the `t` of `\sqrt` qualifies. That runs inside normalizeVisibleProse,
which splits out inline code spans but not math, so TeX is fed to rewrites
written for prose.
Numeric-only, which is why `\sqrt[n]{8}` survives and made this look like a
layout edge case rather than a preprocessing one.
Shield math the same way inline code is already shielded: split each prose
part on math spans and rewrite only the segments between them. That also
takes math out of the reach of the other rewrites in that pass
(autoLinkRawUrls, LOCAL_PREVIEW_URL_RE, the ``` stripper, linkifySessionRefs),
any of which can corrupt TeX the same way with different input.
The split is capturing, and math segments are identified by index parity
rather than a leading `$`, so a prose run that merely opens with a stray
dollar cannot be mistaken for math. Escaped `\$` delimiters stay prose, which
is what keeps `$5 and $10` escaping intact.
Verified in the real renderer through the desktop mock-backend E2E harness:
`.katex .root` (the span KaTeX emits for a radical index) goes from 1 to 4 on
the same four-radical reply, and the MathML annotations show KaTeX receiving
`\sqrt[3]{8}` intact rather than `\sqrt{8}`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SPGjEQ2yrS4nWiooUEdYti
Multi-line display math whose $$ delimiters hug the body
(e.g. $$\begin{aligned}...\end{aligned}$$) renders as raw error text.
remark-math's flow-math construct is fence-shaped: text after the
opening $$ on the same line is read as an info string and discarded,
and the closing $$ is only recognized alone on its own line. So the
block never closes and KaTeX paints the remains via its error fallback.
splitHuggingDisplayMath moves those delimiters onto their own lines. It
runs AFTER normalizeMathDelimiters because that rewrite is itself a
source of the hugging form: a multi-line \[...\] comes out of it as
$$\begin{aligned}...\end{aligned}$$, so the same bug reached users who
never typed a $$ at all.
Single-line $$...$$ is left alone (it routes through the inline
math-text construct and already renders), container prefixes are
replayed onto the delimiter lines, and both patterns anchor $$ to the
start of the line, which keeps them from firing inside an inline code
span.
Verified end to end: the repro emits katex-error through
remark-math + rehype-katex before this change and not after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bot Mode wake-ups died on a routing split-brain: session.resume /
session.activate / session.usage were dispatched on whatever socket was
active at request time, while the bot's own backend sat healthy and idle
(zero traffic until the idle reaper killed it). Two divergence sources,
both fixed:
1. Registry-owned route truth. applyActive() now publishes the active
route's bare profile ($activeGatewayRoute + onActiveRouteChanged), and
use-gateway-boot mirrors it into $activeGatewayProfile. Previously,
eviction fallbacks (idle reap, connection removal, profile delete)
moved the SOCKET back to the primary while the profile atom kept
naming the evicted bot — ensureGatewayProfile's "already active" fast
path then trusted the stale atom and skipped the re-swap forever.
2. Request-time routing for session-scoped RPCs. resumeSession's RPCs go
through requestForSessionProfile (store/session-request-router.ts):
when the active route serves the session's owning profile the ambient
dispatcher is kept (reauth-aware reconnect); when it diverges — a
concurrent switch won the mutex, a failed dial left the old socket
active, an eviction re-pointed the route — the RPC is pinned to the
owning profile's own socket via requestGatewayForProfile.
Diagnosed from zero trust's debug bundle (loki/hulk/teknium-kun backends
READY then idle-reaped, renderer stuck on "Waking up… → retries gave
up") and DanBennettUK's #89206 trace (profile socket accepts, closes
with messages=0, no resume RPC observed).
Both layers sabotage-proven: reverting the route publish fails the
lockstep/eviction tests; reverting the request-time routing fails the
wrong-socket dispatch test.
Assistant messages that link a file the agent wrote —
[report](/home/user/report.md), file://…, ~/…, C:\… — rendered as dead
anchors: file:// is blocked in the renderer, Streamdown's URL hardening
turns file:/~/ hrefs into "[blocked]" spans, and on a remote gateway the
path isn't on the viewer's disk at all. Issue #82140 proposed exposing
the Desktop connection mode to skills/MCP/plugins so EXTENSIONS could
emit different output per viewer; this fixes the symptom at the right
layer instead — the viewer surface resolves paths at VIEW time, so
extension output stays surface-agnostic and the same transcript works
from every machine that opens it.
- markdown-preprocess: routeFileLinksToPreview() rewrites filesystem-path
links in prose to the renderer's existing hash-href doors —
#preview/… (PreviewAttachment) for documents, #media:… for
audio/video/image extensions. These pass URL hardening by design and
resolve through normalizeOrLocalPreviewTarget / resolveMedia*Src:
local connections read the file directly, remote connections fetch
over the authenticated /api/fs bridge. Image syntax, fences, inline
code, anchors, relative and http(s) links untouched.
- markdown-text: MarkdownLink routes any filesystem href that still
reaches it (bypassing preprocess) to PreviewAttachment/MediaAttachment
instead of a bare dead <a>.
- media.ts: export isFileMediaPath.
Live E2E (built app, CDP-driven, fixture session with links to a real
gateway-side file):
- BEFORE: [report.md] = dead <a href="/home/…"> (click: nothing),
[notes](file://…) = "notes [blocked]" span, 0 preview affordances.
- AFTER: both render as attachment rows; Open preview shows the file's
content in the preview pane; zero blocked spans; screenshots verified.
Closes#82140. Supersedes PR #82187 (connection-mode API): with view-time
resolution the extension layer no longer needs to know where the viewer
sits.
A `.md` delivered via MEDIA has no entry in MEDIA_BY_EXT, so mediaKind()
classified it as a generic 'file' and MarkdownLink rendered a download-style
anchor. Markdown is renderable content: route markdown document paths to
PreviewAttachment (source='tool-result'), which opens them in the right-rail
preview pane — where .md already renders with a rendered/source toggle and,
since #89381, full KaTeX math, tables, images, and links.
Resolves#84951 (the MEDIA delivery half; the rail-side rendering half landed
in #89381).
The Bots pane docks into the sessions zone but did not declare
collapsible. Below the sidebar-collapse breakpoint, the sessions pane
left the grid and the zone kept a stranded BOTS tab on screen.
The Bots pane now declares collapsible, so it leaves the grid with
its zone. The narrow edge overlay now mirrors the zone's tab strip
when the revealed pane has collapsed zone-mates. Without the strip,
only the first pane of the zone was reachable while collapsed. A
lone pane keeps the stripless overlay form.
Each closeable horizontal tab now shows a close button when the
pointer is on the tab. A small gradient fades the button into the
tab surface, so long labels fade under it instead of a hard clip.
The gradient reads the tab's effective surface color. This color
tracks the hover and selection washes, so the fade is correct on
every theme.
Middle-click and Cmd-click still close the tab. Vertical rail tabs
keep those gestures and do not get the button.
Export MarkdownPreview and cover the regression this PR fixes: KaTeX output present with no raw $$ delimiters left, GFM table structure, image alt/src, and external links opening with noopener noreferrer. Aligns the import order (perfectionist/sort-imports) and documents the mathPlugin module-scope setup to match the chat renderer.
The preview renderer for .md files was missing the math plugin, table/image/link components, and the markdown preprocessing pipeline that the chat transcript renderer has. Add KaTeX math rendering (inline $...$ and block $$...$$), table, image, and link support so file previews match the chat rendering.
DOM-level render test mounting the exact file-preview pipeline
(normalizeFilePreviewMath -> Streamdown + memoized math plugin) and asserting
`.katex` output for inline $.x.$, display $$..$$, and \(..\) delimiter math,
plus that a code-fence $.HOME.) stays code.
The right-rail file preview rendered markdown through Streamdown with no
plugins and no math preprocessing, so $...$ and \(...\) stayed as raw
source text. Wire the memoized KaTeX plugin (same one the chat transcript
uses) into the preview and preprocess prose with a math-only normalizer that
skips chat-only transforms (reasoning-block stripping, session-ref linking,
preview-target stripping, URL autolinking, citation stripping) so a file's
prose, code fences, and inline code spans are never mangled.
- preview-file.tsx: import + module-scope createMemoizedMathPlugin, pass
plugins={{ math }} to Streamdown, preprocess text with
normalizeFilePreviewMath before render
- markdown-preprocess.ts: add exported normalizeFilePreviewMath
- markdown-preprocess.file-preview.test.ts: 8 tests (currency escaping,
delimiter normalization, fence/inline-code preservation, verbatim
citations/URLs/reasoning blocks)
Follow-up to the root-cause fix: comment documenting why the opt-in
exists, plus a source-contract test proven to fail without the
attribute (sabotage run).
Group-chat message bodies in the hermes-bots plugin render without the
data-selectable-text attribute, so they inherit the app-wide
body { user-select: none } and cannot be drag-selected or copied.
1:1 chat messages already carry the equivalent marker
(aui_assistant-message-content), so this aligns group chat with that
behavior by adding data-selectable-text="true" to the message body
wrapper.
The salvaged gate only requested an explicit session.resume when the target
stored session was ALREADY selected — but the field failure (#89206) is the
cold open, where the persisted route points at the bot's session while
selection/runtime/transcript are all unsettled. The precondition skipped the
resume exactly when it was needed, and the hydration wait timed out into a
blank pane.
- sdk/index.ts: judge the main surface AFTER openSession() navigates, and
request a sequenced resume whenever the surface is not healthy (selected +
runtime bound + expected transcript present). Redundant requests are
consumed as no-ops by the route-resume effect.
- hermes-bots plugin: widen the fix to the sibling open path — the profile
session browser (openProfileSession) now opens with the same
awaitHydration/expectHistory contract as canonical Bot Chats, so a stale
main surface gets the same explicit resume instead of a silent blank pane.
- Regression test for the cold-open shape, proven failing against the
pre-fix gate (sabotage run) and passing with it.
Bot Mode group chats were named once at creation and could never be
renamed, and rooms had no picture — only the fanned member faces.
- New Group Chat dialog: optional room picture (upload from device or
image.generate, same 256px normalize pipeline as bot avatars).
- Room header: gear button opens Group settings — rename the group or
set/replace/remove the picture after creation.
- renameGroupChat re-keys the room record (log, watermarks, sessions,
members, picture), swaps the name in every local member's ui_meta
groups list, follows open views to the new name, and rejects
collisions instead of silently suffixing. Stored member sessions keep
resuming by sid, so no history is lost.
- Room picture persists in the durable room record, hydrates on window
load, and renders in the roster row (over the face pile), and the
room header.
A profile belongs to one gateway, but the Capabilities surface (Skills /
Tools / MCP) always read and wrote through the window's active backend —
scoping to a remote-owned profile silently edited the wrong machine.
- hermes.ts: capability REST helpers accept a ProfileScope
(string | {connectionId, profile}); ambient path now also carries the
active registry connection tag (same contract as the cron helpers,
#87882); profileScopeKey namespaces cache keys per connection.
- SkillsView: scope selector lists (profile, device) rows from the union
agent roster on multi-connection desktops; new fixedConnection prop
pins the whole view to a registered connection (plugin door), with a
probe-able SkillsView.supportsFixedConnection flag.
- MCP tab: live reload.mcp RPC withheld for cross-backend scopes (it
rides the active gateway socket and would reload the wrong machine).
- Bot Mode: remote-target drafts now get the live Capabilities tab
pinned to the target machine via fixedConnection, feature-detected so
older desktops keep the staged checklists.
- Config-record/hub-action stores accept scopes; cache keys fold in the
connection id so two gateways' same-named profiles never share rows.
Remote mode only offered Copy Path, which is a Linux server path and useless on the local machine. Reuse the existing gateway save bridge so a selected file can land on this computer.
Both entry points into the browser pane now ask for a reachable URL
first, so the dev server an agent names over a remote gateway actually
loads, and typing that address by hand behaves the same.
Every fallback keeps the original URL, which leaves the pane free to
explain an address it still cannot reach.