Commit Graph

3315 Commits

Author SHA1 Message Date
brooklyn! 0d19e37b95 Merge pull request #90197 from NousResearch/bb/preview-act
The agent can use the in-app browser, not just look at it
2026-08-20 05:32:05 -05:00
Brooklyn Nicholson b3f6218aa5 fix(desktop): a checkbox reports whether it is ticked, not the string "on"
An unset checkbox's `.value` is "on" per the HTML spec, and the inventory read
the value before the state — so a ticked box and an empty one both came back as
`value: "on"`, and the agent had no way to tell them apart. Anything built on
reading a form back ("is the newsletter box already checked?") was answering
from a coin flip.

The state check now runs first, gated on the input's type rather than on
`checked` being defined — it is defined, as false, on every input including
text fields, which is what made the original ordering look reasonable. ARIA
checkboxes and switches built out of divs read `aria-checked`, which the old
branch would have missed anyway since a div has no `.checked`.

Found by putting the extracted helper under a direct test. It was unreachable
before: the only way to observe it was to run a whole action against a whole
document.
2026-08-20 05:26:37 -05:00
Brooklyn Nicholson df06be0a16 refactor(desktop): give the act engine's pure logic real modules
act-in-page.ts had grown to 1,054 lines: one function holding twenty-eight
closures, of which roughly four hundred lines were pure string and geometry
work that never touched the holder, the action, or any page state. None of it
could be tested. `slug`, `affinity`, `coin` and the rest were reachable only by
running a whole action against a whole document and inferring what they must
have done.

Four modules now, at the seams the dependency graph actually has:

  types.ts       the six shared interfaces, re-exported from act-in-page.ts so
                 no import site anywhere changes
  naming.ts      what an element is called — labels, slugs, stems, anchors
  visibility.ts  whether it is really there and whether it is on screen
  identity.ts    the re-bind ladder and handle minting

Each is a factory rather than a bag of exports, and that shape is load-bearing
rather than taste. These sources are stringified into the guest page, where
module scope does not exist; separate exports would be separate names for the
bundler to mangle independently of the call sites inside the stringified core.
A factory that stringifies whole has no cross-module reference to break. The
core takes the kits as a parameter for the same reason — it names nothing it
did not receive.

That failure mode deserves spelling out, because it is why this is not a plain
import. The renderer minifies. An imported binding referenced inside the core
would be renamed to something the injected bundle never declares, and it would
break in packaged builds ONLY — green in dev, green under vitest, broken for
users. `actEngineSource()` is now the single supported way to obtain the
source, so a partial injection is not something a caller can express.

The self-containment test moves with it, and gets stronger: it evaluates the
assembled bundle rather than one function, which is what the guest actually
receives. It caught the contract change on the first run.

Bodies moved unchanged; the only edits are closure captures becoming
parameters, and `coin` taking the counter it used to read off the holder. The
engine is 634 lines, all of it orchestration. The 53 existing engine tests pass
untouched, which is the evidence the move preserved behaviour, and 27 new tests
exercise the extracted helpers directly for the first time.
2026-08-20 05:26:37 -05:00
Brooklyn Nicholson 0fd3b61ea9 feat(desktop): durable element handles, and a delta instead of the whole page
Every drive_preview action answered with the entire inventory — around 120
elements of ref, role, label, and an up-to-eight-rung `:nth-child` selector
chain. On a real app shell that was ~24.5k characters, re-sent after every
click, so a ten-step task paid for ten copies of a page that had barely moved.

Handles are now durable and legible. An element is named after what it is and
what it says — `btn-sign-in`, `inp-email`, `srch-search-projects` — minted once
per page and never reused, with duplicates disambiguated as `btn-edit`,
`btn-edit-1`. Each one remembers a stable attribute, its role, its accessible
name, and the nearest landmark it sits in, so when a framework destroys the
node and builds a new one the handle moves across and the agent is told
`rebound` rather than being handed a removal it has to react to and an addition
it has to re-read. The re-bind ladder is anchortree's (Apache-2.0), minus its
geometry rung, which can never clear the threshold on its own.

Because the handles hold, the first look at a page returns the inventory and
every look after it returns only what moved. `changed` carries the ref and
whichever of label/value/disabled actually shifted — role and selector are
absent by construction, since a change in either would mean the re-bind ladder
was looking at a different element. A delta gives way to a full re-read when
half the page is new, where there is nothing left to reuse.

The selector column is gone with it. It was 74% of the inventory on an
85-element page, nothing downstream ever read it, and a positional chain is
wrong the moment a sibling appears. An `#id` or `[data-testid]` survives when
the page offers one; everything else is addressed by handle.

Legibility is what makes the delta work rather than a nicety. `+ btn-sign-in`
on turn nine reads on its own, where `+ @e42` sends the model back to an
inventory twenty thousand tokens ago.

Measured on an 85-element app shell: 18,693 -> 4,930 characters for a baseline,
and a steady turn that moved two things costs ~200.
2026-08-20 05:26:37 -05:00
Brooklyn Nicholson f262e684d8 feat(desktop): an overlay that shows what the agent is doing to the page
Driving someone's browser invisibly is unnerving, and this browser is the one
they are signed into. The pane now draws the field the agent can reach, a box
round what it is touching, a cursor that goes there, and a wipe over text it
just read — one cursor primitive and one mark primitive, in a closed shadow
root so the agent's own inventory cannot see them. Marks carry the same handle
the agent addresses them by, so the word on screen and the word in the
transcript are the same string.

The point is supervision rather than decoration: a person glancing at the pane
can tell what is about to happen to their live session, and stop it.

It also has to cover the waiting. The agent flashes through a click in under a
second and then sits idle for the twenty to a hundred seconds the model spends
deciding what to do next, which is most of the wall clock of any task — so the
surface used to look broken during the part where it was working hardest. A
think stage runs off the $busy edge, sparsely flashing elements from the field
the last action left behind, and rest stops it. It guards itself: started
before there is an overlay or a field, it idles until there is one, so it can
be raised on the turn boundary without knowing whether anything has been
inventoried yet.

read_preview had the same hole from the other side. Reading is the cheapest
thing the agent does — hundredths of a second between two model round trips —
so paging through a document left the pane dark for twenty seconds immediately
after the one moment that showed anything. It draws a top-to-bottom wipe over
the text it took, and that is the one stage allowed to be a wipe: reading is
the only thing the agent does to a page in an order a person could follow.

Both go through preview-nudge, which says a single stage to an overlay the page
already has rather than re-shipping the engine to narrate. On a page the agent
never acted on it is a no-op, which is the honest answer — chrome there would
be a lie about what it did.

Everything respects prefers-reduced-motion.
2026-08-20 05:26:37 -05:00
Brooklyn Nicholson c358a6a570 feat(desktop): drive the preview with real input, not synthetic events
A dispatched MouseEvent is untrusted, so hover menus never opened and any
control that gates on isTrusted ignored it. The pane now sends input through
the webview itself: the pointer travels to its target and the page cannot tell
it from a hand.
2026-08-20 05:26:37 -05:00
Brooklyn Nicholson c57581cd0d feat(tools): drive_preview and annotate_preview — the agent can use the page it opened
The in-app browser was a one-way mirror. open_preview put a page in the pane
and read_preview read its text back, but nothing could touch it. A click meant
falling back to the browser_* tools, which drive a separate Chromium the user
cannot see — so "log into this and pull my invoices" happened in a different
browser from the one on screen, with none of the sessions the user is already
signed into.

Four pieces, and they only make sense together:

  · an in-page engine that inventories what is interactable and performs the
    verb, injected as source because it has to run inside the guest page;
  · the preview.act.request bridge from the gateway into the pane;
  · drive_preview, for acting: elements, click, type, scroll, press, and the
    pane's own back/forward/reload;
  · annotate_preview, for marking without acting.

Those last two started as one tool doing two unrelated jobs. Leaving a mark is
not an action — it outlives the turn that drew it — so it gets its own verb,
and the interaction verb gets a name that says what it does.

Gating is the existing surface rule: desktop_ui folds in on session
source: 'desktop', and the bridge refuses to act for a background session, so a
turn running behind the user's back cannot reach into the page they are working
in.

Two details worth a reviewer's attention. Typing assigns through the
prototype's value setter, because React shadows value with its own accessor and
ignores an input event whose value it believes it already wrote — a plain
el.value = … types into a field that snaps back on the next render. And
clicking replays the pointer/mouse pair before activation, because frameworks
bind to mousedown as often as to click.
2026-08-20 05:26:37 -05:00
Teknium d29f76c70e fix(bot-mode): group chat no longer doubles into the Bots pane beside its main tab (#89788 follow-up)
The #89788 gate read main-tab ownership from a plain module Map — invisible
to React — and openGroupChat set the selection atom before recording the
tab. Every open therefore rendered BotsPane in a selected-but-unowned
window, painting the in-pane room beside the main tab, and the duplicate
stuck because the later Map write repaints nothing.

- $groupMainTabsRev atom shadows tab-map membership; all mutations go
  through recordGroupMainTab/dropGroupMainTab; BotsPane subscribes, so the
  in-pane gate re-evaluates on tab open/close.
- openGroupChat records the tab BEFORE setting the selection atom; older
  desktops without the main-window door (and a throwing door) still get
  the in-pane fallback.
- Regression tests: gate is false at the instant the selection atom flips
  (fails on the old ordering — sabotage-verified), and rev bumps on tab
  open/close.
2026-08-20 03:12:39 -07:00
hermes-seaeye[bot] 6f31cfad78 fmt(js): npm run fix on merge (#90690)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-20 09:20:15 +00:00
Teknium 6170021714 fix(desktop): remote-gateway desktop stops lying after disconnects — roster survives outages, spawn failures log, host-key change stops the retry wall
Three fixes from one remote-gateway (VPS) debug bundle, all live-reproduced
and re-verified on a headed Electron seat via CDP:

- Bots roster no longer shrinks during a gateway outage: source enumeration
  is bounded (10s/source instead of wedging the roster IPC >30s behind a
  dead dial) and a bounced remote source keeps painting its last-known
  profile list (was SSH-only), so 4 bots never show as 2 mid-outage.
- Pool backend spawns that die before the child exists (forced-local spawn
  of a profile that only exists on the remote) now log the failure to
  desktop.log, and the profile-exists guard runs BEFORE the Starting line —
  no more orphaned no-READY/no-exit spawn bursts in bundles.
- An SSH host-key change (VPS reinstall) is classified terminal like a
  reauth rejection: it latches, the boot-failure overlay shows the
  ssh-keygen -R guidance, and the renderer stops the infinite boot-retry
  loop (one bundle had 157 consecutive failures over 2.5h). Reset/repair/
  apply-config clear the latch; live-verified Retry-after-fix boots clean.
2026-08-20 02:07:10 -07:00
Teknium a78211b15a fix(desktop): sort updates.ts imports for perfectionist lint 2026-08-20 01:48:05 -07:00
Teknium bd5b221a61 feat(desktop): updating now updates every target — remote backends, other gateways, and the app itself
Remote-mode installs had every update affordance (About panel Update now,
⌘K Update Hermes, the update-ready toast) pointed at the BACKEND only, so
users updated their VPS forever while the desktop app itself sat weeks
stale — with no signal it was behind (the skew warning only fired the
other way). Reported by Santiago Sarceda: mac app on v0.20.0 kept
repro'ing UI bugs fixed on main because 'update' never touched the app.

- store/updates.ts: applyEverythingUpdate() orchestrates all targets —
  active backend first (detailed progress), every other eligible
  registered gateway via the existing Electron fan-out (cloud rows skip),
  the client LAST (its apply relaunches the app). startActiveUpdate/
  requestActiveUpdate route through it whenever more than one update
  target exists; single-machine installs keep the one-button flow.
- After ANY successful backend update, the client version is re-checked
  and a one-click 'Update desktop app' warning fires if the GUI is still
  behind — the reverse-skew signal that didn't exist.
- electron: hermes:connections:update-all accepts optional excludeIds so
  the flow doesn't double-dispatch the active backend / local runtime.
- i18n: 7 new updates.* keys across en/zh/zh-hant/ja/ar.
- docs: desktop.md Updating section + multi-connection guide.
- tests: 10 new cases (gating, ordering, exclusions, failure isolation,
  memoization, nudge on/off).
2026-08-20 01:48:05 -07:00
hermes-seaeye[bot] 27562ad5f8 fmt(js): npm run fix on merge (#90637)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-20 07:37:56 +00:00
Brooklyn Nicholson 90e30bdd27 refactor(desktop): one script runner into the preview guest page, not a tour-only one 2026-08-20 02:33:00 -05:00
Brooklyn Nicholson bd853747bb fix(desktop): a fresh profile follows the OS, and plugins stay behind the SDK
Two things a genuinely fresh instance surfaced that no existing profile could.

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

The accent plugin reached straight into `@/components` and `@/themes`, which
the plugin lint rule exists to prevent: plugins import `@hermes/plugin-sdk`
and nothing else, so the app can move its internals without breaking them.
The fix is to widen the SDK rather than exempt the plugin — it now exports the
OKLCH colour maths, `useTheme`, `retintTheme`, and the accent override, so any
plugin can derive a palette instead of hardcoding one.
2026-08-20 02:28:24 -05:00
Brooklyn Nicholson 50e2f970c8 feat(desktop): keep midnight, and retint themes that shade their accent
Midnight is monotone in a way none of the other skins are, and it turns out
to be a good test of the retint: its ring is `#8b80e8` under a `#ddd6ff`
primary — the same violet at a different lightness, not a repeat of one hex.

Matching accent slots by exact equality with the primary left that ring
behind, so re-seeding produced a half-retinted theme with a purple ring under
a teal accent. Slots now join the family by HUE, within a tolerance, and each
keeps its own lightness and chroma when it moves. A theme that deliberately
runs a deeper ring keeps that relationship instead of being flattened onto
one colour.

Near-greys are excluded by chroma rather than hue, so mono's neutral ring
still stays exactly where its author put it.
2026-08-20 02:28:24 -05:00
Brooklyn Nicholson b8c6547ca4 fix(desktop): off means off when glass is turned down
The light default carries a single point of fade so the window edge reads as
glass rather than as paint. That point followed anyone who dragged the tint
to zero, leaving a window that asked to be opaque sitting at 0.9999.

Fade now applies only while glass is actually active, not merely selected.
2026-08-20 02:28:24 -05:00
Brooklyn Nicholson 98bac5feb4 feat(desktop): an accent picker plugin, off by default
Finding a colour by hex is guesswork; finding one by eye needs a picker that
does not lie about where you will land. HSV crushes the whole blue family
into a narrow band of its hue rail, so dragging "to blue" puts you on pure
sRGB blue, which reads violet — every blue that actually looks blue lives in
a few degrees you cannot reliably hit there.

This one is OKLCH. The hue rail is perceptually even and previews the
current colour at every hue rather than showing a generic rainbow, and the
field is a canvas drawn per-pixel through the real conversion, so its curved
edge is the true sRGB gamut boundary — every pixel is a colour the display
can show. Dragging repaints the whole app against the real derivation.

It ships off (`defaultEnabled: false`) and holds no persisted state: the
override clears on dispose, so turning the plugin off returns every surface
to the authored theme rather than stranding a colour with no control to
clear it. The retint itself stays in core, where Appearance settings and the
command palette can reach it.
2026-08-20 02:28:24 -05:00
Brooklyn Nicholson be3166607e feat(desktop): glass ships on, tuned per appearance and platform
Translucency was one number serving both appearances and both platforms,
resting at zero. A lever that starts at zero is a feature nobody finds, and
one number cannot serve four situations: a tint that reads as a whisper over
a dark palette is a milky sheet over a light one, and the same numbers that
read as frost on macOS vibrancy read as a washed sheet over Windows acrylic,
which composites its own tint in DWM before the page is drawn.

So the state splits. `mode` stays global — clear versus glass is a choice
about the window, not the palette — while the values resolve through a
ladder, per key: the appearance you are looking at, then a shared base, then
the platform default. Tuning light mode stays in light mode; an untouched
dark keeps inheriting. A v1 state lands in base, so a window someone already
tuned crosses the upgrade with exactly what was on screen.

Main reads the same defaults at window creation, because a window born
opaque cannot reliably be swapped to glass afterwards.

The chat backdrop goes off by default in the same pass: it was competing
with the glass field for the same surface.
2026-08-20 02:28:24 -05:00
Brooklyn Nicholson 38f46504fd fix(desktop): the finished-session dot follows the theme
Every other dot in the set reads a token; unread was a hardcoded
`emerald-500`. On a blue theme that left eight green marks down the sidebar
fighting the palette around them.

It now paints `--ui-success`, a success green rotated part of the way toward
the accent along the shortest hue arc. Partway rather than all the way,
because landing on the accent would make "finished" and "running" the same
colour. The default costs nothing by construction: emerald sits at 162
degrees and GitHub green at 148, so a quarter rotation moves the dot about
three degrees. The work only happens when the accent is genuinely far away,
which is the case that was clashing.
2026-08-20 02:28:24 -05:00
Brooklyn Nicholson 6dbd7d11d3 feat(desktop): re-seed any theme's accent from one colour
A palette's accent is not one value, it is a family: the seed plus the soft
surfaces mixed from it — seven slots per appearance in nous, all derived
from one colour. `retintTheme` moves the whole family at once, reusing the
converter's own mix ratios so re-seeding a theme with its existing accent
returns the identical object.

The colour work this needed is the interesting half. Mixing toward white in
gamma-encoded sRGB bends hue: a saturated blue lands 7.6 degrees violet of
where it started, which is how a clean blue accent produced a lavender
selection row. `mixOklab` holds the hue and moves only chroma and lightness.
`ensureContrastOklch` adapts a seed for an appearance that cannot carry it
by walking lightness rather than blending toward white, which would gut the
chroma and wash the brand colour out.

`readableOn` picked text colour from a luminance threshold, and got five
shipped accents wrong in the direction that matters — white on GitHub's own
dark green measured 3.29:1, below AA, where near-black measures 5.50:1. It
now measures both candidates and takes the better one.
2026-08-20 02:28:24 -05:00
Brooklyn Nicholson f298612429 feat(desktop): ship the GitHub themes, with Nous blue on top
The bundled skins were an ad-hoc set that had drifted from anything
recognisable. They are now forks of the VS Code themes people already know,
produced by the repo's own marketplace converter rather than transcribed by
hand, so each palette is byte-identical to what installing the extension
would give you.

`nous` keeps GitHub's chrome and carries the brand blue as its accent. Two
seeds, one colour: `#0053fd` reads at 5.4:1 on the light sidebar but only
3.6:1 on the near-black dark one, so dark carries `#4a84fe` — the same hue
at 263°, lifted to clear AA at 5.9:1. Everything else in both palettes is
upstream's, and a test holds that line.

`github` ships alongside it, unmodified, so the original stays available on
its own terms instead of only existing as the thing nous diverged from.
Catppuccin, Everforest and Solarized join them; the skins nobody could name
are retired, with `midnight` folded into the retired list so anyone sitting
on it lands on nous rather than a dead name.
2026-08-20 02:28:24 -05:00
Teknium ad7a14a539 fix(desktop): renamed Bot Mode agents stay @-taggable by their new name
Renaming a bot (Bot Mode title or 'hermes profile rename' display_name)
changed the roster row but not what the user could @-tag it with — mentions
still only resolved the original profile handle, and the composer
autocomplete never offered the new name.

- mentionNameForms()/botFriendlyNames()/botMentionTag(): one resolver for
  the taggable forms a friendly name yields (slugged + collapsed), with
  reserved tokens (hermes/default/everyone/all/user) excluded so a rename
  can never hijack them.
- resolveRosterMentions() and parseGroupChatMentions() accept the friendly
  forms alongside the profile name/handle (both keep working).
- Composer @ autocomplete (global provider + group-room popover) inserts
  the renamed tag and prefix-matches on tag, handle, and display name.
- Mention middleware's cold-cache fallback now runs the same resolver
  instead of a bare-names-only parse, so renamed tags resolve there too.
- durableGroupChatMembers persists title/display_name so renamed-tag
  mentions survive connection switches in cross-machine rooms.
- Docs: bot-mode.md documents renamed tags.
2026-08-20 00:08:46 -07:00
Brooklyn Nicholson dc90b1b31d feat(desktop): the composer collapse ladder continues below stacking
Stacking was the ladder's last rung, but a tile can be dragged far narrower
than the stacked controls row costs. Two more width stages, from the same
measured-width engine: under 260 the three voice toggles fold into the one
menu HUD mode already uses, and under 180 the model pill and the menu drop
too — input and Send, nothing else, down to the 80px pane floor. The model
pill also shrinks and truncates between stages instead of holding its width,
and the metrics hook returns one ComposerFit so a resize re-renders only when
a stage actually flips.
2026-08-20 02:06:35 -05:00
Brooklyn Nicholson b656b0d3ba fix(desktop): the composer surface can no longer be widened by its own content
The surface is a grid that never declared a column, and an implicit auto
column sizes to its items' min-content. The coding status row (branch, PR
chip, worktree path, counts — none of it wrapping) out-measured narrow panes
and silently set the track wider than the surface; every w-full child laid
out against that phantom width and overflow-hidden clipped the right edge,
send button first. grid-cols-[minmax(0,1fr)] pins the track to the surface.
2026-08-20 02:06:35 -05:00
Brooklyn Nicholson b8850e17b5 fix(desktop): floating sidebar overlays keep an opaque background under glass 2026-08-20 01:13:51 -05:00
Gille 9ae7247616 fix(desktop): skip source restore in auxiliary windows 2026-08-20 01:04:42 -05:00
Gille c25aa35e1a fix(desktop): keep peer windows on shared gateway 2026-08-20 01:04:42 -05:00
Brooklyn Nicholson f983828077 fix(desktop): stamp the focused profile on projects RPCs and refresh a remote scan
Remote mode used to return before asking the host for repos, and never sent
profile, so the sidebar stayed on the launch list. Ask discover_repos to scan,
forward the focused profile on every projects call, and drop late responses
from a profile the user already left.

Co-authored-by: Chen Jin <Enough1122@users.noreply.github.com>
Co-authored-by: Ryan Weddle <weddle@gmail.com>
Co-authored-by: izumi0uu <izumi0uu@gmail.com>
Co-authored-by: webtoolbox <1911826+webtoolbox@users.noreply.github.com>
2026-08-20 01:02:17 -05:00
hermes-seaeye[bot] bbb7b607b4 fmt(js): npm run fix on merge (#90552)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-20 05:49:39 +00:00
Brooklyn Nicholson bf4f5e17f8 feat(desktop): show unread count on the sessions sidebar toggle
A small overlay on the left sidebar icon (right when panes are flipped) so a
closed sessions list still reports unfinished-unread chats.
2026-08-20 00:42:21 -05:00
Brooklyn Nicholson 508b3cf65d feat(desktop): count unread sessions from the shared status map
The titlebar badge needs the same unread answer the green dots use, without
double-counting lineage aliases that are not listed rows.
2026-08-20 00:42:21 -05:00
Brooklyn Nicholson cce0427905 fix(desktop): authenticate gated file downloads like REST
saveGatewayFile rode the OAuth cookie partition even when hermes:api already
held a native bearer, so listing worked and Download 401'd.

Co-authored-by: 686f6c61 <github@00b.tech>
2026-08-20 00:39:56 -05:00
Brooklyn Nicholson b7b6ee0118 fix(desktop): name the gated file-download auth decision
Downloads have to present the same bearer-vs-cookie choice as oauth REST.
A cookie-only save against a cookieless native session is the Files-panel 401.

Co-authored-by: 686f6c61 <github@00b.tech>
2026-08-20 00:39:56 -05:00
Brooklyn Nicholson ddb305c602 fix(desktop): give mermaid diagrams a pixel size in the overlay and on copy
Mermaid emits width="100%". Inside the zoom viewer's shrink-to-fit grid
that percentage can collapse, and svgSize's parseFloat("100%") made a
100px PNG so copy fell back to raw SVG text.

Co-authored-by: Robert Mohid <rmohid@gmail.com>
2026-08-20 00:31:43 -05:00
Brooklyn Nicholson 6a3a1f411b fix(desktop): mermaid zoom overlay body collapsed to zero height
The Dialog shell is a fixed-height flex column, but the body had no
flex-1. The toolbar is absolutely positioned, so the in-flow stage had
nothing to resolve against and clipped the SVG.

Co-authored-by: Anuvrat Rastogi <anuvrat.rastogi@sap.com>
2026-08-20 00:31:43 -05:00
hermes-seaeye[bot] e1d54926a1 fmt(js): npm run fix on merge (#90536)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-20 05:18:09 +00:00
Brooklyn Nicholson 9c12d2d6c3 feat(desktop): unfocused session panes recede
With two sessions tiled side by side nothing said which one you were in —
both painted at full strength, both composers looked live. The unfocused
surface now fades and desaturates as one layer (thread, timeline rail,
composer, header together), so the focused conversation is the one with
colour in it. Light and dark carry their own opacity; a single pane never
dims, since focus falls back to the primary's selection.

The sidebar gains the matching half: every session open in a pane keeps the
active band, the unfocused ones at reduced strength through their own mixed
token — a colour rather than row opacity, which would have dimmed the title
and status dot with it.
2026-08-20 00:12:20 -05:00
Brooklyn Nicholson db72b41485 fix(desktop): scrollbars stop carrying the theme accent
The thumb mixed from --dt-midground, so every list had a small tinted bar in
its corner competing with real accent-coloured UI. A new --dt-scrollbar-thumb
derives from the same colour with chroma forced to zero, keeping each theme's
lightness — so the thumb still sits correctly against its own surfaces, just
without the hue. Alpha steps and the Firefox fallbacks are unchanged.
2026-08-20 00:12:20 -05:00
Brooklyn Nicholson 83fac2cf2d feat(desktop): one theme list in the palette, with a mode toggle inside it
The picker split every palette across a Light and a Dark group, so a
built-in appeared twice and picking one silently set the mode too. It now
mirrors Appearance settings: light/dark/system rows, then every theme once,
applied on top of whichever mode is selected.

Mode rows preview on highlight like theme rows already did — system resolves
through the live prefers-color-scheme query, so it previews what committing
it would actually give you.
2026-08-20 00:12:20 -05:00
hermes-seaeye[bot] 7156d0d658 fmt(js): npm run fix on merge (#90523)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-20 05:00:29 +00:00
Brooklyn Nicholson a662d08f37 refactor(desktop): replace every window.confirm with the shared dialog
Ten prompts — deleting sessions, cron jobs, credentials, endpoints and
providers, plus the settings and memory resets — were raw Chromium modals:
unstyled, blocking, and nothing like the rest of the app. Lint now rejects
the native globals so they can't come back.
2026-08-19 23:52:23 -05:00
Brooklyn Nicholson 21198d9401 feat(desktop): add confirm() as the imperative front door to ConfirmDialog
Handlers that need the answer inline had no way to reach the shared dialog
without hoisting state and a JSX mount into their component, so they all
reached for window.confirm instead. This mirrors notify(): a store action
carries the question, one host at the shell renders the real ConfirmDialog.
2026-08-19 23:52:23 -05:00
Brooklyn Nicholson 5df9cd27ea feat(desktop): let ConfirmDialog carry a secondary action
The worktree removal prompt offers a third way out — hide the lane but leave
the worktree on disk — which is why it was still hand-rolled. One optional
slot between Cancel and Confirm covers it, and it keeps Confirm as the
focused button so Enter still means the destructive action.
2026-08-19 23:52:23 -05:00
Brooklyn Nicholson 1e26c02de6 refactor(desktop): route cron delete and review revert through ConfirmDialog
Both were hand-rolled copies of the shared confirm — same two-button shape,
same busy/close beat — and neither answered Enter. Folding them in drops the
duplication and picks up the focus fix.
2026-08-19 23:52:23 -05:00
Brooklyn Nicholson bb0e9ee95a fix(desktop): confirm dialogs take focus so Enter confirms
The delete-session dialog opted out of Radix's autofocus, which left focus
on the sidebar row that opened it — Enter re-activated the row instead of
confirming, and ConfirmDialog's Enter handler never saw the key.

ConfirmDialog now focuses its own Confirm button on open. The existing Enter
test fired the key at the dialog node, so it passed over the bug; it now
fires at whatever actually holds focus.
2026-08-19 23:52:23 -05:00
Brooklyn Nicholson bfcfdb30d1 test(desktop): prove the status bar keeps its own right-click menu
The unit test covers the primitive contract — the marker survives Radix's
asChild Slot merge. This adds the end-to-end half: mount the real coordinator
next to the real status bar, right-click it, and assert the customize menu
opens while the app fallback stays shut. That is the assertion that fails on
a build where the two halves drift apart, and it holds regardless of how the
ownership marker is spelled.

Drops the hand-stamped DOM fixture that asserted the coordinator honors an
attribute the test itself wrote.

Co-authored-by: huklaa <huklaa@users.noreply.github.com>
2026-08-19 23:51:10 -05:00
aydnOktay 2d6d7c550f fix(desktop): keep Radix context menus when asChild overwrites data-slot
The app-wide context-menu coordinator recognizes surfaces that own a Radix
menu by `[data-slot="context-menu-trigger"]`. Radix `asChild` merges as
mergeProps(slotProps, childProps), so a child that sets its own `data-slot`
wins and the marker never reaches the DOM. The status bar footer is
`data-slot="statusbar"`, so the coordinator swallowed its right-click and
showed the window-verbs fallback instead — leaving every default-hidden
status bar item, the context meter included, unreachable from the UI.

Stamp a dedicated `data-hermes-context-menu-trigger` after `{...props}` on
ContextMenuTrigger and bail on that marker. Any asChild surface with its own
`data-slot` is covered, not just the status bar.
2026-08-19 23:51:10 -05:00
Teknium 6851841112 fix(bot-mode): group chat opens as one room pane, not two (#89788)
Opening a Bot Mode group chat painted the room twice — once as a main-window
workspace tab (host.openWorkspace) and once as the in-panel fallback, because
the Bots pane rendered off $groupChatWorkspace alone. Two live panes with
independent drafts drove one shared engine, and the roster disappeared behind
the duplicate.

The in-panel room is the fallback surface, not a second copy: it now renders
only while no main tab owns the group. The selection atom stays set either way
so the roster row still highlights, and desktops without the door — or whose
door throws — keep the in-pane room.

Consolidates #89881, #90274 and #90398, which fixed the same bug.

Closes #89788

Co-authored-by: helix4u <helix4u@users.noreply.github.com>
2026-08-19 23:12:25 -05:00
Brooklyn Nicholson 145cd763ca feat(desktop): drag markdown table columns to resize them
A colgroup of percentages is the only state, so widths never touch the
cells: one <col> per column, table-layout fixed, and the browser does the
rest. A drag moves one seam and the pair either side trade width, so the
table box never changes size mid-drag — no reflow of the message around
it, no scrollbar appearing under the pointer.

Handles are markup inside each <th>; the table listens once and resolves
the grabbed seam from the DOM, so there is no context, no per-column
component, and no index threading. Tables stay in auto layout until they
are resized, and double-clicking a seam hands them back to it — the same
reset gesture the pane sashes use.

On a 43-row table a 40-step drag mutates 78 col[style] attributes and
touches no cell.
2026-08-19 23:10:25 -05:00