Looking at the tour UI needed an agent turn per iteration. This adds a demo
that walks the Artifacts page — a route step, a late-mounting target,
per-step narration, the return trip — behind three triggers: a floating
button, Ctrl+Shift+X, and a palette row. The button is not redundant; a chord
can be eaten by a menu accelerator or the main process before the renderer
sees it.
DEV-gated and dynamically imported, so it is absent from production builds.
Tours address elements by selector, and a positional nth-child path breaks on
the next re-render. These are the durable handles, applied at the shared
primitive rather than per screen: every route overlay's nav and its rows, every
settings field (keyed by its config schema key, so new fields are named for
free), the filter tabs on any search shell page, and artifact cards.
One edit per primitive covers every screen built from it, which keeps the tour
vocabulary small enough to stay accurate.
The stock driver.js look is replaced with the app's own: unfocused UI fades
toward var(--background) and desaturates, so it recedes in light and dark
without a mode branch. The dimming is masked to a feathered cutout that tracks
driver's eased stage rect each frame, keeping the highlighted element crisp —
a plain backdrop-filter blurs the spotlight too, because it clips to the
element box rather than the cutout.
Popovers enter from wherever they land: left, right, above or below their
target, and a shorter settle for centered narration steps, which have nothing
beside them to measure against. Travel, duration and easing are tokens on
.driver-popover, and the whole thing collapses to a fade under
prefers-reduced-motion.
A tour step that moved the app deadlocked: driver.js checks waitForElement
before running any hook, so the step waited for an element on a page the hook
had not opened yet. The move now happens in the step's own onHighlightStarted
— the one hook driver fires however a step is reached — so clicking Next,
pressing an arrow key and calling the API all behave the same.
Steps also re-bind themselves. driver.js holds the highlight as a node
reference and re-measures it, so a poll or refetch that swapped the node left
it measuring a detached element and the spotlight vanished, looking like the
tour closed itself. One observer now covers both cases: the target that has
not mounted yet, and the one that was replaced underneath.
Orphaned overlays are swept before a tour starts, since driver.js can only
tear down what its own instance built.
Translates the wire payload into a normalized action and dynamic-imports
the engine, keeping driver.js off the boot path. Active session only — a
background turn must never paint an overlay over what the user is looking
at.
The guest page is out-of-process, so the first action injects a
self-contained bundle over executeJavaScript — the driver.js IIFE, its
stylesheet, and the engine source — parked on window globals so later
actions reuse the live instance. Injection is idempotent and vanishes with
the page, so a navigation resets the tour.
This is what lets a tour walk through any web app open in the in-app
browser, not just Hermes itself.
A surface-agnostic walkthrough engine plus the API that drives it.
collectTourTargets scans any document for addressable elements and marks
each selector stable (identity-based, survives a re-render) or positional;
runTourEngine turns one action into a driver.js highlight, a multi-step
tour, or a step change.
Both are written self-contained — no imports, no closures — so the same
source runs in the renderer and, stringified, inside a webview guest page.
Popovers are repainted from the app's own theme tokens, so tours follow
every theme and custom skin.
The named verbs (startTour, showTourStep, nextTourStep, …) are the public
API: the agent tool is one caller, and a feature can ship its own curated
tour through the same entry.
Pure-ESM, MIT, ~5KB gzipped, no runtime deps. Excluded from optimizeDeps:
it only enters the graph through a dynamic import, so letting the scanner
discover it at first use prebundles the ?raw IIFE as a module (breaking the
raw-text transform) and forces a mid-session page reload.
Bot Mode group rooms only showed a single "is thinking…" line while bots
ran, and nothing after a turn settled or failed — no way to see what the
room did without reading the whole transcript.
- Runtime-only, bounded activity feed per room (GROUP_ACTIVITY_LIMIT),
recording truthful turn events: queued, working, replied, passed,
timed-out, failed, cancelled, settled, delivered.
- Every event is tagged with the room epoch it belongs to; the view shows
only the CURRENT run, so a superseding send (or a rename that re-keys
the room) can never surface stale activity.
- Quiet disclosure in the room header: collapsed by default, the collapsed
row shows the latest event summary; expanding lists the current run's
events newest-first with per-state glyphs and tones.
- Never persisted and never hydrated — the transcript stays the only
durable record, so activity cannot be replayed as history.
Tests: 8 new behavioral + source-contract cases in
tests/group-activity.test.mjs (settled arc, failed turn, supersede/cancel,
epoch filtering, bounded feed, runtime-only guarantee, labels, disclosure
a11y contract).
runGroupChatRounds' responder selection had no awareness of the stranded/
harvest state the previous round's harvest pass just confirmed. A member
whose turn timed out (marked stranded) but is STILL genuinely running
could be re-selected as a responder in a later round of the same
invocation — resolveGroupResponders has no busy filter, and a member's
watermark is bumped past the stranded timeout regardless of outcome, so a
fresh delta re-qualifies them.
Re-selecting them fires another prompt.submit into their live session.
tui_gateway's _handle_busy_submit treats that as a normal busy mid-turn
prompt: by default it either redirects the live turn in place or, for
older agents, hard-interrupts it and queues the new text as the next
turn. Either way the member's original in-flight work — exactly what the
stranded/harvest mechanism exists to protect — gets abandoned or killed,
undermining the "never lost, just late" guarantee.
Filter responders against the room's current stranded map (freshly
confirmed by this round's own harvest pass) before selecting who speaks.
A member with a live stranded marker is skipped; the next harvest pass
picks their reply up once it actually lands.
Added a regression test exercising runGroupChatRounds end-to-end with a
member confirmed still-busy: without the guard the round loop resubmits
into their session (asserted via prompt.submit call count); with the
guard it never does, and the marker survives untouched. Mutation-verified:
temporarily reverted the filter and confirmed the new test fails
(2 !== 0) fast, without a real wall-clock wait.
Full hermes-bots plugin suite: 246/246 pass (47 files). node --check
clean on both changed files.
Every host.openSession call in the Bot Mode plugin omitted
keepAllProfilesScope, so the SDK applied its default and flipped
$showAllProfiles back on whenever the target session belonged to a
different profile than the live gateway (sdk/index.ts:
options.keepAllProfilesScope !== false => setShowAllProfiles(true)).
For anyone running more than one profile this silently undid the sidebar
profile filter: narrow Sessions to one profile, click any other bot, and
the unified all-profiles list came back.
Bot navigation is an explicit context switch into that bot's profile, so
pass keepAllProfilesScope: false at every openSession call site (4 on
current main after the plugin.js refactor consolidated the original 7).
Salvaged from PR #89031 onto current main; includes contributor mapping.
nanostores' .listen() never replays the current value the way .subscribe()
does, so the $focusedBotProfile listener in register() only kept
$selectedBot current from the moment it was attached. A disable -> profile
switch -> re-enable cycle (Settings > Plugins) left $selectedBot pointed at
whichever bot was active before the plugin was disabled, so the roster
highlight fallback and Routines scoping could start from a stale bot.
Extract the sync into bindProfileSync(), which reseeds $selectedBot from
the profile store's current value before attaching the listener. This runs
on every register() call, so re-enabling the plugin always starts in sync.
Salvaged from PR #89637 (the pane-precedence portion was superseded on main
by the $focusedBotProfile design; this residual reseed gap remained).
Regression test mimics real nanostores get/listen semantics and fails
without the reseed.
Fixes-residual-of: #89625
`botHandle()` exists so that, per its own comment, "the word 'default'
never surfaces in the UI" — it presents the primary profile as `hermes`.
The roster rows, mention resolution and the group-chat prompt all route
through it. Two preview paths did not, and rendered the raw profile name:
- `GroupRow`'s room preview line built `@${last.from?.name}`, so a group
room read `@default: …` while the bot answers to `@hermes`.
- `previewKind()` returned the raw captured name from the bot-to-bot
delivery prefix, so the `🤖 @<name>` badge and its tooltip could show
`@default` too.
The mismatch is presentation-only, but it reads as a routing bug: the
room says the message came from `@default` while `@default` is not a
handle the mention resolver accepts, so users reasonably conclude
bot-to-bot addressing is broken when it is working correctly.
Both paths now map through `botHandle()`. `GroupRow` passes the matching
member so a bot with a custom handle keeps it; `previewKind` maps the
lowercased sender name, which leaves every non-primary profile unchanged.
Tests: the primary profile resolves to `hermes` and a named profile keeps
its own handle (behavioural, in the existing previewKind suite), plus a
source-shape assertion for the render path matching that file's
convention. Both new assertions fail against the pre-fix source.
Fixes#89484
blobatar 0.2.0 -> 2.0.0 (gen2). Ten silhouettes instead of six:
capsule, triangle, hexagon and droplet join round, organic, boxy,
nub, cloud and sun. BLOB_KIND_TRAIT repinned to gen2 band centers
(empirically verified against the published package: every pinned
value resolves to its named silhouette across seeds). The avatar
picker derives from BLOB_KINDS, so the new shapes appear there
automatically. Note: gen2 remaps most unpinned seed->face mappings
by design (upstream generation change).
The browser bar gets an open-in-external-browser glyph that opens
the tab's live address. The address field gets a copy control on its
right edge, with the same pre-faded inline appearance as the
code-block copy button. Copy always takes the address the field
shows: on a remote gateway, that is the reach-resolved address. The
page verbs (copy URL, open externally, console, DevTools) live here
and not in the guest context menu, which keeps the node-scoped
tools.
One renderer coordinator owns every right-click and replaces the
native Electron menus. Menus are assembled from what the click
landed on:
- Links and images get open/copy/save sections; chat links add the
reach-aware resolved-URL copy on remote gateways.
- Editables get spell-check suggestions (async-appended when
Chromium's facts arrive from main), cut/copy/paste, and select
all. Cut and copy need a selection; paste needs a non-empty
clipboard; select all needs field content. The verbs show their
accelerators instead of icons and dispatch a frame after the menu
closes, so the radix focus trap cannot steal the target. Select
all runs renderer-side, scoped to the field, because main's
selectAll acts on the focused frame and could grab the transcript.
- Terminals answer through registered xterm handles; the read-only
agent terminal hides paste.
- The in-app browser guest builds the same menus from the webview
tag's context-menu event: Chromium's editFlags gate the edit
verbs, spell-check rides the event, and Inspect element closes
every menu. Coordinates arrive as window-relative device pixels,
so the handler divides by the window zoom factor; guest edit
commands focus the webview first, because they act on the focused
webContents.
- Bare app chrome falls back to the window verbs.
Labels come from the locale files in all five languages. Main keeps
thin IPC verbs: edit commands, copy-image-at-gesture, spell-check
actions, and dictionary-add for guests (the tag has no session API).
The e2e spec exercises the real focus trap; it is blocked today by
the gateway-checking stall that also fails e2e/chat.spec.ts.
`/goal clear` (and pause/resume/status) can come back from the gateway as
a TYPED `{ type: "exec" }` command dispatch instead of the plain
`{ output }` slash.exec shape. The typed exec/plugin branch in
use-prompt-actions/slash.ts rendered the output ("✓ Goal cleared.") and
returned immediately — it never reached the goal-store sync that the
plain-output path runs (`applyGoalStatusText`). The composer status stack
therefore kept showing the stale "Goal paused" card, with the old goal
text, until the chat was left and reopened (which re-hydrates via
`refreshSessionGoal`).
Fix: in the typed exec/plugin dispatch branch, when the command is `goal`,
mirror the dispatch output into the goal store via
`applyGoalStatusText(sessionId, output)` before rendering — exactly what
the plain-output path already does. This covers the whole sibling class
(clear/pause/resume/status/done) since the store's text parser already
understands every /goal output shape; set (`send` dispatch notice) was
already handled.
Tests:
- use-prompt-actions/index.test.tsx: typed exec `✓ Goal cleared.` removes
the session's goal entry immediately (#80348), and typed exec
`▶ Goal resumed:` flips a paused card back to active.
- store/goals.test.ts: `✓ Goal cleared.` output clears a paused goal.
Fixes#80348
The Bots roster highlight and the Routines (Cronjobs) tile were keyed off
host.state.profile — the gateway socket's home. Tab/tile focus moves without
swapping the socket, so opening one bot's chat while the socket was homed on
another highlighted the wrong bot and showed the wrong bot's cronjobs
(community report: Newsanalyst chat open, Hermes highlighted).
- sdk: new host.state.focusedSessionProfile — owner profile of the focused
chat, resolved from the focused stored session's row stamp via
rememberedSessionProfile() (same ladder as remembered navigation and the
HUD), with the gateway profile as the draft/uncached fallback.
- hermes-bots: $focusedBotProfile = focusedSessionProfile || profile
(feature-detected; older desktops keep prior behavior). BotRow highlight,
RoutinesPane scope, and the $selectedBot tracker use it. Turn-busy 'work'
mood stays keyed to the socket-home profile (only it can be mid-turn).
- tests: SDK atom behavior (vitest) + plugin source-shape suite; prewarm
harness stubs gain the new atom.
- docs: SDK page + hermes-agent skill reference list the new atom.
The agent could reveal single panes (focus_pane) but had no way to arrange
the workspace as one act. apply_layout closes that gap: a desktop_ui tool
that emits layout.apply over the existing bridge, resolved in the renderer
against the layouts contribution registry — the same list the layout picker
reads — so core presets (default/focus/terminal-deck/quad), plugin presets,
and user-saved presets are all addressable by id. Active session only, same
as pane.reveal: a background turn never rearranges the user's desktop.
The mock server gains a batch clarify trigger that scripts a
two-question clarify turn. The scripted turn fires only while the
conversation has no tool result, so the answered batch falls through
to the canned reply instead of a repeat of the quiz.
The spec runs the real chain from composer to renderer and asserts
one batch card, the staged-answers confirm gate, and the settled
card. The local harness cannot boot the packaged app in this
environment (the pre-existing chat spec fails the same way), so the
proof for this spec is the CI run.
The batch card previously locked each answer with its own Continue
press. Now picks and typed answers stage locally, and one Confirm and
continue button (enabled when every question has an answer) submits
the whole batch. Staged answers stay editable until that confirm.
The wire protocol is unchanged. The confirm sends the per-question
locks in sequence, because the last lock resolves the blocked tool and
each earlier lock must already be accepted when it lands. Replayed
locked answers from a reconnect pre-stage their questions so restored
progress stays visible. The TUI and CLI keep incremental per-question
locks, so a timeout there still returns partial answers.
A batch clarify rendered as two identical interactive cards. The
tool.start row carries the model tool_call_id and the clarify.request
row carries a gateway request_id. The hydration-race merge correlates
the two rows with the top-level question text. A batch payload has no
top-level question, so the rows never matched and the card mounted
twice.
The correlation key for a batch now comes from the joined per-question
texts. The NUL separator cannot occur in real question text, so a batch
key cannot collide with a single-question key.
New coverage: two hydration-race tests for the batch shape (tool.start
first and clarify.request first), a mock-server batch clarify trigger,
and an E2E spec that runs the full chain and asserts exactly one card,
the per-question locks, the Confirm and continue relabel, and the
settled card.
The clarify card renders every batch question at once. Answers stage
locally per question; the footer button locks the staged answer with a
clarify.respond keyed by question_id. Locked answers stay editable — a
new pick un-locks the row and a re-lock overwrites server-side. When
exactly one question is unanswered the button relabels to Confirm and
continue, and that final lock completes the batch. Skip cancels the
whole batch (no question_id). Reconnect replay seeds the locked map so
a reattached window restores its earlier state.
The settled card lists every question with its answer; blank answers
render as Skipped. Single-question cards are untouched.
Builds on #89551 (@calvinnwq, cherry-picked): his showCloseButton flag
hid the hover X; this completes the model so standing chrome can never
be closed at all, only shown/hidden (#89546).
- hideOnly pane chrome (sessions + Bots): no hover X, no middle/meta
click close, no Close verbs in the tab menu, excluded from
close-others/right/all sweeps
- zone right-click menu gains Show/Hide rows for the strip's chrome
tabs (Hide bots / Show sessions, localized in 6 locales)
- Cmd-K palette: auto-registered "Toggle <tab> tab" rows for every
hideOnly pane, on-screen truth semantics, plugin panes included via
registry subscription
- hides persist across launches (survive the enforced dock re-adopt);
reveal intent and Layout reset clear them
- last-visible-tab guard: hiding the zone's last shown tab is refused
with a toast, so the strip can never become an empty dead zone
Add a pane-level opt-out for the hover close button and apply it to the persistent Sessions and Bots navigation panes. Keep their existing close handlers and other tab behavior intact.\n\nFixes #89546