feat(desktop): run tours inside the preview pane

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.
This commit is contained in:
Brooklyn Nicholson
2026-08-18 20:32:14 -05:00
parent 018176915b
commit ec69940537
3 changed files with 125 additions and 0 deletions
@@ -30,6 +30,7 @@ import { previewConsoleState } from './preview-console-store'
import { LocalFilePreview, PreviewEmptyState } from './preview-file'
import { PREVIEW_BROWSER_ATTR, registerPreviewNav } from './preview-nav'
import { registerPreviewPageReader } from './preview-reader'
import { registerPreviewTourRunner } from './preview-tour-runner'
type PreviewWebview = HTMLElement & {
canGoBack?: () => boolean
@@ -454,6 +455,25 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest =
})
}, [isWebPreview, tabId])
// Publish the TOUR runner for this tab (the tour tool, surface='preview'):
// runs injected driver.js actions inside the guest page so the agent can
// give guided walkthroughs of whatever web app is open here.
useEffect(() => {
if (!isWebPreview || !tabId) {
return
}
return registerPreviewTourRunner(tabId, async code => {
const webview = webviewRef.current
if (!webview?.executeJavaScript) {
throw new Error('preview webview is not ready')
}
return webview.executeJavaScript(code)
})
}, [isWebPreview, tabId])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (!consoleOpen) {
@@ -0,0 +1,38 @@
/**
* PREVIEW TOUR RUNNER REGISTRY — the tour tool's script bridge into the
* preview pane, the tour analog of preview-nav's handle registry.
*
* A live browser pane registers a script runner (its webview's
* `executeJavaScript`) here, keyed by tab id; `activeTourRunner` resolves the
* ACTIVE tab from the store. Kept separate from preview-tour.ts so the pane
* component's static import stays tiny — the engine source + driver.js
* payload only load when a tour actually runs (gateway-event dynamic-imports
* preview-tour.ts).
*/
import { $rightRailActiveTabId } from '@/store/layout'
import { $previewTabs } from '@/store/preview'
/** Runs JS source in the pane's guest page, resolving its completion value. */
export type TourScriptRunner = (code: string) => Promise<unknown>
const runners = new Map<string, TourScriptRunner>()
/** Register a live preview's script runner; returns an idempotent unregister. */
export function registerPreviewTourRunner(tabId: string, runner: TourScriptRunner): () => void {
runners.set(tabId, runner)
return () => {
if (runners.get(tabId) === runner) {
runners.delete(tabId)
}
}
}
/** The ACTIVE preview tab's script runner. Null = no live page to tour. */
export function activeTourRunner(): TourScriptRunner | null {
const tabs = $previewTabs.get()
const tab = tabs.find(t => t.id === $rightRailActiveTabId.get()) ?? tabs[0]
return (tab && runners.get(tab.id)) || null
}
@@ -0,0 +1,67 @@
/**
* PREVIEW TOUR — runs tour actions inside the preview pane's guest page, so a
* tour can walk through ANY web app open in the in-app browser, not just
* Hermes itself.
*
* The guest page is out-of-process; nothing here can touch its DOM directly.
* Instead the first action injects a self-contained bundle over
* `executeJavaScript` — the vendored driver.js IIFE, its stylesheet, and the
* same engine/collector SOURCE the app surface runs (see lib/tour/engine.ts's
* self-containment contract) — parked on window globals so subsequent actions
* reuse the live driver instance. Injection is idempotent and vanishes with
* the page (a navigation resets the tour, which is the right behavior).
*
* Dynamic-imported by run-tour.ts so the raw driver.js payload stays out of
* the boot path.
*/
import driverCss from 'driver.js/dist/driver.css?raw'
import driverIife from 'driver.js/dist/driver.js.iife.js?raw'
import { collectTourTargets } from '@/lib/tour/collect-targets'
import { runTourEngine, type TourAction, type TourResult } from '@/lib/tour/engine'
import { activeTourRunner } from './preview-tour-runner'
/** Build the idempotent inject-and-run script for one tour action. */
function buildTourScript(action: TourAction): string {
return `(function () {
var w = window;
if (!w.__hermesTourEngine) {
${driverIife}
w.__hermesTourHolder = {};
w.__hermesTourCollect = (${collectTourTargets.toString()});
w.__hermesTourEngine = (${runTourEngine.toString()});
}
if (!document.getElementById('__hermes-tour-style')) {
var style = document.createElement('style');
style.id = '__hermes-tour-style';
style.textContent = ${JSON.stringify(driverCss)};
(document.head || document.documentElement).appendChild(style);
}
return JSON.stringify(w.__hermesTourEngine(
w.driver.js.driver,
w.__hermesTourHolder,
${JSON.stringify(action)},
w.__hermesTourCollect,
document
));
})()`
}
/** Run one tour action in the ACTIVE preview tab's page. */
export async function runPreviewTour(action: TourAction): Promise<TourResult> {
const run = activeTourRunner()
if (!run) {
return { error: 'No live page is open in the preview pane — open one first.', success: false }
}
const raw = await run(buildTourScript(action))
if (typeof raw !== 'string' || !raw) {
return { error: 'The page did not answer the tour action.', success: false }
}
return JSON.parse(raw) as TourResult
}