+
{error ? (
) : activeOptions.length > 0 ? (
diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-classifier.ts b/src/renderer/src/components/tab-bar/tab-create-entry-classifier.ts
index dcfe9fde228..1a68effa620 100644
--- a/src/renderer/src/components/tab-bar/tab-create-entry-classifier.ts
+++ b/src/renderer/src/components/tab-bar/tab-create-entry-classifier.ts
@@ -226,7 +226,9 @@ export function getTabEntryOptions(
): TabEntryOption[] {
const trimmed = query.trim()
if (!trimmed) {
- return [{ id: 'empty', classification: { kind: 'empty', message: 'URL, file, or new file' } }]
+ return [
+ { id: 'empty', classification: { kind: 'empty', message: 'Open any file, URL, agent, ...' } }
+ ]
}
const explicitUrl = classifyExplicitUrl(trimmed)
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index 8d120e3730b..e53d648ae58 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -309,7 +309,6 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
experimentalTerminalAttention: false,
experimentalCompactWorktreeCards: false,
experimentalWorktreeSymlinks: false,
- experimentalUnifiedNewTabLauncher: false,
// Why: local desktop remains the default server until the user explicitly
// selects a saved runtime environment.
activeRuntimeEnvironmentId: null,
diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts
index 5ca227560a9..8a5d1c43ff5 100644
--- a/src/shared/telemetry-events.ts
+++ b/src/shared/telemetry-events.ts
@@ -278,7 +278,6 @@ export const SETTINGS_CHANGED_WHITELIST = [
'experimentalActivity',
'experimentalTerminalAttention',
'experimentalWorktreeSymlinks',
- 'experimentalUnifiedNewTabLauncher',
'geminiCliOAuthEnabled'
] as const satisfies readonly BooleanGlobalSettingsKey[]
export const settingsChangedKeySchema = z.enum(SETTINGS_CHANGED_WHITELIST)
diff --git a/src/shared/types.ts b/src/shared/types.ts
index cc6530b87eb..80f8cc661ad 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -2284,9 +2284,7 @@ export type GlobalSettings = {
* configuration surface and edge cases (conflicts with existing paths,
* cleanup on worktree delete) are still being worked out. */
experimentalWorktreeSymlinks: boolean
- /** Experimental: replaces the New Tab menu's static preview row with a
- * command-style launcher for terminals, detected agents, URLs, and files. */
- experimentalUnifiedNewTabLauncher: boolean
+
/** Active non-local runtime environment for client-routed RPC. `null`
* preserves the current local desktop behavior. */
activeRuntimeEnvironmentId?: string | null
From 6e970c8f943125528fb5d7ab01c6cb8ff0b58f7c Mon Sep 17 00:00:00 2001
From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com>
Date: Mon, 8 Jun 2026 11:06:06 -0700
Subject: [PATCH 04/13] fix: address pr-bug-scan validated finding from #4658
(#4741)
Gate the three unread-clear calls in onTerminalKeyDown so modifier-only/repeat/copy-chord keydowns no longer dismiss sibling-pane attention.
Co-authored-by: orca-bug-scan-bot
---
.../terminal-pane/pty-connection.ts | 27 ++++++++++++++++---
1 file changed, 24 insertions(+), 3 deletions(-)
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
index 4e600279ad5..b5f060fd54f 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -895,9 +895,6 @@ export function connectPanePty(
})
commandLifecycle.attachXtermConsumer(pane.terminal)
const onTerminalKeyDown = (event: KeyboardEvent): void => {
- deps.clearTerminalTabUnread(deps.tabId)
- deps.clearTerminalPaneUnread(cacheKey)
- deps.clearWorktreeUnread(deps.worktreeId)
if (isPlainEscapeKeyEvent(event)) {
setPendingTerminalInputIntent('plain-escape')
return
@@ -908,6 +905,30 @@ export function connectPanePty(
}
setPendingTerminalInputIntent('ctrl-c')
}
+ // Why: only treat keydowns that will produce real terminal input as the
+ // "user is here" signal. Modifier-only presses, autorepeat, and Cmd/Ctrl+C
+ // copy chords with an active selection must not dismiss attention on a
+ // sibling pane before the user has seen it.
+ if (
+ event.repeat ||
+ event.key === 'Alt' ||
+ event.key === 'AltGraph' ||
+ event.key === 'Control' ||
+ event.key === 'Meta' ||
+ event.key === 'Shift'
+ ) {
+ return
+ }
+ if (
+ (event.metaKey || event.ctrlKey) &&
+ event.key.toLowerCase() === 'c' &&
+ pane.terminal.hasSelection()
+ ) {
+ return
+ }
+ deps.clearTerminalTabUnread(deps.tabId)
+ deps.clearTerminalPaneUnread(cacheKey)
+ deps.clearWorktreeUnread(deps.worktreeId)
}
// Why: infer only from focused xterm key events. Raw PTY bytes cannot
// distinguish plain Escape from Alt/meta sequences, and programmatic writes
From 1856d6b5854e94a3c01491e6ca98c5f3831220d1 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Mon, 8 Jun 2026 11:21:50 -0700
Subject: [PATCH 05/13] Add terminal rendering golden e2e gate (#4878)
---
.github/workflows/golden-e2e-experiment.yml | 12 ++++++--
...package-electron-runtime-contract.test.mjs | 28 +++++++++++++++++++
package.json | 1 +
...terminal-long-table-scroll-restore.spec.ts | 2 ++
...nal-raw-emoji-table-scroll-restore.spec.ts | 11 ++++----
5 files changed, 47 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml
index 8359c58ff6c..f33b00fdfa0 100644
--- a/.github/workflows/golden-e2e-experiment.yml
+++ b/.github/workflows/golden-e2e-experiment.yml
@@ -6,8 +6,11 @@ on:
- '.github/workflows/golden-e2e-experiment.yml'
- 'package.json'
- 'tests/e2e/golden-core-flows.spec.ts'
+ - 'tests/e2e/fixtures/terminal-emoji-table.md'
- 'tests/e2e/helpers/**'
+ - 'tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts'
- 'src/renderer/src/components/sidebar/SidebarHeader.tsx'
+ - 'src/renderer/src/lib/pane-manager/**'
workflow_dispatch:
inputs:
ref:
@@ -69,11 +72,15 @@ jobs:
- name: Run golden E2E tests on Linux
if: runner.os == 'Linux'
- run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
+ run: |
+ xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
+ xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
- name: Run golden E2E tests on macOS
if: runner.os == 'macOS'
- run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
+ run: |
+ env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
+ env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
- name: Run golden E2E tests on Windows
if: runner.os == 'Windows'
@@ -82,6 +89,7 @@ jobs:
$env:SKIP_BUILD = '1'
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
+ pnpm run test:e2e:terminal-rendering-golden
- name: Upload Playwright traces
if: failure()
diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs
index 9f8eb6779ab..e5da9490494 100644
--- a/config/scripts/package-electron-runtime-contract.test.mjs
+++ b/config/scripts/package-electron-runtime-contract.test.mjs
@@ -211,4 +211,32 @@ describe('Electron runtime package contract', () => {
expect(uploadStep.uses).toBe('actions/upload-artifact@v7')
expect(uploadStep.with.path).toBe('${{ env.ORCA_E2E_TERMINAL_PERF_REPORT_PATH }}')
})
+
+ it('keeps terminal rendering regressions in the fast golden E2E gate', () => {
+ const packageScripts = packageJson.scripts
+ const goldenWorkflow = parse(
+ readFileSync(join(projectDir, '.github/workflows/golden-e2e-experiment.yml'), 'utf8')
+ )
+ const steps = goldenWorkflow.jobs['golden-e2e'].steps
+ const linuxRunStep = steps.find((step) => step.name === 'Run golden E2E tests on Linux')
+ const macRunStep = steps.find((step) => step.name === 'Run golden E2E tests on macOS')
+ const windowsRunStep = steps.find((step) => step.name === 'Run golden E2E tests on Windows')
+ const pullRequestPaths = goldenWorkflow.on.pull_request.paths
+
+ expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain(
+ '@terminal-rendering-golden'
+ )
+ expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain(
+ 'terminal-raw-emoji-table-scroll-restore.spec.ts'
+ )
+ expect(packageScripts['test:e2e:terminal-rendering-golden']).not.toContain(
+ 'terminal-long-table-scroll-restore.spec.ts'
+ )
+ for (const runStep of [linuxRunStep, macRunStep, windowsRunStep]) {
+ expect(runStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
+ }
+ expect(pullRequestPaths).toContain('tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts')
+ expect(pullRequestPaths).toContain('tests/e2e/fixtures/terminal-emoji-table.md')
+ expect(pullRequestPaths).toContain('src/renderer/src/lib/pane-manager/**')
+ })
})
diff --git a/package.json b/package.json
index 8bc94fee7f0..572e5a64c28 100644
--- a/package.json
+++ b/package.json
@@ -60,6 +60,7 @@
"build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
"build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux",
"test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headless",
+ "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
"test:e2e:terminal-perf:scale": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-e2e.mjs",
"test:e2e:terminal-perf:scale:report": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-report-gate.mjs",
diff --git a/tests/e2e/terminal-long-table-scroll-restore.spec.ts b/tests/e2e/terminal-long-table-scroll-restore.spec.ts
index 560da550668..9667ad72a03 100644
--- a/tests/e2e/terminal-long-table-scroll-restore.spec.ts
+++ b/tests/e2e/terminal-long-table-scroll-restore.spec.ts
@@ -731,6 +731,8 @@ test.describe('Terminal long table scroll restore repro', () => {
}
})
+ // Why: keeps the user-shaped markdown path covered in the broader e2e suite;
+ // the faster raw-table spec is the release-blocking golden for this bug.
test('keeps real emoji markdown table right edge clean after restore and scroll', async ({
orcaPage,
testRepoPath
diff --git a/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts b/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts
index a1e9f79b476..794b538a41a 100644
--- a/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts
+++ b/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts
@@ -375,7 +375,9 @@ test.describe('Terminal raw emoji table scroll restore repro', () => {
})
})
- test('keeps raw emoji box table aligned after restore and scroll', async ({
+ // Why: this is the minimal golden for the v1.4.51 regression. It fails if
+ // xterm underfits by one scrollbar column or counts ZWJ emoji as width 4.
+ test('keeps raw emoji box table aligned after restore and scroll @terminal-rendering-golden', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
@@ -395,7 +397,6 @@ test.describe('Terminal raw emoji table scroll restore repro', () => {
await waitForActiveTerminalManager(orcaPage, 30_000)
const ptyId = await waitForActivePanePtyId(orcaPage)
const runId = randomUUID()
- const marker = `RAW_EMOJI_FIXTURE_TABLE_RESTORE_${runId}`
const scriptPath = path.join(testRepoPath, `.orca-raw-emoji-fixture-table-${runId}.mjs`)
writeFileSync(scriptPath, rawEmojiFixtureBoxTableScript(EMOJI_TABLE_FIXTURE, runId))
@@ -410,10 +411,10 @@ test.describe('Terminal raw emoji table scroll restore repro', () => {
await waitForActiveTerminalManager(orcaPage, 30_000)
await expect
.poll(() => getTerminalContent(orcaPage, 30_000), {
- timeout: 10_000,
- message: 'raw emoji table marker did not survive workspace switch'
+ timeout: 30_000,
+ message: 'raw emoji table did not finish streaming after workspace switch'
})
- .toContain(marker)
+ .toContain('Singer')
await scrollActiveTerminalToText(orcaPage, 'Singer')
await closeFeatureTips(orcaPage)
From 61cb7d692a54b1931b32f7f0a76293a0532da74e Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Mon, 8 Jun 2026 11:44:49 -0700
Subject: [PATCH 06/13] Add a browser tour step for importing cookies to stay
signed in (#4836)
Co-authored-by: Orca
---
.../components/browser-pane/BrowserPane.tsx | 9 +-
.../browser-pane/BrowserToolbarMenu.tsx | 38 +++-
.../ContextualTourOverlay.tsx | 94 +++-------
.../contextual-tour-gate.test.ts | 20 +++
...ontextual-tour-overlay-measurement.test.ts | 46 +++++
.../contextual-tour-overlay-measurement.ts | 166 ++++++++++++++++++
src/renderer/src/store/slices/ui.test.ts | 20 ++-
src/renderer/src/store/slices/ui.ts | 16 +-
src/shared/contextual-tours.test.ts | 25 +++
src/shared/contextual-tours.ts | 14 +-
src/shared/feature-interactions.ts | 2 +-
11 files changed, 356 insertions(+), 94 deletions(-)
create mode 100644 src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts
create mode 100644 src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts
diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx
index 5116e9ca4d9..ca1987bab88 100644
--- a/src/renderer/src/components/browser-pane/BrowserPane.tsx
+++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx
@@ -722,9 +722,6 @@ export default function BrowserPane({
const setBrowserPageUrl = useAppStore((s) => s.setBrowserPageUrl)
const runtimeEnvironmentActive = Boolean(activeRuntimeEnvironmentId?.trim())
const activeBrowserPageId = activeBrowserPage?.id ?? null
- const activeBrowserPageIsBlank =
- !runtimeEnvironmentActive &&
- (activeBrowserPage?.url === 'about:blank' || activeBrowserPage?.url === ORCA_BROWSER_BLANK_URL)
const browserPageIds = useMemo(() => browserPages.map((page) => page.id), [browserPages])
const automationVisiblePageIds = useBrowserAutomationVisiblePageIds(browserPageIds)
// Why: inactive Electron webviews must stay mounted in their original DOM
@@ -759,10 +756,7 @@ export default function BrowserPane({
useContextualTour(
'browser',
- isActive &&
- activeBrowserPage !== null &&
- !runtimeEnvironmentActive &&
- !activeBrowserPageIsBlank,
+ isActive && activeBrowserPage !== null && !runtimeEnvironmentActive,
'browser_visible'
)
@@ -4619,6 +4613,7 @@ function BrowserPagePane({
browserPageId={browserTab.id}
viewportPresetId={browserTab.viewportPresetId ?? null}
onDestroyWebview={() => destroyPersistentWebview(browserTab.id)}
+ isActive={isActive}
/>
{downloadState ? (
diff --git a/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx
index 95b004f4aaa..fc5783bd8d1 100644
--- a/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx
+++ b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useLayoutEffect, useState } from 'react'
import { Check, Ellipsis, Import, Monitor, Plus, Settings } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
@@ -40,6 +40,7 @@ type BrowserToolbarMenuProps = {
browserPageId: string
viewportPresetId: BrowserViewportPresetId | null
onDestroyWebview: () => void
+ isActive: boolean
}
export function BrowserToolbarMenu({
@@ -47,7 +48,8 @@ export function BrowserToolbarMenu({
workspaceId,
browserPageId,
viewportPresetId,
- onDestroyWebview
+ onDestroyWebview,
+ isActive
}: BrowserToolbarMenuProps): React.JSX.Element {
const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles)
const detectedBrowsers = useAppStore((s) => s.detectedBrowsers)
@@ -58,6 +60,10 @@ export function BrowserToolbarMenu({
const fetchDetectedBrowsers = useAppStore((s) => s.fetchDetectedBrowsers)
const browserSessionImportState = useAppStore((s) => s.browserSessionImportState)
const setBrowserPageViewportPreset = useAppStore((s) => s.setBrowserPageViewportPreset)
+ const browserCookieTourStepActive = useAppStore(
+ (s) => s.activeContextualTourId === 'browser' && s.activeContextualTourStepIndex === 2
+ )
+ const shouldForceMenuOpen = browserCookieTourStepActive && isActive
const applyViewportPreset = (nextId: BrowserViewportPresetId | null): void => {
setBrowserPageViewportPreset(browserPageId, nextId)
@@ -72,8 +78,22 @@ export function BrowserToolbarMenu({
const [pendingSwitchProfileId, setPendingSwitchProfileId] = useState
(
undefined
)
+ const [menuOpen, setMenuOpen] = useState(false)
const mountedRef = useMountedRef()
+ useLayoutEffect(() => {
+ // Why: step 3 anchors on Import Cookies inside this menu, so open it only
+ // once the tour reaches the final browser step.
+ setMenuOpen(shouldForceMenuOpen)
+ }, [shouldForceMenuOpen])
+
+ const handleMenuOpenChange = (open: boolean): void => {
+ if (shouldForceMenuOpen && !open) {
+ return
+ }
+ setMenuOpen(open)
+ }
+
const effectiveProfileId = currentProfileId ?? 'default'
const defaultProfile = browserSessionProfiles.find((p) => p.id === 'default')
@@ -165,7 +185,7 @@ export function BrowserToolbarMenu({
return (
<>
-
+
{allProfiles.map((profile) => {
- const isActive = profile.id === effectiveProfileId
+ const isSelectedProfile = profile.id === effectiveProfileId
return (
handleSwitchProfile(profile.id === 'default' ? null : profile.id)}
>
{profile.label}
{profile.source?.browserFamily && (
@@ -211,7 +231,13 @@ export function BrowserToolbarMenu({
}
}}
>
-
+
Import Cookies
diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx
index 294e1f3f0a5..14a6bb8b17d 100644
--- a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx
+++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx
@@ -10,16 +10,11 @@ import {
trackContextualTourOutcome,
trackContextualTourShown
} from '@/lib/feature-education-telemetry'
-import { formatShortcutLabel } from '@/hooks/useShortcutLabel'
+import { isContextualTourAllowedForModal } from './contextual-tour-gate'
import {
- getContextualTourStepCopy,
- getContextualTourStepProgress,
- getContextualTourOutcomeStepTotal,
- getMeasurableContextualTourTarget,
- getContextualTourPanelHost,
- getVisibleContextualTourStepIndexes,
- isContextualTourAllowedForModal
-} from './contextual-tour-gate'
+ getContextualTourCleanupOutcome,
+ measureContextualTourOverlayRenderState
+} from './contextual-tour-overlay-measurement'
import { getContextualTourOverlayPanelPosition } from './contextual-tour-overlay-position'
import {
ContextualTourOverlaySurface,
@@ -167,62 +162,33 @@ export function ContextualTourOverlay(): JSX.Element | null {
return
}
- const targetExists = (selector: string): boolean =>
- getMeasurableContextualTourTarget(selector) !== null
- const visibleStepIndexes = getVisibleContextualTourStepIndexes(activeTour, targetExists)
telemetryDefinedStepCountRef.current = activeTour.steps.length
+ const measurement = measureContextualTourOverlayRenderState({
+ tour: activeTour,
+ activeStepIndex,
+ sidebarOpen,
+ keybindings,
+ previousTelemetryTotalSteps: telemetryTotalStepsRef.current
+ })
telemetryTotalStepsRef.current = Math.max(
telemetryTotalStepsRef.current,
- getContextualTourOutcomeStepTotal(visibleStepIndexes)
+ measurement.kind === 'render' ? measurement.telemetryTotalSteps : 0
)
- const activeStep = activeTour.steps[activeStepIndex]
- const target = activeStep ? getMeasurableContextualTourTarget(activeStep.targetSelector) : null
- const progress = getContextualTourStepProgress({
- visibleStepIndexes,
- stepIndex: activeStepIndex
- })
- if (visibleStepIndexes.length === 0) {
+ if (measurement.kind === 'advance') {
+ advanceContextualTour()
+ return
+ }
+ if (measurement.kind === 'wait') {
+ return
+ }
+ if (measurement.kind === 'cancel') {
emitContextualTourOutcome('cancelled')
cancelContextualTour(activeTourId)
return
}
- if (!activeStep || !target || !progress) {
- const hasLaterStep = visibleStepIndexes.some((index) => index > activeStepIndex)
- if (hasLaterStep) {
- advanceContextualTour()
- } else {
- emitContextualTourOutcome('cancelled')
- cancelContextualTour(activeTourId)
- }
- return
- }
-
- // Why: the "Show worktrees" CTA only opens the sidebar; when it's already
- // open the button is a no-op, so fall back to a plain Next and drop the Skip.
- const sidebarAlreadyVisible = activeStep.primaryAction?.kind === 'show-worktrees' && sidebarOpen
- const primaryAction = sidebarAlreadyVisible
- ? ({ kind: 'next', label: 'Next' } as const)
- : activeStep.primaryAction
- const secondaryAction = sidebarAlreadyVisible ? undefined : activeStep.secondaryAction
-
- setRenderState({
- rect: target.rect,
- targetElement: target.element,
- progress,
- title: activeStep.title,
- body: formatContextualTourStepCopy(getContextualTourStepCopy(activeStep), keybindings),
- control: activeStep.control,
- primaryAction,
- secondaryAction,
- preferredPlacement: activeStep.preferredPlacement,
- targetPulse: activeStep.targetPulse,
- hidePrimaryAction: activeStep.hidePrimaryAction,
- isLastStep: progress.current === progress.total,
- isFirstStep: progress.current === 1,
- panelHost: getContextualTourPanelHost(target.element)
- })
+ setRenderState(measurement.renderState)
}, [
activeStepIndex,
activeTour,
@@ -408,20 +374,4 @@ export function ContextualTourOverlay(): JSX.Element | null {
)
}
-export function getContextualTourCleanupOutcome(
- activeTourId: ContextualTourId
-): ContextualTourOutcome {
- return useAppStore.getState().lastCompletedContextualTourId === activeTourId
- ? 'completed'
- : 'cancelled'
-}
-
-function formatContextualTourStepCopy(
- copy: string,
- keybindings: Parameters[1]
-): string {
- return copy.replace(
- '{terminal.splitRight}',
- formatShortcutLabel('terminal.splitRight', keybindings)
- )
-}
+export { getContextualTourCleanupOutcome } from './contextual-tour-overlay-measurement'
diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts b/src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts
index d30cfd65466..dd9b3d1c75a 100644
--- a/src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts
+++ b/src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts
@@ -267,6 +267,26 @@ describe('contextual tour gate', () => {
})
})
+ it('hides the browser cookie step until the Import Cookies menu row is measurable', () => {
+ const tour = getContextualTour('browser')
+ const cookieTarget = tour.steps[2]!.targetSelector
+ const targetExists = (selector: string): boolean => selector !== cookieTarget
+ const visibleStepIndexes = getVisibleContextualTourStepIndexes(tour, targetExists)
+
+ expect(visibleStepIndexes).toEqual([0, 1])
+ expect(
+ getNextVisibleContextualTourStepIndex({
+ tour,
+ currentStepIndex: 1,
+ targetExists
+ })
+ ).toBeNull()
+ expect(getContextualTourStepProgress({ visibleStepIndexes, stepIndex: 1 })).toEqual({
+ current: 2,
+ total: 2
+ })
+ })
+
it('cancels the workspace-agent-sessions tour when the new-worktree button is absent', () => {
const tour = getContextualTour('workspace-agent-sessions')
// Only the split step's target is present; the create-worktree button is not.
diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts
new file mode 100644
index 00000000000..d83fafd3fc1
--- /dev/null
+++ b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest'
+import { getContextualTour } from '../../../../shared/contextual-tours'
+import {
+ getContextualTourDisplayProgress,
+ getContextualTourMeasurementAction,
+ isContextualTourLastDisplayStep
+} from './contextual-tour-overlay-measurement'
+
+describe('contextual tour overlay measurement', () => {
+ it('shows all defined browser steps in progress even when step 3 is hidden', () => {
+ const tour = getContextualTour('browser')
+
+ expect(
+ getContextualTourDisplayProgress({
+ tour,
+ visibleStepIndexes: [0, 1],
+ stepIndex: 1,
+ activeStep: tour.steps[1]
+ })
+ ).toEqual({ current: 2, total: 3 })
+ })
+
+ it('waits for the browser cookie step target instead of cancelling', () => {
+ const tour = getContextualTour('browser')
+
+ expect(
+ getContextualTourMeasurementAction({
+ tour,
+ visibleStepIndexes: [0, 1],
+ activeStepIndex: 2
+ })
+ ).toEqual({ kind: 'wait' })
+ })
+
+ it('treats browser step 3 as the last display step', () => {
+ const tour = getContextualTour('browser')
+
+ expect(
+ isContextualTourLastDisplayStep({
+ tour,
+ activeStepIndex: 2,
+ progress: { current: 3, total: 3 }
+ })
+ ).toBe(true)
+ })
+})
diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts
new file mode 100644
index 00000000000..7078419f841
--- /dev/null
+++ b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts
@@ -0,0 +1,166 @@
+import { formatShortcutLabel } from '@/hooks/useShortcutLabel'
+import type { ContextualTour, ContextualTourId } from '../../../../shared/contextual-tours'
+import type { ContextualTourOutcome } from '../../../../shared/feature-education-telemetry'
+import { useAppStore } from '@/store'
+import {
+ getContextualTourOutcomeStepTotal,
+ getContextualTourPanelHost,
+ getContextualTourStepCopy,
+ getContextualTourStepProgress,
+ getMeasurableContextualTourTarget,
+ getVisibleContextualTourStepIndexes
+} from './contextual-tour-gate'
+import type { ActiveTourRenderState } from './ContextualTourOverlaySurface'
+
+export type ContextualTourMeasurementAction =
+ | { kind: 'wait' }
+ | { kind: 'advance' }
+ | { kind: 'cancel' }
+
+export type ContextualTourOverlayMeasurementResult =
+ | { kind: 'wait' }
+ | { kind: 'advance' }
+ | { kind: 'cancel' }
+ | {
+ kind: 'render'
+ renderState: ActiveTourRenderState
+ telemetryTotalSteps: number
+ }
+
+export function getContextualTourDisplayProgress(args: {
+ tour: ContextualTour
+ visibleStepIndexes: readonly number[]
+ stepIndex: number
+ activeStep: ContextualTour['steps'][number] | undefined
+}): { current: number; total: number } | null {
+ if (!args.activeStep) {
+ return null
+ }
+ if (args.tour.id === 'browser') {
+ return { current: args.stepIndex + 1, total: args.tour.steps.length }
+ }
+ return getContextualTourStepProgress({
+ visibleStepIndexes: args.visibleStepIndexes,
+ stepIndex: args.stepIndex
+ })
+}
+
+export function getContextualTourMeasurementAction(args: {
+ tour: ContextualTour
+ visibleStepIndexes: readonly number[]
+ activeStepIndex: number
+}): ContextualTourMeasurementAction {
+ if (args.visibleStepIndexes.some((index) => index > args.activeStepIndex)) {
+ return { kind: 'advance' }
+ }
+ // Why: browser step 3's Import Cookies row appears only after that step is
+ // active and the toolbar menu opens; keep remeasuring instead of cancelling.
+ if (args.activeStepIndex < args.tour.steps.length - 1 || args.tour.id === 'browser') {
+ return { kind: 'wait' }
+ }
+ return { kind: 'cancel' }
+}
+
+export function isContextualTourLastDisplayStep(args: {
+ tour: ContextualTour
+ activeStepIndex: number
+ progress: { current: number; total: number }
+}): boolean {
+ if (args.tour.id === 'browser') {
+ return args.activeStepIndex === args.tour.steps.length - 1
+ }
+ return args.progress.current === args.progress.total
+}
+
+export function measureContextualTourOverlayRenderState(args: {
+ tour: ContextualTour
+ activeStepIndex: number
+ sidebarOpen: boolean
+ keybindings: Parameters[1]
+ previousTelemetryTotalSteps: number
+}): ContextualTourOverlayMeasurementResult {
+ const targetExists = (selector: string): boolean =>
+ getMeasurableContextualTourTarget(selector) !== null
+ const visibleStepIndexes = getVisibleContextualTourStepIndexes(args.tour, targetExists)
+ const telemetryTotalSteps = Math.max(
+ args.previousTelemetryTotalSteps,
+ getContextualTourOutcomeStepTotal(visibleStepIndexes)
+ )
+ const activeStep = args.tour.steps[args.activeStepIndex]
+ const target = activeStep ? getMeasurableContextualTourTarget(activeStep.targetSelector) : null
+ const progress = getContextualTourDisplayProgress({
+ tour: args.tour,
+ visibleStepIndexes,
+ stepIndex: args.activeStepIndex,
+ activeStep
+ })
+
+ if (visibleStepIndexes.length === 0 || !activeStep || !progress) {
+ return { kind: 'cancel' }
+ }
+
+ if (!target) {
+ const measurementAction = getContextualTourMeasurementAction({
+ tour: args.tour,
+ visibleStepIndexes,
+ activeStepIndex: args.activeStepIndex
+ })
+ if (measurementAction.kind === 'advance') {
+ return { kind: 'advance' }
+ }
+ if (measurementAction.kind === 'wait') {
+ return { kind: 'wait' }
+ }
+ return { kind: 'cancel' }
+ }
+
+ const sidebarAlreadyVisible =
+ activeStep.primaryAction?.kind === 'show-worktrees' && args.sidebarOpen
+ const primaryAction = sidebarAlreadyVisible
+ ? ({ kind: 'next', label: 'Next' } as const)
+ : activeStep.primaryAction
+ const secondaryAction = sidebarAlreadyVisible ? undefined : activeStep.secondaryAction
+
+ return {
+ kind: 'render',
+ telemetryTotalSteps,
+ renderState: {
+ rect: target.rect,
+ targetElement: target.element,
+ progress,
+ title: activeStep.title,
+ body: formatContextualTourStepCopy(getContextualTourStepCopy(activeStep), args.keybindings),
+ control: activeStep.control,
+ primaryAction,
+ secondaryAction,
+ preferredPlacement: activeStep.preferredPlacement,
+ targetPulse: activeStep.targetPulse,
+ hidePrimaryAction: activeStep.hidePrimaryAction,
+ isLastStep: isContextualTourLastDisplayStep({
+ tour: args.tour,
+ activeStepIndex: args.activeStepIndex,
+ progress
+ }),
+ isFirstStep: progress.current === 1,
+ panelHost: getContextualTourPanelHost(target.element)
+ }
+ }
+}
+
+export function getContextualTourCleanupOutcome(
+ activeTourId: ContextualTourId
+): ContextualTourOutcome {
+ return useAppStore.getState().lastCompletedContextualTourId === activeTourId
+ ? 'completed'
+ : 'cancelled'
+}
+
+function formatContextualTourStepCopy(
+ copy: string,
+ keybindings: Parameters[1]
+): string {
+ return copy.replace(
+ '{terminal.splitRight}',
+ formatShortcutLabel('terminal.splitRight', keybindings)
+ )
+}
diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts
index a88e8e615dc..f0ebd83cfe8 100644
--- a/src/renderer/src/store/slices/ui.test.ts
+++ b/src/renderer/src/store/slices/ui.test.ts
@@ -1960,7 +1960,8 @@ describe('createUISlice contextual tours', () => {
const store = createUIStore()
const visibleSelectors = [
'[data-contextual-tour-target="browser-grab-control"]',
- '[data-contextual-tour-target="browser-annotation-control"]'
+ '[data-contextual-tour-target="browser-annotation-control"]',
+ '[data-contextual-tour-target="browser-import-cookies-control"]'
]
stubContextualTourTargets(visibleSelectors)
store.getState().hydratePersistedUI(makeAutoTourEligibleUI())
@@ -1971,7 +1972,24 @@ describe('createUISlice contextual tours', () => {
store.getState().advanceContextualTour()
expect(store.getState().activeContextualTourId).toBe('browser')
+ expect(store.getState().activeContextualTourStepIndex).toBe(2)
+ })
+
+ it('advances the browser tour to the cookie step before Import Cookies is measurable', () => {
+ const store = createUIStore()
+ const visibleSelectors = [
+ '[data-contextual-tour-target="browser-grab-control"]',
+ '[data-contextual-tour-target="browser-annotation-control"]'
+ ]
+ stubContextualTourTargets(visibleSelectors)
+ store.getState().hydratePersistedUI(makeAutoTourEligibleUI())
+ store.getState().requestContextualTour('browser', 'browser_visible')
+
+ store.getState().advanceContextualTour()
expect(store.getState().activeContextualTourStepIndex).toBe(1)
+
+ store.getState().advanceContextualTour()
+ expect(store.getState().activeContextualTourStepIndex).toBe(2)
})
it('advances the active split step when the split command interaction is recorded', () => {
diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts
index 72353b1d7cc..a420cce56d7 100644
--- a/src/renderer/src/store/slices/ui.ts
+++ b/src/renderer/src/store/slices/ui.ts
@@ -1452,15 +1452,23 @@ export const createUISlice: StateCreator = (set, get)
if (!s.activeContextualTourId) {
return s
}
+ const tour = getContextualTour(s.activeContextualTourId)
const nextStepIndex = getNextVisibleContextualTourStepIndex({
- tour: getContextualTour(s.activeContextualTourId),
+ tour,
currentStepIndex: s.activeContextualTourStepIndex,
targetExists: hasContextualTourTarget
})
- if (nextStepIndex === null) {
- return s
+ if (nextStepIndex !== null) {
+ return { activeContextualTourStepIndex: nextStepIndex }
}
- return { activeContextualTourStepIndex: nextStepIndex }
+ // Why: browser step 3's target lives in a closed menu until that step is active.
+ if (
+ s.activeContextualTourId === 'browser' &&
+ s.activeContextualTourStepIndex + 1 < tour.steps.length
+ ) {
+ return { activeContextualTourStepIndex: s.activeContextualTourStepIndex + 1 }
+ }
+ return s
}),
regressContextualTour: () =>
set((s) => {
diff --git a/src/shared/contextual-tours.test.ts b/src/shared/contextual-tours.test.ts
index 28eb1c643e7..b397fd24ad3 100644
--- a/src/shared/contextual-tours.test.ts
+++ b/src/shared/contextual-tours.test.ts
@@ -97,6 +97,31 @@ describe('contextual tour definitions', () => {
})
})
+ it('orders the browser tour as grab, annotate, then import cookies', () => {
+ const tour = CONTEXTUAL_TOURS.find((entry) => entry.id === 'browser') as
+ | ContextualTour
+ | undefined
+
+ expect(tour?.steps.map((step) => step.title)).toEqual([
+ 'Grab page context for agents',
+ 'Mark design feedback in place',
+ 'Stay logged in'
+ ])
+ expect(tour?.steps[0]).toMatchObject({
+ targetSelector: '[data-contextual-tour-target="browser-grab-control"]',
+ preferredPlacement: 'bottom'
+ })
+ expect(tour?.steps[1]).toMatchObject({
+ targetSelector: '[data-contextual-tour-target="browser-annotation-control"]',
+ preferredPlacement: 'bottom'
+ })
+ expect(tour?.steps[2]).toMatchObject({
+ body: 'Bring your existing logins into Orca to stay signed in immediately.',
+ targetSelector: '[data-contextual-tour-target="browser-import-cookies-control"]',
+ preferredPlacement: 'left'
+ })
+ })
+
it('points the tasks tour at the row workspace action before toolbar fallbacks', () => {
const tour = CONTEXTUAL_TOURS.find((entry) => entry.id === 'tasks') as
| ContextualTour
diff --git a/src/shared/contextual-tours.ts b/src/shared/contextual-tours.ts
index 559066c0962..05c76e02761 100644
--- a/src/shared/contextual-tours.ts
+++ b/src/shared/contextual-tours.ts
@@ -102,14 +102,22 @@ export const CONTEXTUAL_TOURS = [
steps: [
{
title: 'Grab page context for agents',
- body: 'Grab controls can copy elements or hand page context to an agent.',
+ body: "Use the grab tool to copy a page element's context for agents.",
targetSelector: '[data-contextual-tour-target="browser-grab-control"]',
- requiredForStart: true
+ requiredForStart: true,
+ preferredPlacement: 'bottom'
},
{
title: 'Mark design feedback in place',
body: 'Annotate elements and send those notes to an agent.',
- targetSelector: '[data-contextual-tour-target="browser-annotation-control"]'
+ targetSelector: '[data-contextual-tour-target="browser-annotation-control"]',
+ preferredPlacement: 'bottom'
+ },
+ {
+ title: 'Stay logged in',
+ body: 'Bring your existing logins into Orca to stay signed in immediately.',
+ targetSelector: '[data-contextual-tour-target="browser-import-cookies-control"]',
+ preferredPlacement: 'left'
}
]
},
diff --git a/src/shared/feature-interactions.ts b/src/shared/feature-interactions.ts
index 73503b3214b..45abcea76be 100644
--- a/src/shared/feature-interactions.ts
+++ b/src/shared/feature-interactions.ts
@@ -71,7 +71,7 @@ export const FEATURE_INTERACTIONS = [
},
{
id: 'browser',
- interaction: 'non-blank browser page viewed'
+ interaction: 'in-app browser opened'
},
{
id: 'tasks',
From c10c2adcad1e144730abb48f55a716f14c0a8d0a Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Mon, 8 Jun 2026 11:45:13 -0700
Subject: [PATCH 07/13] fix: address review findings (#4891)
---
src/main/emulator/emulator-errors.ts | 1 +
.../emulator/simctl-simulator-devices.test.ts | 96 +++++++++++++++++++
src/main/emulator/simctl-simulator-devices.ts | 74 +++++++++-----
.../emulator-pane-error-message.ts | 3 +
.../use-emulator-pane-session.test.tsx | 46 +++++++++
.../use-emulator-pane-session.ts | 79 +++++++--------
.../use-emulator-pane-shutdown.ts | 62 ++++++++++++
7 files changed, 293 insertions(+), 68 deletions(-)
create mode 100644 src/main/emulator/simctl-simulator-devices.test.ts
create mode 100644 src/renderer/src/components/emulator-pane/emulator-pane-error-message.ts
create mode 100644 src/renderer/src/components/emulator-pane/use-emulator-pane-shutdown.ts
diff --git a/src/main/emulator/emulator-errors.ts b/src/main/emulator/emulator-errors.ts
index 5e6e8fdca0d..cda05f8e506 100644
--- a/src/main/emulator/emulator-errors.ts
+++ b/src/main/emulator/emulator-errors.ts
@@ -12,6 +12,7 @@ export type EmulatorErrorCode =
| 'emulator_no_active'
| 'emulator_device_not_found'
| 'emulator_helper_failed'
+ | 'emulator_simctl_unavailable'
| 'emulator_not_macos'
| 'emulator_disabled'
| 'emulator_error'
diff --git a/src/main/emulator/simctl-simulator-devices.test.ts b/src/main/emulator/simctl-simulator-devices.test.ts
new file mode 100644
index 00000000000..452b13d999a
--- /dev/null
+++ b/src/main/emulator/simctl-simulator-devices.test.ts
@@ -0,0 +1,96 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as Os from 'os'
+
+const { execFileMock, platformMock } = vi.hoisted(() => ({
+ execFileMock: vi.fn(),
+ platformMock: vi.fn(() => 'darwin')
+}))
+
+vi.mock('child_process', () => ({
+ execFile: execFileMock
+}))
+
+vi.mock('os', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ platform: platformMock
+ }
+})
+
+vi.mock('./serve-sim-execution', () => ({
+ execServeSimCommand: vi.fn()
+}))
+
+import { listSimulatorDevices } from './simctl-simulator-devices'
+
+describe('listSimulatorDevices', () => {
+ beforeEach(() => {
+ execFileMock.mockReset()
+ platformMock.mockReset()
+ platformMock.mockReturnValue('darwin')
+ })
+
+ it('parses simctl devices on macOS', async () => {
+ execFileMock.mockImplementation((_command, _args, _options, callback) => {
+ callback(
+ null,
+ JSON.stringify({
+ devices: {
+ 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [
+ {
+ name: 'iPhone 17 Pro',
+ udid: 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE',
+ state: 'Shutdown',
+ isAvailable: true
+ }
+ ]
+ }
+ }),
+ ''
+ )
+ })
+
+ await expect(listSimulatorDevices()).resolves.toEqual([
+ {
+ name: 'iPhone 17 Pro',
+ udid: 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE',
+ state: 'Shutdown',
+ runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0',
+ isAvailable: true
+ }
+ ])
+ expect(execFileMock).toHaveBeenCalledWith(
+ 'xcrun',
+ ['simctl', 'list', 'devices', '-j'],
+ { timeout: 15_000 },
+ expect.any(Function)
+ )
+ })
+
+ it('returns no devices on non-macOS hosts', async () => {
+ platformMock.mockReturnValue('linux')
+
+ await expect(listSimulatorDevices()).resolves.toEqual([])
+ expect(execFileMock).not.toHaveBeenCalled()
+ })
+
+ it('normalizes missing simctl errors for UI and CLI callers', async () => {
+ execFileMock.mockImplementation((_command, _args, _options, callback) => {
+ callback(
+ Object.assign(new Error('Command failed: xcrun simctl list devices -j'), { code: 72 }),
+ '',
+ 'xcrun: error: unable to find utility "simctl", not a developer tool or in PATH'
+ )
+ })
+
+ const error = await listSimulatorDevices().catch((caught: unknown) => caught)
+
+ expect(error).toMatchObject({
+ code: 'emulator_simctl_unavailable',
+ message: expect.stringContaining('Xcode Simulator tools are unavailable')
+ })
+ expect(error).toBeInstanceOf(Error)
+ expect((error as Error).message).not.toContain('Command failed')
+ })
+})
diff --git a/src/main/emulator/simctl-simulator-devices.ts b/src/main/emulator/simctl-simulator-devices.ts
index 88520d4cee1..437a35fccae 100644
--- a/src/main/emulator/simctl-simulator-devices.ts
+++ b/src/main/emulator/simctl-simulator-devices.ts
@@ -1,4 +1,5 @@
import { execFile } from 'child_process'
+import type { ExecFileException } from 'child_process'
import { platform } from 'os'
import { EmulatorError } from './emulator-errors'
import { execServeSimCommand, type ServeSimExecutable } from './serve-sim-execution'
@@ -23,6 +24,8 @@ type SimctlDeviceList = {
}
const UDID_RE = /^[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}$/i
+const SIMCTL_UNAVAILABLE_MESSAGE =
+ 'Xcode Simulator tools are unavailable. Install full Xcode, open it once, then select it with `sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer`.'
function parseSimctlDevices(stdout: string): SimulatorDevice[] {
const data = JSON.parse(stdout || '{}') as SimctlDeviceList
@@ -44,22 +47,42 @@ function parseSimctlDevices(stdout: string): SimulatorDevice[] {
return devices
}
+function mapSimctlError(error: ExecFileException, stderr?: string | Buffer): EmulatorError {
+ const raw = `${error.message}\n${stderr?.toString() ?? ''}`
+ const lower = raw.toLowerCase()
+ if (
+ error.code === 'ENOENT' ||
+ (lower.includes('unable to find utility') && lower.includes('simctl')) ||
+ lower.includes('not a developer tool')
+ ) {
+ // Why: xcrun exposes setup problems as raw execFile text; UI and CLI need
+ // the actionable Xcode fix instead of "Command failed: xcrun ...".
+ return new EmulatorError('emulator_simctl_unavailable', SIMCTL_UNAVAILABLE_MESSAGE)
+ }
+ return new EmulatorError('emulator_error', raw.trim() || 'xcrun simctl command failed.')
+}
+
export async function listSimulatorDevices(): Promise {
if (platform() !== 'darwin') {
return []
}
return new Promise((resolve, reject) => {
- execFile('xcrun', ['simctl', 'list', 'devices', '-j'], { timeout: 15_000 }, (error, stdout) => {
- if (error) {
- reject(error)
- return
+ execFile(
+ 'xcrun',
+ ['simctl', 'list', 'devices', '-j'],
+ { timeout: 15_000 },
+ (error, stdout, stderr) => {
+ if (error) {
+ reject(mapSimctlError(error, stderr))
+ return
+ }
+ try {
+ resolve(parseSimctlDevices(stdout))
+ } catch (parseError) {
+ reject(parseError)
+ }
}
- try {
- resolve(parseSimctlDevices(stdout))
- } catch (parseError) {
- reject(parseError)
- }
- })
+ )
})
}
@@ -119,14 +142,14 @@ export async function ensureSimulatorBooted(udid: string): Promise {
try {
await new Promise((resolve, reject) => {
- execFile('xcrun', ['simctl', 'boot', udid], { timeout: 45_000 }, (error) => {
+ execFile('xcrun', ['simctl', 'boot', udid], { timeout: 45_000 }, (error, _stdout, stderr) => {
if (error) {
const message = error.message.toLowerCase()
if (message.includes('booted') || message.includes('current state')) {
resolve()
return
}
- reject(error)
+ reject(mapSimctlError(error, stderr))
return
}
resolve()
@@ -159,17 +182,22 @@ export async function shutdownSimulatorDevice(udid: string): Promise {
}
await new Promise((resolve, reject) => {
- execFile('xcrun', ['simctl', 'shutdown', udid], { timeout: 30_000 }, (error) => {
- if (!error) {
- resolve()
- return
+ execFile(
+ 'xcrun',
+ ['simctl', 'shutdown', udid],
+ { timeout: 30_000 },
+ (error, _stdout, stderr) => {
+ if (!error) {
+ resolve()
+ return
+ }
+ const message = error.message.toLowerCase()
+ if (message.includes('shutdown') || message.includes('current state')) {
+ resolve()
+ return
+ }
+ reject(mapSimctlError(error, stderr))
}
- const message = error.message.toLowerCase()
- if (message.includes('shutdown') || message.includes('current state')) {
- resolve()
- return
- }
- reject(error)
- })
+ )
})
}
diff --git a/src/renderer/src/components/emulator-pane/emulator-pane-error-message.ts b/src/renderer/src/components/emulator-pane/emulator-pane-error-message.ts
new file mode 100644
index 00000000000..e93f1ea3b22
--- /dev/null
+++ b/src/renderer/src/components/emulator-pane/emulator-pane-error-message.ts
@@ -0,0 +1,3 @@
+export function emulatorPaneErrorMessage(error: unknown, fallback: string): string {
+ return error instanceof Error && error.message ? error.message : fallback
+}
diff --git a/src/renderer/src/components/emulator-pane/use-emulator-pane-session.test.tsx b/src/renderer/src/components/emulator-pane/use-emulator-pane-session.test.tsx
index 12e96a0bbe7..a6d22c25406 100644
--- a/src/renderer/src/components/emulator-pane/use-emulator-pane-session.test.tsx
+++ b/src/renderer/src/components/emulator-pane/use-emulator-pane-session.test.tsx
@@ -61,6 +61,15 @@ function runtimeSuccess(result: T) {
}
}
+function runtimeFailure(code: string, message: string) {
+ return {
+ id: 'test',
+ ok: false,
+ error: { code, message },
+ _meta: { runtimeId: 'test-runtime' }
+ }
+}
+
function Probe(): React.JSX.Element {
const state = useEmulatorPaneSession({
worktreeId: WORKTREE_ID,
@@ -74,6 +83,14 @@ function Probe(): React.JSX.Element {
)
}
+function AutoAttachProbe(): React.JSX.Element | null {
+ latest = useEmulatorPaneSession({
+ worktreeId: WORKTREE_ID,
+ autoAttachOnMount: true
+ })
+ return null
+}
+
async function flushEffects(): Promise {
await act(async () => {
await Promise.resolve()
@@ -169,4 +186,33 @@ describe('useEmulatorPaneSession', () => {
expect(latest?.isLive).toBe(true)
expect(latest?.previewUrl).toBe('http://127.0.0.1:3200/stream.mjpeg')
})
+
+ it('keeps simulator discovery setup errors during auto attach', async () => {
+ const message =
+ 'Xcode Simulator tools are unavailable. Install full Xcode, open it once, then select it with `sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer`.'
+ consumePrelaunchedSimulatorSession(WORKTREE_ID)
+ const runtimeCall = vi.fn(async ({ method }: RuntimeCallRequest) => {
+ if (method === 'emulator.listSimulators') {
+ return runtimeFailure('emulator_simctl_unavailable', message)
+ }
+ if (method === 'emulator.attach') {
+ throw new Error('attach should not run without a discovered target')
+ }
+ throw new Error(`Unexpected RPC method: ${method}`)
+ })
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { runtime: { call: runtimeCall } }
+ })
+
+ await act(async () => {
+ root.render()
+ })
+
+ await vi.waitFor(() => expect(latest?.error).toBe(message))
+ expect(latest?.loading).toBe(false)
+ expect(runtimeCall).not.toHaveBeenCalledWith(
+ expect.objectContaining({ method: 'emulator.attach' })
+ )
+ })
})
diff --git a/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts b/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts
index a24bb3621c7..9e2a867e6ae 100644
--- a/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts
+++ b/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts
@@ -9,10 +9,7 @@ import {
} from './emulator-pane-types'
import { markSimulatorDeviceBooted, markSimulatorDeviceShutdown } from './emulator-device-state'
import { useEmulatorPaneControls } from './use-emulator-pane-controls'
-import {
- EMULATOR_LOCAL_SHUTDOWN_EVENT,
- useEmulatorPaneSessionEvents
-} from './use-emulator-pane-session-events'
+import { useEmulatorPaneSessionEvents } from './use-emulator-pane-session-events'
import {
consumePrelaunchedSimulatorSession,
isManualSimulatorLaunchPending
@@ -22,6 +19,8 @@ import { useEmulatorPaneManualLaunchEvents } from './use-emulator-pane-manual-la
import { buildEmulatorPaneSessionView } from './emulator-pane-session-view'
import { resolveEmulatorAttachTarget } from './emulator-attach-target'
import { useEmulatorPaneLifecycle } from './use-emulator-pane-lifecycle'
+import { useEmulatorPaneShutdown } from './use-emulator-pane-shutdown'
+import { emulatorPaneErrorMessage } from './emulator-pane-error-message'
type UseEmulatorPaneSessionArgs = {
worktreeId: string
@@ -54,6 +53,7 @@ export function useEmulatorPaneSession({
const [streamKey, setStreamKey] = useState(prelaunchedState.streamKey)
const mountedRef = useRef(true)
const liveTargetRef = useRef(prelaunchedState.liveTarget)
+ const deviceRefreshErrorRef = useRef(null)
const suppressAutoAttachRef = useRef(false)
const { sendTap, sendButton, sendGesture, sendRotate } = useEmulatorPaneControls(worktreeId)
@@ -68,9 +68,19 @@ export function useEmulatorPaneSession({
if (!mountedRef.current) {
return next
}
+ const hadRefreshError = deviceRefreshErrorRef.current !== null
+ deviceRefreshErrorRef.current = null
setDevices(next)
+ if (hadRefreshError) {
+ setError(null)
+ }
return next
- } catch {
+ } catch (error) {
+ deviceRefreshErrorRef.current = error
+ if (mountedRef.current) {
+ setDevices([])
+ setError(emulatorPaneErrorMessage(error, 'Could not list emulator devices.'))
+ }
return []
}
}, [])
@@ -146,6 +156,9 @@ export function useEmulatorPaneSession({
if (list.length === 0) {
list = (await refreshDevices()) ?? []
}
+ if (list.length === 0 && deviceRefreshErrorRef.current) {
+ throw deviceRefreshErrorRef.current
+ }
const target = resolveEmulatorAttachTarget({
configuredDefaultUdid,
devices: list,
@@ -188,10 +201,13 @@ export function useEmulatorPaneSession({
if (requestedTarget && liveTargetRef.current === requestedTarget) {
return
}
- const msg =
- e instanceof Error
- ? e.message
- : 'Could not start the emulator. Check that Xcode is installed and try another device.'
+ // Why: setup failures otherwise trigger the mount auto-attach loop again
+ // and erase the actionable error before the user can read it.
+ suppressAutoAttachRef.current = true
+ const msg = emulatorPaneErrorMessage(
+ e,
+ 'Could not start the emulator. Check that Xcode is installed and try another device.'
+ )
setError(msg)
if (tabId) {
useAppStore.getState().setTabLabel(tabId, 'Mobile Emulator')
@@ -220,42 +236,15 @@ export function useEmulatorPaneSession({
}
}, [configuredDefaultUdid, selectedUdid])
- const shutdown = useCallback(
- async (deviceTarget?: string) => {
- if (loading) {
- return
- }
- setLoading(true)
- setError(null)
- if (tabId) {
- useAppStore.getState().setTabLabel(tabId, 'Shutting down…')
- }
- try {
- const res = (await callRuntimeRpc({ kind: 'local' }, 'emulator.shutdown', {
- ...(deviceTarget ? { device: deviceTarget } : {}),
- worktree: worktreeId
- })) as { deviceUdid?: string }
- const shutdownTarget = res?.deviceUdid || deviceTarget
- window.dispatchEvent(
- new CustomEvent(EMULATOR_LOCAL_SHUTDOWN_EVENT, {
- detail: { worktreeId, deviceUdid: shutdownTarget }
- })
- )
- void refreshDevices()
- } catch (e: unknown) {
- const msg =
- e instanceof Error
- ? e.message
- : 'Could not shut down the emulator. Try again from Xcode Simulator.'
- setError(msg)
- } finally {
- if (mountedRef.current) {
- setLoading(false)
- }
- }
- },
- [loading, refreshDevices, tabId, worktreeId]
- )
+ const shutdown = useEmulatorPaneShutdown({
+ loading,
+ mountedRef,
+ refreshDevices,
+ setError,
+ setLoading,
+ tabId,
+ worktreeId
+ })
useEmulatorPaneLifecycle({ mountedRef, refreshDevices, tabId, worktreeId })
diff --git a/src/renderer/src/components/emulator-pane/use-emulator-pane-shutdown.ts b/src/renderer/src/components/emulator-pane/use-emulator-pane-shutdown.ts
new file mode 100644
index 00000000000..01d5c7a6c9c
--- /dev/null
+++ b/src/renderer/src/components/emulator-pane/use-emulator-pane-shutdown.ts
@@ -0,0 +1,62 @@
+import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react'
+import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
+import { useAppStore } from '@/store'
+import { EMULATOR_LOCAL_SHUTDOWN_EVENT } from './use-emulator-pane-session-events'
+import { emulatorPaneErrorMessage } from './emulator-pane-error-message'
+
+type UseEmulatorPaneShutdownArgs = {
+ loading: boolean
+ mountedRef: RefObject
+ refreshDevices: () => Promise
+ setError: Dispatch>
+ setLoading: Dispatch>
+ tabId?: string
+ worktreeId: string
+}
+
+export function useEmulatorPaneShutdown({
+ loading,
+ mountedRef,
+ refreshDevices,
+ setError,
+ setLoading,
+ tabId,
+ worktreeId
+}: UseEmulatorPaneShutdownArgs) {
+ return useCallback(
+ async (deviceTarget?: string) => {
+ if (loading) {
+ return
+ }
+ setLoading(true)
+ setError(null)
+ if (tabId) {
+ useAppStore.getState().setTabLabel(tabId, 'Shutting down…')
+ }
+ try {
+ const res = (await callRuntimeRpc({ kind: 'local' }, 'emulator.shutdown', {
+ ...(deviceTarget ? { device: deviceTarget } : {}),
+ worktree: worktreeId
+ })) as { deviceUdid?: string }
+ const shutdownTarget = res?.deviceUdid || deviceTarget
+ window.dispatchEvent(
+ new CustomEvent(EMULATOR_LOCAL_SHUTDOWN_EVENT, {
+ detail: { worktreeId, deviceUdid: shutdownTarget }
+ })
+ )
+ void refreshDevices()
+ } catch (e: unknown) {
+ const msg = emulatorPaneErrorMessage(
+ e,
+ 'Could not shut down the emulator. Try again from Xcode Simulator.'
+ )
+ setError(msg)
+ } finally {
+ if (mountedRef.current) {
+ setLoading(false)
+ }
+ }
+ },
+ [loading, mountedRef, refreshDevices, setError, setLoading, tabId, worktreeId]
+ )
+}
From 32d53d552ed5407d1cd54c5aa0e85897601fce26 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Mon, 8 Jun 2026 11:48:11 -0700
Subject: [PATCH 08/13] Add empty-state usage CTA to status bar (#4854)
Co-authored-by: Orca
---
.../src/components/status-bar/StatusBar.tsx | 72 ++++++++++++-------
.../status-bar/StatusBarUsageEmptyCta.tsx | 69 ++++++++++++++++++
2 files changed, 115 insertions(+), 26 deletions(-)
create mode 100644 src/renderer/src/components/status-bar/StatusBarUsageEmptyCta.tsx
diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx
index db022b7d14f..968dc0838c4 100644
--- a/src/renderer/src/components/status-bar/StatusBar.tsx
+++ b/src/renderer/src/components/status-bar/StatusBar.tsx
@@ -46,6 +46,7 @@ import { ResourceUsageStatusSegment } from './ResourceUsageStatusSegment'
import { PortsStatusSegment } from './PortsStatusSegment'
import { isStatusBarItemAvailable } from './status-bar-agent-gating'
import { isProviderConfigured } from './status-bar-provider-visibility'
+import { StatusBarUsageEmptyCta } from './StatusBarUsageEmptyCta'
import { shouldOpenStatusBarContextMenu } from './status-bar-context-menu-policy'
import { PetStatusSegment } from './PetStatusSegment'
import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
@@ -1621,6 +1622,17 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
floatingTerminalEnabled && floatingTerminalTriggerLocation === 'status-bar'
const anyVisible =
showClaude || showCodex || showGemini || showOpencodeGo || showKimi || showResourceUsage
+ // Why: a brand-new user with no provider configured would otherwise see an
+ // empty left side of the status bar and wonder what's missing. The CTA
+ // names the surface and points to the setup path. Detection is on
+ // configuration (not user toggles) so users who intentionally hid a
+ // provider's bar don't get the teaching prompt back.
+ const isEmptyUsageState =
+ !isProviderConfigured(claude) &&
+ !isProviderConfigured(codex) &&
+ !isProviderConfigured(gemini) &&
+ !isProviderConfigured(opencodeGo) &&
+ !isProviderConfigured(kimi)
const anyFetching =
claude?.status === 'fetching' ||
codex?.status === 'fetching' ||
@@ -1656,33 +1668,41 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
}}
>
- {showClaude &&
}
- {showCodex &&
}
- {showGemini && (
-
+ {isEmptyUsageState ? (
+
+ ) : (
+ <>
+ {showClaude && (
+
+ )}
+ {showCodex &&
}
+ {showGemini && (
+
+ )}
+ {showOpencodeGo && (
+
+ )}
+ {showKimi && (
+
+ )}
+ >
)}
- {showOpencodeGo && (
-
- )}
- {showKimi && (
-
- )}
- {anyVisible && (
+ {anyVisible && !isEmptyUsageState && (