Commit Graph

3966 Commits

Author SHA1 Message Date
teknium1 05051691c6 test(desktop): trim the archived-view reload coverage to two invariants
Keep the two discriminating cases (a sessions.changed tick reloads the archived
set while the view is open; a failed refresh retains the last good rows) and
drop the closed-view control, which passes on the unfixed base as well.
2026-09-15 18:54:00 -07:00
KoNit-K dbda53b8be fix(desktop): refresh archived sessions on external changes
Co-authored-by: DavidMetcalfe <80915+DavidMetcalfe@users.noreply.github.com>
2026-09-15 18:54:00 -07:00
teknium1 a40d90e9be test(desktop): trim the voice-live toast tests to two invariants
Seven per-case tests collapsed to two: one per behaviour class (our own
close reasons → copy, server reasons verbatim; mic DOMException → recorder
copy, non-mic failures untouched). Source is byte-identical to the
contributor commit. Dropped: the `closed`/blank-reason case, the
`close_requested` filter (pre-existing behaviour), and the per-name
DOMException matrix — the mapping table lives in `micError`, asserting one
name proves the live path routes through it.
2026-09-15 18:53:32 -07:00
finn763 c2625370d7 fix(desktop): stop the voice-live toasts leaking machine strings (#111987)
The session-end toast printed the raw wire reason (`connection_lost (127s)`,
`closed`) and a denied `getUserMedia` reached the toast as bare `DOMException`
text, while the recorder path already had friendly copy for those names.

- `liveEndedMessage` (new, exported for the tests) maps our own close reasons —
  `connection_lost`, `closed`, plus a blank reason — to i18n copy; server-sent
  reasons stay verbatim (unbounded, no redaction claim).
- `micError` is exported and reused on the live start path, so a mic
  DOMException gets the recorder's copy; an unmapped DOMException name now falls
  back to `microphoneStartFailed` instead of its raw text.
- The start catch maps DOMExceptions only: non-mic failures ('GPT-Live session
  already started', 'Missing local SDP offer', API errors) keep their message.

New i18n keys: `notifications.voice.liveEndedConnectionLost` / `liveEndedClosed`
(en base + zh translation; the other locales fall back to en).

Tests: `use-voice-live-conversation.test.ts` drives the real toast store through
a fake transport — reason copy, blank/unknown reasons, `close_requested` filter,
mic DOMException names, and untouched non-mic failures.
2026-09-15 18:53:32 -07:00
0xmosta 758638c64c fix(desktop): trap focus in boot recovery 2026-09-15 18:52:15 -07:00
teknium1 d747a71df2 test(desktop): type the fresh-module MCP health import without an import() annotation
The repo's eslint config forbids `typeof import(...)` type annotations
(@typescript-eslint/consistent-type-imports); derive the module type from the
dynamic-import thunk instead so `npm run check:lint` stays at 0 errors.
2026-09-15 18:51:49 -07:00
KoNit-K c9277d25b9 fix(desktop): honor MCP health snooze after restart 2026-09-15 18:51:49 -07:00
teknium1 b469be8cc3 fix(desktop): a push that lands mid-delivery claims its envelope now — lanes outlive the drain
The previous commit claims every outbox before delivering and runs
deliveries per target profile, but `drainBusy` still spanned the delivery
phase: a `bot_relay.outbox.pending` push that arrived while one lane ran a
long turn (up to RELAY_DELIVER_TIMEOUT_MS) only set `drainRerun`, and the new
envelope was claimed after that turn — the reporter's step 3 (bot C mails D
while A→B runs) still ended in `queued_expired`, because the gateway checks
the TTL at the claim.

Scope `drainBusy` to the claim phase and make the delivery lanes module
state: `relayLanes` maps `target_connection::target_profile` to the tail of
that target's in-flight deliveries, so a later drain appends to the running
lane (same target stays ordered, one turn at a time) or starts a new one
(other targets run now). Lane entries drop once idle; stopBotRelay clears
them so a restart begins fresh.

Tests: keep the contributor's red-on-base test (claims every outbox first,
delivers to different targets concurrently) and replace the ordering-only
test — green on base — with one that pins the mid-delivery claim plus the
same-target ordering (red on base AND on the previous commit alone).

Docs: bot-mode.md states the delivery concurrency contract.

Part of #111587 (with the previous commit: Fixes #111587)
2026-09-15 18:51:23 -07:00
John Paul Soliva 1eb771e2ff fix(desktop): the bot relay claims every outbox first and delivers per target, not one envelope at a time
drainRelayOutboxes drained one gateway's outbox and delivered its envelopes
before draining the next gateway, and delivered every envelope one after
another. One long turn — bot_relay.deliver may take up to
RELAY_DELIVER_TIMEOUT_MS, 25 minutes — therefore held every other bot's mail:
a sibling gateway's envelope sat unclaimed in its outbox, and since the
gateway checks the envelope's age against bot_mode.envelope_ttl_seconds
(15 minutes) at the claim, the sender's waiter received queued_expired for a
message nothing was wrong with; envelopes that did get claimed still waited
their turn behind unrelated deliveries, against a finite waiter.

Claim every gateway's outbox first, then deliver in lanes keyed by target
connection and profile: a lane runs its envelopes in order, one turn at a
time (the target gateway serialises that profile's turns behind its turn
lock anyway), and lanes run concurrently.
2026-09-15 18:51:23 -07:00
teknium1 a22c731744 fix(desktop): refresh stale 4px scrollbar-width comments to 8px 2026-09-15 18:49:57 -07:00
teknium1 00c66225d4 fix(desktop): widen the portaled-menu scrollbar to match the app theme
`.dt-portal-scrollbar` is the same themed bar as `.scrollbar-dt`, applied
to overlays that portal under document.body (dropdown/context menus, the
command palette, the session and connection switchers). Widening only the
#root theme (#111634) would have left those lists on the 4px bar that was
too thin to grab; keep the two variants on one width (0.5rem = 8px).
2026-09-15 18:49:57 -07:00
kvnloo 22dc293f2a fix(desktop): widen themed scrollbars from 0.25rem to 0.5rem
The app-wide .scrollbar-dt theme (on #root) rendered 4px scrollbars
everywhere, including the conversation window, making them nearly
impossible to see or grab (#111634). Bump the webkit track size to 8px
so the thumb is actually hittable while staying a slim themed bar.

Fixes #111634
2026-09-15 18:49:57 -07:00
teknium1 d30c05bebf fix(desktop): blank-line padding in the foreground-dial test 2026-09-15 18:46:08 -07:00
teknium1 5eb0ed4531 fix(desktop): Vault settings tab dials its scoped profile foreground
requestGatewayForProfile always dialed with the 'background' spawn priority,
so the Vault tab under the Settings 'Applies to' selector kept the #111651
infinite-spinner path on a cold profile even after the REST side moved to
scopedDialPriority. Let requestGatewayForProfile take a spawnPriority option
and pass 'foreground' from the Vault panel; ambient callers are unchanged.

Part of #111651
2026-09-15 18:46:08 -07:00
teknium1 d3ba5b307a fix(desktop): one scoped-dial priority helper; Capabilities selector dials foreground too
Fold the salvaged per-call ternaries in api/config.ts into a single
scopedDialPriority(scope) helper on api/client.ts and apply it to the
Capabilities scope selector's cold-start reads (getSkills / getToolsets /
getMcpCatalog), which hit the same background-capped pool queue when the
selector targets a stopped profile.

Drop the salvaged main.ts source-text change-detector test; keep only the
string update the existing #90812 wiring test needs. The renderer seam test
(hermes-capability-scope) is the invariant: an explicit scope carries
priority 'foreground', the ambient path stays untagged.

Part of #111651 (salvage #111672)
2026-09-15 18:46:08 -07:00
KoNit-K d6ff2777b7 fix(desktop): prioritize scoped settings backend dials 2026-09-15 18:46:08 -07:00
teknium1 c91770b268 fix(desktop): route tile title heals when its plugin route registers late
A route tile restored (or opened) before its plugin route registered kept the
humanized-path fallback as its tab title forever: paneMirror recomputes titles
only when one of its atoms changes, and watchRouteTiles listened to $routeTiles
alone. #112147 made the pane CONTENT heal on late registration; the title
still lagged.

routes.ts exposes $routesVersion, an atom bumped from registry.subscribeArea
(ROUTES_AREA) only while listened to, and watchRouteTiles passes it as `also`
so the sync re-runs and the pane re-registers with the contribution's title.
One test, red on origin/main.
2026-09-15 15:07:24 -07:00
teknium1 212f661048 fix(desktop): remembered plugin page survives boot when the session list beats disk plugins
A remembered plugin-page route is session-shaped until its route registers,
and disk plugins load through an async IPC chain. When the backend was
already running (remote/URL connection, macOS close-reopen with the process
alive) the session list could arrive first; the restore effect then read
'/html-gallery' as a session id nobody owned, navigated to the last chat and
erased the remembered route, so the page was lost on every later boot too.

The disk door now publishes $diskPluginsScanPending for the duration of its
first scan, and the restore latch holds a session-shaped remembered route
while that scan is pending, mirroring the existing "sessions not loaded yet"
latch. Once the scan settles the route is either a registered page (restored)
or a genuinely stale session (dropped as before). Two tests: the late
registration restores; the stale session still drops.
2026-09-15 15:07:03 -07:00
Austin Pickett 05e82b741d fix(desktop): Tab Strip → Auto copy names the other-zone exception 2026-09-15 16:07:39 -05:00
Austin Pickett 36010d0bba fix(desktop): a lone chat keeps its tab strip while another chat zone is open
Dragging a session tab out of the main strip into its own zone left the
workspace alone in main. Auto treated a lone pane as "not a tab", and the
uncloseable workspace is not stranded, so main lost its tab and its "+" —
while the tile it had just split from kept a strip of its own. Two chats
side by side, one with tabs and one without, reads as "the tabs
disappeared"; ⌥⌘T only helped because it wrote an explicit `always`.

The resolver gains one input, `siblingMainZone`: on auto, a lone main tile
keeps its strip whenever another zone in the layout also hosts a main tile.
A chat that is the whole window stays chromeless, side chrome is untouched,
and an explicit `never` still wins so Hide tabs keeps working.

Both callers read it from `$mainTileZoneCount`, a computed over the tree,
the hidden set and the registry version. It is a number, so zones re-render
only when a main zone appears or goes — never per sash-drag frame — and the
store's answer (what the toggle command flips against) and the strip on
screen cannot disagree.
2026-09-15 16:07:39 -05:00
teknium1 eaef52371f fix(desktop): late-contributed keybind actions reach the Keybinds settings map
Same class as the plugin-route bug: KeybindSettings subscribed to
useContributions(KEYBINDS_AREA) but discarded the snapshot and called the
impure allKeybindActions() in render, so the React Compiler memoized the
action list without the subscription as a visible input. A plugin keybind
registered after the tab mounted never appeared, despite the comment
promising "appear/disappear live".

allKeybindActions()/contributedKeybinds() take an optional contribution
snapshot (default: registry read, so the store/imperative callers are
unchanged) and the settings tab passes its subscription through. One test,
red on origin/main.
2026-09-15 12:08:52 -07:00
teknium1 51c9e8932a test(desktop): trim the #109063 suite to one invariant per surface
Drop the contributedRoutes() helper-contract tests (they pin the helper's
signature, not the user-visible failure) and the hot-replace/dispose
variants of the route-tile test: each surface now carries exactly one test
that is red on origin/main and green with the fix.
2026-09-15 12:08:52 -07:00
rahlquist adbf2103cd test(desktop): late-registered plugin route wins over the chat catch-all
Regression test from #111541: mounts ChatRoutesSurface at a not-yet-registered
path through the real useContributions + registry under the compiler-enabled
vitest ui project, registers the route after mount, and asserts the page
replaces the `:sessionId` chat fallback. Red on origin/main, green with the
snapshot-fed contributedRoutes(). Replaces the equivalent surface test from
#109418 so the workspace surface carries one invariant test.
2026-09-15 12:08:52 -07:00
liuhao1024 6fc7032aac fix(desktop): feed the ROUTES_AREA subscription snapshot into contributedRoutes (#109063)
React route consumers subscribed via useContributions(ROUTES_AREA) but
discarded its snapshot, deriving routes through an independent imperative
contributedRoutes() call. With React Compiler enabled the derived routes
could stay memoized across a late registration or hot replacement the
subscription DID deliver, so an installed plugin's page never mounted
while its sidebar entry did (#109063).

contributedRoutes() now accepts an explicit contribution snapshot,
defaulting to the registry read for imperative callers; both React
consumers (ChatRoutesSurface, RouteTilePane) pass their snapshot through.
2026-09-15 12:08:52 -07:00
teknium1 3272fb35aa docs: profile-scope invariant in AGENTS.md — one process serves many profiles; out-of-turn code binds its scope
Root AGENTS.md § Code Shape Rules replaces "module-level constants are fine — they cache after
_apply_profile_override() sets HERMES_HOME" (true for `hermes -p x <cmd>`, inverted under the
multiplex gateway and the Desktop/dashboard `serve` backend, where os.environ holds the LAUNCH
profile) with the invariant: a profile = home + secret scope + terminal scope, bound per profile
ACTIVITY, and every execution point with no turn on the stack binds it explicitly. Names the real
seams: gateway/run.py::_profile_runtime_scope, tui_gateway @_profile_scoped +
_session_profile_runtime_scope (+ _profile_runtime_scope_tokens, launch_profile_policy ->
set_multiplex_active), cron/scheduler_provider.py::_profile_cron_scope,
gateway/run_agent_cache.py::_run_release_in_profile_scope, tools/environments/local.py::
served_profile_child_env, agent/memory_provider.py::spawn_context_thread. Adds a routing-table row
for profiles / multiplex / secret scope.

Area AGENTS.md paragraphs, one per seam, for gateway/ (activity-not-turn binding, hooks per
profile, adapter YAML never reaches os.environ, unserved shared-ingress reported via
_note_unserved_secondary_platform + needs_attention at the single writer), tui_gateway/ (RPC
binding is home AND secret AND terminal; HOME-only is half-bound; teardown chokepoint), cron/
(per-home tick lock, ticker scope incl. pre-loop code, kanban notifier routing, worker liveness by
(pid, worker_started_at) fingerprint, descendant fence as a path), hermes_cli/ (DEFAULT_CONFIG
key <-> reader parity, service-install matrix, -p vs multiplex home binding), tools/ (check_fn
reads through get_secret and is cached per hermes_home_key, one env builder per spawn, MCP trust
per profile), plugins/ (lifecycle hooks are bound by the caller; never cache the home from
initialize()), apps/desktop/src/ (pooled serve per (connection, profile); remote topologies),
agent/ (end-of-session flush is caller-bound; set_multiplex_active gates fail-closed).

Corrects the statements the multiplex model made wrong, in the same PR: root module-constant
sentence; hermes_cli "sets HERMES_HOME before any import" (+ cli-internals.md);
ADDING_A_PLATFORM.md §2 raw os.getenv loader (now an _ENV_STEPS row through config.py::_getenv)
and §4 platform_env_map in gateway/run.py (now _PLATFORM_ALLOWLIST_ENV in pairing.py + registry
allowed_users_env); platform_registry.py "may set os.environ (guard with not os.getenv)";
cron/AGENTS.md hardcoded ~/.hermes/cron/.tick.lock; gateway-internals.md agent:main as THE key
format, ~/.hermes/hooks/, single-profile `gateway stop`, plus a new "Multiplexed profiles"
section; tools/AGENTS.md os.getenv check_fn sample; "installed per turn" wording; "one temp
HERMES_HOME" E2E wording; multi-profile-gateways.md intro lists system units, Windows tasks, s6
and the Desktop backend.
2026-09-15 10:59:22 -07:00
brooklyn! 673d33095b fix(desktop): keep code geometry stable while highlighting loads 2026-09-15 12:33:45 -05:00
brooklyn! 3ccad12c0a fix(desktop): preserve reading intent across history and pane changes
Co-authored-by: wukangcheng1994 <160389295+wukangcheng1994@users.noreply.github.com>
Co-authored-by: Zhuzewen <zhuzw@seer-robotics.ai>
Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
Co-authored-by: nftpoetrist <264138787+nftpoetrist@users.noreply.github.com>
Co-authored-by: aydnOktay <xaydinoktay@gmail.com>
Co-authored-by: FalconOrtiz <falcon.ortiz11@gmail.com>
Co-authored-by: Ahmett101 <ahmet.tunc@gmail.com>
Co-authored-by: Val Alexander <val@opencoven.ai>
Co-authored-by: networthexplained <networthexplained@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:33:45 -05:00
brooklyn! 76f9bf948b fix(desktop): retain scoped transcript paint through background hydration
Co-authored-by: Benjamin Brumbaugh <benbrumbaugh@gmail.com>
Co-authored-by: Daisuke Suzuki <dai.suzuki.829@gmail.com>
Co-authored-by: xrbs00 <178640517+xrbs00@users.noreply.github.com>
2026-09-15 12:33:45 -05:00
teknium1 69fd61b0ef fix(desktop): anchor the first-paint backfill so long sessions stop lurching (#99920)
Switching to a long tool-heavy session still painted the tail, then jumped a
full viewport when the first backfill step committed, on current main.

Root cause: anchorBeforePrepend() skipped while the load was unsettled, but the
settle loop hands a bottom-pinned load back at the FIRST-PAINT height, before
the backfill transition commits. Every backfill step therefore ran unanchored;
the prepend grew scrollHeight by thousands of px with scrollTop untouched and
the view sat near the top until use-stick-to-bottom's ResizeObserver re-pinned
it frames later (measured CLS 0.6-1.0 per switch, layout-shift entries of 0.61).

Fix:
- anchor a bottom-pinned load even while unsettled (shouldAnchorBeforePrepend);
  an unsettled offset restore still never anchors, the settle loop owns it
- record the anchor's scrollHeight and consume it only on a taller tree, so a
  commit that does not grow content (transcript arriving under the first-paint
  budget, a store-window refresh) cannot spend the anchor as a no-op
- clear any anchor on a cold switch, not only on a key change: the stale rows
  shown under the new key are collapsed by the swap and the anchor with them
- do not arm the backfill while the transcript is empty or nothing is hidden

Live A/B (isolated Desktop, Xvfb, CDP layout-shift capture, 3 rounds x 2 long
sessions): main CLS 0.19-0.65 with one-frame 3-4k px scroll jumps every switch;
fixed CLS 0 on every warm switch and no scroll jump (cold first open of a
session keeps only the sub-0.1 first-paint shifts). Show-earlier and remembered
offset restore controls unchanged.
2026-09-15 08:51:14 -07:00
teknium1 043632e283 feat(desktop): hideable profile rail with a statusbar profile dropdown stand-in
For people who run profiles as bots, the colored profile strip at the sidebar
foot duplicates the sessions list (community request). Add a persisted
`hermes.desktop.profileRailVisible` preference (on by default) toggled from the
Sessions view menu ("Profile rail"), the shell right-click menu, ⌘K
("Toggle profile rail") and an unbound `view.toggleProfileRail` keybind.

While the rail is hidden the statusbar grows a `ProfileSwitcher` dropdown
beside the gateway switcher ("This device ⌄ · Profiles ⌄") offering the same
choices the rail does: this gateway's profiles, All profiles, every other
gateway's agents in fleet mode, New / Import / Manage. It also answers the
`profile.create` hotkey the rail used to own, so no door is lost.
2026-09-15 08:50:18 -07:00
teknium1 6bc3628ce8 fix(desktop): drag gateway/profile groups by their header, not only the hidden handle
The Gateway & profile sidebar sections carried dnd-kit listeners on the lead
glyph alone, and that glyph only reveals its grabber on hover — so a press on
the row's name or empty space did nothing and users fell back to the ⋯ menu's
Move up / Move down. Bind the sortable to the whole header (same shape as a
project row), with the handle and the ⋯/caret cluster keeping their own
gestures; a sub-threshold press on the label is still the fold click.

Live: Playwright pointer drag on the header label reorders sections (before:
unchanged, after: reordered); handle-drag still works. Test drives dnd-kit's
keyboard sensor on the label and is red on the old binding.
2026-09-15 08:50:18 -07:00
Robin Fernandes a241f42fc8 copy(free-tier): "it's free" without "keeps the free model" — signed-in free models are not the same model
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:44:42 +05:30
Robin Fernandes 16def7b8cc fix(free-tier): the desktop renders a free-tier refusal as its own card, not an OAuth re-login
A welcome-tier 403 classifies as auth_permanent, so the desktop's error
surface mapped it to "Your Nous Portal sign-in expired" with a Nous Portal
re-login button — the chat sentence never reached the user. Terminal results
on the free route now carry a structured free_tier block (kind + the chat
sentence); agent/error_surface.py turns it into a free_tier_<kind> code on
the provider layer with the sentence as `message`. The desktop gives those
codes their own titles, shows the backend sentence as the body, and offers
"Sign in with a Nous account" (the free-tier dialog) instead of the OAuth
re-login, with Retry only where a later send can succeed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:44:42 +05:30
Robin Fernandes 59fad62a40 fix(free-tier): review follow-ups — read the classifier's context, never replace a locked identity, re-inventory on retry
Correctness
- The welcome-tier recovery hooks (model_not_free move, wrong-host heal) and
  the long-wait rate-limit check read the turn's extract_api_error_context()
  dict, which never carries welcome_refusal / welcome_route. They now read
  classified.error_context, where _nous_welcome_tier parks them; the guard
  records the classifier's reset_at. Tests drive the real classifier and the
  real extractor so the two-context boundary is exercised.
- The connector path caught every AnonCredentialDead and re-minted; a locked
  account (anon_account_locked) is now retired without replacement, matching
  the inference resolver.
- A background bootstrap retry reused the boot-time provider inventory; it
  re-inventories, so a provider connected during the cooldown keeps
  inference.
- The desktop's setup.ready listener only refreshes an untouched picker
  (oauth mode, no local endpoint, idle flow) and re-checks after the
  readiness round, so an API-key form opened meanwhile is never dismissed.
- /__log on the rehearsal server sent its response while holding the state
  lock that _send re-acquires; the log is copied out first.

Reductions
- One shared FakePortal / install_portal (tests/hermes_cli/anon_portal.py)
  behind both free-tier fixtures, with a single httpx.Client transport seam.
- The rehearsal server's static inference answers are a table; dead
  scaffolding (REAL_PAID_URL, claim_codes, the no-op dead_once branch,
  extra_headers) removed.
- Setup-notice copy is a code-to-key map; its test uses real codes (the old
  loop built nonexistent ones and only exercised the fallback).
- The ineffective FreeTierErrorCode union is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:44:42 +05:30
Robin Fernandes 51e39af967 feat(free-tier): ruled behaviour for every welcome-api failure, with friendly copy and a fault-injecting rehearsal server
The free tier depends on the account service (NAS) and the welcome inference
host, and Hermes had no honest answer for most of the ways either can refuse
or fail: the NAS codes it matched were never sent, the tier-dark 403 carried
no message to match, a single boot-time blip disabled minting for the whole
process, and a structured rate-limit refusal never reached the cross-session
guard, so the "sign in for a bigger allowance" prompt was dead code.

Backend
- anon_auth: classify what NAS actually sends (404 not_found, 503
  temporarily_disabled, 429 + Retry-After, 428 pow_*, 403 account_locked)
  into one ANON_* code each, carrying retry_after / retryable on AuthError.
- Replace the process-lifetime mint memo with a per-profile cooldown that
  honours the server's wait, climbs a short ladder when the service is
  unreachable, never retries terminal codes, and yields to the user's own
  retry (force=True).
- Bootstrap record carries error_code / retryable / retry_after; a bounded
  background loop retries transient failures and re-announces setup.ready.
  setup.status and free_tier.status expose the block; free_tier.provision is
  the forced retry.
- Inference: a generic 403 from a welcome host is the tier refusing (keyed on
  the route); model_not_free moves onto the gateway's alternate once;
  anon_on_paid_host re-reads the route once; a long rate_limited refusal
  trips the cross-session guard; a locked account is retired but never
  replaced; terminal copy on the free route is one plain sentence.
- Sign-in: Failed keeps the service's code and wait; account_busy is
  retryable; the OAuth poll reports retryable / retry_after.
- All user-facing copy rewritten for first-time users: never "the free
  service is off" (what is unavailable is using Hermes without signing in,
  and signing in is free), no jargon, spoken waits.

Desktop
- A setup-failure notice above the provider picker: one sentence per code,
  a retry when the backend says one can work, the sign-in pointer only when
  the account service answered at all. The overlay re-checks readiness on
  setup.ready so a background success dismisses it.
- Sign-in dialog gains busy / unreachable / unavailable screens.

Rehearsal
- scripts/free_tier_fault_server.py stands in for both services with the
  real wire contract and a CORS-open scenario switch; HERMES_EXTRA_WELCOME_HOSTS
  (dev-only, env-only) lets the route rules treat it as the welcome host.
  Walkthrough in website/docs/developer-guide/free-tier-fault-rehearsal.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:44:42 +05:30
teknium1 15ebf7ec90 test(desktop): drop the never-assigned descriptor-profile knob from the connections mock
The salvaged switch-back regression only ever exercised the profile-less
primary shape; the mock's string branch had no writer. Publish the
profile-less local descriptor unconditionally and say why.
2026-09-15 06:32:53 -07:00
Konstantin Khlopkov 825324fdc9 fix(desktop): restore the last-used profile when the primary local descriptor is profile-less
The local primary backend (startHermes) publishes its descriptor without a
profile field, unlike the pooled/forced-local child path. Downstream, a
profile-less descriptor normalizes to 'default', so on a switch back to the
local source the store either refused to remember the source/profile pair or
killed the commit in targetIsActive() — landing the window on 'default'
every time (#110819).

- The primary local descriptor now carries the profile it booted with, and
  the unshared primary branch of ensureBackend() keeps the requested key on
  the descriptor instead of dropping it.
- selectConnection() tolerates a profile-less descriptor on the source it is
  landing: the activation already published the route that was asked for, so
  a missing profile no longer strands the switch.
2026-09-15 06:32:53 -07:00
teknium1 d735097376 fix(desktop): keep createdBy=learn through the starmap share code
The share-code codec encodes createdBy through a fixed table that only
knew none/agent/user, so idxOf returned 0 for 'learn' and a learned
skill round-tripped as createdBy: null. The 2-bit field has a free slot,
so 'learn' now occupies it, and metaBadges labels agent- and
learn-created skills alike as 'learned' — matching the Python side,
where both values count as learning signals.

Review finding: learn nodes lost createdBy in share-code round-trip.
2026-09-15 05:38:31 -07:00
teknium1 ee6fb0ed22 fix: clear the relay roster per sole connection, not once per below-two regime
rosterCleared was a single boolean latched the first time the peer set fell
below two connections. When the sole connection a was replaced by c between
ticks (still length 1) the flag stayed set, so c's gateway never received
the empty-roster push and kept the stale roster. Track the id of the
connection that got the clear instead and push whenever the sole
connection's id differs; reset once two or more connections relay again.

Review finding: routes [a] → tick → routes [c] → tick pushed no roster clear to c.
2026-09-15 05:19:12 -07:00
teknium1 c02a269351 fix(desktop): an empty relay route list does not spend the one roster clear
relayConnections() returns [] before the registry loads (and whenever the
host bridge is missing). The below-two clear must not latch on that empty
list, or the single connection that arrives on the next tick never gets its
roster cleared. Gate the clear on exactly one connection; formats the
salvaged test file.
2026-09-15 05:19:12 -07:00
John Paul Soliva 8b091e539c fix(desktop): the relay clears the remaining gateways' remote roster when the peer set drops below two
syncRelayRosters returned early with fewer than two connections, so a machine
removed from the registry stayed in every remaining gateway's
bot_relay/roster.json — still listed in each bot's system-prompt roster and
still a message_agent target — until a second connection appeared again.
Push the now-empty roster once when the set shrinks below two (and once at
start with a single connection, for a roster left behind by an earlier peer);
union pushes resume when a peer returns.
2026-09-15 05:19:12 -07:00
teknium1 bc59c39cfd docs(desktop): point the copy-control inset comment at the real scroller
The salvaged comment cited tool/fallback.tsx, which does not exist; name ExpandableBlock/CodeCardBody (the .scrollbar-overlay scroller) and log-tail.tsx (the 12px sibling) instead.
2026-09-15 05:15:42 -07:00
David Metcalfe 6e04bf8e59 fix(desktop): keep the code-block copy control clear of the scrollbar
The fenced code card's scroller (CodeCardBody / ExpandableBlock) carries
`.scrollbar-overlay`, which hands the card's right edge back to the
platform's ~15-17px scrollbar lane. The hover copy control sat at a 6px
inset with a 10px icon, inside that lane and on top of the bar itself.
Inset it 16px and use the 12px icon the other corner copy controls use
(log-tail.tsx).

Salvaged from #110548 without its className change-detector test; the fix
is pure CSS (Tailwind inset/icon size) and is verified by a static trace
against the .scrollbar-overlay scroller.
2026-09-15 05:15:42 -07:00
KoNit-K fccdcdaea8 fix(desktop): expose Codex compression auto-raise 2026-09-15 05:14:59 -07:00
KoNit-K ec5c975902 fix(desktop): refetch vault sources after closed-to-open remount
Per-query staleTime: 0 so a fresh installed:false cache still refetches when the Passwords page remounts disabled and the gateway opens later.
2026-09-15 05:14:15 -07:00
KoNit-K c89a4404fd fix(desktop): refresh vault source detection on mount 2026-09-15 05:14:15 -07:00
DavidMetcalfe 6201a8236f fix(desktop): drop unsaved credential edits when the settings target changes
The shared Settings "Applies to" target re-fetches `vars`, but the in-flight
edit and revealed maps are keyed by var name alone and were not reset with it.
A value typed while targeting one profile therefore survived a switch to
another, where the still-live Save button wrote it into the profile then being
targeted — the credential landed in the wrong profile.
2026-09-15 05:13:32 -07:00
teknium1 7c47e5517b test(desktop): trim the live-draft helper coverage to two invariants
Keep the case the fix exists for (editor holds text the mirror has not
seen yet) and the pre-mount fallback; the whitespace, stale-non-empty and
cleared-editor cases all reduce to "the helper returns composerPlainText
of the editor", which the first test already pins.
2026-09-15 05:12:33 -07:00
DavidMetcalfe 7679556d90 test(desktop): tighten the live-draft race coverage and dedupe the sibling reads
Tighten the empty-editor case against a stale non-empty mirror, add JSDOM body cleanup for editorWith, and route the bare-Enter and Cmd/Ctrl+Enter live reads through liveComposerDraft.
2026-09-15 05:12:33 -07:00
DavidMetcalfe bc62b270ed fix(desktop): read the live composer draft for the recall guard
draftRef is a once-per-frame mirror, so an ArrowUp in the same frame as a keystroke or paste saw the pre-keystroke text and let the sent-message recall overwrite what the user just wrote.
2026-09-15 05:12:33 -07:00