feat(onboarding): add feature wall tour (#1772)
Co-authored-by: Orca <help@stably.ai>
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
)
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
After Width: | Height: | Size: 726 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
|
@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
|
@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 117 KiB |
|
|
@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
|
@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 75 KiB |
|
|
@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
|
@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 110 KiB |
|
|
@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
|
@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 770 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 18 KiB |
|
|
@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 331 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: () => () => {},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export const FEATURE_WALL_MAX_DWELL_MS = 86_400_000
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||