Commit Graph

111 Commits

Author SHA1 Message Date
Michael Knaap 3d0ac691af feat(desktop): user-made sections in the bot roster, with drag-and-drop filing
The roster already has sections, but only automatic ones: one per gateway
connection plus the group-chat bucket. Those answer "where does this bot
run", which is not the question being asked when someone wants two client
bots filed together under "Clients" and the internal ones under "Team".

This adds a second axis that composes with the first: gateway sections keep
the top level whenever more than one connection is showing, and user
sections group the flat list underneath.

Design choices, each deliberate:

- Membership lives on the BOT (`ui_meta.sectionId`), not as a member list on
  the section. A bot can only be in one place, deleting a section cannot
  orphan anybody, and the assignment rides the same profile.yaml sync every
  other bot setting already uses, so it follows the profile to another
  machine. Section records (id, name, icon) live in plugin storage.
- "Unassigned" is not a section. It is whatever is left, always drawn last,
  and it is where members of a deleted section land. No record, so nothing
  to keep in sync.
- Three gestures, one rule: drag a row onto a section heading; cmd/ctrl-click
  and shift-click build a multi-selection (shift ranges in DOCUMENT order,
  anchored Finder-style); and the row's context menu gets "Move to section…"
  with the same targets the drag would use. Dragging a row that is part of
  the selection drags the whole selection.
- The drag uses a private MIME type, so a bot dropped on the composer or the
  transcript is simply not a valid payload there instead of pasting its key
  as text.
- Section headings rename inline (double-click / menu), reorder, hide their
  glyph, and delete (keeping their bots). Right-click and the ⋯ button open
  the same menu so neither can drift.

With no sections created the roster renders exactly as before.

Tests: user-sections.test.ts covers the pure model (normalisation, grouping
with unknown/deleted sections falling to Unassigned, drag payload
round-trip). The existing hermes-bots suite passes; tsc and eslint clean.
2026-09-02 06:06:32 -07:00
Teknium 209de12d5f fix(desktop): Bot Mode tabs caption a Bot Chat with the bot's name, not "Bot Chat"
Every bot's canonical chat is stored under the same title ("Bot Chat" — the
name the gateway resolves it by, and an invariant roster-actions.ts's stale-tile
probe and #90102 rely on), so the main tab strip captioned every open bot chat
identically and two bots' tabs were indistinguishable (#99152).

Fix at the presentation layer, leaving the stored title and tabTitle untouched:
- workspace-scope.ts gains `$workspaceOwnerLabels` + `workspaceOwnerTitle()`:
  a bots-mode tab whose resolved title still equals its registered placeholder
  reads its owner's label instead. Side threads / Sessions tabs are untouched.
- session-tile.tsx captions tiles through it (and the drag payload); the main
  `workspace` tab (controller.tsx) does the same via `$botChatScopes`, the
  bot-mode scope the main tab was last opened under (it has no tile).
- The hermes-bots roster publishes displayName() per owner key through the new
  `host.setWorkspaceOwnerLabel` (feature-detected), so renames follow.

Supersedes #99177, which set tabTitle at open time — that reverts after mount
because tileTitle() prefers the stored row's title once the hidden row is
upserted, and breaks the `workspaceTabTitle === 'Bot Chat'` invariant.

Tests: one unit test on workspaceOwnerTitle() (bot chat → bot name; side
thread / sessions tab / unlabeled owner untouched) and one Electron e2e
(tab strip reads "Alpha", not "Bot Chat"); both fail on main, pass here.

Closes #99152
Supersedes #99177

Co-authored-by: twotnguyen <nguyenngoctinh011258@gmail.com>
2026-09-02 05:38:10 -07:00
Teknium 6e7c7c7da9 fix(desktop): a bot row click always lands on the Bot Chat the row previews
A plain roster click fronted whatever bots-workspace tab the user last had
active for that bot (#96649). A '+' side thread persists in Local Storage
across restarts, so it won every click forever while the row kept previewing
the canonical Bot Chat (profiles.list canonical_session) — sidebar and center
described two different conversations; a message typed there landed in the
side thread and the row never moved. Support thread "[Bots] - Sessions is not
in sync again" (bundle 7dfff039), reproduced live on origin/main.

- roster-actions: the open-tab shortcut may front only the canonical chat
  (registry id or lineage tip, via a new onlyStoredIds allowlist on
  focusWorkspaceOwnerSessionTile); anything else resolves the registry and
  opens in place. Side tabs stay open beside it. "Open Bot Chat" in the row
  menu is the same action; the `canonical` option goes away.
- roster-actions: when the FOCUSED Bot Chat's canonical session advances on
  the gateway (cron bot-chat delivery, message_agent, group round, CLI turn —
  none reach this window's stream), re-open it in place so the transcript
  refreshes instead of waiting for an app restart (#99393 class).

Tests: the fronting-shortcut unit file and its e2e spec pinned the reversed
behavior; replaced by one unit file (5 tests) and one e2e spec that fails on
main and passes here. group-to-local-bot-handoff e2e still passes.
2026-09-02 03:41:44 -07:00
Teknium 714930f256 fix(desktop): a bot roster click no longer opens a stale finished session (#90102)
The roster click's fronted-tab shortcut trusted the persisted session-tile
bucket (Local Storage 'hermes.desktop.sessionTiles.v2') unconditionally: a
persisted 'Bot Chat' tile naming a session the canonical registry no longer
resolves to — a superseded row from the retired ui_meta pointer design, a
re-minted canonical chat, a stale finished (often hidden) session — was
fronted on every click and remembered as the zone's active pane, so the row's
click target stuck to that stale session forever while its preview/age
described the live one, and clearing Local Storage only healed until the next
click re-persisted the same tile.

Per the Desktop guide the backend is authoritative for session state and the
renderer copy is a cache that must reconcile. focusWorkspaceOwnerSessionTile
now takes an optional staleness probe: tiles the probe rejects are discarded
(same no-undo rationale as discardSessionTile — resurrecting one would just
front the stale session again) and never fronted. The roster click supplies
the probe: a canonical-titled tile whose stored id matches neither the
server-resolved canonical_session registry row nor its compression-lineage
tip is stale, so the click falls through to the authoritative name-registry
open. Side-chat tabs carry no registry identity and are never judged; older
gateways without canonical_session (and shells without the probe) keep the
previous behavior unchanged.
2026-08-31 07:28:07 -07:00
hermes-seaeye[bot] 105b8650ef fmt(js): npm run fix on merge (#98247)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-30 01:23:27 +00:00
Max Hsu 544ad1aa61 fix(desktop): make the relay delivery deadline outlive the backend ceiling
Review follow-up on #93911: the previous constant was set to 1_320_000 ms,
which is exactly the backend's maximum work budget (120s turn-lock wait plus a
600s attempt and its policy-gated re-run) rather than something greater than
it. After those bounded waits the handler still classifies the failure, builds
and runs the retry, serializes the terminal result, unwinds the temp-file and
lock scopes, and returns through the event loop -- so a turn that consumes
nearly the whole budget could still lose the race to the client timer and
resurface #93911 at the upper boundary, with the backend holding a typed
reason while Desktop reported its generic timeout.

The deadline is now composed from the three mirrored backend values plus an
explicit settlement/transport margin, so the arithmetic is visible instead of
being a magic number, and bot-relay-deliver-budget.test.mjs reads
config_defaults.py and methods_bot_relay.py to fail when a mirror drifts or
the margin stops being positive. Nothing in the type system links a JS
constant to a Python default; that test is the seam.

Also adds an adversarial virtual-clock regression: a gateway that answers only
after the full ceiling plus settlement is rejected by a deadline set at the
ceiling and accepted by one with margin.
2026-08-29 18:17:37 -07:00
Max Hsu 10f1c30768 fix(desktop): let bot_relay.deliver outlive the generic 30s request deadline
host.requestProfile() had no way to express a per-call timeout, so every
routed plugin RPC fell to the gateway pool's generic 30s deadline. The
bot_relay.deliver contract is much longer: the backend holds the turn lock
(bot_mode.turn_wait_seconds, default 120s) and then runs a 600s turn, doubled
when the retry policy grants one bounded re-run, so methods_bot_relay.py
documents ~1320s as the bound a client must tolerate. Long turns (Computer
Use, deep research) were therefore killed at 30s and reported back as
unclassified failures rather than the typed reason the backend had classified.

requestGatewayForAgent()/requestGatewayForProfile() already accept timeoutMs;
only the two SDK layers above them dropped it. Thread it through and pass the
documented bound at the bot_relay.deliver call site. The argument is omitted
entirely when unset, so every other caller stays on the pool default.
2026-08-29 18:17:37 -07:00
Brooklyn Nicholson 21cc469eb1 Merge remote-tracking branch 'origin/main' into bb/bot-mode-design-system
# Conflicts:
#	apps/desktop/src/plugins/hermes-bots/plugin.js
#	apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs
#	apps/desktop/src/sdk/profile-routing.test.ts
2026-08-28 12:05:11 -05:00
hermes-seaeye[bot] d5d80963fa fmt(js): npm run fix on merge (#97119)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-28 12:05:29 +00:00
Finn763 108783b44d fix(desktop): drop legacy tiles missing connectionId and guard partial routes
Enough1122 review of #94426:

- Legacy Bot tiles persisted before ownerRoute.connectionId existed carry no
  connection id, so the local-delete branch (`ownerConnection === 'local'`)
  never matched them and they resurrected the deleted profile on relaunch.
  Treat the empty connection id as the local connection.
- A route without profile now throws instead of silently falling into the
  local-delete branch and dropping nothing remotely owned.
- Pin the 'local' connection spelling with tests covering the legacy
  (no id), canonical ('local'), and divergent ('Local') spellings.
- Comment the live-atom filter arms by bucket and unify the duplicated
  #94235 call-site comments in sdk/index.ts and delete-profile-dialog.tsx.
2026-08-28 04:58:53 -07:00
Finn763 ae6d3880ae fix(desktop): drop persisted tiles of deleted profiles so bots cannot resurrect
A Bot Mode bot cloned via 'Clone from profile' resurrects after
deletion: the desktop keeps the bot's chat tile in local storage
(bot-meta cleanup removes the roster entry, but the persisted tile
survives). On relaunch the tile restores, re-dials the deleted
profile's backend, and ensure_hermes_home() re-creates the profile
directory the delete just removed — the empty skeleton with no
config.yaml reported in #94235.

dropTilesForProfile() removes a deleted profile's session-tile
bucket and every Bot Mode tile whose ownerRoute points at it
(exact connection + backend target when a source-scoped route is
given) from memory and local storage, with discard (no Cmd+Shift+T
undo) semantics. Wired into both delete doors: the core
DeleteProfileDialog and the SDK host.deleteProfile path the Bots
plugin uses.

Regression tests cover local and source-scoped routes; verified red
with the cleanup disabled.
2026-08-28 04:58:53 -07:00
Brooklyn Nicholson 6559448306 Merge origin/main into bb/bot-mode-design-system
Keeps plugin.js and its new .mjs test deleted. main's closed-chat fix
(7c91079) landed in both; it is a real behaviour change, so the commit that
follows ports it onto the split modules rather than dropping it with the
files. Its core half — focusWorkspaceOwnerSessionTile and the
host.focusOpenWorkspaceSession verb — merged cleanly and is used as-is.
2026-08-27 22:51:30 -05:00
Zeus-Deus 7c910793bf fix(desktop): a bot row click returns to its open tabs instead of re-opening a closed Bot Chat
In Bot Mode every roster click resolved the bot's canonical "Bot Chat" by
name and opened it as a tab. Nothing records a tab close (the plugin keeps no
closed set; core's tile bucket only forgets), so a Bot Chat the user had
closed came back beside every newer thread on every bot switch — close it,
start a new thread, visit another bot, come back: two tabs again, forever.

A row click is now "go to this bot": when the bot's workspace already holds
tabs, the one the user last had active is fronted and no chat is resolved or
opened. The canonical chat is opened only when the bot has nothing open, or
on the explicit asks — a new "Open Bot Chat" row-menu item and the Bots home
"Open chat" button (`openRosterBot(bot, { canonical: true })`).

- session-states: `focusWorkspaceOwnerSessionTile(ownerKey)` fronts the
  owner's remembered-active tile (else its most recent) and reports it.
- sdk: `host.focusOpenWorkspaceSession(ownerKey)` exposes it to plugins;
  feature-detected in the plugin so older shells keep the canonical open.
- hermes-bots: `focusExistingBotTab` short-circuits `openRosterBot`; the
  claim it records carries only the fronted tab, and the session.reclaimed
  re-resume now skips such claims so it cannot resurrect the closed chat.

Tests: vitest for the core helper, a node test for the click path (open tabs
win, nothing open → canonical, explicit canonical, older shell, throwing
host), and an e2e that seeds two bots with real "Bot Chat" rows, closes one,
starts a thread, switches bots and back, and asserts the Bot Chat stays
closed until asked for explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 20:30:49 -07:00
Brooklyn Nicholson bbcf5691a6 Merge origin/main into bb/bot-mode-design-system
main's Bots-home flash fix (6f8be61) landed in plugin.js, which this branch
deletes. Both files stay deleted: the guard it adds (botOpenInFlight gating
botsHomeMayOpen) protects a surface this branch removes, and the two sites
where it renamed the generation bump to cancelBotOpen already bump here via
bumpBotOpenGeneration in shared.ts.
2026-08-27 21:52:40 -05:00
686f6c61 6ac193e02b fix(desktop): refetch Bot Chat on roster reopen instead of idle snapshot
forceResume already requested a main-route resume, but Bot Chat is a
tile. Reopening reused the warm cached transcript and skipped REST, so
cron bot-chat deliveries that landed while the panel was closed stayed
invisible until app restart. Refresh the tile transcript on explicit
open and merge the persisted tail into the cache.
2026-08-27 19:46:43 -07:00
Brooklyn Nicholson 610d1ed0da refactor(desktop): give the sidebar row geometry and a bounded cache one owner
Row geometry lived inside chrome.tsx as private consts, so anything that
wanted to line up with a session row copied the literals instead — the lead
cell alone appeared verbatim in six files. Its own comment warns that owning
height anywhere else makes rows float 1-2px off sessions, which is exactly
what a copy invites. Split the measurements into row-geometry.ts, keep the
public names re-exported from chrome.tsx, and put the lead cell and
ConnectionGlyph on the plugin SDK so a plugin composes the same box rather
than re-deriving it. ConnectionGlyph takes a className now so a caller can
tint it without forking the component.

LruCache is the same move for a different leak: katex-memo.ts had a private
one with a hardcoded ceiling, and the bot rail had two Maps that never
evicted. One shared class, exported, katex's twin deleted.
2026-08-27 21:46:40 -05:00
Brooklyn Nicholson 0fececa733 chore(desktop): lint the bot-mode modules clean
The plugin shipped as bundled JavaScript, so eslint never saw it. Now that it
is .tsx under src/, the whole ruleset applies: sorted JSX props, curly braces,
statement padding, unused imports.

Three of the ref writes the atom-mirror rule flagged are the cases its own
comment carves out — a scroll-position tracker fed by a DOM listener, a
previous-value tracker for the hidden -> visible edge (lagging a render IS its
contract), and a timer handle cleared on unmount. Those get the documented
disable. The fourth was a genuine mirror: McpSetupButton copied its profile
prop into a ref every render so two callers could read it. They read the prop
directly now, and the ref holds only the profile the component creates on
demand for the New Bot flow.

no-redeclare counts a TypeScript overload signature as a redeclaration of its
implementation, which is what botSelectionKey and botMetaKey tripped. Swapped
for the TS-aware version alongside the no-undef swap already there; hermes-ink,
whose vendored yoga bindings merge a const and a type under one name, extends
its existing carve-out to the new rule name.
2026-08-27 20:05:36 -05:00
Brooklyn Nicholson 45176219a2 feat(desktop): let a plugin title an empty chat it owns
Core has one empty state, the intro splash, and it belongs to a fresh
draft with no session selected. A session that exists but has nothing in
it yet falls outside that, and whoever opened it is the only one who
knows what should stand in the gap — core has no business learning a
bot's face and name.

Add `chat.empty` as a contribution area, resolved by the transcript the
same way `transcript.directives` resolves an inline widget. A
contribution mounts for every empty session and renders nothing for the
ones it does not own, so it can subscribe to its own stores and appear
once they load.

Pull the splash's lettering out of `intro.tsx` into a `Wordmark`
primitive so a second caller cannot fork it, and move the typeface,
weight and tracking into `.wordmark` beside the `.fit-text` rules they
travel with — as utilities they were one class-merge or one missed scan
away from silently rendering the wordmark in body text. `Wordmark` takes
its width, because fit-text sizes to fill and a short name set at the
splash's full width comes out enormous.
2026-08-27 19:08:28 -05:00
Brooklyn Nicholson 4fcd16e224 feat(desktop): widen the plugin SDK to what Bot Mode had to reinvent
Every primitive Bot Mode hand-rolled was one the app already had but never
exported: the Panel master-detail family, SessionStatusDot, ColorSwatches +
PROFILE_SWATCHES, RowButton, DisclosureCaret, formatAgo, translateNow, and
the unread store behind the dot. Export them, with docblocks that say which
hand-rolled shape each one replaces so the next plugin doesn't repeat it.

warmAgent/ensureAgent accept an undefined connectionId alongside null, since
a roster row's is optional and both mean "no explicit source".
2026-08-27 19:08:28 -05:00
hermes-seaeye[bot] 36b0a96dcb fmt(js): npm run fix on merge (#96076)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-27 04:43:58 +00:00
Teknium 61b6788dd4 fix(desktop): Bots-mode picker routes guarded model switches through the shared confirm handler
The Bots editor's model write (profiles.configure) was the one switch
surface that bypassed the data-policy / expensive-model selection guard:
a guarded pick (e.g. muse-spark contributor tier) was applied silently,
with no confirm flow anywhere — the #95293 remainder after the core
picker's confirm handshake landed in use-model-controls.

Gateway: profiles.configure now answers confirm_required +
confirm_message for a guarded model (same handshake as config.set
model) and writes NOTHING until the client resends with
confirm_expensive_model: true. Other sections still apply; the pending
model section is not reported as failed.

Desktop: the confirm flow is extracted out of use-model-controls into
one shared applier (lib/guarded-model-switch.ts, exported through the
plugin SDK) — warning toast, staleness-guarded Confirm, single
confirmed resend, never a retry loop. The core picker and the Bots
editor now consume the SAME handler; the Bots editor's Confirm resends
only the model section with confirm_expensive_model: true.

Fixes #95293 (Bots surface remainder).
2026-08-26 21:38:41 -07:00
Shaq Wu 77b5bb39e7 fix(desktop): preserve bounded bot hydration budget 2026-08-26 21:37:19 -07:00
Teknium dd0aae4173 fix(desktop): paint stored Bot Chat history immediately instead of stranding the wake on an unsatisfiable profile gate
Fixes #89843. On a shared-remote connection every profile is served
through the primary socket, so waitForFocusedSessionHydration's
profileMatches gate could never become true — a bot chat whose stored
transcript painted within seconds still burned the whole 20s hydration
budget and then stranded the pane with 'Timed out loading <bot>'s
session history'.

The wake now resolves paint-first: once the stored transcript is painted
on exactly the target session, the content is its own proof — the pane
opens immediately and a subtle 'Syncing…' badge (new $hydrationSyncProfile
atom + ChatSyncBadge) shows until the profile gate catches up in the
background. Fail-closed everywhere content is not its own proof: a
superseded/conflicting concurrent wake still rejects, and an
expected-empty chat still waits for the full runtime gate.
2026-08-26 08:39:45 -07:00
Teknium 254af55728 fix(desktop): force session.resume on explicit bot-switch open so Bot Chat never paints a stale cached transcript (#93604)
The post-open surface-health check in host.openSession trusts any
non-empty cached transcript ($messages.length > 0), so re-opening a
bot's canonical chat after switching bots could pass the check while
painting a stale snapshot kept by the session-states cache — skipping
requestSessionResume and leaving old messages on screen until an app
restart.

Add an opt-in forceResume flag to PluginOpenSessionOptions, honored only
alongside awaitHydration, and set it on the one explicit bot-switch open
path (openStoredBotChat, which serves openBotCanonicalChat and stored
opens). Resume is cheap and idempotent per the route-resume effect's own
contract, so the extra request is a no-op when the transcript is already
fresh. All other navigation paths keep the existing heuristic.
2026-08-26 07:28:10 -07:00
fangliquanflq 66186dc58f fix(desktop): keep bot reconciliation off inactive backends 2026-08-26 00:21:29 -07:00
hermes-seaeye[bot] bc943d5672 fmt(js): npm run fix on merge (#95299)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-26 06:20:59 +00:00
Kavyrocom 76af834ddd fix(desktop): route ambient SSH profile deletion to remote 2026-08-25 23:15:49 -07:00
Teknium c29c9d1790 fix(desktop): hold a per-turn socket lease so group-chat member turns survive the runtime-session reaper (#93602)
A group member turn is a session-scoped RPC sequence (resume → attach →
prompt.submit → poll) issued with the runtime id its first RPC minted, but
requestForBot routes every RPC through its own request-scoped socket lease
(retained:false secondaries in store/gateway). Between two RPCs the refcount
hits 0, the leased socket closes, the gateway detaches the runtime session on
WS disconnect, the orphan reaper frees it after grace, and the next RPC —
prompt.submit, unwrapped — dies 4001 'not in memory'. The member turn aborts
and the sub-profile bot goes silent in the room.

- store/gateway: retainGatewayForAgent(connectionId, profile) — refcounted
  hold on the pooled socket with an idempotent release, mirroring the
  existing request-lease machinery.
- sdk: host.retainProfile(route) exposes the retain to plugins
  (feature-detected by consumers; older hosts keep working).
- hermes-bots plugin: runGroupChatMemberTurn acquires the lease before
  ensureGroupChatSession's first RPC and releases in finally, so the socket
  that minted the runtime id stays open across attach+submit+poll; and
  prompt.submit gets a one-shot catch-and-retry that re-resumes via the
  STORED session id on 4001-class failures (belt-and-braces for routes the
  lease can't cover). 4007 'never existed' keeps flowing to session.create.

Tests: simulated 4001 on first submit recovers via re-resume and delivers;
lease held across attach+submit (mock refcount never hits 0 mid-turn); lease
released after success AND failure; no-retainProfile host feature detection;
store-level retain/release + idempotent double-release + the unretained
disposal race.
2026-08-24 03:41:34 -07:00
Teknium 06fc941d23 fix(desktop): stop bot-relay drain loop from redialing a WebSocket per connection per tick
The bot relay's drain loop RPCs every registered connection through
requestGatewayForAgent's per-request lease. With no other consumer
holding the route, the refcount hit 0 after every tick and the pooled
secondary was disposed — a fresh WebSocket dial + teardown per
connection every 4s, flooding the gateway logs with connect/disconnect
pairs (#93594).

Two changes, both directions from the issue:

- Retained relay-route secondaries: retainGatewayForRelay pins a
  route's pooled socket with a counted retention (never clobbering the
  foreground 'retained' flag) for the relay's active lifetime, reusing
  the existing scheduleReconnect/full-jitter machinery on drops. The
  plugin pins each registered connection once via the new feature-
  detected host.retainProfileSocket door, reconciles pins with the
  current connection set on every drain, and releases everything in
  stopBotRelay/dispose. Local routes (null/'local') are exempt so the
  idle reaper can still reclaim spawned local backends. The live-work
  pruner also respects the pin.

- RELAY_DRAIN_INTERVAL_MS 4s -> 30s: the push path (#93091,
  bot_relay.outbox.pending) carries envelope latency, so the poll is
  purely a backstop — 30s matches LIVE_SESSION_STATUS_BACKSTOP_INTERVAL_MS.

Tests: relay-push-drain updated to the new backstop semantics; new
gateway-relay-retention.test.ts proves one socket construction across
5 drain ticks (vs 3 constructions for 3 unretained ticks) and that
release/prune/local-exemption behave; new relay-socket-retention
plugin test pins the pin-once / release-on-departure / stop-releases
contracts.
2026-08-24 03:23:15 -07:00
hermes-seaeye[bot] fc0b2a0ab3 fmt(js): npm run fix on merge (#93123)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-23 18:38:31 +00:00
Teknium 9452bca388 fix(desktop): route bot-chat RPCs to the bot's own gateway via tile ownerRoute (#92956)
The real fix for Bot Mode 'session not found' / endless hang: dispatch
session-scoped RPCs on the OWNING profile's local gateway, using the route
the chat tile already carries — the same multi-connection machinery Sessions
mode uses, which has never had this problem.

Root cause chain:
- A bot chat is a persisted tile that records its exact owner (connectionId +
  profile) in tile.ownerRoute; requestForSessionProfile already dispatches on
  any (connectionId, profile) via the per-profile local gateway pool.
- But wiring's requestGateway resolved the owner via rememberedSessionProfile,
  a $sessions row lookup. Canonical Bot Chats are born hidden (never listed),
  so the lookup missed and fell back to the ACTIVE profile -> prompt.submit hit
  the launch backend that never owned the session -> 4001, and the resume
  ladder re-resolved through the same blind spot, so it hung.
- It also keyed off $selectedStoredSessionId, but a bot chat renders in a TILE
  whose id is $focusedStoredSessionId (selected stays the primary pane), so
  even the row path was reading the wrong session.

Fix:
- wiring requestGateway: resolve owner from the FOCUSED stored id, preferring
  the tile's persisted ownerRoute; fall back to the list-derived profile only
  when no tile route exists. One resolver, every session RPC (submit, resume,
  attach, interrupt, compress) inherits it.
- sdk openSession: synthesize a local ownerRoute from  for bot opens
  that carry no explicit cross-connection route, so LOCAL bot tiles carry their
  owner too (previously only remote routes did). Strictly routing metadata:
  the dial path, all-profiles view, and the route-registry retry check all
  still key off the EXPLICIT route, so a plain local open behaves exactly as
  before (no registry-secondary dial, no forced all-profiles view).

Fixes already-open chats (tile route is persisted, needs no fresh open) and
survives relaunch. 3 tests for sessionTileOwnerRoute. tsc 0 errors.

Co-authored-by: Teknium <teknium1@users.noreply.github.com>
2026-08-23 06:32:51 -07:00
Teknium 2c244f0751 fix(desktop): route hidden Bot Chat RPCs to the owning profile backend (4001 session-not-found) (#92928)
* fix(desktop): route hidden Bot Chat RPCs to the owning profile backend

Bot Mode chats failed with 4001 'session not found' (then hung on retry)
for every bot except the launch profile. Root cause is an identity gap in
the session-RPC router for HIDDEN sessions:

- wiring's requestGateway resolves the owning profile via
  rememberedSessionProfile($sessions, selectedStoredSessionId, active).
- Canonical Bot Chats are born hidden (hermes-agent#86797), so the sidebar
  aggregator NEVER lists them; whenever the in-memory row is absent the
  lookup misses and the resolver silently falls back to the ACTIVE profile.
- prompt.submit then lands on the launch backend, which never owned the
  session -> 4001. withSessionNotFoundResume's session.resume ALSO resolves
  the profile through the same blind spot, so the recovery re-registers the
  wrong backend too and the ladder dies without ever reaching the bot's own
  (healthy) gateway. Log fingerprint: the bot backend shows ws accepts with
  messages=0 and no 'tui prompt accepted' after the reap, while the launch
  backend answers 4001.

Fix at the resolver, so every session-scoped RPC (submit, resume, attach,
interrupt, compress) gets the same answer:

- rememberedSessionProfile: when no session row matches, consult the
  session-owner hint (targetProfile over profile) before falling back to
  the active profile.
- sdk openSession: record an owner hint for LOCAL plugin opens that carry
  an explicit profile (remote routes already did via ownerRoute). Hidden
  sessions have no sidebar row, so this hint is the only durable owner
  record the router can consult.

3 focused tests: hint fallback for a hidden session, targetProfile
preference, and row-over-hint precedence. tsc 0 errors (baseline-equal).

* test: add activeGatewayConnectionId to the full-replacement gateway mock

sdk/index.ts now imports it for the local-open owner hint; the full
vi.mock('@/store/gateway') in profile-routing.test.ts must export every
symbol the module under test imports or all 18 suite tests fail at load.

---------

Co-authored-by: Teknium <teknium1@users.noreply.github.com>
2026-08-23 05:44:26 -07:00
David Dudok de Wit 8523819fcf refactor(desktop): consume upstream Bot owner routing 2026-08-23 04:19:55 -07:00
David Dudok de Wit a81854a2bd fix(desktop): preserve Bot tabs across owner lifecycles 2026-08-23 04:19:55 -07:00
David Dudok de Wit 856fc66d19 fix(desktop): bound Bot owner wake races 2026-08-23 04:19:55 -07:00
David Dudok de Wit 9b36c2d43c feat(desktop): make new Bot tabs owner-aware 2026-08-23 04:19:55 -07:00
David Dudok de Wit b8b6f43280 feat(desktop): define workspace-scoped pane ownership 2026-08-23 04:19:55 -07:00
hermes-seaeye[bot] f377140e3d fmt(js): npm run fix on merge (#92815)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-23 08:45:37 +00:00
Teknium e6962b818c style: eslint --fix across src/ + electron/ (CI lints the full tree); drop unused destructure 2026-08-22 22:35:55 -07:00
Teknium 4e4ee7ee75 style: eslint --fix on merged TS surfaces 2026-08-22 22:25:59 -07:00
Sara Burke ba42346e99 Merge latest upstream into hybrid remote Bot routing
Carry the reviewed hybrid Bot routing branch through current main at
4a5b6dd45 without rewriting prior signed commits.
2026-08-20 16:52:28 -04:00
Brooklyn Nicholson 2e1e3cc91d feat(desktop): let plugins switch the theme from outside React
The SDK could contribute a theme through `THEMES_AREA` but never select one,
and `useTheme().setTheme` needs a component to hang the hook on. A plugin that
repaints on an event — a gateway coming up, a socket message — had nowhere to
call, so shipping one meant patching the app and re-patching it after every
upgrade.

`requestTheme(name)` writes to the same one-shot channel the backend `/skin`
sync already uses, so the ThemeProvider drains it through `setTheme` and an
imperative switch normalizes and persists per profile exactly like a manual
pick — one policy, one owner.

An unresolvable name is refused rather than coerced. `setTheme` falls back to
the default skin, which is right for a person picking off a list and wrong for
code reacting to an event: a gateway naming a theme the user never installed
would silently reset an appearance the caller never meant to touch. The
returned boolean doubles as the availability check.
2026-08-20 13:30:31 -05:00
Sara Burke 052513f65b Merge current upstream into hybrid remote Bot routing
Adopt upstream's one-hidden-forever-chat model and remove the per-bot hidden
session browser while preserving connection-bound remote Bot opening, the full
remote action menu, same-name isolation, and global Sessions navigation.
2026-08-20 10:26:19 -04:00
Brooklyn Nicholson bd853747bb fix(desktop): a fresh profile follows the OS, and plugins stay behind the SDK
Two things a genuinely fresh instance surfaced that no existing profile could.

The renderer's mode fell back to `light` when nothing was stored, so a
dark-mode desktop opened a white window on first launch. Main already
defaulted its own themeSource to `system`, so the two disagreed at boot — and
once translucency became per-appearance it also handed those users light's
much heavier tint, tuned for a bright desktop they don't have. Both the
normalizer and the SSR fallback now say `system`; an explicit choice still
wins.

The accent plugin reached straight into `@/components` and `@/themes`, which
the plugin lint rule exists to prevent: plugins import `@hermes/plugin-sdk`
and nothing else, so the app can move its internals without breaking them.
The fix is to widen the SDK rather than exempt the plugin — it now exports the
OKLCH colour maths, `useTheme`, `retintTheme`, and the accent override, so any
plugin can derive a palette instead of hardcoding one.
2026-08-20 02:28:24 -05:00
hermes-seaeye[bot] fab8479aa0 fmt(js): npm run fix on merge (#90408)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-20 01:14:22 +00:00
Brooklyn Nicholson 22b81836d2 style(desktop): prettier 2026-08-19 20:01:40 -05:00
EndeavorYen 0734cfd319 fix(desktop): keep chrome API home when opening a Bot Chat
Opening a plugin/Bot Mode session is navigation, not a workspace switch.
keepAllProfilesScope (default true) now dials the named backend without
moving $activeGatewayProfile or setApiRequestProfile. Session-owned RPCs
still route to the session owner. Pass false to switch chrome and collapse
the Sessions sidebar.
2026-08-19 20:01:40 -05:00
EndeavorYen 2367b90b9f fix(desktop): keep Sessions workspace when opening a Bot Chat
Bot Mode passed keepAllProfilesScope:false, which re-homed the sidebar
onto the bot profile. That profile forever-chat is hidden, so Sessions
and the roster looked empty. Opening a bot is navigation, not a workspace
switch. Also restore all-profiles when the bot backend is already live.

Related: #89789
2026-08-19 20:01:40 -05:00
chelsealong 6ec4aa8c3a fix(desktop): move the hydration-timeout retry into host.openSession
A review of the previous commit found that retrying at the plugin layer
(openStoredBotChat catching and re-calling host.openSession) didn't fix
the reported bug: host.openSession's own catch block unconditionally
calls setResumeExhaustedSessionId on a hydration timeout before
rethrowing, and only an explicit resumeSession() (the manual Retry
button) clears that latch for the currently-routed session. A
plugin-side retry is a different code path that can hydrate the
transcript fine while the full-screen "Couldn't load this session"
overlay stays latched over it.

host.openSession now takes a retryHydrationTimeoutOnce option and
retries the open+hydration-wait internally, before the latch is ever
set, so a successful retry never arms the overlay. openStoredBotChat
just opts in via that option.
2026-08-19 20:01:40 -05:00
Jack Lau 3a50a6bea8 fix(desktop): bound the profile-activation half of a Bot Chat wake
host.openSession awaited ensureGatewayProfile with no deadline. That await
gates waitForFocusedSessionHydration, which arms the only timer on the path,
so a profile dial that never settles left the open pending for the life of the
window: the pane froze with no error, no Retry and - the part that made this
hard to recognise - no timeout either. The gateway log signature is a bare
`ws accepted` with no matching `ws closed`.

Bound the activation with its own copy of the wake budget rather than folding
it into the hydration one. A cold profile backend can legitimately spend most
of the hydration budget painting a large transcript, and that race is already
tight enough to lose, so charging activation to the same clock would trade a
wedge for a regression. The timeout reuses the hydration message prefix on
purpose - openSession keys the core stranded-session surface off it - and the
[bot-wake] support log now names which phase expired, so a stuck dial is not
read as a slow transcript.

Scoped to callers that passed awaitHydration. A plain open never asked for a
deadline and has nowhere to render one, so its behaviour is unchanged.

Two existing tests counted microtask ticks between the call and the core open.
The bounded activation adds a tick, so they now flush a macrotask instead,
which asserts the same thing without depending on the await count.

Refs #89556
2026-08-19 20:01:40 -05:00