diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx index b9d0701881..7d6150b2f3 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -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) { diff --git a/apps/desktop/src/app/chat/right-rail/preview-tour-runner.ts b/apps/desktop/src/app/chat/right-rail/preview-tour-runner.ts new file mode 100644 index 0000000000..84a15c8869 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-tour-runner.ts @@ -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 + +const runners = new Map() + +/** 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 +} diff --git a/apps/desktop/src/app/chat/right-rail/preview-tour.ts b/apps/desktop/src/app/chat/right-rail/preview-tour.ts new file mode 100644 index 0000000000..12ad67dc71 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-tour.ts @@ -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 { + 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 +}