Commit Graph

23619 Commits

Author SHA1 Message Date
Brooklyn Nicholson e69d2fda8a feat(tui): one reference vocabulary for the composer and the transcript
A sent message accented a `/skill` named mid-prose and nothing else, so an
`@file:` ref and an `[[ Image 1 ]]` token flattened into body text. The
composer painted none of it.

splitComposerHighlights covers the whole vocabulary the desktop chips —
`/work` invoked or referenced, every `@ref` shape including quoted values,
and attachment/paste tokens — and both surfaces read it, so what you type
is what you see once it lands. Supersedes splitSlashSkillRefs.
2026-08-18 08:42:37 -05:00
Brooklyn Nicholson 43f395a4f8 feat(tui): export Ink's colorize so callers can match its color depth
Anything writing raw SGR past the renderer has to resolve a tone the same
way Ink does — chalk downgrades to the terminal's real depth, and Apple
Terminal takes a bespoke rich-8-bit path on top of that. Sharing the
renderer's own function is the only way a bypass can't drift from it.
2026-08-18 08:42:37 -05:00
hermes-seaeye[bot] 8a754104ad fmt(js): npm run fix on merge (#89185)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-18 13:37:43 +00:00
brooklyn! eb6922a8cd Settings search is ⌘K: deep field/credential results everywhere, scoped palette + edge pill on Settings (#89167)
* feat(desktop): add scoped credential command search

* feat(desktop): add global settings search

* feat(desktop): settings search lives in the command palette, deep everywhere

Replace the PR's sidebar search input with a settings page inside the ⌘K
palette (the subPages mechanism themes/pets already use). ⌘K on the
Settings overlay opens the palette scoped to settings; Back/Esc/Backspace
step out to the root. The deep catalog — schema-driven config fields,
appearance controls, stored credentials — now also serves the ROOT
palette's typed results, replacing the static section-key list, so ⌘K is
the same settings search everywhere. The input refocuses on page swaps,
and a seed store carries the keystroke that opened the palette so
type-to-search never loses the first character.

* feat(desktop): search pill rides the Settings card's top edge and hands off to ⌘K

The trigger is a fake pill — pure chrome, not an input — centered on the
overlay card's top border, half off the card (a new OverlayView edgeBadge
slot; a sibling of the card since the card clips its own corners). Click
or just start typing to open the scoped palette; while the palette is up
the pill scales up and fades out, then fades back when it closes.
Type-to-search forwards printable keys (outside editable targets) with
the keystroke seeded into the palette filter.

* feat(desktop): plugins are searchable — ⌘K 'kanban' lands on Settings → Plugins with the row flashed

The catalog stopped at config fields and credentials; installed plugins
were invisible to search. Both stores now feed it — desktop plugins from
$pluginRecords, agent plugins via the same plugins.manage RPC the Plugins
page fires (deduped by the store's inflight guard, filtered by the shared
desktop-relevance curation, which moves to the store so both callers use
one predicate). Rows deep-link as ?tab=plugins&plugin=<id|key> and the
page scroll-flashes the row via the shared useDeepLinkHighlight hook.

* style(desktop): satisfy perfectionist import/prop ordering

* test(desktop): add setApiRequestProfile to KeysSettings hermes mock

---------

Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com>
Co-authored-by: Adolanium <adolanium@users.noreply.github.com>
2026-08-18 13:28:39 +00:00
hermes-seaeye[bot] 593ad08496 fmt(js): npm run fix on merge (#89178)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-18 13:19:44 +00:00
Brooklyn Nicholson 3e9859beca fix(desktop): explain why a remote agent's localhost URL won't load in the browser pane
The webview always runs on the user's machine, never on the gateway
host. Against a remote gateway an agent's http://localhost:5173 resolves
here — where that port is usually nothing, or somebody else's service —
and the pane just said 'Server not found'.

The load error now names the mismatch when the address is loopback and
the gateway is remote. It stays quiet on a local gateway and for
non-loopback hosts, which fail for ordinary reasons.

This explains the limitation rather than lifting it; forwarding the port
is a separate, larger job (see #87243 for the SSH case).
2026-08-18 08:13:08 -05:00
Brooklyn Nicholson 0593293962 fix(tui): Ctrl+C clears a typed draft instead of interrupting mid-stream
A non-empty composer used to lose to the busy-turn interrupt branch, so
Cmd/Ctrl+C while typing during a stream killed the agent. Clear first;
interrupt only when the input is already empty.
2026-08-18 08:07:53 -05:00
Brooklyn Nicholson a9a4a04070 feat(desktop): open the browser with a hotkey instead of only through chat
The Browser tab could only be summoned by asking the agent or clicking a
link. Adds Cmd+Shift+L and a command-palette row.

L is the location-bar chord every browser shares; plain Cmd+L is
already the terminal's selection shortcut, hence the shift. Pressing it
re-fronts whatever page is loaded rather than resetting it, so it reads
as 'show me the browser' and not 'start over'.
2026-08-18 07:51:43 -05:00
hermes-seaeye[bot] e02d1e41fc fmt(js): npm run fix on merge (#89135)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-18 11:22:45 +00:00
Brooklyn Nicholson 4d1cdf83c9 fix(desktop): claim Cmd/Ctrl+R in before-input-event so it works off macOS
The application menu is only installed on macOS (#77845) — everywhere
else it is set to null, which strips the role accelerators with it. A
menu-only Cmd+R meant Windows and Linux got no page reload at all.

Moves the claim into the same before-input-event hook Cmd+W already uses
for exactly this reason, and drops the accelerator from the menu item so
one keypress can't fire both paths on macOS.
2026-08-18 06:16:29 -05:00
Brooklyn Nicholson fae8e27a49 fmt(desktop): eslint --fix import and JSX-prop ordering 2026-08-18 06:16:29 -05:00
Brooklyn Nicholson d48b441506 feat(desktop): open web links in the in-app browser, Cmd-click for the system one
Clicking a link in the transcript, a url chip, or the terminal left
Hermes entirely. Now a web page opens in the browser pane — no context
switch to read a doc, and it's the surface the agent can see — while
Cmd/Ctrl-click and middle-click still escape to the real browser.

Routing lives in one helper so every link surface agrees. Anything that
isn't a web page (mailto:, file:, custom schemes) always hands off to
the OS, as do billing, OAuth, and local media, which need a real
logged-in browser.

The terminal keeps its own chord: it already spends Cmd/Ctrl on
activating the link at all, so Shift is what escapes there.
2026-08-18 06:16:29 -05:00
Brooklyn Nicholson ccabff8976 feat(desktop): route native browser gestures to the in-app browser
Cmd+R over a focused page reloads that page instead of the whole shell,
a trackpad swipe walks its history, and a mouse's back/forward buttons
do the same. Shift+Cmd+R still force-reloads the window.

All three arrive as native input, so main decides. A webview guest is
out-of-process: when the pointer and focus are inside the page, no
renderer signal sees it — not activeElement, not the layout tree's
hover/focus ladder. Main asks Electron for the focused webContents and
acts on it directly, and only falls through to the renderer when the
gesture landed on Hermes' own chrome.

Also adds a Reload window command to the palette, since Cmd+R no longer
always means that.
2026-08-18 06:16:29 -05:00
Brooklyn Nicholson 5117eb7c93 feat(desktop): give the in-app browser an address bar and history controls
The Browser tab could only show whatever opened it: no back, no forward,
no way to type an address. Adds the row every browser has — back /
forward / reload / address — above the page.

The console and DevTools toggles move here from the zone strip. They act
on one page, so they belong beside its address rather than on a strip
shared with every other tab; that also frees the space the address bar
needed. Nothing else contributed strip tools, so the extension point
goes with them.
2026-08-18 06:16:29 -05:00
Teknium daca386967 fix(desktop): @-mentions now autocomplete in Bot Mode group composers (#89049)
The group room's composer (new-thread box and reply-in-thread box) rendered
a plain SDK Input. The plugin's mention provider is registered against
COMPOSER_AREAS.atCompletions, which only mounts in the main chat composer —
workspace tiles never see it, so typing @ or / in a group chat did nothing
despite the placeholder advertising "@name to direct, @everyone for all".

Fix: a member-scoped GroupMentionInput wrapper in the plugin itself —
caret-aware @-token detection, popover offering @everyone/@all plus each
seated member's handle (same botHandle the parser uses), keyboard nav
(Up/Down/Enter/Tab/Escape), and insertion that produces exactly the
"@handle " strings parseGroupChatMentions resolves. Both group composers
(GroupChatWorkspace main box + thread reply box) mount it; the per-bot
composer path is untouched.

Root cause traced by the reporter of #89049 — confirmed against source.
2026-08-18 03:00:29 -07:00
hermes-seaeye[bot] 7cae03b8c0 fmt(js): npm run fix on merge (#89070)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-18 09:33:57 +00:00
Teknium a1682376ca feat(profiles): rename any agent — the default profile gets a display name (#45624)
`hermes profile rename default <name>` (and the Desktop/dashboard rename
flows) now set a presentation-only `display_name` in profile.yaml instead
of erroring. The canonical id stays "default"; resolution, comparison,
and spawn paths are untouched. Named profiles keep real renames and their
display_name survives the move.

Surfaces: profile list/show/status, /profile (text only — data.profile
stays canonical), dashboard ProfilesPage, TUI-gateway profiles.list, and
Desktop (rail, switcher, Manage page, and the Bot Mode roster via a
displayName fallback so a renamed default shows its name, not "default").

Slimmer redo of the direction in PR #87760 by @yxssxn — thanks; see PR
body for what changed vs that approach.
2026-08-18 02:27:18 -07:00
Teknium 6abe6edee6 feat(bot-mode): real Slack-style threads in group rooms — main composer starts one, reply-in-thread continues it
Supersedes the display-only conversation folding from #89030 with actual
threads, per Teknium's direction: guessing topic boundaries from user-
message timing was wrong — tasks take many user turns.

- Every room entry carries a thread id. The main composer STARTS a new
  thread with the whole group ('New Thread'); each open thread has its own
  'Reply in thread' box that CONTINUES that work. Explicit intent, no
  heuristics.
- Member turns are thread-scoped end to end: the round-robin drive filters
  the room log to the triggering thread, watermarks key on
  thread::member, and responder resolution reads only that thread — so
  parallel topics never leak into each other's deltas or eat each other's
  watermarks.
- Stranded (timed-out) replies remember their thread and harvest back into
  it; pre-thread bare-number markers still parse.
- Pre-thread logs hydrate through assignLegacyThreads(): a user message
  after a 15-min lull starts a synthetic thread, follow-ups inside the
  window stay together — one-time conversion only, live sends always mint
  real ids.
- UI: threads ordered by last activity, newest open by default, older ones
  fold to summary rows (head text, reply count, last activity) with
  expand/collapse.

Tests: thread minting, explicit-thread continuation + delta scoping (other
thread's text never reaches the member prompt), legacy hydration split
behavior, thread UI source contracts. 258/258 plugin tests pass.
2026-08-18 02:25:41 -07:00
Teknium 7a0cdbcd79 fix(tests): pin workspace snapshot in plugin-prompt-sections byte-stability test
test_real_aiagent_builds_section_once_and_keeps_it_out_of_static_prefix
builds the system prompt twice and asserts byte equality, but the prompt
embeds build_coding_workspace_block() — live git status/log output. A git
call failing between the two builds (xdist contention in CI) makes the
Branch/Recent-commits lines differ and fails the test on unrelated PRs
(first seen on #89027's run: diff showed only '- Branch: (detached HEAD)'
and recent-commit lines).

Mechanism reproduced locally: with coding posture on and cwd inside the
checkout, failing git calls on the second build only => first != rebuilt;
with the snapshot pinned, identical git failure => byte-equal. The real
block's byte-stability is coding_context's own contract; this test is
about plugin sections.
2026-08-18 02:12:30 -07:00
Teknium 8a770aae0d fix(tools): spillover is the canonical home for oversized results on every backend
Per review: even with an active sandbox env, spilled tool results
belong in $HERMES_HOME/cache/spillover with the other Hermes-owned
caches — not the sandbox temp dir as primary storage.

- Host-side write happens first on every backend; local/no-env
  sessions reference the host path directly (unchanged).
- cache/spillover joins the auto-mount/sync cache-dir list
  (credential_files._CACHE_DIRS), so docker bind-mounts it and
  modal/ssh/daytona file-sync it. Remote references use the
  translated in-sandbox path after a readability probe.
- Probe failure (persistent containers created before spillover
  joined the mount list, translation failures) falls back to the
  previous in-sandbox temp-dir copy, so nothing regresses.
2026-08-18 02:12:13 -07:00
Teknium 83d451e7b5 test: budget-replacement assertions accept persisted-output blocks
The steer-survives-budget tests pinned the inline 'Truncated:' fallback
shape, which only occurred because env=None persistence was broken.
Now that host-side spillover succeeds, budget enforcement produces a
<persisted-output> block instead. Assert the actual contract — the
oversized payload was replaced (persisted OR truncated) — via a shared
helper, not which replacement shape was used.
2026-08-18 02:12:13 -07:00
Teknium c91681c69a fix(tools): large tool results persist to HERMES_HOME/cache/spillover instead of truncating when no sandbox env is active
Sessions that never ran a terminal command (MCP-only, cron, gateway)
have no active sandbox environment, so maybe_persist_tool_result()
got env=None and fell through to the inline-truncate fallback --
a 467K MCP result was cut to a ~1.3K preview with no file written
('Full output could not be saved to sandbox').

Now the host-side cases (env=None or the local backend) write the
spill file directly to $HERMES_HOME/cache/spillover/<id>.txt,
alongside the other Hermes-owned caches instead of littering /tmp.
Remote backends (docker/ssh/modal/daytona) keep the in-sandbox
env.execute() write since read_file resolves in-sandbox there.

Cleanup: the gateway housekeeping loop prunes spillover hourly with
the other media caches, and a once-per-process best-effort prune on
first spill covers CLI-only installs.
2026-08-18 02:12:13 -07:00
Teknium 4bdddf4e95 docs: escape backslash in cli-symbols glossary so MDX compiles
acc614e72 added a raw backslash inside a <code> span in a table row;
MDX reads it as an escape and never finds the closing </code>, failing
docs-site-checks on main and every open PR. Escape it as &#92;.
2026-08-18 02:11:03 -07:00
Teknium 8505559fa9 feat(bot-mode): group rooms fold older conversations into one-line summaries (#88925)
Every USER message starts a conversation (the same boundary the turn
engine's delta scoping keys on). The latest conversation renders in full;
older ones collapse to a one-line summary row — head text, reply count,
last-activity time — that expands/collapses on click. db's 'threads per
conversation' ask, delivered as timeline folding: no log-model change, no
new persistence, cross-machine sync untouched.

groupChatConversations() splits the log (leading member run from a trimmed
log forms a headless block); rendering extracts renderEntry() unchanged.

Tests: conversation splitting (headless block, heads, startIndex) + fold
source contracts. 256/256 plugin tests pass.
2026-08-18 01:54:29 -07:00
Teknium 2d511f55aa fix(desktop): closing main in a side-by-side layout promotes the neighboring session instead of spawning a fresh draft (#88924)
nextSessionTileForWorkspace() only walked tabs stacked WITH the workspace
tab. In a side-by-side layout (session tiles in their own zones — db's
three-pane report), the walk found nothing, closeWorkspaceTab() skipped
promotion, and requestFreshSession() dropped main to a new draft: closing
a pane read as 'it gave me a new session'. A tile in any zone now promotes
(its zone collapses via the tile close), so Close is also the path from N
panes back to 1. Ghost panes whose tile is gone still never promote.
2026-08-18 01:38:51 -07:00
Teknium a75d1b5c89 fix(tests): de-flake gateway SSE and goal-verdict tests that raced spawned tasks (#88975)
test_session_chat_stream_treats_pre_existing_poisoned_row_as_no_model
asserted mock_run.call_args right after the 200 status, but the stream
handler runs _run_agent inside asyncio.create_task(_run_and_signal())
and response.prepare() returns the 200 before that task necessarily
starts — on loaded CI runners call_args was still None (TypeError:
cannot unpack non-iterable NoneType). Draining the SSE body (resp.text())
joins the stream end, which guarantees the runner task completed.

test_goal_verdict_send used fixed asyncio.sleep(0.05) waits before
asserting on sends/enqueues produced by spawned tasks; replaced with a
bounded _drain_until() poll (5s cap, returns as soon as the condition
holds) so the asserts stay exact without the fixed-delay race.

These three tests red-flagged unrelated main pushes and PR runs on
Aug 18 (runs 32099139396, 32101135100, 32106224479, 32101070064).
2026-08-18 01:38:40 -07:00
witcheer acc614e72f docs: add CLI symbols glossary reference page 2026-08-18 01:04:28 -07:00
Teknium e624e9fde5 chore: map contributor email for #88864 salvage 2026-08-18 00:26:31 -07:00
Teknium c381aef16f fix(bot-mode): fresh-room name uniquify counts every membership, not just the legacy scalar
With multi-group membership, a bot's meta carries groups[]; the create
dialog's taken-name scan must union all of them (botGroups) or a name only
present as a secondary membership could be reused and resurrect that room.
2026-08-18 00:26:31 -07:00
Goktug Vatandas 7af25a92b0 test(bot-mode): favor behavior over source assertions 2026-08-18 00:26:31 -07:00
Goktug Vatandas 10d6faade9 test(bot-mode): verify shared-member room isolation 2026-08-18 00:26:31 -07:00
Goktug Vatandas 9bea439189 feat(bot-mode): support multiple groups per bot 2026-08-18 00:26:31 -07:00
Teknium 7e05e9080b chore: release v0.20.4 (2026.8.18) 2026-08-18 00:26:29 -07:00
hermes-seaeye[bot] ecc7a354f0 fmt(js): npm run fix on merge (#88981)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-18 07:11:16 +00:00
Teknium d4a72b7d7e chore: map contributor email for #88807 salvage 2026-08-18 00:03:16 -07:00
Christopher Strnad 4bb7e4913f fix(desktop): preserve cross-machine group routing 2026-08-18 00:03:16 -07:00
Hermes Agent b95ec1cb5d fix(delegation): running subagents stay visible to list/steer across parent-agent rebuilds, and child-started process notifications carry delegation attribution
Control path: delegate_task(action=list/steer/stop) resolved ownership
purely through the _delegate_parent_ref weakref identity chain. The CLI
rebuilds its AIAgent mid-session (self.agent = None on route-signature
change, credential refresh, /model, MoA one-shots), so a running child's
chain pointed at a dead object and the child went invisible/unsteerable
while completion delivery (durable session-id routed) still worked.
Observed live 2026-08-17: deleg_88454b70 / sa-0-dc0100f4.

Fix: register each child with the owning conversation's durable session
id (owner_agent_session_id, the same spine delivery routes by) and add a
second ownership tier that matches it against the calling parent's
session_id with compression-lineage resolution on both sides. Foreign
sessions still fail closed.

Presentation path: background processes started BY a subagent (task_id ==
subagent_id) route their notify_on_complete notifications to the parent
conversation by design, but arrived as anonymous raw output walls. The
formatter now resolves the task_id against the live + recently-finished
subagent registry (bounded retention survives child completion) and adds
a provenance line (subagent id, delegation id, goal snippet), trimming
the output tail for subagent-owned processes. Parent-owned process
notifications are byte-identical to before.
2026-08-18 00:01:56 -07:00
Teknium 0bb2399900 fix(tests): sibling hangup-protection tests tolerate the branch+HEAD suffix
test_update_hangup_protection pinned the exact stdout of
_print_update_completion; the new branch+HEAD suffix (parked-branch guard)
broke that pin. The two receipt tests assert the action-identity contract,
not the branch display, so they now neutralize _branch_head_suffix — the
suffix behavior itself is covered by test_update_parked_branch_guard.py.
2026-08-17 23:53:05 -07:00
teknium1 8ce8ffd429 fix(update): 'hermes update' no longer claims success on a parked feature branch — switches back when safe, warns loudly when not
Live incident 2026-08-17: the source checkout was parked on a stale feature
branch (claude-code-inspired/local-terminal-memory-limit, days behind main),
left there by earlier tooling. 'hermes update' autostashed, refreshed lazy
backends, synced skills, and printed '✓ Code updated!' / '✓ Update complete!'
while the checkout stayed on the stale branch with none of main's new code.
Two sessions burned time on 'the fix is missing' confusion.

- Parked-branch guard: auto-switch back to the update target ONLY when the
  parked branch is clean and fully merged (git cherry origin/<target> shows
  nothing unmerged); the checkout then STAYS on the target instead of being
  re-parked. Otherwise: loud CODE UPDATE SKIPPED block naming the branch,
  behind-count, and resolution commands; exit 1; branch untouched.
- The up-to-date (commit_count == 0) path no longer switches back to a
  fully-merged parked branch either.
- Post-pull gate additionally refuses to print '✓ Code updated!' when HEAD
  ends up attached to a non-target branch.
- Summary lines now carry the actual branch + HEAD short-sha:
  '✓ Update complete! [main @ 30fcf9580]' — drift visible at a glance.
- New config toggle updates.auto_switch_parked_branch (default true).
- Real-git-fixture regression tests (init/clone/branch, no subprocess
  mocks): clean+merged auto-switch, dirty skip, unmerged skip, cherry-picked
  equivalence, config opt-out, unverifiable ref, on-main fast path,
  up-to-date no-repark, summary branch/sha assertions.
2026-08-17 23:53:05 -07:00
Teknium a943895f2e test(desktop): prove the install_id collapse (#88828) and boot-descriptor connectionId (salvage #88697) compose without re-appending the twin-address primary 2026-08-17 23:52:54 -07:00
imMxts 7e156c6d2e fix(desktop): preserve primary remote connection identity 2026-08-17 23:52:54 -07:00
Teknium cb337d39ba fix: Bot Mode cronjobs panel no longer shows two empty-state markers
The empty cronjobs list rendered both a calendar-icon placeholder blurb
("Cronjobs are recurring tasks this agent runs on a schedule.") and the
Create Cronjob button — two elements saying the same thing (empty).
Per Teknium's review, drop the generic placeholder and keep only the
create button. The filter hint ("jobs exist but are hidden by the bot
filter") still renders, since it carries real information rather than
just marking emptiness.
2026-08-17 23:52:45 -07:00
Brooklyn Nicholson e9cefa4d75 feat(desktop): pre-select glass on macOS
Window Translucency shipped defaulting to Clear, which means the mode worth
finding is the one nobody sees — Glass is the better-looking half and the
reason the feature exists. A fresh macOS profile now starts with Glass
selected.

Nothing turns on. The intensity still defaults to 0, so the window is
byte-for-byte what it is today until the user moves the lever; the default
only decides which mode that lever will drive. windowOpacityFor stays 1 and
the window is still born with its opaque backing.

The one profile that must NOT flip is one already carrying a non-zero
intensity with no mode recorded: it predates the setting, has been rendering
as clear the whole time, and defaulting it to glass would change a window
someone deliberately tuned. normalizeMode takes the saved intensity and keeps
those on clear.

The renderer store was hand-rolling its own copy of this rule, so it now
routes through the shared normalizer and the two can't disagree.

The store's default test only passed because a beforeEach reset the atom
before it looked — it asserted the post-reset value, not the default, so it
would have stayed green through this change. It now snapshots the atom at
import time. All three mutations (default back to clear, escape hatch
removed, glass leaking onto non-mac) fail the suite.
2026-08-18 01:16:18 -05:00
Brooklyn Nicholson 7a81dd9efd refactor(desktop): trim the glass surface
Pre-handoff polish, no behavior change. The three translucency pickers
shared a verbatim five-line onChange (haptic, set, conditional pulse) —
one pickTranslucency helper now serves mode, frost and area. Dead
re-exports cut: the electron adapter no longer forwards renderer-only
symbols (TRANSLUCENCY_STEP, TranslucencyMode, GlassScope), and the store's
re-export block shrinks to what its call sites read; the store test takes
the shared constants from @hermes/shared/translucency directly.
2026-08-18 00:57:01 -05:00
Brooklyn Nicholson a1fca9b625 fix(desktop): close the glass state and perf leaks
Audit pass over the whole feature's lifecycle, closing four holes:

- Stuck peek. Escape mid-drag unmounts the slider before its pointerup ever
  fires, stranding the counter above zero — every LATER settings overlay
  then renders ghosted at 8% opacity. The appearance surface now drops all
  outstanding holds on unmount (resetTranslucencyPeek); expiring pulse
  timers become no-ops on the zero floor.

- Frozen sibling windows. Under glass an intensity change touches nothing
  native (by design, that's the perf fix), so a second chat window never
  heard about it and its tint froze until reload. The store now adopts a
  sibling's persisted state off the storage event — the same cross-window
  pattern themes/context and store/session already use.

- Layout thrash on the sidebar-scope drag. startRailTracking re-measured the
  rail on every store sync: a getBoundingClientRect (forced layout) right
  after the tint's style write, once per slider tick. While tracking is
  live, the ResizeObserver and the resize listener own geometry; the settled
  hot path is now one boolean check. Re-acquisition still covers the two
  real cases — a rail that hasn't mounted yet, and a rail that REMOUNTED
  (layout reset swaps the element, leaving the observer on a detached node
  that never fires again).

- The rail observer/listener pair was already torn down whenever glass or
  the sidebar scope ends (stopRailTracking) — audited, covered by the scope
  attribute tests, unchanged.
2026-08-18 00:57:01 -05:00
Brooklyn Nicholson 6eb23c79d3 fix(desktop): clear-mode fade tracks the drag again
The perf pass over-corrected: it debounced the renderer's IPC send along
with the localStorage write. Under glass that was harmless (the renderer
paints the effect itself), but in clear mode the effect IS the native window
opacity, and main can only move it when told — so dragging the slider did
nothing for 120ms and then snapped to the released value. Exactly the jank
the debounce was meant to remove, reintroduced on the other mode.

The send is per-tick again; only the localStorage write stays debounced.
Per-tick sends are cheap now because main diffs the state: one setOpacity in
clear mode, nothing at all under glass.

The store test asserting the send was coalesced encoded the bug — flipped to
assert every tick reaches the bridge, with a comment naming the rule.
2026-08-18 00:57:01 -05:00
Brooklyn Nicholson f3bfa9ae53 perf(desktop): stop the translucency slider thrashing the main process
Dragging the intensity slider was janky, and the four frost levels looked
nearly identical. Same cause.

At step=1 a drag emits ~100 updates, and every one of them did four
expensive things: a synchronous localStorage.setItem, an IPC wake, a
synchronous fs.writeFileSync in main, and setVibrancy + setBackgroundColor
on every open window. The vibrancy call is the one that also broke the
frost picker — it animates over 150ms, so re-issuing it per tick restarted
the animation before macOS could ever settle the material. The levels
weren't indistinguishable, they were never finishing.

The fix follows from a property the mode already has: under glass the
intensity is a pure renderer concern. windowOpacityFor returns 1 for the
whole range, so main has nothing to do on an intensity change at all.

- main diffs the incoming state against the current one and passes a
  `changed` set to applyWindowTranslucency. An intensity-only change under
  glass now touches zero native properties. Crossing zero still moves the
  backing, since that flips glass on and off.
- main's disk write is coalesced onto a 250ms trailing timer, flushed on
  before-quit. Only a cold launch reads that file.
- the renderer paints every tick (the field has to track the hand) but
  coalesces the localStorage write and the IPC send onto a 120ms trailing
  timer, flushed on pagehide.

Covered as contracts rather than timings: a table asserting exactly what
each kind of update changes natively (nothing, across the whole intensity
range under glass), and store tests that a six-tick drag produces one write
and one IPC call while every intermediate value still paints. Both
directions mutation-checked — restoring per-tick writes fails, and
debouncing the paint fails too.
2026-08-18 00:57:01 -05:00
Brooklyn Nicholson b86db1410b test(desktop): the glass assertions were never running
jsdom reports an EMPTY navigator.platform and a userAgent of "darwin", so
GLASS_SUPPORTED resolved false and every `if (GLASS_SUPPORTED)` assertion in
the store suite took its else-branch — on a Mac too. The suite was green
because it was checking that glass does nothing.

Found by mutation: removing the `data-hermes-glass-scope` cleanup, hardcoding
the keep percentage, and dropping the isChatWindow guard all survived a full
run. Pinning navigator.platform before the store module evaluates makes the
glass path the one under test, and all three now fail as they should.

Also adds the coverage those mutants exposed as missing:

- Each of the three ways glass can end (intensity to zero, mode to clear,
  window kind) clears the scope attribute independently — a stale scope keeps
  the split-paint gradient selector live over a non-glass field.
- The store must CONSULT isChatWindow, not merely export it. Driving
  window.location.search proves the HUD / pet overlay / quick entry don't get
  their page surfaces rewritten while still honouring the user's saved mode.
- A guard asserting GLASS_SUPPORTED is true in this environment, so if the
  env ever stops reporting mac the suite fails instead of quietly hollowing
  itself out again.
2026-08-18 00:57:01 -05:00
Brooklyn Nicholson efa15b5eb4 feat(desktop): frost picker, sidebar glass, full-range tint and peek
Restores the four features SHL0MS built on #84329 that an earlier pass on
this branch had carved out, reconciled onto the shared translucency state
rather than the four-atom store they were written against.

- Frost picker. macOS exposes no blur-radius knob, so the vibrancy material
  IS the frost control. The four in the ladder come from a pixel census on
  macOS 26: the 14 Electron materials collapse to 9 distinct looks, and
  these four are the widest separations that stay distinct in BOTH
  appearances. sidebar/hud collapse into under-window when unfocused, which
  is why they're deliberately absent -- normalizeMaterial rejects them, with
  a test saying why.
- Sidebar-only glass, the Finder shape. <body> stays the single painter and
  splits at the rail's live-measured edge with a hard gradient stop, so
  there's no smear across the seam and no per-layer tint stacking. RTL
  mirrors.
- Full-range tint. glassSurfaceKeep runs linear to zero, so the top of the
  lever is bare untinted blur instead of stopping at a 30% wash. Text, cards
  and the composer keep their own opaque tokens, which is what makes 100%
  usable rather than unreadable.
- Peek. The settings overlay covers the very effect its slider controls, so
  holding the slider ghosts the whole overlay layer and the live window
  becomes the preview. A counter, not a boolean: a held drag and a timed
  pulse from a picker click overlap, and the drag must not be cancelled by a
  pulse expiring underneath it.

One change from the original: the peek's transition is scoped with :has() to
the overlay that arms it. The version on #84329 shipped a bare
`[data-overlay-surface] { transition: opacity 420ms }`, which gave every
overlay in the app -- command center, cron, agents, model picker -- a 420ms
opacity transition for the life of the process to serve one slider. Verified
by running the candidate rules through lightningcss: the scoped selectors
survive minification and no un-gated overlay transition remains.

visualEffectState is pinned to 'active' at each chat window, because several
materials collapse to a shared inactive look on blur -- without it the frost
choice silently erases itself whenever the user clicks another app.

Co-authored-by: SHL0MS <SHL0MS@users.noreply.github.com>
2026-08-18 00:57:01 -05:00
Brooklyn Nicholson 113a07f29d refactor(desktop): one surface helper for the three chat windows
Every chat window constructor repeated the same three translucency-related
options -- the vibrancy material, the native opacity, the webContents
backing -- and the glass work was about to make that four things to keep in
step across three sites, with the cold-launch backing rule (omit
backgroundColor, never pass alpha) restated at each. chatWindowSurfaceOptions()
states it once, and the comment naming the HUD, pet overlay, quick entry and
wake indicator as deliberately-not-chat-windows lives with it.

The settings row reads the store's single object rather than two atoms.
2026-08-18 00:57:01 -05:00