Sibling of #65254 (main-slot endpoint preservation): the auxiliary scope of
POST /api/model/set dropped the request's base_url/api_key on the floor, so
an aux slot pinned to a custom/local endpoint silently depended on
model.base_url — and broke the moment the main slot switched away and
cleared it. The aux resolver already reads auxiliary.<task>.base_url/api_key
(_resolve_task_provider_model); this persists them.
Desktop side: setAuxiliaryToMain / applyAuxiliaryDraft now carry the
user-defined provider's api_url as base_url, mirroring applyMainModel.
Two follow-ups from the voice PR (#70509).
1. macOS ARM64 onnx migration. Existing users who pinned
openwakeword.inference_framework=onnx before the tflite fix landed kept a
wake word that arms but never fires (ONNX's embedding model is broken on
Apple Silicon, upstream #336). New resolve_inference_framework() honors an
explicit framework everywhere ONNX actually works, but coerces the one
provably-dead combination (explicit onnx + macOS ARM64) to tflite with a
one-time warning. No config mutation; empty still falls back to the platform
default. Both read sites (engine init + requirements check) route through
the shared resolver.
2. Voice turn-timeout leak. Each listen cycle reassigned turnTimeoutRef
without clearing the prior 60s timer, so a stale timer from an earlier
cycle could fire handleTurn() mid-way through a later listen — after enough
idle re-listens this wedged the loop into a non-re-arming state (the
'voice chat deactivates after ~a minute' report). Clear before re-arm.
Tests: 64 wake tests (added onnx-coercion / intel-kept / tflite-kept /
empty-default cases; updated the stale 'explicit onnx kept on ARM64' test
that encoded the old broken behavior), 39 desktop voice/wake vitest, tsc +
eslint clean.
The sidebar's messaging section renders one PlatformAvatar (labelIcon) and
StatusDot per platform group. The sidebar re-renders on every streaming tick
($sessions/$workingSessionIds/$messagingSessions churn), and both leaves were
unmemoized — so every platform's avatar + dot re-rendered on each delta even
though their props (platformId/tone/className) never changed.
- PlatformAvatar: memo(forwardRef(...)) — pure fn of platformId/name/class/style
- StatusDot: memo() — pure fn of tone/class
Measured (Messaging open, settled, 2 sessions streaming, 3s window):
before: 96 wasted (PlatformAvatar 32, StatusDot 32, + brand icons)
after: 0 wasted
Both are shared primitives; the memo also helps every other consumer
(session rows, gateway menu, session tiles) that renders them under a hot
parent.
- command-center/command-palette/skills: import ordering + drop unused
HermesGateway type
- settings: wrap openSubView/openProviderView/openKeysView in useCallback so
the navGroups memo deps are honest and stable
- command-center: add setSection to navGroups memo deps
The sidebar stays mounted beneath every overlay/page, and it subscribes to
$sessions + $workingSessionIds — both tick on every streaming token. An
unmemoized SidebarSessionRow re-rendered the whole list (Codicon, labels,
status dots) on each delta, and that churn bled into every overlay opened on
top: Cron, Profiles, Agents, Starmap, Webhooks, Command Center, Settings.
- SidebarSessionRow: memo() with a custom comparator that ignores the pure
id-forwarding callbacks (fresh closures by design) and compares only the
data that changes what the row paints. Rows bail out while siblings stream.
- SessionStatusDot: the 5 $...SessionIds arrays now read via useStoreSelector
returning this session's boolean, so a dot repaints only when ITS OWN
membership flips, not on every array tick.
- Artifacts: stable cellCtx (useMemo) + memoized Primary/Location/Session
cells so a link-title fetch on one row stops re-rendering the whole table.
Measured before/after (2s idle, sessions streaming), sidebar-fed overlays:
Cron 407->~30 wasted, Profiles 732->~80, Agents 132->9, Starmap 188->56,
Webhooks 154->22.
Assert isMessagingSource('photon') is true and that Photon/iMessage search aliases resolve, so a silent removal of the photon entry from MESSAGING_SESSION_SOURCE_IDS fails CI instead of regressing the sidebar section added in #47395.
Register Photon as a messaging platform in the Desktop sidebar:
- Add Photon brand icon (three-bar mark) to PlatformAvatar catalog
- Add photon to SOURCE_LABELS, SOURCE_ALIASES, and MESSAGING_SESSION_SOURCE_IDS
Closes#46761
The lane is `inset-x-0`, which resolves against the composer root's
padding box, while the surface and the underside strip sit in its
content box — so the pill strip hung 5px further left than both.
Measured: pills 321.83, chip 326.83, surface 326.83.
Keep Settings-configurable preview/image loads on readFileDataUrl, and
route remote non-image attach through a 256 MiB IPC so uploads are not
stuck on the 16 MiB default after #73221.
Co-authored-by: Börje <borje@dqsverige.se>
The pill strips live inside the composer root, so their box sits within
the drag region and their gaps read as grab area. Exclude anything
marked composer-no-drag, matching how buttons and menu items are already
excluded.
Pills pin to the top of the composer's overlay lane, outside the status
card and outside its scroller, so nothing stacks above them and a long
todo list can't scroll them away. The underside slot sits below the
surface. Both share one grid constant and one parent, so their left
edges match by construction rather than by matching numbers in two
files.
The strips take pointer events while their empty space falls through to
the pop-out drag region, which keeps the composer draggable by the band
its badges live in.
Two new seams on the composer, both through the existing contribution
registry: a data area whose providers resolve badge descriptors per
session, and a render area for a chrome-free strip below the surface.
Core registers nothing in either — they stay empty until something
contributes.
Providers resolve from live session context rather than registering
static entries, so a contribution can be conditional without the
reactive when() the registry deliberately doesn't offer.
xterm paints to a canvas, so its selection is not a DOM selection: the Edit
menu's Copy and the right-click Copy both call webContents.copy(), find
nothing, and copy nothing. On macOS the menu also swallows the Cmd+C
accelerator before the renderer sees it. That left Ctrl+C as the only key that
did anything, and xterm correctly forwards it to the PTY as SIGINT.
Mirror the selection into xterm's hidden helper textarea (the mechanism xterm
already uses for Linux middle-click paste) so the OS sees a real selection and
every platform copy path works, and add explicit chords on top: Cmd+C/Cmd+V on
macOS, Ctrl+Shift+C/V elsewhere, matching VS Code. Plain Ctrl+C copies only
when text is selected -- the behavior Windows Terminal and Tabby ship -- and
stays SIGINT otherwise, so interrupting a process never breaks.
Reads go through a new hermes:readClipboard IPC handler for the same reason
writes already do: the renderer's clipboard API throws whenever the document
isn't focused.
The `/` popover listed skills alphabetically, so on a 200-skill install
the ones invoked daily sat below a wall of skills that shipped with
Hermes and were never opened — /research-paper-writing (never used)
outranked /research (60 invocations).
Sort the Skills section most-used first, A-Z within a tie, and on a bare
`/` drop bundled skills with no recorded activity. Typing a query keeps
every match: a search that hides a result is broken, so a typed `/re`
only reorders.
Recents were hard-sorted by started_at and only refreshed last_active after
turn complete — so reviving an old thread stayed buried until the assistant
finished. Stamp last_active on prompt seed, keep it monotonic across mid-turn
refreshes, and sort agent recents by activity.
Command Center, Settings, Skills, and Command Palette all subscribed to
hot stores unconditionally — ticks on every streaming token (title
updates), on every connect/disconnect. Components that only need
that data on one tab were re-rendering on every stream delta while sitting
on an unrelated tab.
Fixes:
- CommandCenterView: gate / to the Sessions tab
via useStoreSelector returning a stable empty array on other tabs
- SkillsView: gate to the MCP tab only
- SettingsView: memoize navGroups (was rebuilt inline on every render with
fresh onSelect closures for every nav item)
- OverlayNavItem: memo() so nav items don't re-render when a sibling's
active state changes
- CommandPalette: extract memoized PaletteRow so items don't re-render on
unrelated parent state changes (open/close, theme, etc)
- CommandCenterView: memoize navGroups array (was inline JSX)
Real temp-HERMES_HOME tests for _broadcast_watched_changes: silent seed,
cron/sessions signature moves, the 2s sessions floor's trailing edge, the
pet signature staying 'off' without a renderable pet, meta payload on
pet.changed, and a broken probe never killing the pass. Part of #73618.
- watchDirectory IPC (same registry/channel as the preview file watchers)
replaces the disk-plugin door's 5s readdir poll; older shells without
the capability keep the poll, which self-upgrades to the watch once the
plugins dir exists.
- Status snapshot: 15s → 60s, skips round-trips while hidden, and
refreshes immediately on visibilitychange so re-focus never shows stale
health.
Part of #73618.
One controller (store/live-sync.ts, the workspace-events twin for gateway
state): gateway-event.ts routes pet.changed / cron.changed /
sessions.changed into tick atoms, gated on the active profile like
skin.changed; gateway.ready's change_events flag arms them; a gateway wipe
resets the capability.
Consumers subscribe to ticks and demote their polls to slow backstops
(legacy cadence is kept verbatim against older backends):
- session.active_list 1.5s → sessions.changed + 30s backstop
- cron list 30s → cron.changed + 5min backstop
- messaging lists 10s → trailing sessions.changed refresh + legacy poll
only when events are unavailable
- open messaging transcript 5s → sessions.changed + 30s backstop
- pet.info 3s/15s → pet.changed, NO timer on event-capable backends
(users with no pet used to poll hardest); enabled=false broadcasts
clear the mascot with zero round-trips
- cron runs page/peek 8s → cron.changed + 60s backstop
Idle renderer traffic drops from ~89 req/min to the backstops (~5/min).
Part of #73618.
The app-wide `.scrollbar-dt *` webkit rules force classic always-on
gutters on every descendant scroller. Opt code-card scroll surfaces out
via `.scrollbar-overlay` so Electron keeps platform overlay bars (fade
in on scroll, no reserved track).
When the status stack collapses (last subagent/background/queue item
finishes), React removes the stack div before the layout-effect cleanup
runs. Resolving the surface via closest('[data-chat-surface]') from that
now-detached node falls back to the document root, so the stale measured
height was cleared from the wrong element and stayed on the surface —
inflating --thread-last-message-clearance (the thread's bottom padding)
until the next publish. Same latent hazard in the composer's unmount
cleanup.
Capture the surface root while the node is still attached and clear from
it directly.
CI's electron vitest project collects electron/**/*.test.ts; the
node:test-based suite reported 'No test suite found'. Import
describe/it from vitest like sibling electron tests.
The preflight's execFileSync froze the Electron UI event loop for up to
15s while psutil scanned the full process table. Convert
scanVenvBlockers to async execFile and await it in applyUpdates; tests
updated to async DI stubs. Also adds trailing newlines.
* Revert "fix(credits): remove the 'Grant spent · $X top-up left' notice"
This reverts commit 5dc6a14c14.
* fix(credits): reintroduce grant_spent behind an in-session crossing gate
The removed notice nagged because its condition is a steady state for
accounts living on top-up, the latch is per-session, and the cold-start seed
runs the policy at every session open — every session re-announced
'Grant spent · $X top-up left'. Reintroduce it gated so only a session that
WATCHES the grant run out announces:
- seen_grant_unspent crossing gate, mirroring seen_below_90: opens when the
session observes the grant meaningfully unspent (>= GRANT_UNSPENT_MIN_MICROS,
1 cent — portal-seeded states derive micros from float dollars and can carry
sub-cent residue where headers report exactly spent). Seeds never prime it.
- The gate guards only the show branch and is consumed by the announcement —
one announcement per crossing. Header flicker (used_fraction None and back)
clears the sticky line but cannot re-announce; a renewal re-opens the gate.
- new_credits_latch() centralizes the latch shape (agent build, lazy re-init,
and the policy test helper all build through it).
- Tests: steady-state-open stays silent (seed + policy seams), live crossing
announces once, flicker/renewal/residual cases locked; the rendering test
primes the gate and asserts its leg count so a gate regression cannot
silently shrink coverage.
When the status stack collapses (last subagent/background/queue item
finishes), React removes the stack div before the layout-effect cleanup
runs. Resolving the surface via closest('[data-chat-surface]') from that
now-detached node falls back to the document root, so the stale measured
height was cleared from the wrong element and stayed on the surface —
inflating --thread-last-message-clearance (the thread's bottom padding)
until the next publish. Same latent hazard in the composer's unmount
cleanup.
Capture the surface root while the node is still attached and clear from
it directly.
* fix(desktop): allow camera capture through the permission handlers
The session permission hooks were written for the voice composer and denied
video outright, so any renderer getUserMedia({video}) failed with
NotAllowedError before the OS was ever consulted.
Rename isAudioCapturePermission to isMediaCapturePermission and accept video
alongside audio in both the request and check handlers. The OS capture
permission still applies, so the user keeps a real allow/deny.
* build(desktop): declare camera usage for signed macOS builds
A hardened-runtime build needs the camera entitlement and an
NSCameraUsageDescription string, or the packaged app is killed on first
camera access instead of prompting.