Commit Graph

7639 Commits

Author SHA1 Message Date
teknium1
fa190d2187 docs(desktop): note the local-secondary exception on the owner stamp helper 2026-10-04 00:04:51 -07:00
teknium1
cd6d6086e6 fix(desktop): re-check primary ownership after the reconnect ticket mint
Both primary recovery paths fenced the route lookup but not the WebSocket
ticket mint that follows it. When a connection apply (boot hook) or a
foreground activation (request recovery) landed while the ticket was in
flight and the newer socket had already dropped, the stale attempt dialed the
old primary route. connect() no-ops on an open socket, so the gap only shows
once the applied socket is closed, but then it re-attached the window to the
gateway the user had just left.

The boot reconnect now compares the route revision it recorded against the
current one again before dialing; request recovery re-checks ownsForeground().
2026-10-04 00:04:51 -07:00
teknium1
ffe3d823fa fix(desktop): keep a stale primary recovery from overwriting the foreground descriptor
useGatewayRequest's primary recovery resolves the connection profile-less, so
main answers through the window's CURRENT route. When the user activated
another source while that lookup was in flight, the late answer was published
with setConnection() over the newer foreground descriptor (and a failed
lookup cleared it), so REST and filesystem helpers routed to the old backend
while the newer source was in front.

Recovery now snapshots the gateway activation epoch and publishes, clears, or
dials only while the primary still owns the foreground. The boot hook's
own-route reconnect loop keeps restoring the primary socket, and the replay
guard already refuses to resend the original request.

Reported-by: dokterdok
2026-10-04 00:04:51 -07:00
unsupportedpastels
97565aeac5 fix(desktop): drop a primary reconnect lookup a connection apply replaced
Address review on #132237: an in-flight primary reconnect whose route lookup
resolved after a connection apply recorded its old descriptor over the newly
applied primary. The next socket drop then dialed the old gateway by its
explicit route, where the profile-less lookup on base followed the applied
window route.

Every primary route record now bumps a revision; a reconnect snapshots it
before the lookup and drops the result if a boot or apply recorded a newer
route meanwhile, so it no longer records, publishes or dials the old route.
The source-qualified primary lookup is unchanged.
2026-10-04 00:04:51 -07:00
unsupportedpastels
042e925ea2 fix(desktop): keep a newer new-chat draft from a late switch re-home
Address the ordinary-switch draft-intent race from review on #132237: a
draft started on another gateway while a source switch was still doing its
bookkeeping (rememberConnection) was cleared by the switch's late re-home,
so the next session.create went to the switched-to source instead.

Every new-chat owner intent goes through captureNewChatSource, which now
bumps an intent revision. selectConnection snapshots it when the switch
starts and only re-homes the draft if no newer intent arrived, so a newer
intent wins even when it repeats the same route values. Completed switches
with no newer intent re-home as before.
2026-10-04 00:04:51 -07:00
unsupportedpastels
2b5abdfafe fix(desktop): keep review-flagged owner rules in the gateway-switch fixes
Address review on #132237:

- Project-tree rows go through the canonical stampRowsWithOwningConnection:
  a row that already names an owner is never re-owned, and local stays bare
  on the primary path so the legacy profile door (#94166) is unchanged. local
  is stamped only when it is an active registry secondary.
- Resume remembers an ambient owner hint only for a row that actually
  resolved; an unresolved id no longer gets pinned to the foreground source.
- The silent boot restore keeps an explicit new-chat route pinned while it
  was in flight; the route clear lives in one helper.
- Drop the untested pre-recovery route check in useGatewayRequest (the
  post-recovery check covers it) and note why the two primary reconnect
  paths resolve differently.
- Tests: move the primary-reconnect cases into the reconnect describe with an
  inline resolver fake, assert the explicit primary profile exactly, and drop
  an overlapping parametrized case.
2026-10-04 00:04:51 -07:00
unsupportedpastels
aae0a97a56 fix(desktop): reconnect the primary socket to its own route, not the foreground
Primary reconnect called profile-less getConnection(), which main resolves from
the window's foreground route. A drop while a secondary was foregrounded
re-dialed the primary socket to that secondary's backend. Reconnect by the
primary's recorded registry route (or legacy profile); boot and connection
apply keep following the foreground.
2026-10-04 00:04:51 -07:00
unsupportedpastels
6cd5b5ca18 fix(desktop): never replay a failed RPC on a different gateway
useGatewayRequest recovered a transport failure through the current active
route and retried the original payload there. A gateway switch between
dispatch and the delayed rejection replayed the request on the other source.
Retry only while the dispatch route is still active; otherwise surface the
original error.
2026-10-04 00:04:51 -07:00
unsupportedpastels
ad4fed6b8d fix(desktop): keep an active local registry secondary as a resumed session's owner
Resume kept the ambient connection only for remote mode, so an untagged row
while This device was an active registry secondary reduced to a bare profile
and routed to the window primary. Keep the legacy primary-local profile door
(#94166) unchanged, and remember the resolved owner for later prompts.
2026-10-04 00:04:51 -07:00
unsupportedpastels
f6c15b0971 fix(desktop): tag project-tree session rows with their registry source
projects.tree rows were published without connection_id, unlike the
Electron-tagged REST lists. Opening a tree-only session fell back to the bare
profile, which resolves to the window primary, so resume/prompt ran on Home
while This device was active. Tag rows with the same registry connection REST
requests are pinned to, and drop late responses after a source change.
2026-10-04 00:04:51 -07:00
unsupportedpastels
be3c9206a9 fix(desktop): clear the pinned new-chat route when a gateway switch commits
A draft started from a gateway-group + pins $newChatRoute. selectConnection
captured the new source but left that route, and resolveNewChatOwnerRoute lets
an explicit route win when profile names match, so the next session.create
landed on the previous gateway while the UI showed the new one. Superseded or
failed switches leave a newer draft untouched.
2026-10-04 00:04:51 -07:00
teknium1
344a23bce5 feat(messaging): edit allowed users one ID per box on Desktop and dashboard
Allowlists (*_ALLOWED_USERS, LINE groups/rooms, SimpleX groups) rendered as a
single empty input with a redacted "«redacted:…»" placeholder, so a saved list
looked like a hidden secret and could only be replaced wholesale.

- Backend: messaging env rows carry is_list, and list rows carry their plain
  value (IDs are not secrets; password fields still only leave redacted).
- Desktop + dashboard: one visible text box per entry, x to remove, "+ Add
  another" (Enter also adds); a pasted comma list splits into boxes. Saved as
  the same comma-separated value the gateway already parses; removing every
  entry and saving clears the key.
- Desktop help copy for the allowlist fields says "one per box" in all locales.
2026-10-03 23:40:33 -07:00
emozilla
5059f20e1b Merge remote-tracking branch 'origin/main' into ethie/llamacpp-0.5.0-linux-cuda-main
# Conflicts:
#	hermes_cli/local_runtime/bootstrap.py
#	hermes_cli/main.py
2026-10-03 22:53:47 -04:00
emozilla
361963828d feat(pm): llama.cpp CUDA on Linux arm64; bundle libgomp for Linux engines
b11146 ships ubuntu-cuda-13.4-arm64 (+ cudart), so llamacpp-cuda gains
linux-arm64, auto resolves NVIDIA arm64 Linux to CUDA, and the desktop's
localModels flag ships on Linux arm64 too.

Every upstream Linux llama.cpp build (CPU included) links the system
libgomp, which minimal hosts (WSL, containers) lack, and a normal install
never touches the system package manager. Each Linux engine now pins
Ubuntu 24.04's libgomp1 .deb (glibc 2.38, the floor upstream already sets)
and stages libgomp.so.1 beside llama-server, where the $ORIGIN RUNPATH
finds it; the artifact mirror covers the pool URL after 24.04's EOL.
DebPackage's ar+tar unpack becomes pm.package.unpack_deb and reads zstd
data.tar (Ubuntu's default) with the stdlib codec PM's 3.14 runtime has.

Pre-PM engines on Linux carry only llama.cpp's own archives, so adoption
maps their manifests to those and records them as outdated (usable now,
update offered) instead of failing to match.

Verified on an RTX Spark N1X (WSL, Ubuntu 26.04, no system libgomp):
ensure_engine() for cuda, cpu and vulkan through PM's real download/stage/
verify path; ldd resolves libgomp from the entry; llama-server serves
completions (CUDA0 at 445 tok/s). linux-x64 cpu/vulkan staged through the
same path with no NEEDED library missing beyond glibc/libstdc++/OpenSSL 3.
2026-10-03 22:32:13 -04:00
Brooklyn Nicholson
158fd638da test(desktop): every rail layout keeps a door to the active default; rail changes run the core E2E
A layout matrix (single/fleet × strip/condensed × local/remote active,
starting from a named profile or the all view) asserts the active
gateway's default is one click away in each. The four fleet+condensed
cases fail on main.

The core E2E lane only starts on PRs touching its guarded paths, so a
profile-rail edit would have met fleet-condensed-default.spec.ts only on
main. Add the rail sources to the lane.
2026-10-03 17:17:25 -05:00
Brooklyn Nicholson
f341e5697c test(desktop): core e2e for the active gateway's default in the condensed fleet menu 2026-10-03 17:17:25 -05:00
Brooklyn Nicholson
ad798ee921 fix(desktop): list the active gateway's default in the condensed fleet menu
With a second gateway registered the sidebar rail drops its default/all
home pill, and past the condensed threshold the profile menu is the only
pointer route to a default. The menu listed every at-rest gateway's
default but never the active one's, so on the usual setup the local
default vanished while the remote one stayed listed. The trigger also
fell back to "Profiles" while on default.

Prepend the active default in fleet mode, give its row and the trigger
the home glyph, and route the row like the home pill does.

Hovering that row would have warmed the wrong backend: rail and dropdown
rows prewarmed by bare name, which resolves on the legacy door, so a
remote source's `default` warmed This device's. Route current-source
picks' prewarm through the same connection the click dials.

Fixes #106017
Fixes #131632

Co-authored-by: NingCiQiu <138146243+NingCiQiu@users.noreply.github.com>
Co-authored-by: Ugur Aydogdu <ugur.aydogdu@epigra.com>
Co-authored-by: Suhasan Kanagala <57094456+suhas-svg@users.noreply.github.com>
2026-10-03 17:17:25 -05:00
hermes-seaeye[bot]
eb04406323 fmt(js): npm run fix on merge (#132458)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-10-03 20:52:38 +00:00
kshitijk4poor
595d29fd81 test(desktop): Priority new chats send fast alone, never service_tier
Pins the ultrafast-only service_tier send in desktopSessionCreateParams: a Priority pick
must reach a pre-Ultrafast backend without the field it rejects.
2026-10-04 02:12:57 +05:30
kshitijk4poor
cf8f9a803f refactor(desktop): one config speed-tier parse for the composer and Settings
Settings had its own isFastTier alias list next to the composer's composerServiceTier; a
new Fast spelling added to one would make the two surfaces disagree about the same
config value. composerServiceTier moves to lib/model-status-label and Settings derives
its Standard/Fast/Ultrafast value from it.
2026-10-04 02:12:57 +05:30
kshitijk4poor
95cb32ab0e fix(desktop): seed the composer with an exact speed tier from config
Review finding (FASTTIER r1, Medium regression): the composer copied
`agent.service_tier` verbatim, so a raw OpenAI tier word in config.yaml
(`flex`, `scale`, `auto`, ...) reached session.create, which only accepts
normal/priority/ultrafast and answers 4002 — every Desktop new chat failed.

Map the config value onto the three tiers the composer speaks: `ultrafast`
stays Ultrafast, the existing Fast spellings become Priority, anything else is
Standard, unset stays unset. The Ultrafast-only send in the previous commit
already keeps such a value off the wire; this keeps the composer state, the
speed menu and the pill honest about it too.
2026-10-04 02:12:57 +05:30
kshitijk4poor
bb2a08495f fix(desktop): keep new chats working against a pre-Ultrafast backend
Review finding (FASTTIER r1, High regression): Desktop sent `service_tier` on
every session.create whenever a tier was selected, and a backend predating the
field rejects the whole create at contract admission (4000 "invalid params for
session.create: service_tier: Extra inputs are not permitted ...") — every new
chat failed on a Desktop newer than its backend.

Send the tier only for Ultrafast (`fast` already pins Priority / normal on every
backend) and, like the existing cwd_explicit fallback, drop a rejected
`service_tier` and resend once with `fast` kept, so an old backend serves
Priority instead of failing. The matcher now covers both droppable fields, one
resend per field, on the same stable prefix + field name.
2026-10-04 02:12:57 +05:30
kshitijk4poor
474594cfca test(desktop): one invariant per surface for the exact speed tier
Trims #130990's tests to the headline behaviour: Ultrafast must never read
as plain Fast. Python: one relationship test in test_openai_ultrafast.py
(inventory reports ultrafast only where the request path can apply it;
session.create keeps the exact tier, explicit tier beats legacy fast,
unknown words are rejected). Desktop: the composer pill label assertion
joins the existing Fast-label test, and the preset round-trip test stays.
The rollback/race and component tests duplicated coverage of the same
mapping.
2026-10-04 02:12:57 +05:30
FroRaut
d8e72728d0 fix(desktop): release settled edit stamps and complete speed labels
(cherry picked from commit 09155f388a4ec516eebd5b1cb587000699b17527)
2026-10-04 02:12:57 +05:30
FroRaut
9cf6b73df0 fix(desktop): reconcile speed tiers with current menu and evict settled rollback state
Carries the substantive hunks from #130990's merge commit 5afdf9d9db (a
merge of origin/main into the PR branch that also changed PR code):
the confirmed-value snapshot is taken per write chain and released together
with pendingWrites in a finally, so confirmedValues no longer grows per
(connection, profile, session, dimension) for the life of the renderer
(AI review on #130990). Also fills the ar/zh-hant speed labels.

Applied on BASE rather than cherry-picking the merge, which would drag in
unrelated main changes.

(hunks from commit 5afdf9d9db0233d48b0422b4ef77128cd89654a7)
2026-10-04 02:12:57 +05:30
FroRaut
3778021382 feat(desktop): exact subscription speed tiers (Standard / Fast / Ultrafast)
Ultrafast collapsed into ordinary Fast across the stack: the catalog only
reported `fast`, presets and drafts stored a boolean, session.create took
only `fast`, and Settings had a single Fast switch, so an Ultrafast default
read as Fast and a new chat could lose it.

- Inventory reports `ultrafast` per model from the same route-aware
  resolver that builds the request; Daybreak aliases offer no accelerated
  speed (their priority request is ignored).
- session.create accepts an exact `service_tier` that wins over the legacy
  `fast` flag; unknown tiers are rejected.
- Desktop keeps the exact tier through presets, draft creation, session
  options and labels; an unsupported remembered tier falls back to
  Standard, and failed edits reconcile to the confirmed state.
- Settings → Model → Main model offers one Speed choice (Standard / Fast /
  Ultrafast) on models that support Ultrafast; others keep the Fast switch.

Split out of #129251 (which keeps Daybreak).

(cherry picked from commit 4e332fb850c989ef9f34bd70aaca174042beecc6)
2026-10-04 02:12:57 +05:30
alt-glitch
f1c79255fe feat(plugins): requires.gpu gates a plugin server on the host's GPU vendor
A portable plugin can now declare `requires: {gpu: nvidia}` for an MCP
server. On a host without an NVIDIA GPU, Hermes then refuses the
install, hides the server's tools, and the catalog card says "needs an
NVIDIA GPU". The check is the existing
`hermes_platform.host.facts.gpu_class()` read.

The requirement is independent of `app:`. The RTX Remix Toolkit is the
first consumer: it is a package the user unpacks anywhere, so there is
no install location to check, but it needs an NVIDIA GPU.

An unreadable GPU (`unknown`) passes, so a failed registry or sysfs
read does not lock out a machine that has the GPU. Only `nvidia` is
accepted: gpu_class() reports the highest-priority vendor, which
answers the question exactly for NVIDIA and not for AMD or Intel.

A registered server on such a host reports the new `unsupported_gpu`
state, carried through the gateway contract and the Plugins tab, with
a sentence that names the GPU instead of "Install <app>".
2026-10-03 10:26:12 -07:00
kshitijk4poor
370b3a91c8 fix(tui_gateway): type cron last_delivery_unverified as a list of targets
The scheduler persists last_delivery_unverified as a list of unconfirmed
platform:chat_id[:thread_id] targets and cron.manage passes it through, but
CronJobRow typed it bool | None, so contract validation rejected any job
with an unconfirmed delivery. Regenerate the TS/OpenRPC contract.
2026-10-03 22:23:27 +05:30
teknium1
be3fd671d7 fix(desktop/bots): name a bot by what its own backend reports on every surface
Follow-up to #131853 and #131867 from an independent review, each finding
reproduced in native Electron against two real serve backends.

- Cold composer path: the cache reconciliation (mergeServerMeta) ran only in
  the Bots pane's publish effect, so a launch that never opened the pane kept
  a stale cached title ahead of the backend's own in @handles and group
  prompts ("You are @local-stale-name"). It now runs where every snapshot is
  produced (data.ts::fetchRosterSnapshot), with the same fetch-time write
  fence, so the pane query and primeRoster reconcile alike.
- Pane label vs @handle: displayName ranked display_name above the backend's
  Bot Mode title on a thin remote row (and a titled remote `default` with no
  display_name fell to the connection label), so the row read "Remote Human
  Name" while mentions and prompts said @remote-bot-title. It now takes the
  same backend-title rung botFriendlyNames uses.
- Relay trace: a connection removed and re-added under a new id was logged
  `MISROUTED (same install as <removed id>)`; the remembered owner is now
  pruned against the current connection set.

The roster-pane-lifecycle test probed "republished" via mergeServerMeta's call
count; reconciliation now happens per fetch by design, so that invariant is
asserted via trackInboundActivity alone.
2026-10-03 09:49:07 -07:00
hermes-seaeye[bot]
f4d3e626a7 fmt(js): npm run fix on merge (#132174)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-10-03 12:30:27 +00:00
kshitijk4poor
44533f11e3 fix(desktop): call isFocused on the window instead of destructuring it
shouldFocusToTakeKeyboard destructured `{ isFocused }` from its argument
and called it unbound. On a real Electron BrowserWindow the native method
needs its receiver, so every call threw `TypeError: Object has been
destroyed` (proven in Electron 40). activateWindow (second-instance, tray
restore, dock activate, live deep-link delivery) threw before focus(), so
deep links were dropped; main.ts focusWindow hit the same throw. The bug
has been latent on main since #83998 introduced the policy; plain-object
test mocks hid it.

Call `window.isFocused()` on the receiver. The kept activateWindow
regression test now drives a class instance whose methods read `this`,
so destructuring fails it (red before, green after).
2026-10-03 17:49:37 +05:30
kshitijk4poor
67d502a1d4 fix(desktop): run last-surface quit fallback when the last Browser pop-out closes
Gate r2 finding 2c: the #130810 fallback counted popped-out Browser windows
as surfaces that keep the app alive, but only ran from a chat window's
'closed' handler. Closing the last Browser pop-out after the primary was
gone therefore never re-checked, stranding a windowless single-instance
lock holder on Windows/Linux.

Extract quitIfNoSurfaceLeft() and call it from both registerChatWindow's
and spawnBrowserWindow's 'closed' handlers. The browser call is deferred
with setImmediate because the browserWindows registry attaches its own
self-delete 'closed' listener after the factory returns, so its size is
only accurate on the next tick.
2026-10-03 17:49:37 +05:30
kshitijk4poor
e473b72df2 refactor(desktop): route deep-link activation through activateWindow
Gate findings (reuse + quality, Low, convergent):
- handleDeepLink's live-window delivery branch hand-rolled
  restore -> show-if-hidden -> focus-if-unfocused, step-for-step the new
  activateWindow; call it instead. The close-preview branch is left alone:
  its unconditional focus() has different foreground semantics (#83998).
- activateWindow now uses shouldFocusToTakeKeyboard from
  window-focus-policy, so the #83998 focus rule has one owner.
- Import activateWindow directly; the activateRestoredWindow alias had no
  name clash.
- shouldQuitOnLastChatClosed is one boolean expression.
- The #130810 rationale lives only in the shouldQuitOnLastChatClosed
  docstring; the quitInProgress declaration and the closed handler keep
  one-line pointers.
- The predicate test is table-driven, dropping the two duplicate rows
  (linux remaining=2, linux quitInProgress).
2026-10-03 17:49:37 +05:30
kshitijk4poor
d0c1bd206d fix(desktop): keep the app alive while a Browser pop-out is open
Gate finding (2c, Low REGRESSION): the #130810 last-chat fallback counted
only chatWindows, but popped-out Browser windows (spawnBrowserWindow) are
user-visible and never registered as chat windows. Closing the primary on
Windows/Linux with a pop-out open therefore app.quit()ed and killed it,
whereas before the fallback window-all-closed did not fire and the app kept
running.

Count the existing browserWindows session registry (it self-deletes on
'closed') alongside chatWindows. This is the smallest correct option:
registerChatWindow would also attach the active-work close guard to a
pop-out, and a BrowserWindow.getAllWindows() visibility filter would need a
helper-window classifier that does not exist.
2026-10-03 17:49:37 +05:30
kshitijk4poor
bd54e94d97 fix(desktop): clear quitInProgress when a re-entered quit is held
Gate finding (2c, Low): a proceeding before-quit sets quitInProgress and
then preventDefaults (managed-update wait / teardown coordinator). If the
re-entered app.quit() is held by the active-work guard and the user picks
"Keep Running", quitInProgress stayed true for the rest of the process, so
every later last-chat close skipped the #130810 fallback and could strand a
windowless single-instance lock holder again. Reset it next to appQuitting.
2026-10-03 17:49:37 +05:30
kshitijk4poor
7ba530a5a6 test(desktop): trim #130810 lifecycle tests to the two invariants
Keep the shouldQuitOnLastChatClosed decision matrix and the activateWindow
restore -> show -> focus test. The two scenario tests rebuilt main.ts state
in local variables and then asserted it (tautological), and the remaining
activateWindow cases only pin no-op paths.
2026-10-03 17:49:37 +05:30
kshitijk4poor
5e8f97c827 refactor(desktop): reuse ensureMainWindow for second-instance relaunch
The salvaged fix added decideSecondInstanceAction, which re-implemented the
create / defer / focus branches ensureMainWindow already owns, plus a
shouldQuitOnAllClosed helper that only restated the existing
window-all-closed condition. Route second-instance through
ensureMainWindow(focusWindow: activateRestoredWindow) like the tray path,
keep one rememberLog line so a relaunch stays visible in desktop.log, and
inline the window-all-closed check back.

activateWindow wrapped restore/isVisible/show/focus in try/catch and
typeof-probed each method; the window is already checked with
isDestroyed() and Electron's calls do not throw here, so type the window
with required methods and call them directly. Also fixes a comment typo.
2026-10-03 17:49:37 +05:30
JerryLiu369
41ffe047f4 fix(desktop): base last-chat-window quit fallback on quitInProgress state (#130810)
(cherry picked from commit ec53f082ba6f7a3cc7147c248c469a7185e50bdd)
2026-10-03 17:49:37 +05:30
JerryLiu369
38413a36a8 fix(desktop): exit on window close and restore window on second instance (#130810)
(cherry picked from commit 14711fc196ffcf60f6683f9e87da06eec2d68ad4)
2026-10-03 17:49:37 +05:30
hermes-seaeye[bot]
1cb26bf248 fmt(js): npm run fix on merge (#132157)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-10-03 11:53:14 +00:00
kshitijk4poor
ca952a57a3 fix(desktop): forget a dead renderer's active work, not only a destroyed one
activeWorkByWebContents dropped an entry only on 'destroyed'. A renderer
that dies keeps its webContents, and some render-process-gone exits never
reload, so its last in-flight count stayed in the merged picture until
quit: stream throttling stayed off and, now, the 'while-working' blocker
stayed held. Forget the entry on render-process-gone too; a reloaded
renderer re-reports its live turns on mount.
2026-10-03 17:11:39 +05:30
kshitijk4poor
f1b902e66a refactor(desktop): one keep-awake mode list for main and the renderer
The mode union and its validator were written three times: power-save.ts,
the renderer store and global.d.ts. The store and the preload typing now
import KeepAwakeMode and parseKeepAwakeMode from electron/power-save (no
electron import, as pool-limits-setting already does), so a new mode
cannot drift between main and the picker.
2026-10-03 17:11:39 +05:30
kshitijk4poor
56d075bf8f i18n(desktop): translate the keep-awake modes in de, fr and es
The three-way keep-awake setting shipped labels in en, ja, ru, zh and
zh-hant only, so German, French and Spanish showed English "Off / While
working / Always" beside a description that still promised the old
always-on behaviour. Translate the labels and the new description, add
"while working" / "turn" to the settings-search keywords, and register the
three new keys in locales/_keys.desktop.json for language-pack validation.
2026-10-03 17:11:39 +05:30
John Paul Soliva
da5cc1f258 i18n(desktop): keep-awake "while working" reads as a duration, not "runtime", in zh and zh-hant
运行时 / 執行時 parse as "runtime" at first glance; 运行期间 / 執行期間 are
the unambiguous "while a run is in progress". Review nit from #102209.

(cherry picked from commit 176c77ea693af9c2d398c63fac2c7f1857afed5d)
2026-10-03 17:11:39 +05:30
John Paul Soliva
3acd949217 feat(desktop): keep-awake follows the turn — a "while working" mode alongside off/always
"Keep computer awake" was a boolean: off, or a machine-global power-save
blocker held for as long as Hermes Desktop is open. That is the wrong shape
for its own stated purpose. An unattended overnight run dies when the
machine sleeps unless the user predicted, before starting, that they would
need the toggle — and a user who did flip it leaves a laptop pinned awake
all week, including the idle hours after the run finished (#101991).

Main already knows, live, whether any turn is in flight:
`mergeActiveWork(activeWorkByWebContents.values()).count > 0` is the exact
expression that drives stream throttling. This change wires the blocker to
that same signal behind a three-way mode:

  off            never hold the blocker (unchanged default)
  while-working  hold it only while a turn is in flight; release at idle
  always         hold it whenever the app is open (what `on` used to mean)

Both persisted copies migrate in place. The renderer's localStorage boolean
(`hermes.desktop.keepAwake.v1`) and main's `keep-awake.json` `{ on }` are
read once and mapped `true -> always`, `false -> off`, so an existing user
sees exactly the behaviour they had. The IPC channel keeps its name and
accepts either the mode or the old boolean. An unrecognised saved value is
refused rather than trusted and falls back to `off`.

No new listener, timer, or IPC channel: `updateStreamThrottleFromActiveWork`
computes the merged picture once and hands it to both consumers, and the
cold-launch apply reads the mode instead of the flag. No trailing delay
between back-to-back turns — a sub-second release cannot let a machine
sleep, and `powerSaveBlocker` start/stop are cheap.

The policy is three pure functions in power-save.ts (parse, read, wanted)
so it is testable without the main process; main only wires them.

(cherry picked from commit c6dab931a2891ea7026eea2c07c271bcf48446b9)
2026-10-03 17:11:39 +05:30
kshitijk4poor
57ba305a38 test(desktop): describe the interim-redelivery specs as they now stand
The header said the duplicate paints "on current main" (stale once the
fix lands) and pointed at boundary "specs below" when only the
message.start spec remains. Also apply prettier to the delta helper,
which --check flagged.
2026-10-03 16:34:20 +05:30
kshitijk4poor
a97f51c16c refactor(desktop): drop the dead streamIndex guard in identical-interim collapse
collapseDuplicateFinalOntoIdenticalInterim has one caller and it sits
inside `if (streamIndex >= 0)`, so the `streamIndex < 0` check could
never fire. Note the precondition in the comment instead.
2026-10-03 16:34:20 +05:30
kshitijk4poor
bc3de98ed7 refactor(desktop): index every part row in backfill and say why anchors differ
The query side reads identity through transcriptRowIds, which takes
sourceRowId from every part, while the index side still filtered on
`part.type === 'text'`. Only text parts are ever stamped, so the filter
did nothing but make the two sides use different rules; drop it.

Replace the comment that restated the rowId-vs-last-part ternary with
the reason for it: a fold with a rowId is addressed by its first source
row (hydration's key); without one, its last part row is the final
reply the settled live bubble holds, so pending rows land before it.
No behaviour change.
2026-10-03 16:34:20 +05:30
kshitijk4poor
9d1d52f24b refactor(desktop): gate identical-interim reuse on failure in one place
identicalInterimSiblingIndex now returns -1 for a failed completion, so
the stream path and the fallback settle path share one failure gate
(previously the fallback path settled a failed completion onto the
sealed interim). collapseDuplicateFinalOntoIdenticalInterim drops its
own hasFailure option and the dead guards a non-negative interim index
already implies, and shares the merge-and-drop tail with the tool-interim
collapse via a private helper. The no-fallback branch's
`identicalInterimIndex >= 0` arm was unreachable (any such index is a
visible assistant past lastUserIndex, which makes fallbackIndex >= 0) and
is removed.

partRowAnchor reuses transcriptRowIds instead of re-deriving the row
list; the anchor choice (message row first, else the last part row) is
unchanged.
2026-10-03 16:34:20 +05:30
kshitijk4poor
f9829a4887 fix(desktop): stop anchoring a backfilled fold on its first part row alone
The extra `existingPartRowIndices.get(message.rowId)` clause matched a
fetched fold as soon as its FIRST row was held as a part of another
bubble, short-circuiting partRowAnchor's every-row-held rule. Any unheld
row in that fold (e.g. the final reply) was then dropped and never
rendered. For single-row messages partRowAnchor already covers the
part-row match, so the clause only added the bug.

Add an it.each row for that shape and make the table assert explicit
merged ids instead of branching on a `kept` flag.
2026-10-03 16:34:20 +05:30