Commit Graph

4156 Commits

Author SHA1 Message Date
liuhao1024
b08ec00a70 fix(desktop): hand preview guest links to the audited opener via a guest preload
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
2026-09-17 09:05:24 -07:00
teknium1
6c057dbaa5 test(desktop): the no-heartbeat check expects the capability advertisement
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.
2026-09-17 09:04:38 -07:00
teknium1
ec14e8dfa2 test(desktop): session reads no longer expect the foreground dial tag
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.
2026-09-17 09:01:59 -07:00
teknium1
6ae79db3eb fix(desktop): explicitly scoped session reads keep the ambient dial priority
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
2026-09-17 09:01:59 -07:00
teknium1
53517082c8 fix(desktop): every explicitly scoped api/ read dials its profile backend foreground
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
2026-09-17 09:01:59 -07:00
kshitijk4poor
ab6e665807 refactor(desktop): a missing server-request registry lets the channel answer -32601
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.
2026-09-17 21:18:37 +05:30
kshitijk4poor
4c214157d8 test(desktop): registry-missing server request answers -32601
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.
2026-09-17 21:18:37 +05:30
kshitijk4poor
dc8fe4def8 refactor(shared): a crashed server-request handler reports through an owner hook, not console.error
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.
2026-09-17 21:18:37 +05:30
kshitijk4poor
e35743a6fe refactor(desktop): drop the socket-listener try/catch and share the registry-missing guard
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.
2026-09-17 21:18:37 +05:30
Pond
ae43ded1bc fix(desktop): answer server→client requests when a handler crashes instead of stalling the backend
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.
2026-09-17 21:18:37 +05:30
kshitijk4poor
43f94bc1c4 test(desktop): reasoning tests follow the sibling naming
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.
2026-09-17 21:18:30 +05:30
kshitijk4poor
74b4d0e41c fix(desktop): a half-arrived reasoning tag at a block boundary is held back too
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.
2026-09-17 21:18:30 +05:30
kshitijk4poor
bc6bd6998a perf(desktop): reasoning-block seam check reads two chars, not two string copies
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.
2026-09-17 21:18:30 +05:30
kshitijk4poor
a12ab2280a refactor(desktop): align reasoning tags with think_scrubber, trim #113302 tests
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.
2026-09-17 21:18:30 +05:30
finn763
956abb9d25 fix(desktop): reasoning blocks stop eating streamed prose
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).
2026-09-17 21:18:30 +05:30
kshitijk4poor
1450c7fcfb refactor(desktop): derive SidebarGrouping from the grouping order; invariant cycle test
`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.
2026-09-17 20:36:03 +05:30
kshitijk4poor
206e054063 refactor(desktop): one ordering for sidebar groupings shared by the menu and the keybind
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.
2026-09-17 20:36:03 +05:30
Ayush Nangia
81de5afa44 feat(desktop): add rebindable sidebar grouping cycle 2026-09-17 20:36:03 +05:30
Ayush Nangia
a9bba684b2 fix(desktop): hide no-op pop-out for file previews 2026-09-17 20:33:49 +05:30
kshitijk4poor
82e87e9ff5 refactor(desktop): read the primary view's turn-busy directly, no $petBusy alias
`$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`.
2026-09-17 20:33:19 +05:30
kshitijk4poor
8edc4085cf refactor(desktop): pet reads the active runtime's busy slice, drop the 30s recency TTL
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`.
2026-09-17 20:33:19 +05:30
Ayush Nangia
91f679a140 fix(desktop): keep pet activity bound to the live runtime 2026-09-17 20:33:19 +05:30
Ayush Nangia
e252aeac00 style(desktop): lint-fix pet import order (perfectionist/sort-named-imports) 2026-09-17 20:33:19 +05:30
Ayush Nangia
1878e5f969 fix(desktop): pet animates for reclaimed/draft sessions via recency-guarded activity
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.
2026-09-17 20:33:19 +05:30
Ömer Faruk Okumuş
bbaf7af5c8 fix(desktop): await page-owned promises before Electron IPC
Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
Co-authored-by: Ömer Faruk Okumuş <23411283+omerlefaruk@users.noreply.github.com>
2026-09-17 02:35:52 -05:00
teknium1
9f077100cc fix(desktop): enable New Group Chat with one local bot plus remote-connection bots
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>
2026-09-17 00:22:51 -07:00
brooklyn!
36842e639a test(desktop): exercise native retirement against live serve children
Verify cap preservation, foreground promotion, real cron survival and renderer parking across authenticated retirement and actual child exit.
2026-09-17 00:34:50 -05:00
brooklyn!
768e5c2af3 fix(desktop): serialize idle reclamation and release slots on real exit
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>
2026-09-17 00:34:50 -05:00
Austin Pickett
0fa67196b4 fix(desktop): a retired scope parks instead of redialing
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>
2026-09-17 00:34:50 -05:00
Austin Pickett
5fef73015a fix(desktop): cooperative pool retirement for foreground dials behind an admission fence
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>
2026-09-17 00:34:50 -05:00
teknium1
2b267f9467 fix(desktop): introduce a renamed primary bot by its own @tag in group-turn prompts
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.
2026-09-16 22:25:33 -07:00
brooklyn!
d6ee487993 test(desktop): cover titlebar replacement and navigation lifecycle
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>
2026-09-17 00:16:00 -05:00
brooklyn!
444c75c10a fix(desktop): keep sidebar navigation stable beside page titles
Keep standing sidebar tabs below the native band on every page and render page-owned titles in the workspace panel header. Preserve existing hide preferences and fullscreen measurements while rebinding replaced titlebar clusters.

Co-authored-by: Muhammed Emin Boydak <me@eminboydak.com>
Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
Co-authored-by: wukangcheng1994 <160389295+wukangcheng1994@users.noreply.github.com>
Co-authored-by: bl05b3e <benoit.lavenier@e-is.pro>
2026-09-17 00:16:00 -05:00
teknium1
597310857b fix(desktop): clear the composer's blur-close timer on unmount
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.
2026-09-16 22:15:33 -07:00
teknium1
1abc58adc4 test(desktop): import the SDK statically in the workspace-reveal spec
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.
2026-09-16 22:15:33 -07:00
brooklyn!
140d12545a fix(desktop): settle approval and tool-row layout together 2026-09-17 00:03:58 -05:00
brooklyn!
4bcde77e53 fix(desktop): retire card-stack height without a completion snap 2026-09-17 00:03:58 -05:00
teknium1
4a2ea5b693 fix(bot-mode): report a retained turn failure whose prompt never reached history
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.
2026-09-16 21:55:50 -07:00
teknium1
ae417cf099 fix(bot-mode): a distant stop word never releases the member it addresses
"@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.
2026-09-16 21:55:50 -07:00
teknium1
b466a87bd6 fix(bot-mode): group rooms wake on @all, ignore prose stop words, report dead turns, approve on click
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>
2026-09-16 21:55:50 -07:00
brooklyn!
1022574553 fix(desktop): reconcile failed tails without erasing later replies
Co-authored-by: cervantesh <11169707+cervantesh@users.noreply.github.com>
2026-09-16 23:53:00 -05:00
brooklyn!
0e5da5e9ec fix(desktop): settle duplicate completions within segment boundaries
Co-authored-by: cervantesh <11169707+cervantesh@users.noreply.github.com>
2026-09-16 23:53:00 -05:00
teknium1
ab4bd4ba17 fix(desktop): Group Chat speaker labels qualify twins per room and never show a raw key
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".
2026-09-16 21:52:29 -07:00
teknium1
f9cc4ce975 fix(desktop): resolve Group Chat member identity by owning connection and profile
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>
2026-09-16 21:52:29 -07:00
Paula Rossi
589abfe94d fix(desktop): owner-aware Group Chat transcript avatars (#96432)
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.
2026-09-16 21:52:29 -07:00
addelh
755781341e feat(desktop): auto-grow the Bot group composer for long prompts
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.
2026-09-16 21:49:19 -07:00
teknium1
577bf9c070 fix(bot-mode): Reply to @bot seeds an owner-qualified tag for same-named twins
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.
2026-09-16 21:39:30 -07:00
teknium1
e04ce9d122 feat(bot-mode): render room @mentions as inline references and add Reply to @bot
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.
2026-09-16 21:39:30 -07:00
teknium1
bd626ec1ae fix(desktop): Model settings reads for an "Applies to" profile dial foreground
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.
2026-09-16 17:58:13 -07:00
wzslr321
dbc18a0591 fix(desktop): keep the cold-boot recovery overlay up while main retries behind it
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>
2026-09-16 17:56:33 -07:00