diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e85f321bc..b70a92d35 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -68,6 +68,9 @@ jobs: exit 1 fi + - name: Check feature wall asset budget + run: pnpm check:feature-wall-assets + - name: Typecheck run: pnpm typecheck diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index ac5ee7e2a..30fc98ae8 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -3,6 +3,10 @@ const { execFileSync } = require('node:child_process') const { join, resolve } = require('node:path') const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' +const featureWallResources = { + from: 'resources/onboarding/feature-wall', + to: 'onboarding/feature-wall' +} /** @type {import('electron-builder').Configuration} */ module.exports = { @@ -18,7 +22,10 @@ module.exports = { '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}', '!{.env,.env.*,.npmrc,pnpm-lock.yaml}', '!tsconfig.json', - '!config/*' + '!config/*', + // Why: feature-wall media is copied via extraResources so runtime can read + // it from process.resourcesPath; exclude the source copy from app.asar. + '!resources/onboarding/feature-wall/**' ], // Why: the CLI entry-point lives in out/cli/ but imports shared modules // from out/shared/ (e.g. runtime-bootstrap). Both directories must be @@ -85,7 +92,8 @@ module.exports = { { from: 'native/computer-use-windows/runtime.ps1', to: 'computer-use-windows/runtime.ps1' - } + }, + featureWallResources ] }, nsis: { @@ -137,7 +145,8 @@ module.exports = { { from: 'native/computer-use-macos/.build/release/Orca Computer Use.app', to: 'Orca Computer Use.app' - } + }, + featureWallResources ], target: [ { @@ -172,7 +181,8 @@ module.exports = { { from: 'native/computer-use-linux/runtime.py', to: 'computer-use-linux/runtime.py' - } + }, + featureWallResources ], target: ['AppImage', 'deb'], maintainer: 'stablyai', diff --git a/config/scripts/check-feature-wall-assets.mjs b/config/scripts/check-feature-wall-assets.mjs new file mode 100644 index 000000000..ed234ea80 --- /dev/null +++ b/config/scripts/check-feature-wall-assets.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +import { readdir, stat } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = path.join(__dirname, '..', '..') +const FEATURE_WALL_ASSET_DIR = path.join(ROOT, 'resources', 'onboarding', 'feature-wall') +const MAX_BYTES = 11 * 1024 * 1024 +const MEDIA_TILE_IDS = [ + 'tile-01', + 'tile-02', + 'tile-03', + 'tile-04', + 'tile-05', + 'tile-06', + 'tile-07', + 'tile-08', + 'tile-09', + 'tile-10', + 'tile-11', + 'tile-12' +] +const EXPECTED_FILES = MEDIA_TILE_IDS.flatMap((id) => [ + `${id}.gif`, + `${id}.poster.jpg`, + `${id}.recorded-at.json` +]) + +async function collectFiles(dir) { + let entries + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch (error) { + if (error && error.code === 'ENOENT') { + return [] + } + throw error + } + + const files = [] + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + files.push(...(await collectFiles(fullPath))) + } else if (entry.isFile()) { + files.push(fullPath) + } + } + return files +} + +const files = await collectFiles(FEATURE_WALL_ASSET_DIR) +const fileNames = new Set(files.map((file) => path.relative(FEATURE_WALL_ASSET_DIR, file))) +const missingFiles = EXPECTED_FILES.filter((file) => !fileNames.has(file)) +if (missingFiles.length > 0) { + // Why: a byte-budget-only check lets an empty asset directory pass, which + // ships the feature tour as text-only cards instead of the recorded media. + console.error(`Feature wall assets are missing: ${missingFiles.join(', ')}`) + process.exit(1) +} + +let totalBytes = 0 +for (const file of files) { + const fileStat = await stat(file) + totalBytes += fileStat.size +} + +if (totalBytes > MAX_BYTES) { + const totalMb = (totalBytes / 1024 / 1024).toFixed(2) + const maxMb = (MAX_BYTES / 1024 / 1024).toFixed(2) + console.error( + `Feature wall assets are ${totalMb} MB, which exceeds the ${maxMb} MB installer budget.` + ) + process.exit(1) +} + +console.log( + `Feature wall assets: ${(totalBytes / 1024 / 1024).toFixed(2)} MB / ${( + MAX_BYTES / + 1024 / + 1024 + ).toFixed(2)} MB` +) diff --git a/config/scripts/vendor-feature-wall-assets.mjs b/config/scripts/vendor-feature-wall-assets.mjs new file mode 100644 index 000000000..61892dc94 --- /dev/null +++ b/config/scripts/vendor-feature-wall-assets.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +import { copyFile, mkdir, writeFile } from 'node:fs/promises' +import { spawnSync } from 'node:child_process' +import { homedir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = path.join(__dirname, '..', '..') +const DEFAULT_MARKETING_REPO = path.join( + homedir(), + 'source', + 'repos', + 'Stably', + 'orca-marketing-website' +) +const MARKETING_REPO = process.env.ORCA_MARKETING_REPO || DEFAULT_MARKETING_REPO +const DEST_ROOT = path.join(ROOT, 'resources', 'onboarding', 'feature-wall') + +const TILES = [ + { + id: 'tile-01', + sourceRoot: ROOT, + gifRelativePath: 'docs/assets/feature-wall/parallel-worktrees.gif', + posterRelativePath: 'docs/assets/feature-wall/parallel-worktrees.jpg' + }, + { + id: 'tile-02', + gifRelativePath: 'public/whats-new/ghostty-style-terminal.gif', + posterRelativePath: 'public/whats-new/posters/ghostty-style-terminal.jpg' + }, + { + id: 'tile-03', + gifRelativePath: 'public/whats-new/orca-github.gif', + posterRelativePath: 'public/whats-new/posters/orca-github.jpg' + }, + { + id: 'tile-04', + gifRelativePath: 'public/whats-new/any-cli-agent.gif', + posterRelativePath: 'public/whats-new/posters/any-cli-agent.jpg' + }, + { + id: 'tile-05', + gifRelativePath: 'public/whats-new/orca-design-mode.gif', + posterRelativePath: 'public/whats-new/posters/orca-design-mode.jpg' + }, + { + id: 'tile-06', + gifRelativePath: 'public/whats-new/ssh-demo.gif', + posterRelativePath: 'public/whats-new/posters/ssh-demo.jpg' + }, + { + id: 'tile-07', + gifRelativePath: 'public/file-drag.gif', + posterRelativePath: 'public/whats-new/posters/file-drag.jpg' + }, + { + id: 'tile-08', + gifRelativePath: 'public/whats-new/annotate-ai-diff.gif', + posterRelativePath: 'public/whats-new/posters/annotate-ai-diff.jpg' + }, + { + id: 'tile-09', + gifRelativePath: 'public/whats-new/orca-cli-demo.gif', + posterRelativePath: 'public/whats-new/posters/orca-cli-demo.jpg' + }, + { + id: 'tile-10', + gifRelativePath: 'public/whats-new/keyboard-native.gif', + posterRelativePath: 'public/whats-new/posters/keyboard-native.jpg' + }, + { + id: 'tile-11', + gifRelativePath: 'public/whats-new/codex-account-switcher.gif', + posterRelativePath: 'public/whats-new/posters/codex-account-switcher.jpg' + }, + { + id: 'tile-12', + gifRelativePath: 'public/whats-new/orca-markdown-editor.gif', + posterRelativePath: 'public/whats-new/posters/orca-markdown-editor.jpg' + } +] + +function sourceRootForTile(tile) { + return tile.sourceRoot ?? MARKETING_REPO +} + +function gitRecordedAtSeconds(tile, relativePath) { + const result = spawnSync('git', ['log', '--format=%at', '-1', '--', relativePath], { + cwd: sourceRootForTile(tile), + encoding: 'utf8' + }) + if (result.status !== 0) { + throw new Error(`git log failed for ${relativePath}: ${result.stderr || result.stdout}`) + } + const value = result.stdout.trim() + if (!value) { + throw new Error(`No git history found for ${relativePath}`) + } + return Number(value) +} + +await mkdir(DEST_ROOT, { recursive: true }) + +for (const tile of TILES) { + const sourceRoot = sourceRootForTile(tile) + const sourceGif = path.join(sourceRoot, ...tile.gifRelativePath.split('/')) + const sourcePoster = path.join(sourceRoot, ...tile.posterRelativePath.split('/')) + const destGif = path.join(DEST_ROOT, `${tile.id}.gif`) + const destPoster = path.join(DEST_ROOT, `${tile.id}.poster.jpg`) + const recordedAtSeconds = gitRecordedAtSeconds(tile, tile.gifRelativePath) + + await copyFile(sourceGif, destGif) + await copyFile(sourcePoster, destPoster) + await writeFile( + path.join(DEST_ROOT, `${tile.id}.recorded-at.json`), + `${JSON.stringify( + { + recordedAtUnixSeconds: recordedAtSeconds, + recordedAtIso: new Date(recordedAtSeconds * 1000).toISOString(), + sourceGif: tile.gifRelativePath, + sourcePoster: tile.posterRelativePath + }, + null, + 2 + )}\n` + ) + + console.log(`Vendored ${tile.gifRelativePath} -> ${tile.id}`) +} diff --git a/package.json b/package.json index ebae6f3ba..427435b01 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "lint": "oxlint", "prepare": "husky", "test": "vitest run --config config/vitest.config.ts", + "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", + "vendor:feature-wall-assets": "node config/scripts/vendor-feature-wall-assets.mjs", "tc:node": "tsgo --noEmit -p config/tsconfig.node.json", "tc:cli": "tsgo --noEmit -p config/tsconfig.tc.cli.json", "tc:web": "tsgo --noEmit -p config/tsconfig.tc.web.json", diff --git a/resources/onboarding/feature-wall/.gitkeep b/resources/onboarding/feature-wall/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/resources/onboarding/feature-wall/.gitkeep @@ -0,0 +1 @@ + diff --git a/resources/onboarding/feature-wall/tile-01.gif b/resources/onboarding/feature-wall/tile-01.gif new file mode 100644 index 000000000..69193d129 Binary files /dev/null and b/resources/onboarding/feature-wall/tile-01.gif differ diff --git a/resources/onboarding/feature-wall/tile-01.poster.jpg b/resources/onboarding/feature-wall/tile-01.poster.jpg new file mode 100644 index 000000000..403fdff52 Binary files /dev/null and b/resources/onboarding/feature-wall/tile-01.poster.jpg differ diff --git a/resources/onboarding/feature-wall/tile-01.recorded-at.json b/resources/onboarding/feature-wall/tile-01.recorded-at.json new file mode 100644 index 000000000..34380500b --- /dev/null +++ b/resources/onboarding/feature-wall/tile-01.recorded-at.json @@ -0,0 +1,6 @@ +{ + "recordedAtUnixSeconds": 1778710735, + "recordedAtIso": "2026-05-13T22:18:55.000Z", + "sourceGif": "docs/assets/feature-wall/parallel-worktrees.gif", + "sourcePoster": "docs/assets/feature-wall/parallel-worktrees.jpg" +} diff --git a/resources/onboarding/feature-wall/tile-02.gif b/resources/onboarding/feature-wall/tile-02.gif new file mode 100644 index 000000000..861c89595 Binary files /dev/null and b/resources/onboarding/feature-wall/tile-02.gif differ diff --git a/resources/onboarding/feature-wall/tile-02.poster.jpg b/resources/onboarding/feature-wall/tile-02.poster.jpg new file mode 100644 index 000000000..716d4ed03 Binary files /dev/null and b/resources/onboarding/feature-wall/tile-02.poster.jpg differ diff --git a/resources/onboarding/feature-wall/tile-02.recorded-at.json b/resources/onboarding/feature-wall/tile-02.recorded-at.json new file mode 100644 index 000000000..52d0e446f --- /dev/null +++ b/resources/onboarding/feature-wall/tile-02.recorded-at.json @@ -0,0 +1,6 @@ +{ + "recordedAtUnixSeconds": 1777835257, + "recordedAtIso": "2026-05-03T19:07:37.000Z", + "sourceGif": "public/whats-new/ghostty-style-terminal.gif", + "sourcePoster": "public/whats-new/posters/ghostty-style-terminal.jpg" +} diff --git a/resources/onboarding/feature-wall/tile-03.gif b/resources/onboarding/feature-wall/tile-03.gif new file mode 100644 index 000000000..8946d4fbf Binary files /dev/null and b/resources/onboarding/feature-wall/tile-03.gif differ diff --git a/resources/onboarding/feature-wall/tile-03.poster.jpg b/resources/onboarding/feature-wall/tile-03.poster.jpg new file mode 100644 index 000000000..eefd0f534 Binary files /dev/null and b/resources/onboarding/feature-wall/tile-03.poster.jpg differ diff --git a/resources/onboarding/feature-wall/tile-03.recorded-at.json b/resources/onboarding/feature-wall/tile-03.recorded-at.json new file mode 100644 index 000000000..10420d054 --- /dev/null +++ b/resources/onboarding/feature-wall/tile-03.recorded-at.json @@ -0,0 +1,6 @@ +{ + "recordedAtUnixSeconds": 1778711287, + "recordedAtIso": "2026-05-13T22:28:07.000Z", + "sourceGif": "public/whats-new/orca-github.gif", + "sourcePoster": "public/whats-new/posters/orca-github.jpg" +} diff --git a/resources/onboarding/feature-wall/tile-04.gif b/resources/onboarding/feature-wall/tile-04.gif new file mode 100644 index 000000000..bbc128af7 Binary files /dev/null and b/resources/onboarding/feature-wall/tile-04.gif differ diff --git a/resources/onboarding/feature-wall/tile-04.poster.jpg b/resources/onboarding/feature-wall/tile-04.poster.jpg new file mode 100644 index 000000000..3af037a41 Binary files /dev/null and b/resources/onboarding/feature-wall/tile-04.poster.jpg differ diff --git a/resources/onboarding/feature-wall/tile-04.recorded-at.json b/resources/onboarding/feature-wall/tile-04.recorded-at.json new file mode 100644 index 000000000..f25cc8ff8 --- /dev/null +++ b/resources/onboarding/feature-wall/tile-04.recorded-at.json @@ -0,0 +1,6 @@ +{ + "recordedAtUnixSeconds": 1777623672, + "recordedAtIso": "2026-05-01T08:21:12.000Z", + "sourceGif": "public/whats-new/any-cli-agent.gif", + "sourcePoster": "public/whats-new/posters/any-cli-agent.jpg" +} diff --git a/resources/onboarding/feature-wall/tile-05.gif b/resources/onboarding/feature-wall/tile-05.gif new file mode 100644 index 000000000..ec59d5c3c Binary files /dev/null and b/resources/onboarding/feature-wall/tile-05.gif differ diff --git a/resources/onboarding/feature-wall/tile-05.poster.jpg b/resources/onboarding/feature-wall/tile-05.poster.jpg new file mode 100644 index 000000000..37b4730dc Binary files /dev/null and b/resources/onboarding/feature-wall/tile-05.poster.jpg differ diff --git a/resources/onboarding/feature-wall/tile-05.recorded-at.json b/resources/onboarding/feature-wall/tile-05.recorded-at.json new file mode 100644 index 000000000..10e3a908e --- /dev/null +++ b/resources/onboarding/feature-wall/tile-05.recorded-at.json @@ -0,0 +1,6 @@ +{ + "recordedAtUnixSeconds": 1777835257, + "recordedAtIso": "2026-05-03T19:07:37.000Z", + "sourceGif": "public/whats-new/orca-design-mode.gif", + "sourcePoster": "public/whats-new/posters/orca-design-mode.jpg" +} diff --git a/resources/onboarding/feature-wall/tile-06.gif b/resources/onboarding/feature-wall/tile-06.gif new file mode 100644 index 000000000..b6dc393fb Binary files /dev/null and b/resources/onboarding/feature-wall/tile-06.gif differ diff --git a/resources/onboarding/feature-wall/tile-06.poster.jpg b/resources/onboarding/feature-wall/tile-06.poster.jpg new file mode 100644 index 000000000..f6df8ef91 Binary files /dev/null and b/resources/onboarding/feature-wall/tile-06.poster.jpg differ diff --git a/resources/onboarding/feature-wall/tile-06.recorded-at.json b/resources/onboarding/feature-wall/tile-06.recorded-at.json new file mode 100644 index 000000000..405486c2c --- /dev/null +++ b/resources/onboarding/feature-wall/tile-06.recorded-at.json @@ -0,0 +1,6 @@ +{ + "recordedAtUnixSeconds": 1776227184, + "recordedAtIso": "2026-04-15T04:26:24.000Z", + "sourceGif": "public/whats-new/ssh-demo.gif", + "sourcePoster": "public/whats-new/posters/ssh-demo.jpg" +} diff --git a/resources/onboarding/feature-wall/tile-07.gif b/resources/onboarding/feature-wall/tile-07.gif new file mode 100644 index 000000000..1875a51ee Binary files /dev/null and b/resources/onboarding/feature-wall/tile-07.gif differ diff --git a/resources/onboarding/feature-wall/tile-07.poster.jpg b/resources/onboarding/feature-wall/tile-07.poster.jpg new file mode 100644 index 000000000..5e1eec9f7 Binary files /dev/null and b/resources/onboarding/feature-wall/tile-07.poster.jpg differ diff --git a/resources/onboarding/feature-wall/tile-07.recorded-at.json b/resources/onboarding/feature-wall/tile-07.recorded-at.json new file mode 100644 index 000000000..c3f9fa2f9 --- /dev/null +++ b/resources/onboarding/feature-wall/tile-07.recorded-at.json @@ -0,0 +1,6 @@ +{ + "recordedAtUnixSeconds": 1774682237, + "recordedAtIso": "2026-03-28T07:17:17.000Z", + "sourceGif": "public/file-drag.gif", + "sourcePoster": "public/whats-new/posters/file-drag.jpg" +} diff --git a/resources/onboarding/feature-wall/tile-08.gif b/resources/onboarding/feature-wall/tile-08.gif new file mode 100644 index 000000000..015d63c84 Binary files /dev/null and b/resources/onboarding/feature-wall/tile-08.gif differ diff --git a/resources/onboarding/feature-wall/tile-08.poster.jpg b/resources/onboarding/feature-wall/tile-08.poster.jpg new file mode 100644 index 000000000..b01a69f8c Binary files /dev/null and b/resources/onboarding/feature-wall/tile-08.poster.jpg differ diff --git a/resources/onboarding/feature-wall/tile-08.recorded-at.json b/resources/onboarding/feature-wall/tile-08.recorded-at.json new file mode 100644 index 000000000..68f1ec026 --- /dev/null +++ b/resources/onboarding/feature-wall/tile-08.recorded-at.json @@ -0,0 +1,6 @@ +{ + "recordedAtUnixSeconds": 1777102332, + "recordedAtIso": "2026-04-25T07:32:12.000Z", + "sourceGif": "public/whats-new/annotate-ai-diff.gif", + "sourcePoster": "public/whats-new/posters/annotate-ai-diff.jpg" +} diff --git a/resources/onboarding/feature-wall/tile-09.gif b/resources/onboarding/feature-wall/tile-09.gif new file mode 100644 index 000000000..d6b6b6409 Binary files /dev/null and b/resources/onboarding/feature-wall/tile-09.gif differ diff --git a/resources/onboarding/feature-wall/tile-09.poster.jpg b/resources/onboarding/feature-wall/tile-09.poster.jpg new file mode 100644 index 000000000..9f7bc9143 Binary files /dev/null and b/resources/onboarding/feature-wall/tile-09.poster.jpg differ diff --git a/resources/onboarding/feature-wall/tile-09.recorded-at.json b/resources/onboarding/feature-wall/tile-09.recorded-at.json new file mode 100644 index 000000000..0f1d4bc8c --- /dev/null +++ b/resources/onboarding/feature-wall/tile-09.recorded-at.json @@ -0,0 +1,6 @@ +{ + "recordedAtUnixSeconds": 1777623672, + "recordedAtIso": "2026-05-01T08:21:12.000Z", + "sourceGif": "public/whats-new/orca-cli-demo.gif", + "sourcePoster": "public/whats-new/posters/orca-cli-demo.jpg" +} diff --git a/resources/onboarding/feature-wall/tile-10.gif b/resources/onboarding/feature-wall/tile-10.gif new file mode 100644 index 000000000..a407acbfc Binary files /dev/null and b/resources/onboarding/feature-wall/tile-10.gif differ diff --git a/resources/onboarding/feature-wall/tile-10.poster.jpg b/resources/onboarding/feature-wall/tile-10.poster.jpg new file mode 100644 index 000000000..641c41748 Binary files /dev/null and b/resources/onboarding/feature-wall/tile-10.poster.jpg differ diff --git a/resources/onboarding/feature-wall/tile-10.recorded-at.json b/resources/onboarding/feature-wall/tile-10.recorded-at.json new file mode 100644 index 000000000..d46e703c7 --- /dev/null +++ b/resources/onboarding/feature-wall/tile-10.recorded-at.json @@ -0,0 +1,6 @@ +{ + "recordedAtUnixSeconds": 1777623672, + "recordedAtIso": "2026-05-01T08:21:12.000Z", + "sourceGif": "public/whats-new/keyboard-native.gif", + "sourcePoster": "public/whats-new/posters/keyboard-native.jpg" +} diff --git a/resources/onboarding/feature-wall/tile-11.gif b/resources/onboarding/feature-wall/tile-11.gif new file mode 100644 index 000000000..ba23ea67a Binary files /dev/null and b/resources/onboarding/feature-wall/tile-11.gif differ diff --git a/resources/onboarding/feature-wall/tile-11.poster.jpg b/resources/onboarding/feature-wall/tile-11.poster.jpg new file mode 100644 index 000000000..915fcf2be Binary files /dev/null and b/resources/onboarding/feature-wall/tile-11.poster.jpg differ diff --git a/resources/onboarding/feature-wall/tile-11.recorded-at.json b/resources/onboarding/feature-wall/tile-11.recorded-at.json new file mode 100644 index 000000000..e666c3d1d --- /dev/null +++ b/resources/onboarding/feature-wall/tile-11.recorded-at.json @@ -0,0 +1,6 @@ +{ + "recordedAtUnixSeconds": 1775978251, + "recordedAtIso": "2026-04-12T07:17:31.000Z", + "sourceGif": "public/whats-new/codex-account-switcher.gif", + "sourcePoster": "public/whats-new/posters/codex-account-switcher.jpg" +} diff --git a/resources/onboarding/feature-wall/tile-12.gif b/resources/onboarding/feature-wall/tile-12.gif new file mode 100644 index 000000000..591cd2269 Binary files /dev/null and b/resources/onboarding/feature-wall/tile-12.gif differ diff --git a/resources/onboarding/feature-wall/tile-12.poster.jpg b/resources/onboarding/feature-wall/tile-12.poster.jpg new file mode 100644 index 000000000..fee2cc8fe Binary files /dev/null and b/resources/onboarding/feature-wall/tile-12.poster.jpg differ diff --git a/resources/onboarding/feature-wall/tile-12.recorded-at.json b/resources/onboarding/feature-wall/tile-12.recorded-at.json new file mode 100644 index 000000000..fd3ebae8c --- /dev/null +++ b/resources/onboarding/feature-wall/tile-12.recorded-at.json @@ -0,0 +1,6 @@ +{ + "recordedAtUnixSeconds": 1777623672, + "recordedAtIso": "2026-05-01T08:21:12.000Z", + "sourceGif": "public/whats-new/orca-markdown-editor.gif", + "sourcePoster": "public/whats-new/posters/orca-markdown-editor.jpg" +} diff --git a/src/main/feature-wall/first-agent-tour.test.ts b/src/main/feature-wall/first-agent-tour.test.ts new file mode 100644 index 000000000..ff54976a0 --- /dev/null +++ b/src/main/feature-wall/first-agent-tour.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + FEATURE_WALL_FIRST_AGENT_TOUR_DELAY_MS, + registerFeatureWallFirstAgentTour +} from './first-agent-tour' +import type { StatsCollector } from '../stats/collector' + +function createStatsSource() { + let listener: ((totalAgentsSpawned: number) => void) | null = null + const dispose = vi.fn(() => { + listener = null + }) + const stats = { + onAgentStarted: vi.fn((nextListener: (totalAgentsSpawned: number) => void) => { + listener = nextListener + return dispose + }) + } satisfies Pick + + return { + stats, + dispose, + emit: (totalAgentsSpawned: number) => listener?.(totalAgentsSpawned) + } +} + +function createWindow() { + return { + isDestroyed: vi.fn(() => false), + webContents: { + send: vi.fn() + } + } +} + +describe('registerFeatureWallFirstAgentTour', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('shows the feature tour nudge shortly after the first agent starts', () => { + vi.useFakeTimers() + const source = createStatsSource() + const window = createWindow() + + registerFeatureWallFirstAgentTour({ + stats: source.stats, + getWindow: () => window + }) + source.emit(1) + + expect(window.webContents.send).not.toHaveBeenCalled() + vi.advanceTimersByTime(FEATURE_WALL_FIRST_AGENT_TOUR_DELAY_MS - 1) + expect(window.webContents.send).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + + expect(window.webContents.send).toHaveBeenCalledWith('ui:showFeatureTourNudge') + }) + + it('does not open for later agent starts or destroyed windows', () => { + vi.useFakeTimers() + const source = createStatsSource() + const window = createWindow() + registerFeatureWallFirstAgentTour({ + stats: source.stats, + getWindow: () => window + }) + + source.emit(2) + vi.advanceTimersByTime(FEATURE_WALL_FIRST_AGENT_TOUR_DELAY_MS) + window.isDestroyed.mockReturnValue(true) + source.emit(1) + vi.advanceTimersByTime(FEATURE_WALL_FIRST_AGENT_TOUR_DELAY_MS) + + expect(window.webContents.send).not.toHaveBeenCalled() + }) + + it('disposes the stats listener and pending tour timer', () => { + vi.useFakeTimers() + const source = createStatsSource() + const window = createWindow() + const dispose = registerFeatureWallFirstAgentTour({ + stats: source.stats, + getWindow: () => window + }) + + source.emit(1) + dispose() + vi.advanceTimersByTime(FEATURE_WALL_FIRST_AGENT_TOUR_DELAY_MS) + source.emit(1) + + expect(source.dispose).toHaveBeenCalledTimes(1) + expect(window.webContents.send).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/feature-wall/first-agent-tour.ts b/src/main/feature-wall/first-agent-tour.ts new file mode 100644 index 000000000..a0d834763 --- /dev/null +++ b/src/main/feature-wall/first-agent-tour.ts @@ -0,0 +1,43 @@ +import type { BrowserWindow } from 'electron' +import type { StatsCollector } from '../stats/collector' + +type FeatureWallWindow = Pick & { + webContents: Pick +} + +export const FEATURE_WALL_FIRST_AGENT_TOUR_DELAY_MS = 1_500 + +export function registerFeatureWallFirstAgentTour(args: { + stats: Pick + getWindow: () => FeatureWallWindow | null +}): () => void { + let pendingTimer: ReturnType | null = null + let didScheduleTour = false + + const disposeStatsListener = args.stats.onAgentStarted((totalAgentsSpawned) => { + if (totalAgentsSpawned !== 1 || didScheduleTour) { + return + } + + didScheduleTour = true + + // Why: first-agent education should invite without taking focus from the + // just-started terminal; older users skip this because their total is > 1. + pendingTimer = setTimeout(() => { + pendingTimer = null + const window = args.getWindow() + if (!window || window.isDestroyed()) { + return + } + window.webContents.send('ui:showFeatureTourNudge') + }, FEATURE_WALL_FIRST_AGENT_TOUR_DELAY_MS) + }) + + return () => { + disposeStatsListener() + if (pendingTimer) { + clearTimeout(pendingTimer) + pendingTimer = null + } + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 0e3abf55f..47de211cc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -55,6 +55,7 @@ import { getPtyIdForPaneKey, registerPaneKeyTeardownListener, getLocalPtyProvide import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { browserManager } from './browser/browser-manager' import { setUnreadDockBadgeCount } from './dock/unread-badge' +import { registerFeatureWallFirstAgentTour } from './feature-wall/first-agent-tour' import { AutomationService } from './automations/service' let mainWindow: BrowserWindow | null = null @@ -74,6 +75,7 @@ let runtime: OrcaRuntimeService | null = null let rateLimits: RateLimitService | null = null let runtimeRpc: OrcaRuntimeRpcServer | null = null let starNag: StarNagService | null = null +let disposeFeatureWallFirstAgentTour: (() => void) | null = null let watcherShutdownPromise: Promise | null = null let watcherShutdownDone = false let automations: AutomationService | null = null @@ -320,6 +322,12 @@ function openMainWindow(): BrowserWindow { return window } +function sendOpenFeatureTour(targetWindow?: BrowserWindow | null): void { + const webContents = + targetWindow && !targetWindow.isDestroyed() ? targetWindow.webContents : mainWindow?.webContents + webContents?.send('ui:openFeatureTour') +} + function shutdownWatchersOnce(): Promise { if (watcherShutdownDone) { return Promise.resolve() @@ -523,6 +531,10 @@ app.whenReady().then(async () => { }) automations = new AutomationService(store) runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) + disposeFeatureWallFirstAgentTour = registerFeatureWallFirstAgentTour({ + stats, + getWindow: () => mainWindow + }) starNag = new StarNagService(store, stats) starNag.start() starNag.registerIpcHandlers() @@ -546,6 +558,13 @@ app.whenReady().then(async () => { onOpenSettings: () => { mainWindow?.webContents.send('ui:openSettings') }, + onOpenFeatureTour: (targetWindow) => { + // Why: menu clicks provide the BrowserWindow that invoked the item. Use it + // first so hidden/headless E2E windows and future multi-window flows route + // the tour to the correct renderer instead of relying on global focus. + const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null + sendOpenFeatureTour(targetBrowserWindow) + }, onZoomIn: () => { mainWindow?.webContents.send('terminal:zoom', 'in') }, @@ -673,6 +692,8 @@ app.whenReady().then(async () => { app.on('before-quit', () => { isQuitting = true + disposeFeatureWallFirstAgentTour?.() + disposeFeatureWallFirstAgentTour = null // Why: PTY cleanup is deferred to will-quit so the renderer has a chance to // capture terminal scrollback buffers before PTY exit events race in and // unmount TerminalPane components (removing their capture callbacks). diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 7070d26ee..70e7e9c6f 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -1,6 +1,8 @@ import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' import { mkdir } from 'node:fs/promises' import path from 'node:path' +import { pathToFileURL } from 'node:url' import { promisify } from 'node:util' import { app, ipcMain } from 'electron' import type { FloatingTerminalCwdRequest } from '../../shared/types' @@ -39,7 +41,38 @@ async function resolveFloatingTerminalCwd(args?: FloatingTerminalCwdRequest): Pr } } +function getFeatureWallAssetBaseUrl(): string { + const assetDir = app.isPackaged + ? path.join(process.resourcesPath, 'onboarding', 'feature-wall') + : resolveDevFeatureWallAssetDir() + + if (!app.isPackaged && process.env.ELECTRON_RENDERER_URL) { + const vitePath = assetDir.split(path.sep).join('/') + const absoluteVitePath = vitePath.startsWith('/') ? vitePath : `/${vitePath}` + // Why: the dev renderer is served from http://localhost, where Chromium + // blocks file:// image loads. Vite's /@fs route serves the same local media. + return new URL(`/@fs${absoluteVitePath}/`, process.env.ELECTRON_RENDERER_URL).toString() + } + + return `${pathToFileURL(assetDir).toString()}/` +} + +function resolveDevFeatureWallAssetDir(): string { + const relativeDir = path.join('resources', 'onboarding', 'feature-wall') + const candidates = [ + path.join(app.getAppPath(), relativeDir), + path.resolve(app.getAppPath(), '..', '..', relativeDir), + path.join(process.cwd(), relativeDir) + ] + + // Why: E2E launches out/main/index.js, so app.getAppPath() can point at + // out/main even though development resources still live at the repo root. + return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0] +} + export function registerAppHandlers(): void { + ipcMain.handle('app:getFeatureWallAssetBaseUrl', (): string => getFeatureWallAssetBaseUrl()) + ipcMain.handle('wsl:isAvailable', (): boolean => isWslAvailable()) ipcMain.handle('pwsh:isAvailable', (): boolean => isPwshAvailable()) diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index 9bbf4faf9..9d910daff 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -27,6 +27,7 @@ function buildMenuOptions() { return { onCheckForUpdates: vi.fn(), onOpenSettings: vi.fn(), + onOpenFeatureTour: vi.fn(), onZoomIn: vi.fn(), onZoomOut: vi.fn(), onZoomReset: vi.fn(), @@ -171,7 +172,7 @@ describe('registerAppMenu', () => { expect(fileLabels).toEqual(expect.arrayContaining(['Export as PDF...', 'Settings', 'Exit'])) const helpLabels = getSubmenu(template, 'Help').map((item) => item.label) - expect(helpLabels).toEqual(expect.arrayContaining(['Check for Updates...'])) + expect(helpLabels).toEqual(expect.arrayContaining(['Feature tour', 'Check for Updates...'])) }) it.runIf(isMac)('keeps the macOS app-named menu with Settings and quit roles', () => { @@ -186,8 +187,24 @@ describe('registerAppMenu', () => { const fileLabels = getSubmenu(template, 'File').map((item) => item.label) expect(fileLabels).not.toContain('Settings') expect(fileLabels).not.toContain('Exit') - // No Help menu on macOS — About/Check for Updates live in the app menu. - expect(template.find((item) => item.label === 'Help')).toBeUndefined() + const helpLabels = getSubmenu(template, 'Help').map((item) => item.label) + expect(helpLabels).toEqual(['Feature tour']) + }) + + it('routes Feature tour through its callback', () => { + const options = buildMenuOptions() + registerAppMenu(options) + + const featureTourItem = getSubmenu(getTemplate(), 'Help').find( + (entry) => entry.label === 'Feature tour' + ) + expect(featureTourItem?.accelerator).toBeUndefined() + + const targetWindow = {} as Electron.BaseWindow + featureTourItem?.click?.({} as never, targetWindow, {} as Electron.KeyboardEvent) + + expect(options.onOpenFeatureTour).toHaveBeenCalledTimes(1) + expect(options.onOpenFeatureTour).toHaveBeenCalledWith(targetWindow) }) it('exposes an Appearance submenu under View with checkbox items reflecting state', () => { diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index 61d51284e..2d14dfc36 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -10,6 +10,7 @@ export type AppearanceMenuKey = keyof AppearanceMenuState type RegisterAppMenuOptions = { onOpenSettings: () => void + onOpenFeatureTour: (window?: Electron.BaseWindow | null) => void onCheckForUpdates: (options: { includePrerelease: boolean }) => void onZoomIn: () => void onZoomOut: () => void @@ -23,6 +24,7 @@ type RegisterAppMenuOptions = { function buildAndApplyMenu(options: RegisterAppMenuOptions): void { const { onOpenSettings, + onOpenFeatureTour, onCheckForUpdates, onZoomIn, onZoomOut, @@ -73,6 +75,11 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { click: () => onOpenSettings() } + const featureTourItem: Electron.MenuItemConstructorOptions = { + label: 'Feature tour', + click: (_menuItem, window) => onOpenFeatureTour(window) + } + const exportPdfItem: Electron.MenuItemConstructorOptions = { label: 'Export as PDF...', accelerator: 'CmdOrCtrl+Shift+E', @@ -251,13 +258,21 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { submenu: [{ role: 'minimize' }, { role: 'zoom' }] } - // Why: Windows/Linux have no app-named menu, so About + Check for Updates - // go into a Help menu — the standard place for those entries on those - // platforms. On macOS the system "About Orca" and "Check for Updates" - // already sit under the app menu, so we don't duplicate them here. + // Why: the feature tour is product education, so it belongs under Help on + // every platform. macOS still keeps About/Updates in the app menu, while + // Windows/Linux keep those entries here because they have no app menu. const helpMenu: Electron.MenuItemConstructorOptions = { label: 'Help', - submenu: [{ role: 'about' }, checkForUpdatesItem] + submenu: [ + featureTourItem, + ...(isMac + ? [] + : ([ + { type: 'separator' }, + { role: 'about' }, + checkForUpdatesItem + ] satisfies Electron.MenuItemConstructorOptions[])) + ] } const template: Electron.MenuItemConstructorOptions[] = [ @@ -266,7 +281,7 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { editMenu, viewMenu, windowMenu, - ...(isMac ? [] : [helpMenu]) + helpMenu ] Menu.setApplicationMenu(Menu.buildFromTemplate(template)) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index f7342f091..81b9b13d5 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -417,6 +417,9 @@ export type CodexUsageApi = { } export type AppApi = { + /** Returns a URL base for feature-wall assets. In dev this is Vite /@fs; + * in packaged builds this is file:// resources. Renderer appends filenames. */ + getFeatureWallAssetBaseUrl: () => Promise /** Relaunches the app via Electron's app.relaunch() + app.exit(0). Used * by settings panes that need a full restart to apply changes (e.g. the * terminal-window blur setting in TerminalWindowSection). */ @@ -1206,6 +1209,8 @@ export type PreloadApi = { get: () => Promise set: (args: Partial) => Promise onOpenSettings: (callback: () => void) => () => void + onOpenFeatureTour: (callback: () => void) => () => void + onShowFeatureTourNudge: (callback: () => void) => () => void onToggleLeftSidebar: (callback: () => void) => () => void onToggleRightSidebar: (callback: () => void) => () => void onToggleWorktreePalette: (callback: () => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index a54d1df24..209f89987 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -305,6 +305,8 @@ document.addEventListener( // Custom APIs for renderer const api = { app: { + getFeatureWallAssetBaseUrl: (): Promise => + ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'), relaunch: (): Promise => ipcRenderer.invoke('app:relaunch'), // Why: on macOS this returns AppleCurrentKeyboardLayoutInputSourceID so // the renderer's keyboard-layout probe can distinguish Polish Pro / US @@ -1785,6 +1787,16 @@ const api = { ipcRenderer.on('ui:openSettings', listener) return () => ipcRenderer.removeListener('ui:openSettings', listener) }, + onOpenFeatureTour: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openFeatureTour', listener) + return () => ipcRenderer.removeListener('ui:openFeatureTour', listener) + }, + onShowFeatureTourNudge: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:showFeatureTourNudge', listener) + return () => ipcRenderer.removeListener('ui:showFeatureTourNudge', listener) + }, onToggleLeftSidebar: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:toggleLeftSidebar', listener) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c2e0befc3..68a809ddd 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -44,6 +44,7 @@ import RightSidebar from './components/right-sidebar' import { StatusBar } from './components/status-bar/StatusBar' import { UpdateCard } from './components/UpdateCard' import { StarNagCard } from './components/StarNagCard' +import { FeatureTourNudge } from './components/feature-wall/FeatureTourNudge' import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface' import { ZoomOverlay } from './components/ZoomOverlay' import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding' @@ -161,6 +162,7 @@ const Settings = lazy(() => import('./components/settings/Settings')) const QuickOpen = lazy(() => import('./components/QuickOpen')) const WorktreeJumpPalette = lazy(() => import('./components/WorktreeJumpPalette')) const NewWorkspaceComposerModal = lazy(() => import('./components/NewWorkspaceComposerModal')) +const FeatureWallModal = lazy(() => import('./components/feature-wall/FeatureWallModal')) // Why: lazy-loaded so the WebP asset + overlay module aren't fetched unless // the user opts into the experimental flag. const PetOverlay = lazy(() => import('./components/pet/PetOverlay')) @@ -979,7 +981,8 @@ function App(): React.JSX.Element { if ( activeModal !== 'quick-open' && activeModal !== 'worktree-palette' && - activeModal !== 'new-workspace-composer' + activeModal !== 'new-workspace-composer' && + activeModal !== 'feature-wall' ) { return } @@ -1343,6 +1346,7 @@ function App(): React.JSX.Element { {mountedLazyModalIds.has('quick-open') ? : null} {mountedLazyModalIds.has('worktree-palette') ? : null} + {mountedLazyModalIds.has('feature-wall') ? : null} {/* Why: mount PetOverlay only when the experimental flag is on AND the user hasn't hit "Hide pet" in the status-bar menu. Both @@ -1354,6 +1358,7 @@ function App(): React.JSX.Element { ) : null} + {/* Why: the existing-user opt-in banner mounts at App root so it renders once per renderer session, not per view. It gates diff --git a/src/renderer/src/components/feature-wall/FeatureTourNudge.tsx b/src/renderer/src/components/feature-wall/FeatureTourNudge.tsx new file mode 100644 index 000000000..843d07abc --- /dev/null +++ b/src/renderer/src/components/feature-wall/FeatureTourNudge.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from 'react' +import type { JSX } from 'react' +import { PlayCircle, X } from 'lucide-react' +import { + FEATURE_WALL_TILES, + isFeatureWallMediaTile, + type FeatureWallMediaTile +} from '../../../../shared/feature-wall-tiles' +import { useAppStore } from '@/store' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { toFeatureWallAssetUrl, useFeatureWallAssetBaseUrl } from './feature-wall-assets' + +const FEATURE_TOUR_NUDGE_TILE = FEATURE_WALL_TILES.find( + (tile): tile is FeatureWallMediaTile => tile.id === 'tile-03' && isFeatureWallMediaTile(tile) +) + +export function FeatureTourNudge(): JSX.Element | null { + const visible = useAppStore((s) => s.featureTourNudgeVisible) + const activeModal = useAppStore((s) => s.activeModal) + const updateStatus = useAppStore((s) => s.updateStatus) + const dismissFeatureTourNudge = useAppStore((s) => s.dismissFeatureTourNudge) + const openModal = useAppStore((s) => s.openModal) + const shouldRender = visible && activeModal !== 'feature-wall' + const assetBaseUrl = useFeatureWallAssetBaseUrl(shouldRender) + const [mediaFailed, setMediaFailed] = useState(false) + const [mediaLoaded, setMediaLoaded] = useState(false) + const gifUrl = FEATURE_TOUR_NUDGE_TILE + ? toFeatureWallAssetUrl(assetBaseUrl, FEATURE_TOUR_NUDGE_TILE.gifPath) + : null + const posterUrl = FEATURE_TOUR_NUDGE_TILE + ? toFeatureWallAssetUrl(assetBaseUrl, FEATURE_TOUR_NUDGE_TILE.posterPath) + : null + const mediaUrl = gifUrl ?? posterUrl + const updateCardVisible = updateStatus.state !== 'idle' && updateStatus.state !== 'not-available' + + useEffect(() => { + setMediaFailed(false) + setMediaLoaded(false) + }, [mediaUrl]) + + useEffect(() => { + if (!shouldRender) { + return + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + dismissFeatureTourNudge() + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [dismissFeatureTourNudge, shouldRender]) + + if (!shouldRender || !FEATURE_TOUR_NUDGE_TILE) { + return null + } + + const handleOpenTour = (): void => { + openModal('feature-wall', { source: 'popup' }) + } + + return ( +
+ +
+
+
+
+ Explore some of Orca's features +
+

{FEATURE_TOUR_NUDGE_TILE.title}

+
+ +
+ + + +

+ {FEATURE_TOUR_NUDGE_TILE.caption} +

+ + +
+
+
+ ) +} diff --git a/src/renderer/src/components/feature-wall/FeatureWallModal.tsx b/src/renderer/src/components/feature-wall/FeatureWallModal.tsx new file mode 100644 index 000000000..649fecc74 --- /dev/null +++ b/src/renderer/src/components/feature-wall/FeatureWallModal.tsx @@ -0,0 +1,301 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { JSX, KeyboardEvent, RefObject } from 'react' +import { + FEATURE_WALL_TILES, + isFeatureWallMediaTile, + type FeatureWallTileId +} from '../../../../shared/feature-wall-tiles' +import type { FeatureWallOpenSourceTelemetry } from '../../../../shared/telemetry-events' +import { FEATURE_WALL_MAX_DWELL_MS } from '../../../../shared/feature-wall-telemetry' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { track } from '@/lib/telemetry' +import { useAppStore } from '@/store' +import { + getFeatureWallGridNavigationTarget, + type FeatureWallNavigationKey +} from './feature-wall-grid-navigation' +import { toFeatureWallAssetUrl, useFeatureWallAssetBaseUrl } from './feature-wall-assets' +import { FeatureWallTileCard } from './FeatureWallTileCard' + +const AUTO_ROTATE_MS = 3_500 +const TILE_FOCUS_TELEMETRY_MS = 500 +const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)' +const NAVIGATION_KEYS = new Set([ + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + 'Home', + 'End' +]) + +function getFeatureWallOpenSource( + modalData: Record +): FeatureWallOpenSourceTelemetry { + const source = modalData.source + return source === 'help_menu' || source === 'popup' ? source : 'unknown' +} + +function usePrefersReducedMotion(): boolean { + const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => { + if (typeof window === 'undefined' || !window.matchMedia) { + return false + } + return window.matchMedia(REDUCED_MOTION_QUERY).matches + }) + + useEffect(() => { + const media = window.matchMedia(REDUCED_MOTION_QUERY) + setPrefersReducedMotion(media.matches) + const onChange = (event: MediaQueryListEvent): void => { + setPrefersReducedMotion(event.matches) + } + media.addEventListener('change', onChange) + return () => media.removeEventListener('change', onChange) + }, []) + + return prefersReducedMotion +} + +function useGridColumnCount(gridRef: RefObject, open: boolean): number { + const [columnCount, setColumnCount] = useState(3) + + useEffect(() => { + if (!open || !gridRef.current) { + return + } + + const grid = gridRef.current + const updateColumnCount = (): void => { + const children = Array.from(grid.children).filter( + (child): child is HTMLElement => child instanceof HTMLElement + ) + const first = children[0] + if (!first) { + return + } + const firstTop = first.offsetTop + const nextColumnCount = children.findIndex((child) => child.offsetTop !== firstTop) + setColumnCount(nextColumnCount === -1 ? children.length : Math.max(1, nextColumnCount)) + } + + updateColumnCount() + const observer = new ResizeObserver(updateColumnCount) + observer.observe(grid) + return () => observer.disconnect() + }, [gridRef, open]) + + return columnCount +} + +export default function FeatureWallModal(): JSX.Element | null { + const activeModal = useAppStore((s) => s.activeModal) + const modalData = useAppStore((s) => s.modalData) + const closeModal = useAppStore((s) => s.closeModal) + const isOpen = activeModal === 'feature-wall' + const assetBaseUrl = useFeatureWallAssetBaseUrl(isOpen) + const prefersReducedMotion = usePrefersReducedMotion() + const [autoIndex, setAutoIndex] = useState(0) + const [hoveredTileId, setHoveredTileId] = useState(null) + const [focusedTileId, setFocusedTileId] = useState(null) + const [rovingIndex, setRovingIndex] = useState(0) + const tileRefs = useRef<(HTMLDivElement | null)[]>([]) + const gridRef = useRef(null) + const columnCount = useGridColumnCount(gridRef, isOpen) + const mediaTileIndexes = useMemo( + () => + FEATURE_WALL_TILES.map((tile, index) => (isFeatureWallMediaTile(tile) ? index : -1)).filter( + (index) => index >= 0 + ), + [] + ) + const telemetryRef = useRef<{ + open: boolean + openedAtMs: number + }>({ open: false, openedAtMs: 0 }) + const manualTileId = hoveredTileId ?? focusedTileId + const autoTileId = + isOpen && !prefersReducedMotion && manualTileId === null + ? FEATURE_WALL_TILES[autoIndex]?.id + : null + const playingTileId = manualTileId ?? autoTileId ?? null + + const assetUrlsByTileId = useMemo(() => { + return new Map( + FEATURE_WALL_TILES.filter(isFeatureWallMediaTile).map((tile) => [ + tile.id, + { + gifUrl: toFeatureWallAssetUrl(assetBaseUrl, tile.gifPath), + posterUrl: toFeatureWallAssetUrl(assetBaseUrl, tile.posterPath) + } + ]) + ) + }, [assetBaseUrl]) + + const emitCloseTelemetry = useCallback(() => { + if (!telemetryRef.current.open) { + return + } + const dwellMs = Math.min( + FEATURE_WALL_MAX_DWELL_MS, + Math.max(0, Math.round(performance.now() - telemetryRef.current.openedAtMs)) + ) + track('feature_wall_closed', { + dwell_ms: dwellMs + }) + telemetryRef.current.open = false + }, []) + + useEffect(() => { + if (isOpen && !telemetryRef.current.open) { + telemetryRef.current = { + open: true, + openedAtMs: performance.now() + } + track('feature_wall_opened', { + source: getFeatureWallOpenSource(modalData) + }) + return + } + if (!isOpen) { + emitCloseTelemetry() + } + }, [emitCloseTelemetry, isOpen, modalData]) + + useEffect(() => { + return () => emitCloseTelemetry() + }, [emitCloseTelemetry]) + + useEffect(() => { + if (isOpen) { + return + } + setHoveredTileId(null) + setFocusedTileId(null) + setRovingIndex(0) + }, [isOpen]) + + useEffect(() => { + if (!isOpen || prefersReducedMotion || manualTileId !== null) { + return + } + + const timer = window.setInterval(() => { + setAutoIndex((index) => { + const currentPosition = mediaTileIndexes.indexOf(index) + const nextPosition = + currentPosition === -1 ? 0 : (currentPosition + 1) % mediaTileIndexes.length + return mediaTileIndexes[nextPosition] ?? 0 + }) + }, AUTO_ROTATE_MS) + return () => window.clearInterval(timer) + }, [isOpen, manualTileId, mediaTileIndexes, prefersReducedMotion]) + + useEffect(() => { + if (!isOpen || manualTileId === null) { + return + } + + const timer = window.setTimeout(() => { + track('feature_wall_tile_focused', { + tile_id: manualTileId + }) + }, TILE_FOCUS_TELEMETRY_MS) + return () => window.clearTimeout(timer) + }, [isOpen, manualTileId]) + + const handleOpenChange = (open: boolean): void => { + if (!open) { + closeModal() + } + } + + const handleTileKeyDown = (event: KeyboardEvent, index: number): void => { + if (!NAVIGATION_KEYS.has(event.key)) { + return + } + + event.preventDefault() + const nextIndex = getFeatureWallGridNavigationTarget({ + currentIndex: index, + key: event.key as FeatureWallNavigationKey, + tileCount: FEATURE_WALL_TILES.length, + columnCount + }) + setRovingIndex(nextIndex) + tileRefs.current[nextIndex]?.focus() + } + + if (!isOpen && !telemetryRef.current.open) { + return null + } + + return ( + + { + event.preventDefault() + // Why: Radix would otherwise focus the first roving tile on open, + // which counts as user interaction and disables auto-rotation. + const content = event.currentTarget as HTMLElement + content.focus({ preventScroll: true }) + }} + > + + Explore some of Orca's features + + Tasks, terminal, agents, browser, SSH, review, and more. + + + +
+ {FEATURE_WALL_TILES.map((tile, index) => { + const urls = assetUrlsByTileId.get(tile.id) + return ( + { + tileRefs.current[index] = node + }} + tile={tile} + isPlaying={playingTileId === tile.id} + tabIndex={rovingIndex === index ? 0 : -1} + posterUrl={urls?.posterUrl ?? null} + gifUrl={urls?.gifUrl ?? null} + onPointerEnter={() => setHoveredTileId(tile.id)} + onPointerLeave={() => + setHoveredTileId((current) => (current === tile.id ? null : current)) + } + onFocus={() => { + setFocusedTileId(tile.id) + setRovingIndex(index) + }} + onBlur={() => setFocusedTileId((current) => (current === tile.id ? null : current))} + onKeyDown={(event) => handleTileKeyDown(event, index)} + onOpenDocs={() => { + track('feature_wall_tile_clicked', { + tile_id: tile.id + }) + void window.api.shell.openUrl(tile.docsUrl) + }} + /> + ) + })} +
+
+
+ ) +} diff --git a/src/renderer/src/components/feature-wall/FeatureWallTileCard.tsx b/src/renderer/src/components/feature-wall/FeatureWallTileCard.tsx new file mode 100644 index 000000000..6a9b8bd1c --- /dev/null +++ b/src/renderer/src/components/feature-wall/FeatureWallTileCard.tsx @@ -0,0 +1,132 @@ +import { useEffect, useState } from 'react' +import type { JSX, KeyboardEvent } from 'react' +import { ExternalLink } from 'lucide-react' +import type { FeatureWallTile } from '../../../../shared/feature-wall-tiles' +import { cn } from '@/lib/utils' + +export function FeatureWallTileCard(props: { + tile: FeatureWallTile + isPlaying: boolean + tabIndex: number + posterUrl: string | null + gifUrl: string | null + refCallback: (node: HTMLDivElement | null) => void + onPointerEnter: () => void + onPointerLeave: () => void + onFocus: () => void + onBlur: () => void + onKeyDown: (event: KeyboardEvent) => void + onOpenDocs: () => void +}): JSX.Element { + const { + tile, + isPlaying, + tabIndex, + posterUrl, + gifUrl, + refCallback, + onPointerEnter, + onPointerLeave, + onFocus, + onBlur, + onKeyDown, + onOpenDocs + } = props + const [posterFailed, setPosterFailed] = useState(false) + const [gifFailed, setGifFailed] = useState(false) + const showPoster = posterUrl !== null && !posterFailed + const showGif = tile.kind === 'media' && isPlaying && gifUrl !== null && !gifFailed + const showMockup = tile.kind === 'agent-status-mockup' + const textOnly = !showMockup && !showGif && !showPoster + + useEffect(() => { + setPosterFailed(false) + setGifFailed(false) + }, [gifUrl, posterUrl]) + + const handleKeyDown = (event: KeyboardEvent): void => { + onKeyDown(event) + if (event.defaultPrevented) { + return + } + if (event.key !== 'Enter' && event.key !== ' ') { + return + } + event.preventDefault() + onOpenDocs() + } + + return ( +
+
+ {showMockup ? : null} + {showPoster ? ( + setPosterFailed(true)} + /> + ) : null} + {showGif ? ( + setGifFailed(true)} + /> + ) : null} + {textOnly ? ( +
+
{tile.title}
+
{tile.caption}
+
+ ) : null} +
+
+
+ {tile.title} + +
+

{tile.caption}

+
+
+ ) +} + +function AgentStatusMockup(): JSX.Element { + return ( +
+
+ ● Claude Code · finished tests, pushing +
+
+ ● Codex · refactoring handlers +
+
+ ● OpenCode · blocked on API response +
+
+ ) +} diff --git a/src/renderer/src/components/feature-wall/feature-wall-assets.ts b/src/renderer/src/components/feature-wall/feature-wall-assets.ts new file mode 100644 index 000000000..d718ffa03 --- /dev/null +++ b/src/renderer/src/components/feature-wall/feature-wall-assets.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react' + +export function toFeatureWallAssetUrl(baseUrl: string | null, assetPath: string): string | null { + if (!baseUrl) { + return null + } + try { + return new URL(assetPath, baseUrl).toString() + } catch { + return null + } +} + +export function useFeatureWallAssetBaseUrl(load: boolean): string | null { + const [assetBaseUrl, setAssetBaseUrl] = useState(null) + + useEffect(() => { + if (!load || assetBaseUrl !== null) { + return + } + + let cancelled = false + void window.api.app + .getFeatureWallAssetBaseUrl() + .then((url) => { + if (!cancelled) { + setAssetBaseUrl(url) + } + }) + .catch(() => { + if (!cancelled) { + setAssetBaseUrl('') + } + }) + return () => { + cancelled = true + } + }, [assetBaseUrl, load]) + + return assetBaseUrl +} diff --git a/src/renderer/src/components/feature-wall/feature-wall-grid-navigation.test.ts b/src/renderer/src/components/feature-wall/feature-wall-grid-navigation.test.ts new file mode 100644 index 000000000..9d418e109 --- /dev/null +++ b/src/renderer/src/components/feature-wall/feature-wall-grid-navigation.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { getFeatureWallGridNavigationTarget } from './feature-wall-grid-navigation' + +describe('getFeatureWallGridNavigationTarget', () => { + it('keeps right/down movement bounded in the 3 + 3 + 1 layout', () => { + const target = (currentIndex: number, key: 'ArrowRight' | 'ArrowDown') => + getFeatureWallGridNavigationTarget({ + currentIndex, + key, + tileCount: 7, + columnCount: 3 + }) + + expect(target(2, 'ArrowRight')).toBe(2) + expect(target(5, 'ArrowRight')).toBe(5) + expect(target(6, 'ArrowRight')).toBe(6) + expect(target(4, 'ArrowDown')).toBe(4) + expect(target(5, 'ArrowDown')).toBe(5) + }) + + it('moves vertically by the active column count', () => { + const target = (currentIndex: number, key: 'ArrowUp' | 'ArrowDown') => + getFeatureWallGridNavigationTarget({ + currentIndex, + key, + tileCount: 7, + columnCount: 2 + }) + + expect(target(0, 'ArrowDown')).toBe(2) + expect(target(4, 'ArrowUp')).toBe(2) + expect(target(5, 'ArrowDown')).toBe(5) + }) + + it('jumps Home and End to the first and last tile', () => { + expect( + getFeatureWallGridNavigationTarget({ + currentIndex: 3, + key: 'Home', + tileCount: 7, + columnCount: 3 + }) + ).toBe(0) + expect( + getFeatureWallGridNavigationTarget({ + currentIndex: 3, + key: 'End', + tileCount: 7, + columnCount: 3 + }) + ).toBe(6) + }) +}) diff --git a/src/renderer/src/components/feature-wall/feature-wall-grid-navigation.ts b/src/renderer/src/components/feature-wall/feature-wall-grid-navigation.ts new file mode 100644 index 000000000..7c8dd2881 --- /dev/null +++ b/src/renderer/src/components/feature-wall/feature-wall-grid-navigation.ts @@ -0,0 +1,51 @@ +export type FeatureWallNavigationKey = + | 'ArrowLeft' + | 'ArrowRight' + | 'ArrowUp' + | 'ArrowDown' + | 'Home' + | 'End' + +export function getFeatureWallGridNavigationTarget(args: { + currentIndex: number + key: FeatureWallNavigationKey + tileCount: number + columnCount: number +}): number { + const { currentIndex, key, tileCount } = args + const columnCount = Math.max(1, Math.min(args.columnCount, tileCount)) + + if (tileCount <= 0 || currentIndex < 0 || currentIndex >= tileCount) { + return currentIndex + } + + switch (key) { + case 'Home': + return 0 + case 'End': + return tileCount - 1 + case 'ArrowLeft': + if (currentIndex % columnCount === 0) { + return currentIndex + } + return currentIndex - 1 + case 'ArrowRight': { + const next = currentIndex + 1 + if ( + next >= tileCount || + Math.floor(next / columnCount) !== Math.floor(currentIndex / columnCount) + ) { + return currentIndex + } + return next + } + case 'ArrowUp': { + const next = currentIndex - columnCount + return next >= 0 ? next : currentIndex + } + case 'ArrowDown': { + const next = currentIndex + columnCount + return next < tileCount ? next : currentIndex + } + } +} diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 93c460abf..88c2e46b3 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -1376,10 +1376,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }, [closeModal, openSettingsPage, openSettingsTarget]) const applyWorktreeMeta = useCallback( - async ( - worktreeId: string, - meta: Partial - ): Promise => { + async (worktreeId: string, meta: Partial): Promise => { if (Object.keys(meta).length === 0) { return } diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 3d1588606..a91316e83 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -153,6 +153,8 @@ describe('useIpcEvents updater integration', () => { }, ui: { onOpenSettings: () => () => {}, + onOpenFeatureTour: () => () => {}, + onShowFeatureTourNudge: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, onToggleWorktreePalette: () => () => {}, @@ -362,6 +364,8 @@ describe('useIpcEvents updater integration', () => { }, ui: { onOpenSettings: () => () => {}, + onOpenFeatureTour: () => () => {}, + onShowFeatureTourNudge: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, onToggleWorktreePalette: () => () => {}, @@ -579,6 +583,8 @@ describe('useIpcEvents updater integration', () => { }, ui: { onOpenSettings: () => () => {}, + onOpenFeatureTour: () => () => {}, + onShowFeatureTourNudge: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, onToggleWorktreePalette: () => () => {}, @@ -863,6 +869,8 @@ describe('useIpcEvents browser tab close routing', () => { }, ui: { onOpenSettings: () => () => {}, + onOpenFeatureTour: () => () => {}, + onShowFeatureTourNudge: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, onToggleWorktreePalette: () => () => {}, @@ -1067,6 +1075,8 @@ describe('useIpcEvents browser tab close routing', () => { }, ui: { onOpenSettings: () => () => {}, + onOpenFeatureTour: () => () => {}, + onShowFeatureTourNudge: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, onToggleWorktreePalette: () => () => {}, @@ -1266,6 +1276,8 @@ describe('useIpcEvents browser tab close routing', () => { }, ui: { onOpenSettings: () => () => {}, + onOpenFeatureTour: () => () => {}, + onShowFeatureTourNudge: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, onToggleWorktreePalette: () => () => {}, @@ -1474,6 +1486,8 @@ describe('useIpcEvents CLI-created worktree activation', () => { }, ui: { onOpenSettings: () => () => {}, + onOpenFeatureTour: () => () => {}, + onShowFeatureTourNudge: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, onToggleWorktreePalette: () => () => {}, @@ -1675,6 +1689,8 @@ describe('useIpcEvents agent status snapshot integration', () => { }, ui: { onOpenSettings: () => () => {}, + onOpenFeatureTour: () => () => {}, + onShowFeatureTourNudge: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, onToggleWorktreePalette: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 1c3e120b6..65ee868a2 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -99,6 +99,18 @@ export function useIpcEvents(): void { }) ) + unsubs.push( + window.api.ui.onOpenFeatureTour(() => { + useAppStore.getState().openModal('feature-wall', { source: 'help_menu' }) + }) + ) + + unsubs.push( + window.api.ui.onShowFeatureTourNudge(() => { + useAppStore.getState().showFeatureTourNudge() + }) + ) + // Why: the View > Appearance menu toggles settings directly in main (so // checkbox state reflects the persisted value without a round-trip) and // broadcasts the change. Merge it into the store so the sidebar and diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 6cd1ef420..8307e5946 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -55,9 +55,7 @@ export const createSettingsSlice: StateCreator // and avoid materializing a `voice` key when neither current nor incoming // settings define one. const mergedVoice = - updates.voice !== undefined - ? { ...s.settings.voice, ...updates.voice } - : s.settings.voice + updates.voice !== undefined ? { ...s.settings.voice, ...updates.voice } : s.settings.voice return { settings: { ...s.settings, diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index b1036488f..bc12e7e10 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -419,3 +419,30 @@ describe('createUISlice settings navigation', () => { expect(store.getState().activeView).toBe('tasks') }) }) + +describe('createUISlice feature tour nudge', () => { + it('shows and dismisses the feature tour nudge', () => { + const store = createUIStore() + + store.getState().showFeatureTourNudge() + expect(store.getState().featureTourNudgeVisible).toBe(true) + + store.getState().dismissFeatureTourNudge() + expect(store.getState().featureTourNudgeVisible).toBe(false) + }) + + it('keeps the nudge hidden while the full feature tour is open', () => { + const store = createUIStore() + + store.getState().openModal('feature-wall') + store.getState().showFeatureTourNudge() + expect(store.getState().featureTourNudgeVisible).toBe(false) + + store.getState().closeModal() + store.getState().showFeatureTourNudge() + expect(store.getState().featureTourNudgeVisible).toBe(true) + + store.getState().openModal('feature-wall') + expect(store.getState().featureTourNudgeVisible).toBe(false) + }) +}) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 12f210418..44acdabf5 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -273,11 +273,15 @@ export type UISlice = { | 'add-repo' | 'quick-open' | 'worktree-palette' + | 'feature-wall' | 'new-workspace-composer' | 'confirm-orca-yaml-hooks' modalData: Record openModal: (modal: UISlice['activeModal'], data?: Record) => void closeModal: () => void + featureTourNudgeVisible: boolean + showFeatureTourNudge: () => void + dismissFeatureTourNudge: () => void trustedOrcaHooks: PersistedTrustedOrcaHooks markOrcaHookScriptConfirmed: ( repoId: string, @@ -554,8 +558,20 @@ export const createUISlice: StateCreator = (set, get) activeModal: 'none', modalData: {}, - openModal: (modal, data = {}) => set({ activeModal: modal, modalData: data }), + openModal: (modal, data = {}) => + set((state) => ({ + activeModal: modal, + modalData: data, + featureTourNudgeVisible: modal === 'feature-wall' ? false : state.featureTourNudgeVisible + })), closeModal: () => set({ activeModal: 'none', modalData: {} }), + featureTourNudgeVisible: false, + showFeatureTourNudge: () => { + if (get().activeModal !== 'feature-wall') { + set({ featureTourNudgeVisible: true }) + } + }, + dismissFeatureTourNudge: () => set({ featureTourNudgeVisible: false }), trustedOrcaHooks: {}, markOrcaHookScriptConfirmed: (repoId, kind, contentHash) => diff --git a/src/shared/feature-wall-telemetry.ts b/src/shared/feature-wall-telemetry.ts new file mode 100644 index 000000000..2e33a49f5 --- /dev/null +++ b/src/shared/feature-wall-telemetry.ts @@ -0,0 +1 @@ +export const FEATURE_WALL_MAX_DWELL_MS = 86_400_000 diff --git a/src/shared/feature-wall-tiles.ts b/src/shared/feature-wall-tiles.ts new file mode 100644 index 000000000..ae6c592e4 --- /dev/null +++ b/src/shared/feature-wall-tiles.ts @@ -0,0 +1,201 @@ +export type FeatureWallTileId = + | 'tile-01' + | 'tile-02' + | 'tile-03' + | 'tile-04' + | 'tile-05' + | 'tile-06' + | 'tile-07' + | 'tile-08' + | 'tile-09' + | 'tile-10' + | 'tile-11' + | 'tile-12' + +type FeatureWallTileBase = { + id: FeatureWallTileId + title: string + caption: string + owner: string + docsUrl: string +} + +export type FeatureWallTile = + | (FeatureWallTileBase & { + kind: 'media' + gifPath: string + posterPath: string + recordedAtPath: string + }) + | (FeatureWallTileBase & { + kind: 'agent-status-mockup' + }) + +export const FEATURE_WALL_MEDIA_TILE_IDS = [ + 'tile-01', + 'tile-02', + 'tile-03', + 'tile-04', + 'tile-05', + 'tile-06', + 'tile-07', + 'tile-08', + 'tile-09', + 'tile-10', + 'tile-11', + 'tile-12' +] as const satisfies readonly FeatureWallTileId[] + +export type FeatureWallMediaTileId = (typeof FEATURE_WALL_MEDIA_TILE_IDS)[number] + +export type FeatureWallMediaTile = Extract + +export function isFeatureWallMediaTile(tile: FeatureWallTile): tile is FeatureWallMediaTile { + return tile.kind === 'media' +} + +export const FEATURE_WALL_TILES: readonly FeatureWallTile[] = [ + { + id: 'tile-01', + kind: 'media', + title: 'Parallel worktree orchestration', + caption: + 'Every task runs in its own isolated git worktree - no stashing, no branch juggling. Fan one prompt across 5 agents, compare, merge the winner.', + gifPath: 'tile-01.gif', + posterPath: 'tile-01.poster.jpg', + recordedAtPath: 'tile-01.recorded-at.json', + owner: 'worktree-orchestration', + docsUrl: 'https://www.onorca.dev/docs/model/worktrees' + }, + { + id: 'tile-02', + kind: 'media', + title: 'Ghostty-class terminal', + caption: + 'WebGL rendering, infinite splits, scrollback restored on restart, full scrollback search.', + gifPath: 'tile-02.gif', + posterPath: 'tile-02.poster.jpg', + recordedAtPath: 'tile-02.recorded-at.json', + owner: 'terminal', + docsUrl: 'https://www.onorca.dev/docs/terminal' + }, + { + id: 'tile-03', + kind: 'media', + title: 'GitHub & Linear, native', + caption: + 'Browse GitHub and Linear tasks in-app. Start worktrees, review PRs, and approve without switching context.', + gifPath: 'tile-03.gif', + posterPath: 'tile-03.poster.jpg', + recordedAtPath: 'tile-03.recorded-at.json', + owner: 'task-integrations', + docsUrl: 'https://www.onorca.dev/docs/review/linear' + }, + { + id: 'tile-04', + kind: 'media', + title: 'Works with every CLI agent', + caption: + 'Claude Code, Codex, Cursor CLI, Gemini, Copilot, OpenCode, Pi - preconfigured. Any other CLI agent drops right in.', + gifPath: 'tile-04.gif', + posterPath: 'tile-04.poster.jpg', + recordedAtPath: 'tile-04.recorded-at.json', + owner: 'agent-integrations', + docsUrl: 'https://www.onorca.dev/docs/agents/supported' + }, + { + id: 'tile-05', + kind: 'media', + title: 'Embedded browser + Design Mode', + caption: + 'A real Chromium window per worktree. Click any UI element to send its HTML, CSS, and a cropped screenshot into your agent.', + gifPath: 'tile-05.gif', + posterPath: 'tile-05.poster.jpg', + recordedAtPath: 'tile-05.recorded-at.json', + owner: 'browser-experience', + docsUrl: 'https://www.onorca.dev/docs/browser/design-mode' + }, + { + id: 'tile-06', + kind: 'media', + title: 'Remote worktrees over SSH', + caption: + 'Run agents on a beefy remote box with full file editing, git, and terminals. Auto-reconnect, port forwarding, passphrase caching.', + gifPath: 'tile-06.gif', + posterPath: 'tile-06.poster.jpg', + recordedAtPath: 'tile-06.recorded-at.json', + owner: 'ssh-workspaces', + docsUrl: 'https://www.onorca.dev/docs/ssh' + }, + { + id: 'tile-07', + kind: 'media', + title: 'Monaco editor, drag-to-agent', + caption: + "VS Code's editor, autosave everywhere, quick-open with hidden files, drag-drop files or Finder images into an agent prompt.", + gifPath: 'tile-07.gif', + posterPath: 'tile-07.poster.jpg', + recordedAtPath: 'tile-07.recorded-at.json', + owner: 'editor', + docsUrl: 'https://www.onorca.dev/docs/editing/file-explorer' + }, + { + id: 'tile-08', + kind: 'media', + title: 'Inline review, back to the agent', + caption: + 'Drop markdown comments on any diff line, batch them, ship them back to the agent. Inspect CI, resolve conflicts, open PRs - all in-app.', + gifPath: 'tile-08.gif', + posterPath: 'tile-08.poster.jpg', + recordedAtPath: 'tile-08.recorded-at.json', + owner: 'diff-review', + docsUrl: 'https://www.onorca.dev/docs/review/annotate-ai-diff' + }, + { + id: 'tile-09', + kind: 'media', + title: 'Orca CLI', + caption: 'Agents drive Orca too: orca worktree create, snapshot, click, fill.', + gifPath: 'tile-09.gif', + posterPath: 'tile-09.poster.jpg', + recordedAtPath: 'tile-09.recorded-at.json', + owner: 'orca-cli', + docsUrl: 'https://www.onorca.dev/docs/cli/overview' + }, + { + id: 'tile-10', + kind: 'media', + title: 'Keyboard-native', + caption: + 'Jump across worktrees, open files, and remap every shortcut. Move at the speed of your fingers.', + gifPath: 'tile-10.gif', + posterPath: 'tile-10.poster.jpg', + recordedAtPath: 'tile-10.recorded-at.json', + owner: 'keyboard-ux', + docsUrl: 'https://www.onorca.dev/docs/model/quick-open' + }, + { + id: 'tile-11', + kind: 'media', + title: 'Usage & rate-limit aware', + caption: + 'See Claude and Codex usage, rate-limit resets, and hot-swap Codex accounts without re-logging in.', + gifPath: 'tile-11.gif', + posterPath: 'tile-11.poster.jpg', + recordedAtPath: 'tile-11.recorded-at.json', + owner: 'usage-rate-limits', + docsUrl: 'https://www.onorca.dev/docs/agents/usage-tracking' + }, + { + id: 'tile-12', + kind: 'media', + title: 'PDFs, images, CSV, Markdown', + caption: + 'Preview everything your repo carries: PDFs, image diff modes, CSV tables, wiki-linked Markdown with search.', + gifPath: 'tile-12.gif', + posterPath: 'tile-12.poster.jpg', + recordedAtPath: 'tile-12.recorded-at.json', + owner: 'file-preview', + docsUrl: 'https://www.onorca.dev/docs/editing/viewers' + } +] as const diff --git a/src/shared/telemetry-events.test.ts b/src/shared/telemetry-events.test.ts index 1f8f10dd2..bf0d57a4c 100644 --- a/src/shared/telemetry-events.test.ts +++ b/src/shared/telemetry-events.test.ts @@ -11,6 +11,7 @@ import { commonPropsSchema, errorClassSchema, eventSchemas, + featureWallTileIdSchema, SETTINGS_CHANGED_WHITELIST, settingsChangedKeySchema } from './telemetry-events' @@ -210,6 +211,50 @@ describe('settings_changed schema', () => { }) }) +describe('feature wall schemas', () => { + it('accepts the unconditional open and close payloads', () => { + expect(eventSchemas.feature_wall_opened.safeParse({ source: 'help_menu' }).success).toBe(true) + expect(eventSchemas.feature_wall_opened.safeParse({ source: 'popup' }).success).toBe(true) + expect(eventSchemas.feature_wall_closed.safeParse({ dwell_ms: 1200 }).success).toBe(true) + }) + + it('rejects stale or invalid source variants', () => { + expect(eventSchemas.feature_wall_opened.safeParse({}).success).toBe(false) + expect(eventSchemas.feature_wall_opened.safeParse({ surface: 'help_tour' }).success).toBe(false) + expect(eventSchemas.feature_wall_opened.safeParse({ source: 'help_tour' }).success).toBe(false) + }) + + it('rejects out-of-range dwell time', () => { + expect(eventSchemas.feature_wall_closed.safeParse({ dwell_ms: -1 }).success).toBe(false) + }) + + it('accepts only known tile ids for tile focus telemetry', () => { + expect( + eventSchemas.feature_wall_tile_focused.safeParse({ + tile_id: 'tile-12' + }).success + ).toBe(true) + expect( + eventSchemas.feature_wall_tile_focused.safeParse({ + tile_id: 'tile-99' + }).success + ).toBe(false) + }) + + it('accepts only known tile ids for tile click telemetry', () => { + expect( + eventSchemas.feature_wall_tile_clicked.safeParse({ + tile_id: 'tile-03' + }).success + ).toBe(true) + expect( + eventSchemas.feature_wall_tile_clicked.safeParse({ + tile_id: 'tile-99' + }).success + ).toBe(false) + }) +}) + describe('commonPropsSchema', () => { it('round-trips a realistic payload', () => { const parsed = commonPropsSchema.safeParse({ @@ -284,4 +329,8 @@ describe('exported enum schemas', () => { expect(settingsChangedKeySchema.safeParse(key).success).toBe(true) } }) + + it('feature wall enum schemas accept known values', () => { + expect(featureWallTileIdSchema.safeParse('tile-01').success).toBe(true) + }) }) diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 48ba1f0b3..5bf5b2228 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -14,6 +14,7 @@ // re-check string length. import { z } from 'zod' +import { FEATURE_WALL_MAX_DWELL_MS } from './feature-wall-telemetry' import { AGENT_HOOK_TARGETS } from './agent-hook-types' import { ONBOARDING_FINAL_STEP } from './constants' @@ -139,6 +140,25 @@ export type LaunchSource = z.infer export const requestKindSchema = z.enum(['new', 'resume', 'followup']) export type RequestKind = z.infer +export const featureWallTileIdSchema = z.enum([ + 'tile-01', + 'tile-02', + 'tile-03', + 'tile-04', + 'tile-05', + 'tile-06', + 'tile-07', + 'tile-08', + 'tile-09', + 'tile-10', + 'tile-11', + 'tile-12' +]) +export type FeatureWallTileIdTelemetry = z.infer + +export const featureWallOpenSourceSchema = z.enum(['help_menu', 'popup', 'unknown']) +export type FeatureWallOpenSourceTelemetry = z.infer + // `env_var` is deliberately absent — env-var and CI paths override consent at // runtime only (see consent.ts); they never mutate `optedIn` and therefore // never fire a `telemetry_opted_in/out` event. If a future path explicitly @@ -241,6 +261,27 @@ const settingsChangedSchema = z const telemetryOptedInSchema = z.object({ via: optInViaSchema }).strict() const telemetryOptedOutSchema = z.object({ via: optInViaSchema }).strict() +const featureWallOpenedSchema = z + .object({ + source: featureWallOpenSourceSchema + }) + .strict() +const featureWallClosedSchema = z + .object({ + dwell_ms: z.number().int().min(0).max(FEATURE_WALL_MAX_DWELL_MS) + }) + .strict() +const featureWallTileFocusedSchema = z + .object({ + tile_id: featureWallTileIdSchema + }) + .strict() +const featureWallTileClickedSchema = z + .object({ + tile_id: featureWallTileIdSchema + }) + .strict() + const addRepoSetupStepActionEventSchema = z .object({ action: addRepoSetupStepActionSchema, nth_repo_added: nthRepoAddedSchema }) .strict() @@ -578,6 +619,11 @@ export const eventSchemas = { telemetry_opted_in: telemetryOptedInSchema, telemetry_opted_out: telemetryOptedOutSchema, + feature_wall_opened: featureWallOpenedSchema, + feature_wall_closed: featureWallClosedSchema, + feature_wall_tile_focused: featureWallTileFocusedSchema, + feature_wall_tile_clicked: featureWallTileClickedSchema, + onboarding_started: onboardingStartedSchema, onboarding_step_viewed: onboardingStepViewedSchema, onboarding_step_completed: onboardingStepCompletedSchema, @@ -640,8 +686,12 @@ type _CohortExtendedRoster = | 'workspace_create_failed' | 'agent_started' | 'agent_error' +// Why: `z.object({}).strict()` infers a string index signature, which would +// make every key appear present. Ignore index-signature-only keys here so +// strict empty event payloads do not get pulled into keyed telemetry rosters. +type _KnownPayloadKeys = string extends keyof T ? never : keyof T type _DerivedCohortExtendedEvents = { - [N in EventName]: 'nth_repo_added' extends keyof EventMap[N] ? N : never + [N in EventName]: 'nth_repo_added' extends _KnownPayloadKeys ? N : never }[EventName] type _CohortExtendedRosterSync = _CohortExtendedRoster extends _DerivedCohortExtendedEvents ? _DerivedCohortExtendedEvents extends _CohortExtendedRoster @@ -689,7 +739,7 @@ type _OnboardingCohortRoster = | 'onboarding_ghostty_import_clicked' | 'onboarding_ghostty_import_failed' type _DerivedOnboardingCohortEvents = { - [N in EventName]: 'cohort' extends keyof EventMap[N] ? N : never + [N in EventName]: 'cohort' extends _KnownPayloadKeys ? N : never }[EventName] type _OnboardingCohortRosterSync = _OnboardingCohortRoster extends _DerivedOnboardingCohortEvents ? _DerivedOnboardingCohortEvents extends _OnboardingCohortRoster diff --git a/tests/e2e/feature-wall.spec.ts b/tests/e2e/feature-wall.spec.ts new file mode 100644 index 000000000..64d102b88 --- /dev/null +++ b/tests/e2e/feature-wall.spec.ts @@ -0,0 +1,165 @@ +import { test, expect } from './helpers/orca-app' +import { getStoreState, waitForSessionReady } from './helpers/store' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' + +async function openFeatureTourFromMenu(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(({ BrowserWindow, Menu }) => { + const featureTourItem = Menu.getApplicationMenu() + ?.items.find((item) => item.label === 'Help') + ?.submenu?.items.find((item) => item.label === 'Feature tour') + + if (!featureTourItem) { + throw new Error('Feature tour menu item was not registered') + } + + const window = BrowserWindow.getAllWindows()[0] + featureTourItem.click(featureTourItem, window, { + triggeredByAccelerator: false, + shiftKey: false, + metaKey: false, + ctrlKey: false, + altKey: false + } as Electron.KeyboardEvent) + }) +} + +async function loadedFeatureWallImageCount(page: Page): Promise { + return page.evaluate(() => { + return Array.from(document.querySelectorAll('[data-feature-wall-tile-id] img')).filter( + (image): image is HTMLImageElement => + image instanceof HTMLImageElement && image.complete && image.naturalWidth > 0 + ).length + }) +} + +test.describe('Feature tour modal', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + }) + + test('opens from the Help menu, renders bundled media, and closes cleanly', async ({ + electronApp, + orcaPage + }) => { + await openFeatureTourFromMenu(electronApp) + + await expect( + orcaPage.getByRole('dialog', { name: "Explore some of Orca's features" }) + ).toBeVisible({ + timeout: 10_000 + }) + await expect( + orcaPage.getByText('Tasks, terminal, agents, browser, SSH, review, and more.') + ).toBeVisible() + await expect(orcaPage.getByRole('listitem')).toHaveCount(12) + await expect( + orcaPage.getByRole('listitem', { name: /Remote worktrees over SSH/i }) + ).toBeVisible() + + await expect + .poll(async () => loadedFeatureWallImageCount(orcaPage), { + timeout: 10_000, + message: 'feature-wall media did not load' + }) + .toBeGreaterThanOrEqual(12) + + const assetSources = await orcaPage + .locator('[data-feature-wall-tile-id] img') + .evaluateAll((images) => images.map((image) => (image as HTMLImageElement).src)) + expect(assetSources.length).toBeGreaterThanOrEqual(12) + expect(assetSources.every((src) => src.includes('/onboarding/feature-wall/'))).toBe(true) + + await electronApp.evaluate(({ shell }) => { + const testGlobal = globalThis as typeof globalThis & { + __featureWallOpenedDocsUrl: string | null + __featureWallOriginalOpenExternal?: typeof shell.openExternal + } + testGlobal.__featureWallOpenedDocsUrl = null + testGlobal.__featureWallOriginalOpenExternal = shell.openExternal + shell.openExternal = ((url: string) => { + testGlobal.__featureWallOpenedDocsUrl = url + return Promise.resolve() + }) as typeof shell.openExternal + }) + try { + await orcaPage.locator('[data-feature-wall-tile-id="tile-02"]').click() + await expect + .poll(() => + electronApp.evaluate( + () => + ( + globalThis as typeof globalThis & { + __featureWallOpenedDocsUrl: string | null + } + ).__featureWallOpenedDocsUrl + ) + ) + .toBe('https://www.onorca.dev/docs/terminal') + } finally { + await electronApp.evaluate(({ shell }) => { + const originalOpenExternal = ( + globalThis as typeof globalThis & { + __featureWallOriginalOpenExternal?: typeof shell.openExternal + } + ).__featureWallOriginalOpenExternal + if (originalOpenExternal) { + shell.openExternal = originalOpenExternal + } + }) + } + + await orcaPage.locator('[data-feature-wall-tile-id="tile-01"]').focus() + await orcaPage.keyboard.press('ArrowRight') + await expect + .poll(() => + orcaPage.evaluate( + () => (document.activeElement as HTMLElement | null)?.dataset.featureWallTileId + ) + ) + .toBe('tile-02') + + await orcaPage.getByRole('button', { name: 'Close' }).click() + await expect( + orcaPage.getByRole('dialog', { name: "Explore some of Orca's features" }) + ).toHaveCount(0) + await expect.poll(async () => getStoreState(orcaPage, 'activeModal')).toBe('none') + }) + + test('shows the bottom-right nudge and opens the full tour', async ({ orcaPage }) => { + await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + store.getState().showFeatureTourNudge() + }) + + const nudge = orcaPage.getByRole('complementary', { + name: "Explore some of Orca's features" + }) + await expect(nudge).toBeVisible() + await expect(nudge.getByText('GitHub & Linear, native')).toBeVisible() + await expect( + nudge.getByText( + 'Browse GitHub and Linear tasks in-app. Start worktrees, review PRs, and approve without switching context.' + ) + ).toBeVisible() + await expect + .poll( + () => nudge.locator('p').evaluate((node) => node.scrollHeight <= node.clientHeight + 1), + { + message: 'feature tour nudge caption should not be clipped' + } + ) + .toBe(true) + await expect(nudge.locator('img')).toHaveAttribute('src', /tile-03/) + + await nudge.getByRole('button', { name: /^Open tour$/ }).click() + await expect( + orcaPage.getByRole('dialog', { name: "Explore some of Orca's features" }) + ).toBeVisible() + await expect + .poll(async () => getStoreState(orcaPage, 'featureTourNudgeVisible')) + .toBe(false) + }) +})