Post-merge review follow-up for #93080: the isDisabled guard test
documented 'flipping back also notifies' but never asserted it — a
regression making the true->false transition silent (e.g. gating
disabledChanged on truthiness) would still pass. Pin the flip-back
notify with a beforeFlipBack capture (mutation-checked: gating the
seed on newDisabled truthiness now fails this test).
Review follow-up: __internal_setAdapter assigned this.isDisabled before
the fast path but never fed it into the new 'changed' flag, so an
isDisabled-only flip on an otherwise-identical adapter swap would have
been silently swallowed. Seed 'changed' with the isDisabled comparison
and add a guard test (mutation-checked: reverting the seed fails it).
Two independent desktop-boot/runtime bugs found driving the app over CDP
against current main, each pinned by a regression test:
1) Adapter no-op notify loop: IncrementalExternalStoreThreadRuntimeCore.
__internal_setAdapter's fast path (same isRunning + same messageRepository)
called _notifySubscribers() unconditionally. ChatRuntimeBoundary passes a
fresh adapter literal every render, so any subscriber whose notification
re-renders the boundary loops render->setAdapter->notify->render until
React kills the tile with 'Maximum update depth exceeded' (reproduced live
on every bot-profile switch; session tile dies behind its error boundary).
Now the fast path notifies only when extras/suggestions/capabilities
actually changed.
2) Dual-venv interpreter mismatch: findPythonForRoot() prefers .venv over
venv, but createPythonBackend() hardcoded venvRoot=root/venv for
PYTHONPATH. A checkout with BOTH venvs (dev .venv 3.12 + install venv
3.11) got a 3.12 interpreter with 3.11-compiled native wheels on
PYTHONPATH and died on the first import (pydantic_core) before the
gateway bound - the renderer then showed 'Gateway offline' on every
profile. venvRootForPython() now maps the selected interpreter back to
ITS venv; root/venv remains the fallback for system pythons only.
/voice arms SERVER-side capture (voice.record → PortAudio on the backend
host) — meaningless on desktop, which has its own composer-native voice
conversation (mic menu / Ctrl+B). It was already suppressed from the
slash palette, but typing it got the generic 'advanced' shrug that never
mentioned the button exists. New composer-voice unavailability reason
with a message naming the actual surface.
The speak-stream WebSocket resolved its URL through the bare v1
getConnection/getGatewayWsUrl pair, which answers for the PRIMARY backend.
When a registry remote connection rides over a machine that also has a
local Hermes install (the common case — the installer always installs the
full agent), spoken replies dialed the LOCAL backend and hit its
unconfigured TTS, while chat (connectionScoped REST) correctly went remote.
Users saw 'configure STT/TTS' although their remote gateway had voice
fully configured.
Resolve the PCM socket through the same (connectionId, profile) bridges
store/gateway's openSecondary uses, and never overwrite a backend-namespace
profile the registry mint already wrote into the URL (SSH remoteProfile
aliasing, sharedRemote scoping).
Every REST audio call already carried connectionScoped(); this was the one
remaining self-built audio URL. Contract pinned by
voice-playback.routing.test.ts (sabotage-verified: 2/4 fail on the old
resolver).
Lowest-hop voice path in both directions for desktop + remote gateway:
mic audio goes straight to the profile's STT provider and reply text is
synthesized on the desktop with the profile's TTS provider. The
desktop-gateway link carries only text (which the chat stream carries
anyway). No second key store: GET /api/audio/voice-config returns the
profile's resolved provider/model/language/key using the exact resolution
chains transcription_tools/tts_tool use, over the authenticated REST
channel. Keys live in renderer memory only.
Backend:
- tools/voice_client_config.py: single resolver; per-provider client
wire shapes (openai-multipart, xai-stt, elevenlabs-stt, openai-speech,
elevenlabs-tts). Server-host-only providers (local whisper, edge,
command/plugin) and missing credentials resolve to {mode: relay}.
xAI OAuth stays relay (bearer refreshes server-side).
- web_server.py: GET /api/audio/voice-config, profile-scoped via the
same _config_profile_scope seam as /api/audio/transcribe.
- config_defaults.py: voice.client_direct gate (default true).
Desktop:
- lib/voice-client-direct.ts: config fetch keyed by (connection,
profile) with 60s TTL, provider-direct STT + TTS calls, sentence
cutter mirroring the server pipeline's contract.
- Dictation (use-prompt-actions + session-tile) tries client-direct
first; null -> existing relay unchanged; provider rejections surface.
- voice-playback.ts: client-direct speech session as the top rung of
startSpeechStream/playSpeechText; WS relay + POST fallback unchanged
below it. Barge-in via the same stopVoicePlayback sequence bump.
Validation: 13/13 backend E2E (real temp HERMES_HOME + real resolution),
live FastAPI TestClient E2E (direct + gate-flip), 15/15 client tests
(wire shapes, scope-keyed caching, rejection surfacing, sentence cutter),
sibling suites 72/72 + 36/36, tsc + eslint + ruff clean.
Docs: voice-mode.md client-direct section ships in this PR.
Salvage of saralilyb's remote-bot routing work onto current main:
- kept: immutable (connectionId, profile) owner capture, requestForBot
routing, backendTargetProfile aliasing, group session owners,
connection-qualified deletion, focused-owner atoms, remote roster
merge, Electron profile-delete routing, sdk/store/transcript changes
- reconciled: canonical Bot Chat resolution stays NAME-identity (the
'Bot Chat' registry row) and FAIL-CLOSED on lookup errors — now
consulted on the bot's own source via the captured owner route, so
remote bots get the same no-fork guarantees
- dropped: pointer-pin plumbing (preferredSessionIds, saveBotMeta chat
writes, pin verification) — superseded by name-identity on main;
renderer-side remote DM delivery (deliverRemoteRosterMentions /
pollRemoteDmReply / ensureRemoteCanonicalChat) — superseded by the
message_agent tool architecture (#91802/#91915: middleware identifies,
never delivers); pointer-era test files deleted on main
- openStoredBotChat/createCanonicalChat: remote opens keep Desktop's
chrome home (keepAllProfilesScope: true on routed opens); local bots
keep the measured workspace re-home
- prepareBotSource: capability gate only — routed RPCs never require
activation authority
Two follow-up layers on top of the salvaged runtime-boundary guard (#87871):
- coalesceToolOnlyAssistants now folds via concatToolPartsUnique, dropping an
incoming tool-call part whose toolCallId the predecessor already carries.
Two individually-clean rows sharing an id (structural carry-over re-attaching
a cached row's calls) no longer become one crashing message — and no longer
render the same call twice. Root-cause analysis by @marketing2981 (#87857).
- loadTranscriptTail repairs a poisoned persisted tail on read; installs
already carrying a duplicate in hermes.transcript-tail.v1:* stop
crash-looping after upgrade instead of re-deriving the same collision
every launch.
- Regression tests for all three layers, incl. the end-to-end repository link
test (from #92093 by @RasputinKaiser) and the cross-message ids-stay-
untouched contract (per-response tool numbering, e.g. Kimi — #90545 by
@M7MMAD-OMAR). Each test sabotage-verified against its reverted layer.
A message whose content carries two tool-call parts with the same
toolCallId makes assistant-ui's useResources throw
"Duplicate key toolCallId-<id> in useResources", which the workspace
error boundary turns into a renderer crash loop that blanks the window.
The existing withUniqueToolCallIds dedup runs only on the static
toChatMessages output; the streaming reducer (which can append the same
tool-call part twice under an optimistic-update ordering) and tool-only
assistant coalescing both reach the runtime without passing through it.
Add withUniqueToolCallIdsWithinMessage and apply it in
useRuntimeMessageRepository, the single ChatMessage->ThreadMessage
boundary shared by the static and streaming paths, right where the
repeated-message.id guard already lives. The dedup is per-message (the
assistant-ui key space is per-message) and returns the same reference
when clean, so the repository's identity cache is untouched in the
common no-duplicate case.
Chromium's native selection copy serializes the selection as text/html
with every element's computed color inlined. Copied from a dark theme,
body text lands on the clipboard as near-white (the app ink computes to
color(srgb 0.902 0.929 0.953 / 0.94)); pasted into a light-background
target such as an email, it is invisible.
The renderer never writes rich text itself, so this payload can only
come from Chromium's serializer — which runs after copy handlers decline,
meaning clipboardData reads back empty inside the event. The new guard
therefore decides from the live DOM: it scores the computed ink of the
selected text against the rendered theme mode, and only when they are
opposite schemes does it own the payload, writing text/plain plus a
tag-structured text/html with no paint declarations.
Structure (headings, lists, tables, links, bold/italic, code layout)
survives; colors come from the paste target's defaults. A generic
font-family anchor (sans-serif, monospace inside code) keeps receivers
that convert HTML to rich text on their own compose font instead of the
Times browser default. Same-scheme copies and selections starting inside
editable fields pass through untouched.
Addresses @helix4u's review on #91493:
- conversation_loop now stamps failure_retryable (the real ClassifiedError
verdict) next to failure_reason; error_surface prefers it and only falls
back to the reason set for older results. Fallback set corrected to match
classify_api_error (auth, format_error, billing_unverified now
non-retryable).
- The descriptor carries the failing session's provider/model captured at
classification time; Copy error details prefers them over the foreground
composer atoms.
- Open logs is labeled 'Open Desktop logs' on remote/cloud connections —
the local folder holds transport logs, not the remote runtime's.
- API-exception module allowlist widened to botocore/boto3/google/grpc/
requests/aiohttp so other adapter SDKs don't misclassify as gateway.
Turn errors now carry a structured {layer, code, retryable} descriptor
(agent/error_surface.py) built from the same classifier the retry loop
uses. The tui_gateway stamps it on terminal error frames, retained
failed-turn snapshots, and resume replay; the Desktop error card renders
the layer title (provider / endpoint / streaming / auth / billing /
gateway / runtime / disk) plus matched actions: Retry, Switch provider,
Open logs, Copy diagnostics.
Older backends that omit the descriptor keep today's behavior (generic
title, string-sniff fallbacks) — the field is advisory on both sides.
The OpenCode Zen wire slug for the Ox Alpha stealth model is opaque
(x-preview-f-free); users searching the picker for 'ox' or 'ox-alpha'
found nothing. Adds the search alias across all four synced alias
tables (CLI, desktop, web, TUI) plus tests. Wire id is unchanged and
still what renders and gets sent to the provider, matching the k3 →
kimi-k3 precedent. No canonical-dedup collision with opencode-go's
keyed ox-alpha-free slug.
The strip could only be hidden by an undiscoverable double-tap, and once hidden
the zone had no chrome left to click — no tab, no ✕, no menu holding "Show".
This puts it on the same footing as the status bar, whose hide has never
stranded anyone: ⌥⌘T, a ⌘K row, the shell context menu, and the zone menu, which
now prints the keystroke on the row that takes the strip away so the way back is
stated at the moment it matters. All four resolve their target zone the same way
the other tab verbs do (hovered, else focused, else the workspace) and describe
themselves from what is on screen rather than from a stored value, so "toggle"
always means the opposite of what the user is looking at.
Adds an app-wide default alongside it, in Appearance next to Session List
Density — auto, always, or never, matching VS Code's `workbench.editor.showTabs`
and Zed's `tab_bar.show` for people who want one answer everywhere instead of a
per-zone choice they repeat. A zone that has stated its own preference still
wins, and neither value can strand a pane.
Messaging platforms, the MCP servers tab, and the connector surfaces each
drew their own square brand chip, and the three copies had already drifted
on radius, glyph size, and what an unknown name falls back to.
They now share one AvatarChip. Size and radius stay the caller's call;
everything else is fixed in one place, so a Slack row and a Linear card
wear the same mark. brandFor also stops caring how a name is spelled —
catalog slug, registry id, or display name all reach the same glyph.
Adopt upstream's one-hidden-forever-chat model and remove the per-bot hidden
session browser while preserving connection-bound remote Bot opening, the full
remote action menu, same-name isolation, and global Sessions navigation.
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.
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.
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.
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.
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.
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>
A markdown table has no id — it is re-parsed from text on every render, so
any resize state hung off the transcript dies on the next turn. Key the
record by a hash of the header row instead: the same table resolves to the
same key after a re-render, a session switch, or a reload, without the
transcript carrying anything.
Widths are percentages of the table box, never pixels, so a restored table
stays fluid in a narrow pane. The namespace is deliberately disposable —
one key, 64 entries, 7-day expiry, swept on first access. Losing it costs
one drag.
Community report (X @Cobalt_Peak): Reconnect and Restart gateway in the
statusbar gateway popover rendered the same RefreshCw icon side by side,
so users triggered full gateway restarts when they meant to reconnect.
- Restart now uses a Power icon with a destructive hover tint
- Moved restart to the end of the row, after the system-panel button,
behind a visual divider separating it from the benign actions
Preserve the connection-bound Bot and session routing implementation while
adopting upstream's modular Desktop API split and all changes through
b2057c168.
The same AST sweep over specs found fixtures maintained in parallel
across suites that have no reason to know about each other.
Twenty-one specs each mounted useMessageStream themselves and ten of the
harnesses were byte-identical; twenty now take renderMessageStream, with
overrides for the seams that genuinely vary. The SessionInfo builder was
spelled out field-by-field in seven specs, so a new backend field broke
seven files instead of one. Twenty specs carried their own inert
ResizeObserver and eleven repeated the animation-frame, CSS.escape,
scrollTo and WAAPI stubs the transcript needs to mount at all — split by
scope into src/test/jsdom for what any component might need and the
assistant-ui folder's own kit for the transcript. Plus the window-state
bridge, deferred, the external-store thread runtime, the manual
createRoot harness, and the per-folder caret, env-var, provider and
session fixtures.
Left alone on purpose: the store suites' makePrimary, where the vi.mock
harness around it is the actual duplication and cannot be hoisted out of
a hoisted factory; electron's deferred, where reaching into src/ from
the main process would invert the layering for eight lines; and the two
suites that compose another hook alongside the stream.
Splitting the god files made a pile of copy-paste helpers visible and,
for the first time, fixable — sharing them previously meant importing a
god file. Hashing function bodies through the TypeScript AST found
twelve groups desktop-wide; production code is now at zero duplicates.
Each helper went to the module that already owns its concern:
firstStringField to lib/text, the two REST 404 predicates to
lib/gateway-rpc beside isMissingRpcMethod, useDebounced and
prefersReducedMotion to their hooks, the superseded-bootstrap guard to
electron/ssh-connection, the composer keyup handler to the trigger hook
that owns the rest of that state machine, and clampDataUrlReadMaxMb to
apps/shared, replacing a "keep these in sync" comment between two
copies.
Only helpers with no existing owner got a new file: lib/mcp-servers,
lib/audio-context, lib/keyed-timeouts, lib/pointer-drag, and the command
palette's status row. Error-shape predicates are the worst thing to
copy — when the backend changes how it reports a missing route, every
copy has to be found.
Types, part builders, tool parts, hydration and reconciliation, behind a
barrel that keeps the @/lib/chat-messages path.
The folder was added without removing chat-messages.ts, so resolution
preferred the file and all its importers kept hitting the monolith while
the new modules sat dead. Deleting it surfaced a missing preset field on
GatewayEventPayload that layout.apply needs, hidden until the folder
actually resolved, and a completeOpenStreamParts helper copied into two
modules when only one calls it.
Glass was macOS-only because it rode setVibrancy. Windows 11 22H2 has a
first-party equivalent in setBackgroundMaterial, so the mode now resolves
its backing per platform instead of per-OS-check: macOS keeps vibrancy,
Windows 11 gets DWM acrylic / tabbed / mica, and everything older stays on
Clear. No third-party native addon.
Two Windows-specific details the mapping has to respect. DWM only paints
the client area of a transparent window (electron#49443), so glass-capable
Windows chat windows are born transparent with the opaque themed
backgroundColor covering them while glass is off — a live Clear/Glass
toggle then needs no window recreate. And Windows exposes three backdrops
for four frost rungs, so the two heaviest both resolve to mica; the mapping
stays total so a frost saved on a Mac still renders.
Glass support is computed once from os.release() and shared: main uses it
for the persisted default and every window, preload publishes it to the
renderer so the UI can't offer a mode the window can't back.
Preserve current upstream Desktop and Bot Mode behavior while carrying the
connection-bound remote Bot routing implementation forward without rewriting
the signed feature commit.
Carry immutable connection and profile ownership through Bot Mode actions,
session hydration, transcript loading, new chats, and profile deletion.
Keep same-named local and remote profiles isolated, preserve the full context
menu, and migrate Bot metadata to connection-qualified storage.
Add adversarial routing, alias, migration, cache, and compatibility coverage.
The demo existed to iterate on the tour UI without spending an agent turn per
look. That work is done and merged, so it is scaffolding now — a floating
button, a hotkey, a palette row and a window hook, none of which anyone needs
again.
Removing it restores the credit-notice demo's original single-disposer wiring
and drops the Compass icon export, which the demo was the only consumer of.
The tour feature itself is untouched.
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.
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.
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.