feat(onboarding): add feature wall tour (#1772)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-14 15:20:49 -07:00 committed by GitHub
parent b1c03b1281
commit fdf7d9e97a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 1824 additions and 24 deletions

View File

@ -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

View File

@ -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',

View File

@ -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`
)

View File

@ -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}`)
}

View File

@ -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",

View File

@ -0,0 +1 @@

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -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"
}

View File

@ -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<StatsCollector, 'onAgentStarted'>
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()
})
})

View File

@ -0,0 +1,43 @@
import type { BrowserWindow } from 'electron'
import type { StatsCollector } from '../stats/collector'
type FeatureWallWindow = Pick<BrowserWindow, 'isDestroyed'> & {
webContents: Pick<BrowserWindow['webContents'], 'send'>
}
export const FEATURE_WALL_FIRST_AGENT_TOUR_DELAY_MS = 1_500
export function registerFeatureWallFirstAgentTour(args: {
stats: Pick<StatsCollector, 'onAgentStarted'>
getWindow: () => FeatureWallWindow | null
}): () => void {
let pendingTimer: ReturnType<typeof setTimeout> | 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
}
}
}

View File

@ -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<void> | 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<void> {
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).

View File

@ -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())

View File

@ -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', () => {

View File

@ -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))

View File

@ -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<string>
/** 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<PersistedUIState>
set: (args: Partial<PersistedUIState>) => Promise<void>
onOpenSettings: (callback: () => void) => () => void
onOpenFeatureTour: (callback: () => void) => () => void
onShowFeatureTourNudge: (callback: () => void) => () => void
onToggleLeftSidebar: (callback: () => void) => () => void
onToggleRightSidebar: (callback: () => void) => () => void
onToggleWorktreePalette: (callback: () => void) => () => void

View File

@ -305,6 +305,8 @@ document.addEventListener(
// Custom APIs for renderer
const api = {
app: {
getFeatureWallAssetBaseUrl: (): Promise<string> =>
ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'),
relaunch: (): Promise<void> => 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)

View File

@ -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 {
<Suspense fallback={null}>
{mountedLazyModalIds.has('quick-open') ? <QuickOpen /> : null}
{mountedLazyModalIds.has('worktree-palette') ? <WorktreeJumpPalette /> : null}
{mountedLazyModalIds.has('feature-wall') ? <FeatureWallModal /> : null}
</Suspense>
{/* 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 {
</Suspense>
) : null}
<UpdateCard />
<FeatureTourNudge />
<StarNagCard />
{/* Why: the existing-user opt-in banner mounts at App root so it
renders once per renderer session, not per view. It gates

View File

@ -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 (
<div
className={cn(
'fixed right-4 z-40 w-[360px] max-w-[calc(100vw-32px)]',
'max-[480px]:left-4 max-[480px]:right-4 max-[480px]:w-auto',
// Why: UpdateCard owns bottom-10 when visible; keep this education
// card nearby without covering update actions.
updateCardVisible ? 'bottom-[220px]' : 'bottom-10'
)}
>
<Card
className="cursor-pointer gap-0 overflow-hidden py-0"
role="complementary"
aria-label="Explore some of Orca's features"
onClick={handleOpenTour}
>
<div className="flex flex-col gap-3 p-3.5">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-0.5">
<div className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
Explore some of Orca&apos;s features
</div>
<h3 className="truncate text-sm font-semibold">{FEATURE_TOUR_NUDGE_TILE.title}</h3>
</div>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={(event) => {
event.stopPropagation()
dismissFeatureTourNudge()
}}
aria-label="Dismiss feature tour"
>
<X className="size-3.5" />
</Button>
</div>
<button
type="button"
className="relative block aspect-[16/9] w-full overflow-hidden rounded-md bg-muted text-left outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
onClick={handleOpenTour}
aria-label="Open feature tour"
>
{mediaUrl && !mediaLoaded && !mediaFailed ? (
<div className="absolute inset-0 animate-pulse bg-muted/50" />
) : null}
{mediaUrl && !mediaFailed ? (
<img
src={mediaUrl}
alt=""
className={cn(
'size-full object-cover',
mediaLoaded ? '' : 'absolute inset-0 opacity-0'
)}
draggable={false}
onLoad={() => setMediaLoaded(true)}
onError={() => setMediaFailed(true)}
/>
) : (
<div className="flex size-full items-end p-3 text-sm font-semibold text-foreground">
{FEATURE_TOUR_NUDGE_TILE.title}
</div>
)}
</button>
<p className="line-clamp-2 text-xs leading-snug text-muted-foreground">
{FEATURE_TOUR_NUDGE_TILE.caption}
</p>
<Button variant="default" size="sm" className="w-full gap-1.5" onClick={handleOpenTour}>
<PlayCircle className="size-3.5" />
Open tour
</Button>
</div>
</Card>
</div>
)
}

View File

@ -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<string>([
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
'Home',
'End'
])
function getFeatureWallOpenSource(
modalData: Record<string, unknown>
): 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<HTMLDivElement | null>, 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<FeatureWallTileId | null>(null)
const [focusedTileId, setFocusedTileId] = useState<FeatureWallTileId | null>(null)
const [rovingIndex, setRovingIndex] = useState(0)
const tileRefs = useRef<(HTMLDivElement | null)[]>([])
const gridRef = useRef<HTMLDivElement | null>(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<HTMLDivElement>, 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 (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent
className="scrollbar-sleek max-h-[calc(100vh-2rem)] w-[calc(100vw-2rem)] gap-4 overflow-y-auto p-5 sm:max-w-[1040px]"
tabIndex={-1}
onOpenAutoFocus={(event) => {
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 })
}}
>
<DialogHeader className="gap-1">
<DialogTitle>Explore some of Orca&apos;s features</DialogTitle>
<DialogDescription>
Tasks, terminal, agents, browser, SSH, review, and more.
</DialogDescription>
</DialogHeader>
<div
ref={gridRef}
role="list"
aria-label="Explore some of Orca's features"
className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3"
>
{FEATURE_WALL_TILES.map((tile, index) => {
const urls = assetUrlsByTileId.get(tile.id)
return (
<FeatureWallTileCard
key={tile.id}
refCallback={(node) => {
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)
}}
/>
)
})}
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -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<HTMLDivElement>) => 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<HTMLDivElement>): void => {
onKeyDown(event)
if (event.defaultPrevented) {
return
}
if (event.key !== 'Enter' && event.key !== ' ') {
return
}
event.preventDefault()
onOpenDocs()
}
return (
<div
ref={refCallback}
role="listitem"
aria-label={`Open docs for ${tile.title}. ${tile.caption}`}
tabIndex={tabIndex}
data-feature-wall-tile-id={tile.id}
className={cn(
'group min-w-0 cursor-pointer overflow-hidden rounded-md border border-border/70 bg-card text-left shadow-xs outline-none transition-[border-color,box-shadow,transform]',
'hover:border-ring/60',
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50'
)}
onClick={onOpenDocs}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={handleKeyDown}
>
<div className="relative aspect-[16/10] overflow-hidden bg-muted">
{showMockup ? <AgentStatusMockup /> : null}
{showPoster ? (
<img
src={posterUrl}
alt=""
aria-hidden
className="absolute inset-0 size-full object-cover"
draggable={false}
onError={() => setPosterFailed(true)}
/>
) : null}
{showGif ? (
<img
src={gifUrl}
alt=""
aria-hidden
className="absolute inset-0 size-full object-cover"
draggable={false}
onError={() => setGifFailed(true)}
/>
) : null}
{textOnly ? (
<div className="flex size-full flex-col justify-end gap-1 bg-muted p-4">
<div className="text-sm font-semibold leading-tight text-foreground">{tile.title}</div>
<div className="text-xs leading-snug text-muted-foreground">{tile.caption}</div>
</div>
) : null}
</div>
<div className={cn('space-y-1 p-3', textOnly && 'invisible')}>
<div className="flex min-w-0 items-center gap-1.5 text-sm font-semibold text-foreground">
<span className="truncate">{tile.title}</span>
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
</div>
<p className="line-clamp-2 text-xs leading-snug text-muted-foreground">{tile.caption}</p>
</div>
</div>
)
}
function AgentStatusMockup(): JSX.Element {
return (
<div className="flex size-full flex-col justify-center gap-2 bg-muted p-5">
<div className="rounded-md border border-border/70 bg-background/70 px-3 py-2 font-mono text-[11px] text-muted-foreground">
<span className="text-foreground"> Claude Code</span> · finished tests, pushing
</div>
<div className="rounded-md border border-border/70 bg-background/70 px-3 py-2 font-mono text-[11px] text-muted-foreground">
<span className="text-foreground"> Codex</span> · refactoring handlers
</div>
<div className="rounded-md border border-border bg-accent px-3 py-2 font-mono text-[11px] text-accent-foreground">
<span className="font-medium text-foreground"> OpenCode</span> · blocked on API response
</div>
</div>
)
}

View File

@ -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<string | null>(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
}

View File

@ -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)
})
})

View File

@ -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
}
}
}

View File

@ -1376,10 +1376,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}, [closeModal, openSettingsPage, openSettingsTarget])
const applyWorktreeMeta = useCallback(
async (
worktreeId: string,
meta: Partial<WorktreeMeta>
): Promise<void> => {
async (worktreeId: string, meta: Partial<WorktreeMeta>): Promise<void> => {
if (Object.keys(meta).length === 0) {
return
}

View File

@ -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: () => () => {},

View File

@ -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

View File

@ -55,9 +55,7 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
// 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,

View File

@ -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)
})
})

View File

@ -273,11 +273,15 @@ export type UISlice = {
| 'add-repo'
| 'quick-open'
| 'worktree-palette'
| 'feature-wall'
| 'new-workspace-composer'
| 'confirm-orca-yaml-hooks'
modalData: Record<string, unknown>
openModal: (modal: UISlice['activeModal'], data?: Record<string, unknown>) => void
closeModal: () => void
featureTourNudgeVisible: boolean
showFeatureTourNudge: () => void
dismissFeatureTourNudge: () => void
trustedOrcaHooks: PersistedTrustedOrcaHooks
markOrcaHookScriptConfirmed: (
repoId: string,
@ -554,8 +558,20 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (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) =>

View File

@ -0,0 +1 @@
export const FEATURE_WALL_MAX_DWELL_MS = 86_400_000

View File

@ -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<FeatureWallTile, { kind: 'media' }>
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

View File

@ -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)
})
})

View File

@ -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<typeof launchSourceSchema>
export const requestKindSchema = z.enum(['new', 'resume', 'followup'])
export type RequestKind = z.infer<typeof requestKindSchema>
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<typeof featureWallTileIdSchema>
export const featureWallOpenSourceSchema = z.enum(['help_menu', 'popup', 'unknown'])
export type FeatureWallOpenSourceTelemetry = z.infer<typeof featureWallOpenSourceSchema>
// `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<T> = 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<EventMap[N]> ? 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<EventMap[N]> ? N : never
}[EventName]
type _OnboardingCohortRosterSync = _OnboardingCohortRoster extends _DerivedOnboardingCohortEvents
? _DerivedOnboardingCohortEvents extends _OnboardingCohortRoster

View File

@ -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<void> {
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<number> {
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<string>(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<boolean>(orcaPage, 'featureTourNudgeVisible'))
.toBe(false)
})
})