From 0ae573a03afe0cffdd3b58843751017d71d108fe Mon Sep 17 00:00:00 2001 From: Liam Russell <17897133+liam-russell@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:59:44 +1000 Subject: [PATCH 1/2] feat(onboarding): add first-run home tour and teaching empty states Onboards the worktree-first mental model: a driver.js-powered walkthrough on the home screen (workspace -> worktree -> agent), replayable via a header button, and a rewritten "No worktrees yet" empty state in the sidebar that explains what a worktree is before prompting to create one. Closes #87 --- app/package.json | 1 + app/src/main/index.ts | 5 + app/src/preload/index.ts | 7 + app/src/renderer/onboarding/homeTour.ts | 90 +++++++++++ app/src/renderer/onboarding/tour-theme.css | 106 +++++++++++++ app/src/renderer/routes/index.tsx | 61 +++++++- .../renderer/workspace/WorktreeSidebar.tsx | 16 +- e2e/specs/onboarding-walkthrough.spec.ts | 141 ++++++++++++++++++ pnpm-lock.yaml | 12 +- 9 files changed, 431 insertions(+), 8 deletions(-) create mode 100644 app/src/renderer/onboarding/homeTour.ts create mode 100644 app/src/renderer/onboarding/tour-theme.css create mode 100644 e2e/specs/onboarding-walkthrough.spec.ts diff --git a/app/package.json b/app/package.json index 4f2b774..4ae0649 100644 --- a/app/package.json +++ b/app/package.json @@ -170,6 +170,7 @@ "@sproutgit/ui": "workspace:*", "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.16", + "driver.js": "^1.6.0", "electron-log": "^5.4.4", "electron-updater": "^6.8.9", "highlight.js": "^11.11.1", diff --git a/app/src/main/index.ts b/app/src/main/index.ts index 638aff0..5e4411e 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -168,6 +168,11 @@ function createWindow(): BrowserWindow { sandbox: true, contextIsolation: true, nodeIntegration: false, + // Electron renderer processes don't inherit the main process's argv — + // additionalArguments is the documented way to forward a flag onto the + // renderer/preload process.argv (used by app/src/preload/index.ts to + // expose api.isE2E without a round-trip IPC call). + ...(isE2EMode && { additionalArguments: ['--sproutgit-e2e'] }), }, }); diff --git a/app/src/preload/index.ts b/app/src/preload/index.ts index 1311000..d5407e8 100644 --- a/app/src/preload/index.ts +++ b/app/src/preload/index.ts @@ -75,6 +75,13 @@ function invoke( * to Node.js or the Electron internals. */ const api = { + // ── Environment ─────────────────────────────────────────────────────────── + // Static (not IPC) — preload has direct Node access even under context + // isolation, so this reads argv locally instead of round-tripping to main. + // Lets renderer-only behavior (e.g. the onboarding tour) opt out of + // WebdriverIO's shared, long-lived session without a dedicated IPC channel. + isE2E: process.argv.includes('--sproutgit-e2e'), + // ── Git info ────────────────────────────────────────────────────────────── gitInfo: (): Promise => invoke(IPC.GIT_INFO), diff --git a/app/src/renderer/onboarding/homeTour.ts b/app/src/renderer/onboarding/homeTour.ts new file mode 100644 index 0000000..e9e4db0 --- /dev/null +++ b/app/src/renderer/onboarding/homeTour.ts @@ -0,0 +1,90 @@ +import { driver, type Driver } from 'driver.js'; +import 'driver.js/dist/driver.css'; +import './tour-theme.css'; + +/** Settings-DB key: presence (any value) means the user has seen or dismissed the tour. */ +export const HOME_TOUR_SETTING_KEY = 'onboardingHomeTourDismissed'; + +/** + * Builds and starts the first-run walkthrough over the home screen, ending + * with a step explaining the worktree-first model (worktree → agent) that + * picks up once a workspace is open. `onDestroyed` fires for every exit path + * (Done, Escape, backdrop click, the X button) so dismissal is recorded no + * matter how the user leaves it. + */ +export function startHomeTour(onDismiss: () => void): Driver { + let dismissed = false; + const dismissOnce = () => { + if (dismissed) return; + dismissed = true; + onDismiss(); + }; + + const tour = driver({ + animate: true, + smoothScroll: true, + allowClose: true, + overlayColor: '#000000', + overlayOpacity: 0.5, + stagePadding: 8, + stageRadius: 10, + showProgress: true, + progressText: '{{current}} of {{total}}', + popoverClass: 'sg-tour-popover', + doneBtnText: 'Got it', + onDestroyed: () => dismissOnce(), + steps: [ + { + element: '[data-testid="home-start-actions"]', + popover: { + title: 'Start with a workspace', + description: + 'Clone a repo, open a folder, import an existing one — or pitch an idea and let an AI agent scaffold it. Any of these creates your workspace.', + side: 'right', + align: 'start', + }, + }, + { + element: '[data-testid="recent-projects-panel"]', + popover: { + title: 'Come back anytime', + description: + 'Every workspace you create or open is listed here, so switching between projects is one click away.', + side: 'left', + align: 'start', + }, + }, + { + popover: { + title: 'Then: worktrees + agents', + description: + '

Open a workspace, and the worktree-first workflow takes over:

' + + '
    ' + + '
  1. Create a worktree for each branch you want to work on — an isolated working copy, nothing to stash or switch away from.
  2. ' + + '
  3. Launch an AI agent right inside it. Each worktree can run its own agent, in parallel.
  4. ' + + '
', + }, + }, + ], + }); + + tour.drive(); + + // onDestroyed only fires once driver.js's internal per-step bookkeeping + // (__activeStep/__activeElement) has caught up with the current step — + // that bookkeeping finalizes on an animation-frame timer tied to the + // ~400ms step transition, so advancing through steps faster than that + // (e.g. scripted clicks) can leave it stale and silently skip the + // callback even though the popover really did close. Watching for the + // popover leaving the DOM is a reliable fallback that doesn't depend on + // that internal timing. + const observer = new MutationObserver(() => { + if (!document.querySelector('.driver-popover')) { + observer.disconnect(); + dismissOnce(); + } + }); + observer.observe(document.body, { childList: true }); + + return tour; +} diff --git a/app/src/renderer/onboarding/tour-theme.css b/app/src/renderer/onboarding/tour-theme.css new file mode 100644 index 0000000..683735b --- /dev/null +++ b/app/src/renderer/onboarding/tour-theme.css @@ -0,0 +1,106 @@ +/* Onboarding walkthrough — driver.js popover/overlay retheme onto SproutGit's + --sg-* design tokens. Selectors mirror driver.js's own class names + (see driver.js/dist/driver.css) so this file only needs to win on + specificity, not fight !important. */ + +.sg-tour-popover.driver-popover { + background: var(--sg-surface); + color: var(--sg-text); + border: 1px solid var(--sg-border); + border-radius: 14px; + padding: 18px; + max-width: 320px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); +} + +.sg-tour-popover .driver-popover-title { + font-family: var(--sg-font-heading); + font-size: 15px; + font-weight: 700; + color: var(--sg-text); +} + +.sg-tour-popover .driver-popover-description { + font-family: var(--sg-font-body); + font-size: 12px; + line-height: 1.6; + color: var(--sg-text-dim); +} + +.sg-tour-popover .driver-popover-title[style*='block'] + .driver-popover-description { + margin-top: 8px; +} + +.sg-tour-popover .driver-popover-description p { + margin: 0 0 8px; +} +.sg-tour-popover .driver-popover-description p:last-child { + margin-bottom: 0; +} + +.sg-tour-popover .sg-tour-list { + margin: 0; + padding-left: 18px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.sg-tour-popover .driver-popover-description strong { + color: var(--sg-text); + font-weight: 600; +} + +.sg-tour-popover .driver-popover-close-btn { + color: var(--sg-text-faint); +} +.sg-tour-popover .driver-popover-close-btn:hover, +.sg-tour-popover .driver-popover-close-btn:focus { + color: var(--sg-text); +} + +.sg-tour-popover .driver-popover-footer { + margin-top: 16px; +} + +.sg-tour-popover .driver-popover-progress-text { + color: var(--sg-text-faint); + font-size: 11px; +} + +.sg-tour-popover .driver-popover-footer-btn { + background: transparent; + border: 1px solid var(--sg-border); + color: var(--sg-text-dim); + border-radius: 6px; + padding: 5px 12px; + font-size: 11px; + font-weight: 500; + transition: background-color 0.15s, color 0.15s, border-color 0.15s; +} +.sg-tour-popover .driver-popover-footer-btn:hover, +.sg-tour-popover .driver-popover-footer-btn:focus { + background: var(--sg-surface-raised); +} +.sg-tour-popover .driver-popover-footer-btn.driver-popover-btn-disabled { + opacity: 0.4; +} + +.sg-tour-popover .driver-popover-next-btn, +.sg-tour-popover .driver-popover-done-btn { + background: var(--sg-primary); + border-color: var(--sg-primary); + color: #fff; +} +.sg-tour-popover .driver-popover-next-btn:hover, +.sg-tour-popover .driver-popover-done-btn:hover { + background: var(--sg-primary-hover); + border-color: var(--sg-primary-hover); +} + +/* Retint only the arrow's visible edge — the other three are already + transparent via driver.css's own per-side rules. */ +.sg-tour-popover .driver-popover-arrow-side-left { border-left-color: var(--sg-surface); } +.sg-tour-popover .driver-popover-arrow-side-right { border-right-color: var(--sg-surface); } +.sg-tour-popover .driver-popover-arrow-side-top { border-top-color: var(--sg-surface); } +.sg-tour-popover .driver-popover-arrow-side-bottom { border-bottom-color: var(--sg-surface); } diff --git a/app/src/renderer/routes/index.tsx b/app/src/renderer/routes/index.tsx index c5fd344..865bf5f 100644 --- a/app/src/renderer/routes/index.tsx +++ b/app/src/renderer/routes/index.tsx @@ -2,13 +2,15 @@ import { api } from '../api.js'; import { createRoute, useNavigate } from '@tanstack/react-router'; import { rootRoute } from './__root.js'; import { useState, useEffect, useRef } from 'react'; -import { AppWindow, ArrowRight, Clock, Download, FolderInput, FolderOpen, Play, Settings, Sparkles, X, AlertTriangle } from 'lucide-react'; +import { AppWindow, ArrowRight, Clock, Download, FolderInput, FolderOpen, HelpCircle, Play, Settings, Sparkles, X, AlertTriangle } from 'lucide-react'; import { Spinner, WindowControls, UpdateBadge, Autocomplete, ResizableSidebar } from '@sproutgit/ui'; import type { UpdateState } from '@sproutgit/ui'; +import type { Driver } from 'driver.js'; import type { AgentConfig, GitHubRepo, GitInfo, GitHubAuthStatus, GitOpProgressEvent, RecentWorkspace } from '@sproutgit/types'; import { useToast } from '../toast-context.js'; import { reportError } from '../error-reporting.js'; import { setPendingScaffold } from '../pending-scaffold.js'; +import { startHomeTour, HOME_TOUR_SETTING_KEY } from '../onboarding/homeTour.js'; import logoSvgUrl from '../logo.svg?inline'; // Shared Tailwind class strings @@ -76,6 +78,11 @@ function HomeView() { const [gitNotInstalled, setGitNotInstalled] = useState(false); const [updateState, setUpdateState] = useState({ status: 'idle' }); + // First-run walkthrough — see onboarding/homeTour.ts + const [recentsLoaded, setRecentsLoaded] = useState(false); + const [tourSeen, setTourSeen] = useState(null); + const tourRef = useRef(null); + // GitHub repos for clone autocomplete const [githubRepos, setGithubRepos] = useState([]); @@ -133,7 +140,11 @@ function HomeView() { void api.listRecentWorkspaces().then((ws: RecentWorkspace[]) => { const sorted = [...ws].sort((a, b) => new Date(b.lastOpenedAt).getTime() - new Date(a.lastOpenedAt).getTime()); setRecents(sorted); - }).catch(() => undefined); + }).catch(() => undefined).finally(() => setRecentsLoaded(true)); + + void api.getSetting(HOME_TOUR_SETTING_KEY) + .then(v => setTourSeen(!!v)) + .catch(() => setTourSeen(true)); // fail closed — don't nag if we can't tell void api.getSetting(PROJECTS_FOLDER_SETTING).then(async (v: string | null) => { if (v) { @@ -180,6 +191,45 @@ function HomeView() { return () => { offChecking(); offAvailable(); offNotAvailable(); offDownloading(); offReady(); offError(); }; }, []); + // Auto-launch the first-run walkthrough once we know there's nothing to + // interrupt: git is present, recents have loaded (so we're not showing it + // to a returning user for the split second before their list appears), + // and it hasn't been seen before. Skipped entirely in E2E — WebdriverIO + // reuses one long-lived session across every spec file, and several specs + // click home-screen buttons directly; an unrelated auto-tour stealing that + // first click would make the whole suite's outcome depend on spec order. + // The manual "Replay walkthrough" button still works in E2E for dedicated + // coverage of the tour itself. + useEffect(() => { + if (api.isE2E) return; + if (!gitChecked || gitNotInstalled) return; + if (!recentsLoaded || tourSeen !== false) return; + if (recents.length > 0) return; + tourRef.current?.destroy(); + tourRef.current = startHomeTour(() => { + setTourSeen(true); + void api.setSetting(HOME_TOUR_SETTING_KEY, '1').catch(() => undefined); + }); + }, [gitChecked, gitNotInstalled, recentsLoaded, tourSeen, recents.length]); + + // Unconditional, mount-once cleanup: driver.js appends the popover/overlay + // straight to document.body, outside React's tree — if the user clicks + // through to a workspace without ever dismissing the tour (the highlighted + // step's own action buttons stay clickable), it would otherwise survive + // the route change. Deliberately its own effect, not the guarded one + // above — that effect's cleanup only fires on its OWN re-run, and once + // tourSeen flips true it re-runs into an early return with no cleanup, + // silently dropping the destroy call this needs to guarantee on unmount. + useEffect(() => () => tourRef.current?.destroy(), []); + + function replayTour() { + tourRef.current?.destroy(); + tourRef.current = startHomeTour(() => { + setTourSeen(true); + void api.setSetting(HOME_TOUR_SETTING_KEY, '1').catch(() => undefined); + }); + } + useEffect(() => { if (!cloneFolderManual) setCloneFolderName(repoNameFromUrl(cloneUrl)); }, [cloneUrl, cloneFolderManual]); @@ -436,6 +486,9 @@ function HomeView() {
void api.installUpdate()} /> + @@ -455,7 +508,7 @@ function HomeView() { Start
-
+
{!!agentConfig?.command.trim() && ( diff --git a/e2e/specs/onboarding-walkthrough.spec.ts b/e2e/specs/onboarding-walkthrough.spec.ts new file mode 100644 index 0000000..8cd04de --- /dev/null +++ b/e2e/specs/onboarding-walkthrough.spec.ts @@ -0,0 +1,141 @@ +/** + * Onboarding walkthrough (#87): the home-screen first-run tour and the + * teaching empty state in the worktree sidebar. + * + * The tour never auto-launches in E2E (see api.isE2E in + * app/src/renderer/routes/index.tsx) — each spec file gets its own fresh + * Electron launch with an empty config db, so the "first run, no recents" + * condition that triggers the tour would otherwise be true on literally + * every spec file's home screen, stealing the first click from any spec + * that interacts with home right after launch (e.g. import-workflow, + * new-from-idea-workflow). Coverage here drives the tour explicitly via the + * "Replay walkthrough" button instead. + */ + +import { execSync } from 'child_process'; +import { mkdtempSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { goHome, gotoHash, monitorErrors, closeAndCleanup, cleanupRepo } from '../helpers.js'; + +const HOME_TOUR_SETTING_KEY = 'onboardingHomeTourDismissed'; + +async function clearTourDismissal(): Promise { + await browser.executeAsync((key: string, done: (err?: string) => void) => { + (window as unknown as { api: { deleteSetting: (k: string) => Promise } }) + .api.deleteSetting(key) + .then(() => done(), (e: unknown) => done(String(e))); + }, HOME_TOUR_SETTING_KEY); +} + +async function readTourDismissal(): Promise { + return browser.executeAsync((key: string, done: (v: string | null) => void) => { + (window as unknown as { api: { getSetting: (k: string) => Promise } }) + .api.getSetting(key) + .then(done, () => done(null)); + }, HOME_TOUR_SETTING_KEY) as Promise; +} + +/** + * The tour's onDestroyed hook writes the dismissal setting via a + * fire-and-forget `void api.setSetting(...)` (see homeTour.ts) — it doesn't + * block the click that triggered it. Poll instead of reading once so this + * doesn't race the IPC round-trip. + */ +async function expectTourDismissalPersisted(): Promise { + await browser.waitUntil(async () => (await readTourDismissal()) === '1', { + timeout: 5000, + timeoutMsg: 'expected the tour dismissal setting to be persisted', + }); +} + +/** Bootstrap a fresh git repo with one commit and return its path. */ +function createFreshRepo(name: string): string { + const dir = mkdtempSync(join(tmpdir(), `sg-onboarding-${name}-`)); + execSync('git init', { cwd: dir }); + execSync('git config user.email "test@example.com"', { cwd: dir }); + execSync('git config user.name "Test"', { cwd: dir }); + writeFileSync(join(dir, 'README.md'), `# ${name}\n`); + execSync('git add .', { cwd: dir }); + execSync('git commit -m "init: initial commit"', { cwd: dir }); + return dir; +} + +/** + * Clone a local repo into a brand-new workspace and navigate to it. + * + * Deliberately NOT importWorkspace() — import always recreates the source's + * checked-out branch as a managed worktree (see importInPlace in + * app/src/main/ipc/workspace-init.ts), so an imported workspace never + * actually has zero worktrees. A cloned/newly-created workspace does, until + * the user makes one — that's the real moment this empty state is for. + */ +async function cloneAndNavigate(sourceRepoPath: string, workspacePath: string): Promise { + await browser.execute( + (ws: string, url: string) => (window as any).api.createWorkspace({ workspacePath: ws, repoUrl: url }), + workspacePath, + sourceRepoPath + ); + await gotoHash(`/workspace?path=${encodeURIComponent(workspacePath)}`); + await expect($('[data-testid="btn-open-create-worktree"]')).toBeDisplayed(); +} + +describe('onboarding walkthrough', () => { + it('steps through the home tour and persists dismissal on "Got it"', async () => { + const assertNoErrors = monitorErrors(); + await goHome(); + await clearTourDismissal(); + + await expect($('[data-testid="btn-replay-tour"]')).toBeDisplayed(); + await $('[data-testid="btn-replay-tour"]').click(); + + await expect($('.driver-popover-title')).toHaveText('Start with a workspace'); + await $('.driver-popover-next-btn').click(); + + await expect($('.driver-popover-title')).toHaveText('Come back anytime'); + await $('.driver-popover-next-btn').click(); + + await expect($('.driver-popover-title')).toHaveText('Then: worktrees + agents'); + await expect($('.driver-popover-done-btn')).toBeDisplayed(); + await $('.driver-popover-done-btn').click(); + + await expect($('.driver-popover')).not.toBeExisting(); + await expectTourDismissalPersisted(); + await assertNoErrors(); + }); + + it('counts an early close as seen too', async () => { + const assertNoErrors = monitorErrors(); + await goHome(); + await clearTourDismissal(); + + await $('[data-testid="btn-replay-tour"]').click(); + await expect($('.driver-popover')).toBeDisplayed(); + await $('.driver-popover-close-btn').click(); + + await expect($('.driver-popover')).not.toBeExisting(); + await expectTourDismissalPersisted(); + await assertNoErrors(); + }); + + it('teaches the worktree-first model in the empty worktree sidebar', async () => { + const assertNoErrors = monitorErrors(); + const sourceRepo = createFreshRepo('empty-state'); + const workspacePath = mkdtempSync(join(tmpdir(), 'sg-onboarding-empty-state-ws-')); + try { + await cloneAndNavigate(sourceRepo, workspacePath); + + const emptyState = $('[data-testid="worktree-empty-state"]'); + await expect(emptyState).toBeDisplayed(); + await expect(emptyState).toHaveText(/isolated working copy/); + await expect(emptyState).toHaveText(/Launch an AI agent right inside it/); + + await $('[data-testid="btn-create-first-worktree"]').click(); + await expect($('[data-testid="input-new-branch"]')).toBeDisplayed(); + await assertNoErrors(); + } finally { + await closeAndCleanup(workspacePath); + await cleanupRepo(sourceRepo); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f81908b..1e2b4f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,9 @@ importers: '@tanstack/react-router': specifier: ^1.170.16 version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + driver.js: + specifier: ^1.6.0 + version: 1.6.0 electron-log: specifier: ^5.4.4 version: 5.4.4 @@ -181,7 +184,7 @@ importers: version: '@typescript/native-preview@7.0.0-dev.20260630.1' wdio-electron-service: specifier: ^9.2.1 - version: 9.2.1(electron@43.0.0(supports-color@8.1.1))(expect-webdriverio@5.7.0)(supports-color@8.1.1)(webdriverio@9.29.1(puppeteer-core@22.15.0)) + version: 9.2.1(electron@43.0.0)(expect-webdriverio@5.7.0)(supports-color@8.1.1)(webdriverio@9.29.1(puppeteer-core@22.15.0)) webdriverio: specifier: ^9.29.1 version: 9.29.1(puppeteer-core@22.15.0) @@ -3697,6 +3700,9 @@ packages: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} + driver.js@1.6.0: + resolution: {integrity: sha512-gryo9QS7AZhz8J0bmdf42dwaTzcd1BgSJLnaSM7cPJbu8bTW8wIEuiGDy4MoCvB4Sr8wA9g5o2G9EFNVYZGeVQ==} + drizzle-kit@0.31.10: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true @@ -9731,6 +9737,8 @@ snapshots: dotenv@17.4.2: {} + driver.js@1.6.0: {} + drizzle-kit@0.31.10: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -12659,7 +12667,7 @@ snapshots: defaults: 1.0.4 optional: true - wdio-electron-service@9.2.1(electron@43.0.0(supports-color@8.1.1))(expect-webdriverio@5.7.0)(supports-color@8.1.1)(webdriverio@9.29.1(puppeteer-core@22.15.0)): + wdio-electron-service@9.2.1(electron@43.0.0)(expect-webdriverio@5.7.0)(supports-color@8.1.1)(webdriverio@9.29.1(puppeteer-core@22.15.0)): dependencies: '@babel/parser': 7.29.7 '@electron/fuses': 2.1.3 From fc157ebad9d0f8243af84274c3d4933588bc57d4 Mon Sep 17 00:00:00 2001 From: Liam Russell <17897133+liam-russell@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:13:22 +1000 Subject: [PATCH 2/2] fix(e2e): correct misleading comment in onboarding-walkthrough spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dismissal setting is written from the onDismiss callback wired up in routes/index.tsx, not from homeTour.ts itself — it only invokes whatever callback it's given. --- e2e/specs/onboarding-walkthrough.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/e2e/specs/onboarding-walkthrough.spec.ts b/e2e/specs/onboarding-walkthrough.spec.ts index 8cd04de..2720ba8 100644 --- a/e2e/specs/onboarding-walkthrough.spec.ts +++ b/e2e/specs/onboarding-walkthrough.spec.ts @@ -37,10 +37,11 @@ async function readTourDismissal(): Promise { } /** - * The tour's onDestroyed hook writes the dismissal setting via a - * fire-and-forget `void api.setSetting(...)` (see homeTour.ts) — it doesn't - * block the click that triggered it. Poll instead of reading once so this - * doesn't race the IPC round-trip. + * The tour's onDismiss callback (wired up in routes/index.tsx, not + * homeTour.ts — homeTour.ts only invokes the callback it's given) writes + * the dismissal setting via a fire-and-forget `void api.setSetting(...)` — + * it doesn't block the click that triggered it. Poll instead of reading + * once so this doesn't race the IPC round-trip. */ async function expectTourDismissalPersisted(): Promise { await browser.waitUntil(async () => (await readTourDismissal()) === '1', {