Preview-pane guest pages (Streamlit's traceback "Ask Google" / "Ask …"
buttons, plain `<a target="_blank">` anchors) could not open anything: the
`<webview>` has no `allowpopups`, so Chromium drops the popup before any
handler runs. Opening from `setWindowOpenHandler` is banned
(GHSA-9f4c-93c8-jc8g, window-open-policy.ts), so this adds an explicit
click bridge instead:
- main.ts installs a guest preload through `will-attach-webview`, keyed on
the `persist:hermes-preview` partition only.
- The preload forwards a clicked `_blank` anchor's resolved href to the
host renderer via `ipcRenderer.sendToHost`; it opens nothing itself.
- PreviewPane admits the URL and routes it through the existing audited
`hermes:openExternal` IPC.
Salvaged from PR #112959 (squash of its two commits).
Fixes#112941
Same stale invariant the ui-tui test had: since the shared channel sends one
client.capabilities frame on every gateway.ready, "no frames" is no longer
what this case guards. What it guards is that an older backend without
heartbeat gets no gateway.ping and stays open, so assert the wire holds
exactly the advertisement.
sessionScoped() strips the tag since the review fix; the assertions added by the
first commit of this branch still expected it on session reads and failed CI.
Profile writes keep the tag.
Moving the foreground tag into capabilityScoped() made every helper that
spreads it dial foreground, including sessionScoped() and therefore
getSession/getSessionMessages. resolveStoredSession probes every OTHER
profile sequentially with getSession(id, profile) while resolving a
remembered or 404'd session id — a boot-time path, not a scope-selector
click — so it would have cold-started each profile's backend on the pool's
reserved foreground slot. sessionScoped() now strips the tag: the tag is
for the user-facing scope selectors (Settings, Capabilities, Messaging),
session lookups stay on main's background default.
Part of #111651
Fold the scoped-dial priority rule into profileScoped()/capabilityScoped()
themselves: an explicit scope (string or object) now carries
priority 'foreground' from the scope helper, so every api/*.ts request
that targets a selected profile — skills hub/official lists, learning
nodes, toolset config/models, MCP, messaging/pairing, memory providers,
sessions, profiles — inherits it. The per-call `...scopedDialPriority()`
spreads (config/models/mcp/skills/toolsets) and the helper are removed;
the ambient path (`undefined`) and the deliberate `null` → primary path
stay untagged so hydration cannot take the reserved foreground slot.
WHY: the first pass tagged the six Settings pages, then models.ts, then
Capabilities' three page-open reads, and each round left siblings behind
(getOfficialSkills / getSkillHubSources / getLearningNode /
getToolsetConfig / getToolsetModels / the Messaging page) still queueing
behind roster hydration when the selected profile's backend was cold —
an infinite spinner once the pool's background slots were saturated. A
rule that must be re-spread at every call site is the failure mode; one
that rides on the scope helper cannot be forgotten by the next helper.
messaging.ts spread profileScoped() into request BODIES for the
body-scoped pairing/onboarding endpoints; those now bind the scope once
and forward only `profile`, so the dial tag never reaches the backend.
Tests: hermes-capability-scope.test.ts gains two invariants (red on
base): every scoped helper outside models.ts carries the tag and `null`
does not; the tag never leaks into a body. Exact-shape request
assertions in hermes.test.ts, session-transcript-paging.test.ts and
use-desktop-integrations.test.tsx gain the tag for explicit scopes.
Fixes#111651
dispatchServerRequest hand-rolled request.fail(JSON_RPC_METHOD_NOT_FOUND,
'Hermes Desktop has no server-request registry yet'), but the channel already
owns that reply: JsonRpcRequestChannel.deliverRequest answers -32601 and fires
onUnhandledRequest when a ServerRequestHandler returns false, and
GatewayBootOptions.handleServerRequest already documents "false = no handler
(the channel answers -32601)".
Make dispatchServerRequest return false when the registry has no
onServerRequest and true after forwarding; dispatchPrimaryServerRequest and
both gateway.onRequest registrations (store/gateway.ts secondary sockets,
use-gateway-boot.ts primary) now propagate that value to the channel. Keep
the desktop-specific wording by wiring onUnhandledRequest on HermesGateway
beside the onRequestHandlerError sink (console.warn), which needs the same
GatewayClientOptions passthrough onRequestHandlerError got. Drop the now
unused JSON_RPC_METHOD_NOT_FOUND import from the store and export
JSON_RPC_INTERNAL_ERROR from the shared barrel beside it.
Test: the store test asserts the false return with no fail() call when the
registry is missing, and true + forwarded profile when it is present.
Follow-ups (same class, outside this stack): use-gateway-boot.ts ~L889-891
still hand-rolls -32601 when the registry is present but has no handler;
ui-tui has its own copy.
Mutation check: reverting store/gateway.ts to the merge-base left the desktop
suite green — nothing exercised dispatchServerRequest. Cover both arms through
dispatchPrimaryServerRequest: a registry configured without onServerRequest
answers request.fail(-32601, /registry/) exactly once; with onServerRequest
the request is forwarded carrying the profile and fail is never called.
The deliverRequest catch branch wrote to a module-level console.error sink
(the only direct console.* in apps/shared/src) and fired onUnhandledRequest,
whose contract is "nobody handled it, already answered -32601" — so the TUI
logged a -32603 crash as "unhandled server request".
Add onRequestHandlerError(error, request) to JsonRpcRequestChannelOptions
beside onHeartbeatFailure, call it from the catch after answering -32603,
and drop the console sink. Wire both owners: HermesGateway (desktop, via a
GatewayClientOptions passthrough) logs to console.error like its dial-failure
sink; ui-tui gatewayClient pushes a [protocol] log line. Collapse the two
normalisation arms into the existing `error instanceof Error ? … : new
Error(String(error))` idiom and restore the early `return true` instead of
the handled flag + break — nothing runs after the loop but the -32601
fallthrough.
Test: the crash case now asserts onRequestHandlerError fires once for the
-32603 request and onUnhandledRequest only for the -32601 one.
Follow-up to the salvage of #112791.
- json-rpc-gateway.ts: revert the try/catch around `channel.handleFrame`
to main. deliverRequest is the single chokepoint that dispatches into
feature handlers and now answers -32603 itself; a second catch in the
socket listener is defense-in-depth that would also hide bugs in event
and response handling that should surface as uncaught errors.
- store/gateway.ts: the primary and secondary dispatch sites carried the
same copy-pasted "no registry -> fail -32601" block. Fold both into one
file-local `dispatchServerRequest(request, profile, connectionId)`.
The secondary keeps main's tagging (its own connectionId, no fallback
to the active connection), which the contributor's version changed.
A clarify request renders as an eternal spinner when anything in the
renderer's handler chain throws: deliverRequest had no error handling,
the WS message listener let the exception escape as an uncaught error,
and both dispatch sites silently no-oped via optional chaining when the
registry was absent. In every case the backend (clarify_tool blocks up
to 3600s) never receives any frame — no result, no error — and waits
out its whole deadline.
- json-rpc-channel: wrap each handler invocation; a crash now answers
-32603 ("server request handler crashed: <method>"), fires the
onUnhandledRequest hook, logs the stack, and stops. The unhandled
path still answers -32601.
- json-rpc-gateway: guard the socket message listener so no frame can
escape as an uncaught error.
- store/gateway (primary + secondary wiring): missing registry now
fails the request immediately with -32601 instead of dropping it.
Backend already handles {"error"} response frames
(tui_gateway/server_requests.resolve_response), so fail-fast answers
settle the tool at once; no backend change needed.
Regression test drives boom→-32603, unknown→-32601, and a working
request after the crash.
The lib test sits beside markdown-preprocess.directives.test.ts as
markdown-preprocess.reasoning.test.ts, and the frame-fidelity test joins
its markdown-text.*.test.tsx siblings as markdown-text.reasoning.test.tsx,
so a glance at the directory shows what each file exercises. Also trim the
REASONING_TAGS comment: how the two tag lists converged is git history, not
something a reader of the constant needs.
OPEN_REASONING_BLOCK_RE needs the full `<tag>`, so a frame ending in `<thin`
at a block boundary painted `<thin` as prose for one frame and then erased it
— the paint/un-paint class of #62774, one frame long. The fidelity test carved
an escape hatch around exactly those frames.
Add a third pass that drops a trailing partial open tag at a block boundary,
restricted to prefixes of the known reasoning tag names (built from
REASONING_TAGS) so `<div` at a line start still renders. This is the desktop
counterpart of agent/think_scrubber.py `_hold_partial`/`_max_partial_suffix`,
cited rather than ported. The fidelity test's escape hatch is deleted so the
monotonic-prefix invariant holds on every frame; the unit test gains the
`<thin` positive and `<div` negative cases.
stripReasoningBlocks runs on the accumulated message text every streaming
flush (markdown-text.tsx). Its replacer sliced the whole text twice per
closed block to test whether a space is needed at the seam — O(n) copies plus
a forward scan per block per flush. Read the single char on each side of the
match instead.
The seam test also had a second defect: with two adjacent closed blocks the
second block's `before` ended in the first block's `>`, so a second space was
emitted (`no Hermes`). Matching a run of adjacent blocks as one match makes
the two-edge check see the real prose on both sides. Covered by an extra
expect in the existing seam test.
WHAT: one REASONING_TAGS alternation feeds both regexes and now also covers
`thought` and `reasoning_scratchpad` (agent/think_scrubber.py THINK_TAG_NAMES);
the desktop-only `scratchpad`/`analysis` stay. Behaviour change beyond the
contributor's: closed or unterminated `<thought>`/`<reasoning_scratchpad>`
blocks are now stripped on the desktop too.
Doc comments trimmed to the WHY (block-boundary rule, why an unterminated block
is held back). Tests cut to the ≤2-invariant shape: two unit cases (seam,
unterminated-vs-mention) and one DOM frame-by-frame case; the accents/plain-prose
DOM cases were protection for unrelated code and are dropped.
preprocessMarkdown runs on the accumulated text on every streaming flush, so
the plain REASONING_BLOCK_RE replace damaged the visible answer in two ways a
settled message never shows:
- a model that inlines its chain of thought in the answer channel streams the
open tag long before the close one, and the regex needs both, so the reasoning
rendered as chat prose until the close tag arrived — and when it did the whole
span vanished in one frame, taking text the reader had already read with it
(#62774: the mid-reply "truncation" on long answers).
- the regex ate the whitespace on its seam, so a block sitting between two words
fused them: `no` + `Hermes` rendered as `noHermes`.
An unterminated block now strips at a block boundary — the same rule
agent/think_scrubber.py draws, so a real reasoning preamble (always its own
block) disappears while prose that merely mentions `<thinking>` mid-sentence
survives — and a removal between two prose fragments leaves one space instead of
deleting the seam.
Tests: markdown-reasoning-stream.test.ts (module level: seam, unterminated
blocks, per-delta leak + visibility monotonicity) and
streaming-text-fidelity.test.tsx (real surface: accents/emoji and plain prose
streamed in 1- and 3-character deltas, plus the chain of thought never painting
a frame).
`SidebarGrouping` was a literal union kept in step with `SIDEBAR_GROUPING_ORDER`
by hand — a fifth grouping added to the type would compile while the cycle
silently skipped it. The array is now the single source (`as const`) and the
type is derived from it. The cycle test no longer pins the exact order (a
change-detector on the constant); it asserts one lap visits every grouping
once and returns to the start, and still fails when the cycle skips a step.
Follow-up to Ayush Nangia's feature: the filter menu kept its own literal
list of groupings while the new cycle keybind walked a second one, with only
a test comment saying they should agree. The menu now builds its options
from `SIDEBAR_GROUPING_ORDER`, so reordering or adding a grouping is one
edit and the keybind cannot drift from what the menu shows.
`$petBusy` collided with the gallery download-busy atom of the same name in
store/pet-gallery.ts (imported side by side in five files) and was a bare
alias of `PRIMARY_SESSION_VIEW.$busy`. pet.ts and pet-overlay.ts now read
that atom directly; the overlay push takes `awaiting` from the same view too,
so neither overlay field comes from the global mirror. Stale comments that
still named `$busy` as the fallback are fixed, and the two pet tests fold into
one invariant (runs while the active slice is busy, idles the moment it
settles) that fails when the deps are pointed back at `$busy`.
Follow-up to Ayush Nangia's fix: keep one mechanism. `deriveLivePetState`
now takes `PRIMARY_SESSION_VIEW.$busy` (session-view.tsx already derives the
active slice's turn-busy and returns false for a slice-less stored session)
instead of a private `$activeSessionBusy` re-implementation plus a
`Date.now()`-based recency guard.
Why the TTL had to go: `Date.now()` inside a nanostores computed never
re-evaluates on time alone, so "decays after 30s" only ran on the next
unrelated store change; and a user Stop sends no flag-clearing event, so the
guard kept the pet on `run` for 30s+ after an interrupt — the very case the
original `live &&` gate handled synchronously.
The pop-out overlay push (`store/pet-overlay.ts`) switches to the same
`$petBusy` signal; it was still shipping the stale `$busy` mirror, so the
first fix never reached the overlay window.
Tests: two invariants — the pet animates while the active slice is busy with
`$busy` false, and drops to idle the moment the slice settles with no
clearing event. Both fail when `$petBusy` is pointed back at `$busy`.
The pet's steady flags (toolRunning/reasoning) were gated on the global
$busy mirror, which can read false for a session that is working: a
reclaimed session reopened after idle timeout (#84434) or a desktop-minted
draft (#84438) doesn't always flip the busy mirror for its turn, so the
pet froze on idle while tools ran.
Honor a steady flag while it is recent (stamped on every tool.start /
message.start, 30s TTL) even when busy is stuck false, and let it decay
to idle when the stream goes silent — preserving the original guard
against stale flags from interrupted turns.
Both reports by @camilliaK — shared root cause, one fix.
The roster menu gated "New Group Chat" on `activeSourceRoster.length < 2`
(local-source rows only) while the dialog it opens seats the FULL
multi-source roster (`selectableRoster = roster.filter(bot => !bot.ghost)`,
`canCreate = selected.length >= 2`). With one local bot and any number of
remote-connection bots the door stayed shut on a room the dialog would
happily create; users added a throwaway local bot just to click through.
The gate now counts the same selectable set as the dialog.
Live repro (real Electron + a real second `hermes serve` registered as a
remote connection): e2e/group-create-gate-remote-roster — base: menu entry
`aria-disabled="true"` with 1 local + 2 remote rows in the roster; head:
enabled, dialog seats Hermes + the remote Inbox, "Create Group (2)" enabled.
Co-authored-by: FalconOrtiz <falcon.ortiz11@gmail.com>
Co-authored-by: neil771 <neil@lannscapital.com>
Serve foreground queue promotions and concurrent opens without interrupting backend work. Park retired scopes and keep failed-stop generations fenced until exit.
Co-authored-by: Nikolas de Hor <nikolasdehor79@gmail.com>
Co-authored-by: Mark Sheppard <mark@orchardstreetpress.com>
Co-authored-by: chelsealong <chelsealong@126.com>
When main retires a pooled backend for a foreground open, the renderer entry
riding it is still wantOpen: the socket's 'closed' state ran
scheduleReconnect -> reconnectSecondary -> openSecondary('background') and
immediately re-queued a spawn for the slot the retirement had just freed. The
wake/focus nudge (reconnectSecondaryGateways) re-arms parked entries too, so
even a stall-parked scope would have redialed on the next window focus.
Listen for `hermes:pool:retiring` in use-gateway-boot and route it through a
new `parkSecondariesForRetiredBackend(poolKey)`: every scope riding that
child (bare profile or `conn:local::<profile>`; both ride one local child)
gets wantOpen=false, its reconnect timer cleared, and a sticky `retiredByPool`
flag that the nudge skips. The entry stays, so bot tiles keep their card
without a live socket. Only an explicit open of the scope (rearmSecondary,
reached from ensureGatewayForAgent / openGatewayForAgent /
ensureGatewayForProfile / ensureActiveGatewayOpen) clears the flag and dials
again.
Turn-lease reporting (shape from #104871): retainGatewayForSessionTurn and
touchSecondaryGateways publish `activeTurn` per scope through the widened
touchBackend IPC. On release the SCOPE's lease state is reported, not the
single lease's, so a second session on the same backend keeps it marked.
Test (gateway-connection-lifecycle.test.ts): a retired local scope does not
redial from the nudge across ~400s of fake time while an unrelated remote
scope is untouched; an explicit open re-arms it and the nudge treats it
normally afterwards. RED on base: the nudge redialed the retired scope.
Co-authored-by: bounce12340 <128559392+bounce12340@users.noreply.github.com>
A foreground bot open against a full 3-slot local pool waited 30s for a slot
and timed out (production: 7,122 slot waits, 6,305 timeouts, ~790 cancelled
starts in 10 days on a 17-profile host). Nothing could free a slot: a child's
hard lease lives until the child exits (main.ts child 'exit' handler,
teardownFailedLocalBackend, stopPoolBackend after the bounded SIGTERM->SIGKILL
exit), while LRU eviction and the idle reaper key off lastActiveAt, which the
renderer refreshes every 60s for every open socket. A bot-tile-pinned resident
is keepalive-fresh forever. Occupied is not busy.
New `electron/pool-retire.ts` (pure, DI-testable like pool-spawn-coordinator):
a foreground dial that finds `activeCount >= poolMaxBackends()` may retire ONE
resident, under these rules:
* Proof is the backend's. Each LRU candidate is probed over
`/api/health/idle` (running sessions + running cron jobs + prompts waiting on
a human). Only `true` is idle; `false` and `null` (older runtime 404, probe
error, unreadable ledger) are busy. The renderer-published `activeTurn`
lease is an early skip, never the proof; entries no longer initialise it to
false, so a fresh backend is not evictable by default.
* Admission fence: concurrent foreground dials share one retirement and one
probe; the coordinator hands the freed slot to the ticket that queued first.
* Identity recheck (`pool.get(key) === entry`, no new turn lease) after every
await, and a re-probe immediately before the stop.
* The waiter's `coordinator.request()` is issued BEFORE the stop so it sits at
the queue head; the slot is released only by the retired child's real exit
through the existing stopPoolBackend -> releaseLocalBackendSlot path.
* `hermes:pool:retiring` is broadcast to every window before the SIGTERM so
the renderer parks the scope instead of redialing into the vacated slot.
main.ts grows only the trigger point, the HTTP probe, the broadcast and the
retirer construction. Kept from #104871 with credit: the trigger point before
`coordinator.request`, the `touchBackend(profile, options)` IPC widening
(preload.ts / global.d.ts), and the LRU-among-eligible selector shape.
Tests (pool-retire.test.ts): fence with two concurrent tickets over a real
LocalBackendSpawnCoordinator (one probe, one SIGTERM, slot granted only after
the simulated exit, exactly one waiter served); identity recheck aborts on a
swapped entry or a lease published after the probe; idle null / cron-running
ineligible with fall-through to the queue; renderer activeTurn:false loses to
a backend re-probe.
Co-authored-by: bounce12340 <128559392+bounce12340@users.noreply.github.com>
The primary profile renamed to a Bot Mode title ("Bobby") is @bobby to the
roster, autocomplete and the mention resolver, but buildGroupChatTurnPrompt
introduced it — to itself and to its peers — as @hermes. A member told
"You are @hermes" read "@bobby …" as someone else's message and replied
"(pass)", so the room settled with the addressed bot silent.
Use botMentionTag() (title-derived tag, falls back to the @handle) for the
viewer and the peer list; an unrenamed primary still reads @hermes.
Live repro: e2e/group-prompt-renamed-primary-handle.spec.ts fails on
origin/main (room log carries only the user line) and passes here (entry
authored by `default` saying "B"). Prompt hunk proposed by @chilledtonic
in #89720.
Exercise real route contributions, StrictMode node replacement, size drift and caption translations without losing the current fullscreen regressions.
Co-authored-by: Muhammed Emin Boydak <me@eminboydak.com>
onBlur scheduled `window.setTimeout(closeTrigger, 80)` and never cleared it, so an
editor unmounted inside that window still ran closeTrigger() → setState after
teardown. In CI vitest surfaced it as an unhandled "ReferenceError: window is not
defined" originating in paste-url-is-text.test.tsx, failing check:test:ui with all
8201 tests green. The timer now lives in a ref, is replaced on re-blur and cleared
on unmount.
The two host.navigate cases did `await import('@/sdk')` inside the test body, so the
first transform of the whole SDK module graph counted against vitest's 15 s per-test
timeout. On loaded CI runners that import alone exceeded it, failing both cases on
~half of unrelated PRs (six Bot Mode PRs hit it today). A static import moves the
cost to module load, where it is not timed; the assertions are unchanged.
A turn that dies before its prompt is committed — agent-init failure,
no-agent refusal, an exception ahead of the commit — leaves the gateway's
retained `inflight: { status: 'error' }` tombstone behind a transcript that
never grew. pollGroupMemberTurn only threw when messages.length > before,
so it sat out the full timeout, recorded timed-out and a stranded marker;
harvestStrandedGroupReply then returned at `messages.length <= before`
ahead of its failure branch and consumed the marker silently. The member
looked like it timed out; nobody saw the error.
Both readers now treat the retained error as the answer when the session
is not busy, regardless of transcript growth. The poll disambiguates it
from an older turn's tombstone by comparing the serialized inflight
snapshot against the one captured at the pre-submit baseline (a tombstone
identical to the leftover is not this turn's death). The harvester's
'failed' row is keyed by groupMemberKey(member), like every other activity
row after #113382, so a source-scoped member's late failure resolves to
its title and owner.
"@bot please just stop now", "hey @bot could you stop", "@bot, I need you
to stop" and "@bot you can stop" put the stop word three or more tokens
from the mention. The #103893 proximity rule read that as prose and fell
through to the plain-mention branch, which RELEASES the mentioned member
— so a genuine stop re-dispatched a held bot, a behaviour flip from base
(which held all four).
classifyGroupHoldDirective now distinguishes three placements: a stop word
adjacent to a mention holds (unchanged), no stop word releases (unchanged),
and a distant stop word is neutral — neither hold nor release, and no
releaseAll for "@all … stop …". A missed hold is one adjacent "stop @bot"
away from repair; waking a bot the user just told to stop is the one flip
the classifier must never make. The German filler cases from #103893 keep
their fix (the member is not held) and the docs say the neutral case out
loud.
Three group-room turn-loop defects, each live-reproduced in the real
Electron app against a real gateway with mock inference:
- Hold classifier (group-rounds.ts): a stop/halt/pause token holds a member
only within two words of an @mention, so "@impl go, das ist halt ein Test"
is delivered instead of setting a sticky hold (#103893); addressing the
whole room (@all/@everyone) without a stop word releases every hold, so a
Stopped room wakes on "@all <task>" without the literal "resume" (#97740).
- Retained failure (group-turns.ts): the gateway keeps a failed turn under
session.resume.inflight as {status:'error'}; the room read that as live
work and slid the deadline to the 20-minute cap while the member looked
busy. groupSessionBusy/retainedGroupTurnError classify it as finished;
the foreground poll throws it into the failed-turn path (activity +
roster badge) and the harvester consumes the stranded marker (#92760,
diagnosis from #95103 by @hrnbld).
- Approval card (group-chat-parts.tsx): approval choices submit on click;
the clip-prone footer Respond button is gone for approvals (#91706).
Room message code blocks wrap instead of overflowing (#91857 by
@piskooooo).
Tests: invariant vitest cases in group-rounds/group-turns/group-chat-parts
(all red on base); three Electron specs under apps/desktop/e2e using two
new mock-inference triggers (gated rm -rf for a real approval prompt, a
non-retryable 401 for a retained failure). Docs: bot-mode.md § Groups.
Co-authored-by: hrnbld <hrnbld@users.noreply.github.com>
Co-authored-by: piskooooo <piskooooo@users.noreply.github.com>
Review follow-up:
- The same-name suffix ("Reviewer · This device") is judged against the
ROOM's seated members when the caller names the room (Activity feed, the
round prompt), not the whole roster — a room whose only `reviewer` is
local reads plain "Reviewer" however many other connections expose one
(#94869 acceptance 3). Callers without a room keep the roster-wide rule.
- A member key with no roster row ($lastRoster is [] until the Bots pane
mounts; the owning connection may be gone) resolves through the
route-keyed meta title and the profile segment, so the label degrades to
"reviewer"/"Hermes", never "local::reviewer".
Group rooms label members through two paths that predate owner-qualified
bot meta: the member header read `allMeta[b.name]` (and cleared remote
titles outright), and `groupSpeakerLabel(name)` — the "X is thinking…"
line, Activity rows and the round prompt — looked a raw profile name up
in `$botMeta`, whose keys are `connectionId::profile` (botMetaKey). Both
either missed (raw id / stale "Hermes") or hit a stale v1 relic, so
re-titling a Bot repainted the roster but not its rooms, remote members
lost their titles, and two same-named `default`s on different gateways
produced identical Activity rows.
Now the header title goes through `botRosterMeta(member)`, Activity
records `groupMemberKey(member)` (the route-qualified key), and
`groupSpeakerLabel` resolves a member key — or a raw name that exactly
one roster row carries — through the same `displayName(row,
botRosterMeta(row))` pipeline the Bots tab renders. When two same-named
members still resolve to one label, the connection label is appended.
The clarify/approval card badge deliberately keeps the plain name (it
matches its member through `memberKey`).
Live repro (real Electron, mock inference): e2e/group-transcript-owner-meta
— re-title a room member through Edit profile; base kept "Programmer" on
the transcript row and Activity, head shows "Infra Ops" on both.
Co-authored-by: FalconOrtiz <falcon.ortiz11@gmail.com>
Co-authored-by: fridde01 <carl@carltaylor.com.au>
Co-authored-by: zhongwater123 <805041391@qq.com>
renderEntry dropped meta whenever entry.from.source was set, so remote
and same-name speakers lost or borrowed avatars. Look up the matched
member through botRosterMeta instead.
The shared GroupMentionInput textarea was fixed at one row (rows=1,
max-h-40, resize disabled), so drafting or reviewing a long brief meant
scrolling through a tiny field (#95300). Size it from its content with
`field-sizing: content` — the idiom the Kanban drawer already uses —
bounded at min(50vh, 24rem) with internal scrolling past the cap, so the
transcript keeps its space. Applies to both the new-thread and the
reply-in-thread composer through the shared component; Enter-to-send,
Shift+Enter, IME guards and @mention completion are untouched.
Ported from PR #95308 (its target was the pre-TypeScript plugin.js) onto
the current group-chat-parts.tsx; one invariant test on the mounted
textarea and a Playwright spec that measures the real Electron composer
at one row, eight lines, and past the cap.
What: `replyToMember` (and its tooltip) now seed `groupReplyMentionTag`,
which keeps the friendly tag when `parseGroupChatMentions` resolves it to
exactly the clicked member, else falls back to the first owner-qualified
form that does: the registry's `@<name>-<device>` handle for a Connections
twin, `@<name>-local` for the room's own-source twin. The parser gains that
one `-local` form for non-remote members.
Why: with a local `reviewer` and a Connections `reviewer@mini` in one room,
both hover actions seeded the bare `@reviewer`, which the parser's
last-wins form map routed to the remote twin, so "Reply to" answered from
the wrong bot. The local twin had no unambiguous address at all — the
registry only qualifies handles it sees duplicated across sources, and
legacy room members carry no handle.
Invariant test: both twins' seeded tags route to their own member key, and
an unambiguous member keeps the autocomplete's friendly tag.
Two transcript affordances in Bot Mode group rooms:
- #91359: recognized @mentions in message bodies render as the app's
inline-reference form (`.ref` + `data-ref`): a routed @bot as `agent`,
`@user` as `human` (the room is waiting on a person), `@everyone`/`@all`
as `broadcast`. Classification reuses `parseGroupChatMentions`, so a
token is styled exactly when routing would honour it; unknown handles,
e-mail addresses and code spans stay prose. Presentation only — stored
text, copy, prompts and routing are untouched (Streamdown `components`
override on p/li/td text nodes).
- #89883: hovering a Bot's message shows "Reply to @handle" beside Copy.
It seeds `@tag ` into the composer that owns the entry's thread (the
open reply box for that thread, else the main composer) and never
doubles the tag, so the next send routes to that member only. "Reply
in thread" is unchanged.
Docs: user guide describes both.
The Model page (Settings → Model) fires getGlobalModelInfo /
getGlobalModelOptions / getAuxiliaryModels / getMoaModels for the scope the
"Applies to" selector picked, alongside the config record. #112216 tagged the
config / schema / skills / toolsets / MCP helpers with the foreground dial
priority so a cold profile takes the pool's reserved slot, but api/models.ts
kept the bare profile scope, so these siblings still queue as background work
and only reach the backend because the config record's foreground dial happens
to promote the same pool entry. Spread the same scopedDialPriority helper over
every explicitly profile-scoped models.ts call; ambient (undefined/null) calls
stay untagged so background hydration is unchanged.
Part of #111651 — the last untagged Settings read class listed in the report.
A Desktop cold boot whose primary remote gateway is unreachable ended in
failDesktopBoot(), but the renderer's concluded-boot guard in
onBootProgress only knew two conclusions: a soft switch in flight and a
completed boot. Main keeps startHermes() available after the renderer gave
up, and every later getConnection() caller re-enters it, replaying
`backend.resolve` (running:true — BootFailureOverlay renders on
`error && !running`) then `backend.remote` (error:null — the boot store's
late-progress guard only protects boot.error while running is false, and
the previous step just set it true). Each replay buried the recovery
surface (Gateway settings / Use local gateway) under the full-screen
CONNECTING overlay for the whole ~45s readiness wait, indefinitely.
Add the third conclusion, `bootFailed`, set at the three sites that end a
live boot through failDesktopBoot() (boot() catch, softSwitch() catch,
onBackendExit during startup). Clear it wherever a FRESH boot lifecycle
starts: softSwitch() (Use local gateway / Settings apply) and the renderer's
own bounded retry (resumeDesktopBootForRetry + boot()), so a latch set by a
startup backend exit cannot blind the retry that follows it.
Re-authored from PR #73442 onto current main (the PR's second commit, the
getBootProgress snapshot guard, already landed via the bootSnapshotSuperseded
path). Diagnosis of the guard gap and the retry-clearing requirement by
@kokhlo on #112899.
Fixes#112899
Co-authored-by: kokhlo <kokhlo@users.noreply.github.com>