Commit Graph

3912 Commits

Author SHA1 Message Date
Brooklyn Nicholson 71afc8155e docs(desktop): record why the Bots-home new-chat refusal is gone
Deleting the Bots home deleted the dead end that "Select a Bot or group
first." was apologizing for: with nothing selected the center is now an
ordinary session and + works there. The refusal still stands for the cases
that remain — a group room, and a selected row too orphaned to route.
2026-08-27 23:23:47 -05:00
Brooklyn Nicholson 832ec82f75 fix(desktop): mount every chat.empty contributor, not just the first
Ownership of an empty transcript is per session and is not known until each
plugin has loaded its own data — which is why the slot mounts contributors
rather than resolving a claim up front. Taking only contributions[0] then let
a plugin that DECLINES a session suppress the one that owns it: silently,
permanently, and only for the users whose installed plugins happen to
register in that order.

Every registration is mounted and answers for itself. Declining is free; two
claiming one session render both, a visible conflict instead of a silent drop.
2026-08-27 23:23:47 -05:00
Brooklyn Nicholson 0f4d4d4d8b fix(desktop): subscribe the bot-chat flag to every store it reads
The composer subscribed to $sessionStates while isBotChatSession reads the
scope set — and, to resolve a live id to the stored one it is filed under,
$sessionTiles too. It stayed roughly right only because $sessionStates
republishes per streaming token, so the stale answer was overwritten within a
frame. A quiet session had no such luck.

Adds useStoresSelector beside useStoreSelector for the general case: a scalar
whose inputs span several stores, recomputed when any notifies, still
re-rendering only when the scalar changes.
2026-08-27 23:23:43 -05:00
Brooklyn Nicholson 19ff8e66f3 test(desktop): port the bot-meta v1 -> v2 migration suite
The migration, its commit marker, and the rollback around it are all still
live in data.ts, but the suite covering them went out with the vm/regex
harness and was never ported — leaving only the happy-path commit ordering
asserted incidentally by bot-delete.

Nine cases: the sole-local topology gate (and the two refusals — multi-source,
and a batch where any one profile has no local route), the marker rule that
makes v2 authoritative (committed, markerless, crash-window), and the three
failed-commit paths (roll back to the last committed generation, clear both
keys when there is none, survive a failed cleanup).

migratedLocalRoutes is module-private, so where the old suite asserted the
map's size this one asserts what the map is for: no route adopted, nothing
written. Mutation-checked — dropping the marker rule fails three of them.
2026-08-27 23:23:43 -05:00
Brooklyn Nicholson 8ab34a76d0 fix(desktop): staleness-probe the adopt-on-conflict canonical open
The adopt branch runs a full extra round-trip past the point every sibling
open in createCanonicalChat is probed at — registry miss, mint, title
conflict, re-consult — so it is the likeliest of them to land after the user
has clicked another bot, and it was the only one that navigated unguarded.

Adopting the winner still settles identity, which is always correct to
return; only the workspace steal is gated.
2026-08-27 23:23:38 -05:00
Brooklyn Nicholson 008bc186ca fix(desktop): a bot row click returns to its open tabs instead of re-opening a closed Bot Chat
Ports 7c91079 onto the split modules — it landed on main in plugin.js, which
this branch deletes.

Every roster click resolved the bot's canonical Bot Chat by name and opened
it as a tab. Nothing records a tab close (this plugin keeps no closed set;
core's tile bucket only forgets), so a Bot Chat the user had closed came back
beside every newer thread on every bot switch. A click is now "go to this
bot": when the bot's workspace already holds tabs, the one the user last had
active is fronted and no chat is resolved or opened. The forever-chat opens
only when the bot has nothing open, or on the explicit ask — a new "Open Bot
Chat" row-menu item.

The claim such a click records carries only the fronted tab, so the reclaim
listener skips it and cannot resurrect the closed chat. focusExistingBotTab
is feature-detected, so an older shell keeps opening the canonical chat.

Dropped from the port: the Bots home "Open chat" button, a surface this
branch removes. The .mjs test is replaced by a real one — its two
source-reading cases become a render of the menu item in bot-row.test.tsx
and, for the reclaim guard, the claim-shape invariant the guard reads.
Stubbing usePluginI18n there also means the row's localized labels render as
text in tests instead of empty.
2026-08-27 22:51:39 -05:00
Brooklyn Nicholson 6559448306 Merge origin/main into bb/bot-mode-design-system
Keeps plugin.js and its new .mjs test deleted. main's closed-chat fix
(7c91079) landed in both; it is a real behaviour change, so the commit that
follows ports it onto the split modules rather than dropping it with the
files. Its core half — focusWorkspaceOwnerSessionTile and the
host.focusOpenWorkspaceSession verb — merged cleanly and is used as-is.
2026-08-27 22:51:30 -05:00
Zeus-Deus 7c910793bf fix(desktop): a bot row click returns to its open tabs instead of re-opening a closed Bot Chat
In Bot Mode every roster click resolved the bot's canonical "Bot Chat" by
name and opened it as a tab. Nothing records a tab close (the plugin keeps no
closed set; core's tile bucket only forgets), so a Bot Chat the user had
closed came back beside every newer thread on every bot switch — close it,
start a new thread, visit another bot, come back: two tabs again, forever.

A row click is now "go to this bot": when the bot's workspace already holds
tabs, the one the user last had active is fronted and no chat is resolved or
opened. The canonical chat is opened only when the bot has nothing open, or
on the explicit asks — a new "Open Bot Chat" row-menu item and the Bots home
"Open chat" button (`openRosterBot(bot, { canonical: true })`).

- session-states: `focusWorkspaceOwnerSessionTile(ownerKey)` fronts the
  owner's remembered-active tile (else its most recent) and reports it.
- sdk: `host.focusOpenWorkspaceSession(ownerKey)` exposes it to plugins;
  feature-detected in the plugin so older shells keep the canonical open.
- hermes-bots: `focusExistingBotTab` short-circuits `openRosterBot`; the
  claim it records carries only the fronted tab, and the session.reclaimed
  re-resume now skips such claims so it cannot resurrect the closed chat.

Tests: vitest for the core helper, a node test for the click path (open tabs
win, nothing open → canonical, explicit canonical, older shell, throwing
host), and an e2e that seeds two bots with real "Bot Chat" rows, closes one,
starts a thread, switches bots and back, and asserts the Bot Chat stays
closed until asked for explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 20:30:49 -07:00
brooklyn! 1acd5bb02b feat(desktop): make tips and guided tours both opt-out (#96835)
Tours had no switch at all, and the tips switch only covered the app's
own rotation — so "I don't want these" was answerable for half of one
feature and none of the other. Both are now a row in Settings →
Appearance, on by default, and off means off for Hermes as well: an
agent tip is dropped at the bridge and a tour request is refused in
words, so the agent hears that the walkthrough didn't run instead of
narrating a spotlight nobody can see.

Renames $tipRotationEnabled to $tipsEnabled to match what it now
governs. The storage key keeps the old name on purpose — renaming it
would read as unset for anyone who had already turned tips off, and
silently turning them back on is the one outcome worth avoiding.

The switches stop at the app's edge for now: the tools are still in the
model's schema and the calls simply don't land. Withdrawing them
entirely needs the setting to reach the backend, which is the next PR.
2026-08-27 22:17:00 -05:00
hermes-seaeye[bot] 4cbbe11ffc fmt(js): npm run fix on merge (#96837)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-28 03:15:46 +00:00
brooklyn! 4bd2793367 feat(desktop): default the in-app tip rotation on (#96831)
Made opt-in when it fired a tip 45 seconds into a launch and another
every six minutes, which is a cadence that owes you a choice. The pacing
has since become a settling delay of five to ten minutes per launch and
a six-hour cooldown persisted across them — roughly a tip a day, weeks
to walk the catalog. At that weight the switch has nothing left to
protect anyone from, and a discovery feature nobody meets is one nobody
has. The switch stays for whoever still wants it off.
2026-08-28 03:10:46 +00:00
hermes-seaeye[bot] 7e7fc6445b fmt(js): npm run fix on merge (#96833)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-28 03:10:27 +00:00
Brooklyn Nicholson 595ee92289 fix(commands): put desktop slash metadata on the CommandDef
Inference is now any args_hint without subcommands → text. Mixed is the
only remaining hint-token path. desktop= and the few argument_mode
overrides live on the registry entry; the side tables are gone. Catalog
aliases get their own dict copy. Composer tests seed the catalog so
/goal stays mixed without an overlay row.
2026-08-27 22:05:40 -05:00
Brooklyn Nicholson 60f58249e5 fix(desktop): resolve slash commands from the catalog
The overlay table is only actions, pickers, and RPCs. Completions group
by backend kind, and argument mode comes from the warmed catalog so
/review and plugin commands stay typeable.
2026-08-27 22:05:40 -05:00
Brooklyn Nicholson e870d3fde3 fix(desktop): don't let slash Space steal a leftover highlight
Space and Enter should complete a prefix (/com → /compress) but keep an
exactly typed name, so leftover /compress cannot replace /review.
2026-08-27 22:05:40 -05:00
Brooklyn Nicholson bbcf5691a6 Merge origin/main into bb/bot-mode-design-system
main's Bots-home flash fix (6f8be61) landed in plugin.js, which this branch
deletes. Both files stay deleted: the guard it adds (botOpenInFlight gating
botsHomeMayOpen) protects a surface this branch removes, and the two sites
where it renamed the generation bump to cancelBotOpen already bump here via
bumpBotOpenGeneration in shared.ts.
2026-08-27 21:52:40 -05:00
Brooklyn Nicholson 198a69421c feat(desktop): pace the tip rotation in hours, not minutes
A first tip 45 seconds in and one every six minutes after walks the
whole catalog in an hour, which is the cadence of a notification rather
than a nicety. Games get this right by being almost absent: a tip while
you settle in, then nothing for the rest of the day.

Two clocks now have to agree. A per-launch settling delay of five to ten
minutes means opening the app is never met with a bubble, and a six-hour
cooldown persisted across launches means quitting and reopening isn't a
way to farm them — the old schedule lived in the effect and re-armed on
every mount. Flipping the switch on skips the settling delay and offers
immediately, since that clock guards a launch you came into with a
purpose, not a deliberate opt-in.

An agent tip starts the cooldown too: whoever just pointed at something,
the user has had their one interruption for a while.
2026-08-27 21:50:18 -05:00
Brooklyn Nicholson 0357982696 feat(desktop): make the idle tip rotation opt-in, ungate the tool
The two halves of tips were behind one switch, which meant the app
volunteering commentary at idle shipped on by default. Split them along
the line that matters: the rotation talks unprompted, so it now waits to
be asked for, while an agent tip stays ungated like the tour it mirrors
— Hermes raises one mid-conversation, in answer to something the user
said.

Drops the tool's config gate along with the config key it read. The
renderer mirrored that key with config.set, which has no branch for it
and answered "unknown config key" into a swallowed catch, so the opt-out
never reached the backend in the first place.
2026-08-27 21:50:18 -05:00
Brooklyn Nicholson 911c6c50d3 feat(tools): let Hermes point at one thing with the tip tool
The quiet sibling of `tour`, in the same `desktop_ui` toolset and reading the
same `tour(action='targets')` discovery call: one bubble with an arrow, for a
sentence that would be clearer with a finger on the thing it's about. Dimming
the whole app to say "the model name is a button" is the wrong weight.

Fire-and-forget rather than a round-trip, because a tip is not a question and
blocking the turn on one would stall the reply it belongs to. The renderer
enforces the user's opt-out itself, so a stale config read can never put a
bubble on a screen that asked for none.
2026-08-27 21:50:18 -05:00
Brooklyn Nicholson baaf304992 feat(desktop): in-app tips
An ambient rotation that points at parts of the app you may not have found yet
— one accent bubble, an arrow, and an outline around the subject. No scrim and
no spotlight: a tip is a pointer beside your work, not a modal in front of it,
so it takes no focus, owns no Esc, and blocks nothing.

It only speaks when the app is genuinely quiet — nothing streaming, no dialog,
menu or tour up, window focused, a few seconds since the last keystroke — and
walks the catalog in order rather than shuffling, so tips arrive as a tour of
neighbouring parts of the app instead of unrelated ones. A tip with nothing on
screen to point at is skipped, not waited for.

Closing one with its ✕ retires it for good. That is the whole reason the ✕ is a
heavier gesture than letting the bubble time out, and Settings → Appearance is
the only way back — alongside the switch that turns the feature off entirely.
2026-08-27 21:50:18 -05:00
Brooklyn Nicholson 46512ee1d6 feat(desktop): give the app's main surfaces durable handles
Tours and tips both address elements by selector, and everything they most want
to point at — the composer, the model pill, the nav rows, the profile rail, the
right-pane toggle — was reachable only by icon, position, or a translated
aria-label. None of those survive a re-render, a theme, or a locale change.

Two handles are needed per surface, not one, because where an arrow points and
what an outline wraps are different questions: a nav row's label carries the
`data-tour` handle so an arrow lands at the end of the word, and defers the
outline to the row via `data-tip-arrow-only`.

The collector also has to skip panes hidden by the keep-alive stack. An inactive
tab stays mounted under `visibility: hidden` to keep its scroll position, so its
rect is identical to the live tab's and no geometry test separates them — which
is how a tour could spotlight a background tab's composer.
2026-08-27 21:50:18 -05:00
Brooklyn Nicholson 50f816abaf feat(desktop): add an accent variant to the popover primitive
The default popover is glass over the app's own chrome, which is right for
something the user opened and wrong for something the app said. The accent
variant fills the same box — same arrow, same placement engine — with a solid
brand colour so an unprompted surface reads as the app speaking.

Filling it with `primary` directly doesn't work across themes: a pale accent is
a perfectly valid primary (imported VS Code themes love a pastel), and the
honest `primaryForeground` for one is near-black, so the loud surface comes out
a pastel card whispering. `--dt-primary-solid` deepens the hue until a light
foreground clears AA — a no-op on an accent that is already deep, and darkening
only, so the hue survives.
2026-08-27 21:50:18 -05:00
Brooklyn Nicholson 355be02793 style(desktop): prettier over this branch's own eslint --fix output
The curly pass left one-line { return x } bodies behind; prettier expands
them. Formatting only.
2026-08-27 21:47:23 -05:00
Brooklyn Nicholson d7f6ef8a17 fix(desktop): three latent bugs in the bot rail, and the dead declarations
groupChatSyncMemberKey keyed on a field the descriptors never carry, so
every member hashed to the empty string and the round engine saw one member
where there were several; it keys on botRosterKey now, which is durable
across machines. botMetaWriteAt grew an entry per write and never dropped
one — it prunes past 60s, which is longer than the echo it exists to
suppress. aliasRouteIndex replaced the whole map on rebuild, so a slower
rebuild finishing second clobbered a newer one; a generation token means
only the newest result lands. Regression tests for each.

Dead since the split: EYE_X/EYE_Y, generatedSessionTitle, enabledMcp,
groupChatSyncDeletedRevision — each down to a declaration with no reader.
The bot source-status labels were half-localized, so they moved onto one
helper here rather than in the i18n pass. no-redeclare is disabled inline
over the two overload pairs it misreads, rather than in the shared config.
2026-08-27 21:47:04 -05:00
Brooklyn Nicholson 99cae5b9f6 refactor(desktop): put the bot dialogs' one-offs on the shared primitives
A raw checkbox in the routine editor where the SDK already exports one. The
same resizable-panel style block inline in three dialogs, now a
ResizableFrame beside the other dialog parts. Four inline styles that were
Tailwind spelled longhand — model-picker's was literally flex flex-col
gap-2. The twenty that remain are computed grid columns and avatar sizes,
which have to stay inline.
2026-08-27 21:46:58 -05:00
Brooklyn Nicholson fa236b31ff i18n(desktop): localize the strings the bot rail still hardcoded
Both roster filter menus, the avatar picker's tabs. Renamed group.newDesc to
group.manageDesc since it describes managing an existing group, not making
one.

The getPluginCtx()?.i18n.t() sites only guarded the context, not i18n on it
— a plugin context without the bundle threw mid-render and painted an empty
rail. Guarded both.
2026-08-27 21:46:54 -05:00
Brooklyn Nicholson 716564531b fix(desktop): bound the two bot-rail caches that grew for the window's life
petFrameCache is keyed by spritesheet URL over a 4500-pet gallery and holds
decoded PNG data URLs, so scrolling pinned every pet you passed until the
window closed. Capped at 120 — five pages, so scrolling back stays instant
and a miss only re-pays the fetch and crop. relayAgentsCache already swept
stale ids, but the sweep sits behind an early return that a shrink to one
connection skips; capped at 32, safe because every live connection is
rewritten each cycle so eviction can only reach ids that stopped being
fetched.

relay.ts also carried eight loose module-level lets, the state outlier
across the plugin's modules. Nothing renders from them, so they stay module
scope — collapsed into one record rather than moved to a store.
2026-08-27 21:46:49 -05:00
Brooklyn Nicholson ff5c5b4ae8 refactor(desktop): compose the shared lead cell instead of copying it
Six verbatim copies of the leading-glyph box down to two owners: transcript
lines get SCAFFOLD_GLYPH_CLASS from scaffold-row, sidebar rows get
SIDEBAR_ROW_LEAD. The bot rail's GatewayKindGlyph was a fork of core's
ConnectionGlyph down to the icon set, so it wraps the real one now and the
duplicate kind-to-icon tables are gone.
2026-08-27 21:46:45 -05:00
686f6c61 6ac193e02b fix(desktop): refetch Bot Chat on roster reopen instead of idle snapshot
forceResume already requested a main-route resume, but Bot Chat is a
tile. Reopening reused the warm cached transcript and skipped REST, so
cron bot-chat deliveries that landed while the panel was closed stayed
invisible until app restart. Refresh the tile transcript on explicit
open and merge the persisted tail into the cache.
2026-08-27 19:46:43 -07:00
Brooklyn Nicholson 610d1ed0da refactor(desktop): give the sidebar row geometry and a bounded cache one owner
Row geometry lived inside chrome.tsx as private consts, so anything that
wanted to line up with a session row copied the literals instead — the lead
cell alone appeared verbatim in six files. Its own comment warns that owning
height anywhere else makes rows float 1-2px off sessions, which is exactly
what a copy invites. Split the measurements into row-geometry.ts, keep the
public names re-exported from chrome.tsx, and put the lead cell and
ConnectionGlyph on the plugin SDK so a plugin composes the same box rather
than re-deriving it. ConnectionGlyph takes a className now so a caller can
tint it without forking the component.

LruCache is the same move for a different leak: katex-memo.ts had a private
one with a hardcoded ceiling, and the bot rail had two Maps that never
evicted. One shared class, exported, katex's twin deleted.
2026-08-27 21:46:40 -05:00
Gille 6f8be61516 fix(desktop): prevent Bots home flash during chat switch 2026-08-27 19:44:14 -07:00
Brooklyn Nicholson 0fececa733 chore(desktop): lint the bot-mode modules clean
The plugin shipped as bundled JavaScript, so eslint never saw it. Now that it
is .tsx under src/, the whole ruleset applies: sorted JSX props, curly braces,
statement padding, unused imports.

Three of the ref writes the atom-mirror rule flagged are the cases its own
comment carves out — a scroll-position tracker fed by a DOM listener, a
previous-value tracker for the hidden -> visible edge (lagging a render IS its
contract), and a timer handle cleared on unmount. Those get the documented
disable. The fourth was a genuine mirror: McpSetupButton copied its profile
prop into a ref every render so two callers could read it. They read the prop
directly now, and the ref holds only the profile the component creates on
demand for the New Bot flow.

no-redeclare counts a TypeScript overload signature as a redeclaration of its
implementation, which is what botSelectionKey and botMetaKey tripped. Swapped
for the TS-aware version alongside the no-undef swap already there; hermes-ink,
whose vendored yoga bindings merge a const and a type under one name, extends
its existing carve-out to the new rule name.
2026-08-27 20:05:36 -05:00
Brooklyn Nicholson 32ca343c83 fix(desktop): /new inside a bot chat compared against a property that does not exist
A bot's canonical chat is the relationship — /new inside one would fork it into
a scratch session, so the composer reroutes /new to /compact there. The guard
deciding "is this chat the canonical one" read host.activeSessionId, which is
not on the host: the real atoms are host.state.activeSessionId (runtime id) and
host.state.focusedStoredSessionId (stored id). The optional chain swallowed it,
the comparison ran against null every turn, and /new reset forever-chats for as
long as the guard shipped.

It reads the focused STORED id now, which is the id space canonical_session
reports in. The comparison itself moves into isCanonicalChatOnScreen so a test
can drive it — matching either the durable registry row or the
compression-lineage tip, since a compacted Bot Chat is on screen under its tip
id while the registry still names it by the root.
2026-08-27 20:05:32 -05:00
Brooklyn Nicholson d3df1a36a8 test(desktop): port the bot-mode suite off the .mjs vm harness
The old harness sliced source text out of plugin.js, re-evaluated the
fragments through vm.runInNewContext, and in a good number of files simply
regex-matched the source for a symbol name. Nothing it asserted survived the
split into modules, and its blind spot was load-bearing: the /new guard
regex-matched clean for as long as it shipped dead.

These are the same contracts driven against the real modules through the real
imports, colocated beside the code they cover. Files whose only content was a
source regex or a re-implementation of the function under test are dropped
rather than translated.
2026-08-27 20:05:28 -05:00
Brooklyn Nicholson e4bd1a0a78 feat(desktop): give a bot's empty chat its own face and name
An untouched bot chat was blank: core's splash stands down for any
session that exists, and nothing took its place. Claim the new
`chat.empty` slot and title the chat with the bot's face above its name
in the splash's lettering, so an empty conversation still says whose it
is. Rendering "HERMES AGENT" there would have been the wrong identity
for the surface.

The transcript hands its slot the RUNTIME session id while a canonical
Bot Chat is keyed by its stored one — the two id spaces behind the
#93080 misroute — so identity resolves through the focus store the rest
of the plugin already trusts. Metadata is read with `botRosterMeta`
rather than by name: it is keyed by the route it came from, so a by-name
read misses the entry a bot's avatar and rename actually live under.

The name, not the stack, sits on the center line; the face hangs above
it by half its own block.
2026-08-27 19:08:28 -05:00
Brooklyn Nicholson 45176219a2 feat(desktop): let a plugin title an empty chat it owns
Core has one empty state, the intro splash, and it belongs to a fresh
draft with no session selected. A session that exists but has nothing in
it yet falls outside that, and whoever opened it is the only one who
knows what should stand in the gap — core has no business learning a
bot's face and name.

Add `chat.empty` as a contribution area, resolved by the transcript the
same way `transcript.directives` resolves an inline widget. A
contribution mounts for every empty session and renders nothing for the
ones it does not own, so it can subscribe to its own stores and appear
once they load.

Pull the splash's lettering out of `intro.tsx` into a `Wordmark`
primitive so a second caller cannot fork it, and move the typeface,
weight and tracking into `.wordmark` beside the `.fit-text` rules they
travel with — as utilities they were one class-merge or one missed scan
away from silently rendering the wordmark in body text. `Wordmark` takes
its width, because fit-text sizes to fill and a short name set at the
splash's full width comes out enormous.
2026-08-27 19:08:28 -05:00
Brooklyn Nicholson 67854b50b0 fix(desktop): bot chats share core's unread and drop the branch rail
Bot Mode kept its own unread map, so a bot's dot and the session dot could
disagree about the same chat. Route it through core's store instead, keyed
by the canonical chat's stored id. A hidden session has no listed row to
read a profile from, so the writers take an explicit profile hint rather
than assuming the live gateway's — otherwise the marker persists into a
bucket that never held it.

The composer also hands a blank repoPath to the branch/worktree rail in a
bot chat. The row already hides itself without a repo and stops probing git
and GitHub with it, so one prop does what a second composer would have.
2026-08-27 19:08:28 -05:00
Brooklyn Nicholson 35b53af10d fix(desktop): stop the main zone vanishing behind Bot Mode
Contributions could scope themselves to a workspace, and Bot Mode scoped
every sessions pane out of the center. Close the last bot chat and the main
zone had nothing left to render, so it collapsed and the sidebar stretched
across where the app used to be. Bot chats are ordinary tabs in the main
strip, beside session tabs, so the filter goes: workspace mode stays as a
SIGNAL consumers adapt to, never a decision about whether a pane renders.

A structural floor backs it up — a subtree hosting the registered main pane
never collapses, whatever happens to its tabs. The shared "+" now falls
through to an ordinary session when the selected owner has no route of its
own, instead of refusing with a toast.

Also lets a pane contribution declare defaultCollapsed, so Bot Mode's
scheduled-jobs pane arrives as the right-edge rail tab rather than an open
zone. It applies when the pane enters the tree, so a user's expand persists
and is never overruled on a later boot.
2026-08-27 19:08:28 -05:00
Brooklyn Nicholson 4fcd16e224 feat(desktop): widen the plugin SDK to what Bot Mode had to reinvent
Every primitive Bot Mode hand-rolled was one the app already had but never
exported: the Panel master-detail family, SessionStatusDot, ColorSwatches +
PROFILE_SWATCHES, RowButton, DisclosureCaret, formatAgo, translateNow, and
the unread store behind the dot. Export them, with docblocks that say which
hand-rolled shape each one replaces so the next plugin doesn't repeat it.

warmAgent/ensureAgent accept an undefined connectionId alongside null, since
a roster row's is optional and both mean "no explicit source".
2026-08-27 19:08:28 -05:00
Brooklyn Nicholson 5afa487e93 refactor(desktop): rebuild Bot Mode on the app's design system
Bot Mode arrived as a 16,933-line plugin.js that reimplemented most of the
app: its own scroll container, color palette, status dots, empty states,
buttons, time formatting and cron surface, none of which could follow the
theme. Split it into 41 focused modules and route every one of those through
the primitives core already ships, so a Bot row now renders the same
SessionStatusDot, swatches and age labels as the session row beside it.

User-facing strings move into a plugin locale bundle instead of sitting
inline, and the UI settles on "bot" as the noun (model-facing prompt text
still says "agent"). The codemod scaffolding that drove the jsx() -> TSX
conversion retires with the conversion.
2026-08-27 19:08:28 -05:00
Brooklyn Nicholson b2e5b1d420 refactor(desktop): convert Bot Mode from hand-written jsx() calls to TSX
hermes-bots/plugin.js was 16,193 lines of hand-written JSX compiler output
— it imported { jsx, jsxs } from 'react/jsx-runtime' and called them
directly. Because the file was .js, eslint (scoped to plugins/**/*.{ts,tsx})
and tsc (allowJs: false) both skipped it entirely, so none of the design
system, import-fence, or type rules that govern the rest of the app ever
reached the largest UI surface we ship on by default.

Converting it back to JSX is an inverse-compile, not a rewrite, so it is
done by script rather than by hand:

- scripts/codemod/dejsx.mjs rewrites jsx()/jsxs() calls into JSX elements.
  735 conversions, none skipped. Comments between children become
  {/* … */} containers, since a bare // in children position is text.
- scripts/codemod/verify.mjs proves the result. It recompiles the .tsx
  through esbuild — an implementation independent of the codemod — and
  compares it to the original after normalizing away esbuild's own
  rewrites (quote style, void 0, numeric format, string/template folding,
  export hoisting) and alpha-renaming every binding per scope. Output:
  IDENTICAL across 333,711 normalized characters.

Two rewrite classes are semantics-preserving but not byte-identical, so
they are named and counted rather than hidden: 12 spread-children folds
(JSX has no spread-children syntax; children={[...xs]} can only be written
{xs}, which React flattens identically and which is the idiom the whole
ecosystem writes) and 1 redundant key prop (passed both in props and as
the third argument; React's jsx runtime never copies key into props).

No behavior change. 16,193 lines become 16,050 of real TSX.
2026-08-27 19:08:27 -05:00
Finn763 253b9d78c1 fix(desktop): keep bot chat focused when clicking the Bots pane (#96062)
Clicking a bot row moved the layout interaction tracker to the sidebar
group, so $focusedStoredSessionId fell back to the primary selection —
which is null in Bot Mode, because bot chats open as tiles and never set
$selectedStoredSessionId. The Bots plugin reads that null 'focused'
edge as 'the chat lost the center', releases its open claim, and the
Bots home re-asserts over the still-visible chat: the UI jumps to the
list instead of staying in the chat.

$focusedStoredSessionId now answers from the main zone's active tile in
Bot Mode before falling back to the selection, so a chrome-sidebar
click no longer fabricates a null edge; a genuinely closed chat (no
tile in main) still surfaces null and the home returns as before.

Regression tests cover the sidebar-click case (red before the fix),
plus guards for the closed-chat and sessions-mode derivations.
2026-08-27 13:48:57 -07:00
Teknium 9a9e9074cb style: sort MINIMIZED_TRACK import (perfectionist lint) for salvaged #95956 2026-08-27 13:48:45 -07:00
Thomas Bekkers dbca7a4f02 fix(hermes-bots): keep the Cronjobs tile registered while it holds focus in Bot Mode
Clicking the Cronjobs tile shifts focus onto the tile itself, momentarily
dropping bot-chat workspace ownership — syncRoutinesPane then unregistered
the pane out from under the user's own click, with no way back. Keep the
tile while Bot Mode is on screen and the tile is the focused surface;
leaving Bot Mode still unregisters as designed. Live-verified.
2026-08-27 13:48:45 -07:00
Thomas Bekkers 584f3a748b fix(desktop): keep a restore tab when a pane or strip collapses (#91223)
Hiding the Sessions/Bots strip, or tapping the header of a lone docked
tile (Cronjobs and any plugin pane beside the workspace), left no mouse
path back: the restore menu lived on chrome the gesture just unmounted,
and a row-collapsed rail could size to 0px.

Treat hide-only chrome as stranded so `never` cannot hide those chips.
Stop collapsing on header tap (chevron only). Size a minimized zone to
MINIMIZED_TRACK and keep the horizontal strip when two or more tabs
remain.
2026-08-27 13:48:45 -07:00
Brooklyn Nicholson a24c12d14f fix(desktop): gate transcript budget cap so Show earlier works
The render-phase cap snapped a visible pane's Show-earlier growth back
on the next render, so the button did nothing. Clamp only hot-hidden
panes, and grow the DOM budget when expanding the store window too.

Supersedes #87686.

Co-authored-by: Kirk <317508070+chukirk-svg@users.noreply.github.com>
Co-authored-by: Per0 <175494353+Per0-1@users.noreply.github.com>
2026-08-27 14:51:13 -05:00
hermes-seaeye[bot] ca2a0d4d6f fmt(js): npm run fix on merge (#96506)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-27 16:29:27 +00:00
HexLab98 c086bb6f71 test(desktop): cover Voxtral JSON unwrap on client-direct STT
Pin the Groq plain-text path and the Mistral envelope so dictation
keeps spoken words, not the raw transcription object, in the composer.
2026-08-27 11:23:40 -05:00
HexLab98 bc737576ba fix(desktop): unwrap Mistral Voxtral JSON in client-direct STT
Mistral ignores response_format=text and returns the full transcription
object. Desktop was dumping that JSON into the composer; pull .text out
so dictation shows the spoken words instead.
2026-08-27 11:23:40 -05:00
fangliquanflq f54d015470 fix(desktop): retain remote owner after session resume 2026-08-27 11:22:35 -05:00