diff --git a/docs/reference/feature-discovery-interaction-tracking.md b/docs/reference/feature-discovery-interaction-tracking.md index c1233fb7a75..8cb5a4a745d 100644 --- a/docs/reference/feature-discovery-interaction-tracking.md +++ b/docs/reference/feature-discovery-interaction-tracking.md @@ -28,10 +28,12 @@ Do not upload this state as broad analytics. Product analytics should continue t | AI PR generation | `ai-pr-generation` | AI pull-request title/body/draft fields are generated. | Suppress future education about AI PR generation. No contextual tour is planned for this branch. | | Floating Workspace | `floating-workspace` | The floating workspace opens, is enabled, or is configured. | Suppress future tips about the global terminal/browser/markdown workspace. No contextual tour is planned for this branch. | | Quick Commands | `quick-commands` | A terminal quick command is created or edited. | Suppress future tips about saved terminal commands. No contextual tour is planned for this branch. | -| Computer Use | `computer-use` | Computer Use is selected in onboarding, a permission setup is opened, or the skill setup terminal opens. | Suppress future tips once the user has started setup. No contextual tour is planned until there is a better non-settings entry point. | +| Computer Use setup | `computer-use-setup` | Computer Use is selected in onboarding, a permission setup is opened, or the skill setup terminal opens. | Suppress setup-focused tips once the user has started setup. | +| Computer Use | `computer-use` | A successful `computer.*` runtime method other than capability probing is handled. | Suppress future usage tips once an agent has actually invoked Computer Use. | | Mobile pairing | `mobile-pairing` | Mobile is enabled or a mobile pairing QR/code is generated. | Suppress future mobile-pairing tips. No contextual tour is planned for this branch. | | Browser element grab | `browser-grab` | Browser grab mode is started, an element is copied, or an element screenshot is copied. | Suppress future tips about grabbing page context once the user has used the element picker. | | Browser annotations | `browser-annotations` | Browser annotation mode is started, an annotation is added, browser annotations are copied, or browser annotations are cleared. | Suppress future tips about annotating local pages and sending concrete UI feedback to an agent. | +| Cookie import | `cookie-import` | Browser cookies are imported from another browser or file, imported through runtime browser profile methods, or default imported cookies are cleared. | Suppress future cookie-import education without conflating cookie setup with Agent Browser Use itself. | | Workspace board actions | `workspace-board-actions` | A card/status action, lane configuration, density change, pin drop, or board drag action is used. | Keep the existing workspace-board tour, and avoid repeating deeper board-action education after real use. | | Automation creation | `automation-created` | A local automation or external Hermes cron is created. | Keep the existing Automations tour, and suppress creation-focused education after the user creates one. | | Automation run | `automation-run` | A local or external automation run is manually queued. | Keep the existing Automations tour, and suppress run/inspection education after the user queues a run. | @@ -42,10 +44,13 @@ Do not upload this state as broad analytics. Product analytics should continue t | Provider usage tracking | `usage-tracking` | Stats & Usage is opened, Claude/Codex/OpenCode usage analytics are enabled, provider usage details are opened from the status bar, Gemini usage/OAuth is configured, or provider usage status-bar toggles are changed. | Suppress future tips about where to find token/rate-limit/usage tracking for Claude, Codex, Gemini, OpenCode, and related providers. | | Claude account switching | `claude-account-switching` | A Claude managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about using multiple Claude accounts once the user has started account management. | | Codex account switching | `codex-account-switching` | A Codex managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about Codex account switching and restart follow-up flows. | -| Workspace tabs | `terminal-tabs` | A workspace tab is created, reordered, renamed, recolored, pinned/unpinned, moved, or closed. | Suppress future tips about tab-level workspace organization. | -| Split panes | `terminal-panes` | A split group is created, a tab is moved into another pane, a split drop happens, a split is resized, or panes are merged. | Suppress future tips about split-pane workflows once the user has used them. | -| Agent Browser Use setup | `agent-browser-use` | Browser Use is selected in onboarding, enabled in settings, its setup terminal opens, or cookies are imported. | Suppress future setup tips once the user has started Browser Use setup. | -| Agent Orchestration setup | `agent-orchestration` | Orchestration is selected in onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Orchestration setup. | +| Workspace tabs | `terminal-tabs` | A workspace tab is created, reordered, renamed, recolored, pinned/unpinned, moved, or closed. | Suppress future tips about tab-level workspace organization. These are real workspace tabs, not workspace board cards. | +| Split panes | `terminal-panes` | A split group is created from the pane shortcut/menu, a split is resized, or panes are merged. | Suppress future tips about pane-level split workflows. | +| Tab splits | `tab-splits` | A workspace tab is moved into another pane or split into a new pane from a tab split/drop action. | Suppress future tips about tab-level split workflows separately from pane creation/resizing. | +| Agent Browser Use setup | `agent-browser-setup` | Browser Use is selected in onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Browser Use setup. | +| Agent Browser Use | `agent-browser-use` | A successful non-profile `browser.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually driven Orca's browser. | +| Agent Orchestration setup | `agent-orchestration-setup` | Orchestration is selected in onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Orchestration setup. | +| Agent Orchestration | `agent-orchestration` | A successful `orchestration.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually used orchestration. | | Notifications | `notifications` | Notifications are enabled in onboarding/settings or a test notification is sent. | Suppress future notification setup tips. | ## Existing Contextual Tour Features diff --git a/src/main/ipc/ui.ts b/src/main/ipc/ui.ts index 7e96be6c81e..2f2624b3415 100644 --- a/src/main/ipc/ui.ts +++ b/src/main/ipc/ui.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron' import type { Store } from '../persistence' import type { PersistedUIState } from '../../shared/types' +import { isFeatureInteractionId } from '../../shared/feature-interactions' export function registerUIHandlers(store: Store): void { ipcMain.handle('ui:get', () => { @@ -10,4 +11,11 @@ export function registerUIHandlers(store: Store): void { ipcMain.handle('ui:set', (_event, args: Partial) => { store.updateUI(args) }) + + ipcMain.handle('ui:recordFeatureInteraction', (_event, id: unknown) => { + if (!isFeatureInteractionId(id)) { + throw new Error('invalid_feature_interaction_id') + } + return store.recordFeatureInteraction(id) + }) } diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index f3a1c2fd7f8..97e3a10c337 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -1703,6 +1703,47 @@ describe('Store', () => { expect(ui.dismissedUpdateVersion).toBeNull() }) + it('updateUI merges feature interactions instead of replacing stale snapshots', async () => { + const store = await createStore() + + store.updateUI({ + featureInteractions: { + 'agent-browser-use': { firstInteractedAt: 100, interactionCount: 1 } + } + }) + store.updateUI({ + featureInteractions: { + tasks: { firstInteractedAt: 200, interactionCount: 1 } + } + }) + + expect(store.getUI().featureInteractions).toEqual({ + 'agent-browser-use': { firstInteractedAt: 100, interactionCount: 1 }, + tasks: { firstInteractedAt: 200, interactionCount: 1 } + }) + }) + + it('recordFeatureInteraction increments from the current persisted UI state', async () => { + const store = await createStore() + + store.updateUI({ + featureInteractions: { + tasks: { firstInteractedAt: 100, interactionCount: 2 } + } + }) + + const ui = store.recordFeatureInteraction('tasks') + + expect(ui.featureInteractions?.tasks).toEqual({ + firstInteractedAt: 100, + interactionCount: 3 + }) + expect(store.getUI().featureInteractions?.tasks).toEqual({ + firstInteractedAt: 100, + interactionCount: 3 + }) + }) + it('updateUI restores fixed card properties from direct UI writes', async () => { const store = await createStore() store.updateUI({ worktreeCardProperties: ['inline-agents'] }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 5e1be871810..4d0b97a8e09 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -84,6 +84,10 @@ import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-command import { normalizeTaskProviderSettings } from '../shared/task-providers' import { normalizeOpenInApplications } from '../shared/open-in-applications' import { normalizeTerminalShortcutPolicy } from '../shared/keybindings' +import { + normalizeFeatureInteractions, + type FeatureInteractionId +} from '../shared/feature-interactions' import { DEFAULT_WORKSPACE_STATUS_ID, clampWorkspaceBoardColumnWidth, @@ -218,6 +222,31 @@ function normalizeGroupBy(groupBy: unknown): PersistedState['ui']['groupBy'] { return getDefaultUIState().groupBy } +function mergeFeatureInteractions( + current: PersistedState['ui']['featureInteractions'], + incoming: PersistedState['ui']['featureInteractions'] +): PersistedState['ui']['featureInteractions'] { + const currentNormalized = normalizeFeatureInteractions(current) + const incomingNormalized = normalizeFeatureInteractions(incoming) + const merged = { ...currentNormalized } + for (const [id, incomingRecord] of Object.entries(incomingNormalized)) { + const currentRecord = currentNormalized[id as keyof typeof currentNormalized] + merged[id as keyof typeof merged] = currentRecord + ? { + firstInteractedAt: Math.min( + currentRecord.firstInteractedAt, + incomingRecord.firstInteractedAt + ), + interactionCount: Math.max( + currentRecord.interactionCount, + incomingRecord.interactionCount + ) + } + : incomingRecord + } + return merged +} + function normalizeSortBy(sortBy: unknown): PersistedState['ui']['sortBy'] { if ( sortBy === 'smart' || @@ -2547,11 +2576,36 @@ export class Store { ), workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( updates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth - ) + ), + // Why: runtime RPCs and the renderer can both record education state. + // Merge instead of replacing so a stale renderer snapshot cannot erase + // runtime-only feature interactions. + featureInteractions: + updates.featureInteractions !== undefined + ? mergeFeatureInteractions( + this.state.ui?.featureInteractions, + updates.featureInteractions + ) + : normalizeFeatureInteractions(this.state.ui?.featureInteractions) } this.scheduleSave() } + recordFeatureInteraction(id: FeatureInteractionId): PersistedState['ui'] { + const featureInteractions = normalizeFeatureInteractions(this.state.ui?.featureInteractions) + const existing = featureInteractions[id] + this.updateUI({ + featureInteractions: { + ...featureInteractions, + [id]: { + firstInteractedAt: existing?.firstInteractedAt ?? Date.now(), + interactionCount: (existing?.interactionCount ?? 0) + 1 + } + } + }) + return this.getUI() + } + // ── Onboarding ──────────────────────────────────────────────────── getOnboarding(): PersistedState['onboarding'] { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index a288e49a4ce..5f7ac93ecce 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -4,6 +4,7 @@ import { EventEmitter } from 'events' import { mkdtemp, rm } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path' +import { ipcMain } from 'electron' import type { WorktreeLineage, WorktreeMeta } from '../../shared/types' import { addWorktree, @@ -34,6 +35,36 @@ import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/s import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' +const electronMocks = vi.hoisted(() => { + type Listener = (...args: unknown[]) => void + const listeners = new Map>() + const ipcMain = { + on: vi.fn((channel: string, listener: Listener) => { + const existing = listeners.get(channel) ?? new Set() + existing.add(listener) + listeners.set(channel, existing) + return ipcMain + }), + removeListener: vi.fn((channel: string, listener: Listener) => { + listeners.get(channel)?.delete(listener) + return ipcMain + }), + emit: vi.fn((channel: string, ...args: unknown[]) => { + for (const listener of listeners.get(channel) ?? []) { + listener(...args) + } + return true + }) + } + return { + BrowserWindow: { fromId: vi.fn((_id: number): unknown => null) }, + ipcMain, + app: { getPath: vi.fn(() => '/tmp') } + } +}) + +vi.mock('electron', () => electronMocks) + const { MOCK_GIT_WORKTREES, addWorktreeMock, @@ -273,6 +304,11 @@ vi.mock('../git/repo', async (importOriginal) => { afterEach(() => { advertisedUrlWatcher.clear() + electronMocks.BrowserWindow.fromId.mockReset() + electronMocks.BrowserWindow.fromId.mockReturnValue(null) + electronMocks.ipcMain.on.mockClear() + electronMocks.ipcMain.removeListener.mockClear() + electronMocks.ipcMain.emit.mockClear() vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES) vi.mocked(addWorktree).mockReset() vi.mocked(assertWorktreeCleanForRemoval).mockReset() @@ -4741,6 +4777,81 @@ describe('OrcaRuntimeService', () => { ]) }) + it('forwards inactive mobile terminal creation to the renderer without focusing it', async () => { + const focusTerminal = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier({ + focusTerminal, + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + closeTerminal: vi.fn(), + closeSessionTab: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + const send = vi.fn((_channel: string, payload: { requestId: string; activate?: boolean }) => { + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'terminal', + id: 'tab-renderer::pane:1', + parentTabId: 'tab-renderer', + leafId: 'pane:1', + title: 'Terminal', + isActive: false + } + ] + } + ] + }) + ipcMain.emit( + 'terminal:tabCreateReply', + {}, + { + requestId: payload.requestId, + tabId: 'tab-renderer', + title: 'Terminal' + } + ) + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents: { send } + }) + + const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: false + }) + + expect(send).toHaveBeenCalledWith( + 'terminal:requestTabCreate', + expect.objectContaining({ + worktreeId: TEST_WORKTREE_ID, + activate: false + }) + ) + expect(focusTerminal).not.toHaveBeenCalled() + expect(result.tab).toMatchObject({ parentTabId: 'tab-renderer', isActive: false }) + }) + it('reports browser tab creation as unsupported for headless runtime servers', async () => { const runtime = new OrcaRuntimeService(store) runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index bb9f56920e2..1c5b3c053b3 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -46,6 +46,7 @@ import type { TabGroupLayoutNode, TuiAgent } from '../../shared/types' +import type { FeatureInteractionId } from '../../shared/feature-interactions' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id' import { isFolderRepo } from '../../shared/repo-kind' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' @@ -406,6 +407,7 @@ type RuntimeStore = { getWorkspaceSession?: Store['getWorkspaceSession'] getUI?: Store['getUI'] updateUI?: Store['updateUI'] + recordFeatureInteraction?: Store['recordFeatureInteraction'] listAutomations?: Store['listAutomations'] listAutomationRuns?: Store['listAutomationRuns'] createAutomation?: Store['createAutomation'] @@ -1291,6 +1293,13 @@ export class OrcaRuntimeService { return this.store.getUI() } + recordFeatureInteraction(id: FeatureInteractionId): PersistedUIState { + if (!this.store?.recordFeatureInteraction) { + throw new Error('runtime_unavailable') + } + return this.store.recordFeatureInteraction(id) + } + getClientSettings(): Pick< GlobalSettings, | 'defaultTuiAgent' @@ -8847,7 +8856,8 @@ export class OrcaRuntimeService { worktreeId, afterTabId: afterDesktopTabId, targetGroupId: opts.targetGroupId, - command: opts.command + command: opts.command, + activate: opts.activate }) }) diff --git a/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts b/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts new file mode 100644 index 00000000000..070468c9a13 --- /dev/null +++ b/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import type { PersistedUIState } from '../../../shared/types' +import { getDefaultUIState } from '../../../shared/constants' +import { RpcDispatcher } from './dispatcher' +import { defineMethod, defineStreamingMethod, type RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' + +function makeRequest(method: string, params: unknown = {}): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +function makeRuntime(ui: PersistedUIState = getDefaultUIState()): OrcaRuntimeService { + let currentUI = ui + return { + getRuntimeId: () => 'test-runtime', + getUIState: vi.fn(() => currentUI), + recordFeatureInteraction: vi.fn((id) => { + const featureInteractions = currentUI.featureInteractions ?? {} + const existing = featureInteractions[id] + currentUI = { + ...currentUI, + featureInteractions: { + ...featureInteractions, + [id]: { + firstInteractedAt: existing?.firstInteractedAt ?? Date.now(), + interactionCount: (existing?.interactionCount ?? 0) + 1 + } + } + } + return currentUI + }), + updateUIState: vi.fn((updates: Partial) => { + currentUI = { ...currentUI, ...updates } + return currentUI + }) + } as unknown as OrcaRuntimeService +} + +const METHODS = [ + defineMethod({ + name: 'browser.click', + params: z.object({}), + handler: () => ({ clicked: true }) + }), + defineStreamingMethod({ + name: 'browser.screencast', + params: z.object({}), + handler: async (_params, _options, emit) => { + emit({ type: 'frame' }) + emit({ type: 'end' }) + } + }), + defineStreamingMethod({ + name: 'browser.screencast.binaryOnly', + params: z.object({}), + handler: async () => {} + }), + defineMethod({ + name: 'browser.screencast.unsubscribe', + params: z.object({}), + handler: () => ({ ok: true }) + }), + defineMethod({ + name: 'browser.profileImportFromBrowser', + params: z.object({}), + handler: () => ({ ok: true }) + }), + defineMethod({ + name: 'browser.profileList', + params: z.object({}), + handler: () => ({ profiles: [] }) + }), + defineMethod({ + name: 'browser.profileClearDefaultCookies', + params: z.object({}), + handler: () => ({ cleared: false }) + }), + defineMethod({ + name: 'computer.permissions', + params: z.object({}), + handler: () => ({ opened: true }) + }), + defineMethod({ + name: 'computer.click', + params: z.object({}), + handler: () => ({ clicked: true }) + }), + defineMethod({ + name: 'orchestration.send', + params: z.object({}), + handler: () => ({ id: 'msg-1' }) + }), + defineMethod({ + name: 'browser.fail', + params: z.object({}), + handler: () => { + throw new Error('nope') + } + }) +] + +describe('RpcDispatcher feature interactions', () => { + it('records runtime feature use after successful runtime tool methods', async () => { + const runtime = makeRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) + + await dispatcher.dispatch(makeRequest('browser.click')) + await dispatcher.dispatch(makeRequest('computer.click')) + await dispatcher.dispatch(makeRequest('orchestration.send')) + + expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('agent-browser-use') + expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('computer-use') + expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('agent-orchestration') + }) + + it('keeps setup and cookie import separate from actual runtime use', async () => { + const runtime = makeRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) + + await dispatcher.dispatch(makeRequest('computer.permissions')) + await dispatcher.dispatch(makeRequest('browser.profileImportFromBrowser')) + await dispatcher.dispatch(makeRequest('browser.profileList')) + await dispatcher.dispatch(makeRequest('browser.profileClearDefaultCookies')) + + expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('computer-use-setup') + expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('cookie-import') + expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(2) + }) + + it('does not record failed runtime methods', async () => { + const runtime = makeRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) + + await dispatcher.dispatch(makeRequest('browser.fail')) + + expect(runtime.recordFeatureInteraction).not.toHaveBeenCalled() + }) + + it('records screencast starts but not screencast cleanup', async () => { + const runtime = makeRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) + const replies: string[] = [] + + await dispatcher.dispatch(makeRequest('browser.screencast.unsubscribe')) + expect(runtime.recordFeatureInteraction).not.toHaveBeenCalled() + + await dispatcher.dispatchStreaming(makeRequest('browser.screencast'), (response) => { + replies.push(response) + }) + + expect(replies).toHaveLength(2) + expect(runtime.recordFeatureInteraction).toHaveBeenLastCalledWith('agent-browser-use') + expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(1) + }) + + it('records streaming runtime feature use when the stream only returns a start result', async () => { + const runtime = makeRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) + const replies: string[] = [] + + await dispatcher.dispatchStreaming(makeRequest('browser.screencast.binaryOnly'), (response) => { + replies.push(response) + }) + + expect(replies).toHaveLength(0) + expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(1) + expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('agent-browser-use') + }) + + it('records each successful non-streaming runtime feature interaction', async () => { + const runtime = makeRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) + + await dispatcher.dispatch(makeRequest('browser.click')) + await dispatcher.dispatch(makeRequest('browser.click')) + await dispatcher.dispatch(makeRequest('browser.screencast.unsubscribe')) + + expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(2) + expect(runtime.recordFeatureInteraction).toHaveBeenLastCalledWith('agent-browser-use') + }) +}) diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index aa693b5b1b1..2852830c5f8 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -14,6 +14,7 @@ import { type RpcResponse } from './core' import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol' +import type { FeatureInteractionId } from '../../../shared/feature-interactions' import { errorResponse, mapBrowserError, mapRuntimeError, successResponse } from './errors' import { ALL_RPC_METHODS } from './methods' import type { OrcaRuntimeService } from '../orca-runtime' @@ -66,6 +67,7 @@ export class RpcDispatcher { runtime: this.runtime, signal: options?.signal }) + this.recordRuntimeFeatureInteraction(request.method, result) return successResponse(request.id, meta, result) } catch (error) { return this.mapError(request, meta, error) @@ -116,6 +118,7 @@ export class RpcDispatcher { sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler }) + this.recordRuntimeFeatureInteraction(request.method, result) reply(JSON.stringify(successResponse(request.id, meta, result))) } catch (error) { reply(JSON.stringify(this.mapError(request, meta, error))) @@ -123,14 +126,20 @@ export class RpcDispatcher { return } + const recordedStreamingFeatureInteractions = new Set() const emit = (result: unknown): void => { + this.recordRuntimeFeatureInteraction( + request.method, + result, + recordedStreamingFeatureInteractions + ) const response = successResponse(request.id, meta, result) response.streaming = true reply(JSON.stringify(response)) } try { - await method.handler( + const result = await method.handler( parsedParams.value, { runtime: this.runtime, @@ -142,6 +151,11 @@ export class RpcDispatcher { }, emit ) + this.recordRuntimeFeatureInteraction( + request.method, + result, + recordedStreamingFeatureInteractions + ) } catch (error) { reply(JSON.stringify(this.mapError(request, meta, error))) } @@ -182,4 +196,58 @@ export class RpcDispatcher { private meta(): RpcEnvelopeMeta { return { runtimeId: this.runtime.getRuntimeId() } } + + private recordRuntimeFeatureInteraction( + method: string, + result: unknown, + alreadyRecorded?: Set + ): void { + const id = getRuntimeFeatureInteractionId(method, result) + if (!id) { + return + } + if (alreadyRecorded?.has(id)) { + return + } + try { + this.runtime.recordFeatureInteraction(id) + alreadyRecorded?.add(id) + } catch { + // Best-effort education state must not break runtime tools. + } + } +} + +function getRuntimeFeatureInteractionId( + method: string, + result: unknown +): FeatureInteractionId | null { + if (method === 'browser.profileImportFromBrowser') { + return hasBooleanResult(result, 'ok') ? 'cookie-import' : null + } + if (method === 'browser.profileClearDefaultCookies') { + return hasBooleanResult(result, 'cleared') ? 'cookie-import' : null + } + if (method === 'browser.screencast.unsubscribe') { + return null + } + if (method.startsWith('browser.') && !method.startsWith('browser.profile')) { + return 'agent-browser-use' + } + if (method === 'computer.permissions') { + return 'computer-use-setup' + } + if (method.startsWith('computer.') && method !== 'computer.capabilities') { + return 'computer-use' + } + if (method.startsWith('orchestration.')) { + return 'agent-orchestration' + } + return null +} + +function hasBooleanResult(value: unknown, key: string): boolean { + return ( + value !== null && typeof value === 'object' && (value as Record)[key] === true + ) } diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 2319d6bc236..0ff6c417dfc 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -201,6 +201,25 @@ describe('client UI RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { ui: updated } }) }) + it('records a feature interaction through the runtime host', async () => { + const updated: PersistedUIState = { + ...getDefaultUIState(), + featureInteractions: { + tasks: { firstInteractedAt: 100, interactionCount: 1 } + } + } + const runtime = { + getRuntimeId: () => 'test-runtime', + recordFeatureInteraction: vi.fn(() => updated) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('ui.recordFeatureInteraction', 'tasks')) + + expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('tasks') + expect(response).toMatchObject({ ok: true, result: { ui: updated } }) + }) + it('rejects unknown and malformed UI update fields', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -234,4 +253,19 @@ describe('client UI RPC methods', () => { expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) expect(runtime.updateUIState).not.toHaveBeenCalled() }) + + it('rejects unknown feature interaction ids for increment RPC', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + recordFeatureInteraction: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('ui.recordFeatureInteraction', 'unknown-feature') + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.recordFeatureInteraction).not.toHaveBeenCalled() + }) }) diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 514d8f1bb73..44d93a0433d 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -1,5 +1,8 @@ import { z } from 'zod' -import { isFeatureInteractionId } from '../../../../shared/feature-interactions' +import { + isFeatureInteractionId, + type FeatureInteractionId +} from '../../../../shared/feature-interactions' import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { PersistedUIState } from '../../../../shared/types' import { defineMethod, type RpcMethod } from '../core' @@ -68,6 +71,9 @@ const FeatureInteractions = z } } }) +const FeatureInteractionIdParam = z.custom(isFeatureInteractionId, { + message: 'Unknown feature interaction id' +}) const GitHubProjectRef = z .object({ owner: z.string(), @@ -207,5 +213,12 @@ export const CLIENT_UI_METHODS: RpcMethod[] = [ handler: (params, { runtime }) => ({ ui: runtime.updateUIState(params as Partial) }) + }), + defineMethod({ + name: 'ui.recordFeatureInteraction', + params: FeatureInteractionIdParam, + handler: (params, { runtime }) => ({ + ui: runtime.recordFeatureInteraction(params) + }) }) ] diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 04b2d71ca59..6c100cfc458 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -268,6 +268,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'terminal.unsubscribe', 'terminal.updateViewport', 'ui.get', + 'ui.recordFeatureInteraction', 'ui.set', 'worktree.activate', 'worktree.create', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index f042239edae..f0383056c06 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -111,6 +111,7 @@ import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' import type { RuntimeAccessGrant } from '../shared/runtime-access-grants' import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' +import type { FeatureInteractionId } from '../shared/feature-interactions' import type { AddIssueCommentBySlugArgs, ClearProjectItemFieldArgs, @@ -1710,6 +1711,7 @@ export type PreloadApi = { ui: { get: () => Promise set: (args: Partial) => Promise + recordFeatureInteraction: (id: FeatureInteractionId) => Promise onOpenSettings: (callback: () => void) => () => void onOpenFeatureTour: (callback: () => void) => () => void onOpenCrashReport: (callback: () => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 352429a8bdb..ec42351047d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2243,6 +2243,8 @@ const api = { ui: { get: (): Promise => ipcRenderer.invoke('ui:get'), set: (args: Record): Promise => ipcRenderer.invoke('ui:set', args), + recordFeatureInteraction: (id: string): Promise => + ipcRenderer.invoke('ui:recordFeatureInteraction', id), onOpenSettings: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:openSettings', listener) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a17d4dca92a..6ff5438ebf4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -257,6 +257,9 @@ function App(): React.JSX.Element { useRadixBodyPointerEventsRecovery() useWebSessionTabsSync() const [floatingTerminalOpen, setFloatingTerminalOpen] = useState(false) + // Why: floating workspace toggles can batch before React re-renders; keep + // functional SetStateAction semantics without side effects inside the updater. + const floatingTerminalOpenRef = useRef(floatingTerminalOpen) // Why: Zustand actions are referentially stable, but each individual // useAppStore(s => s.someAction) still registers a subscription that React @@ -366,16 +369,17 @@ function App(): React.JSX.Element { const setFloatingTerminalOpenWithFocus = useCallback( (nextOpen: SetStateAction): void => { - setFloatingTerminalOpen((currentOpen) => { - const resolvedOpen = typeof nextOpen === 'function' ? nextOpen(currentOpen) : nextOpen - if (resolvedOpen && !currentOpen) { - useAppStore.getState().recordFeatureInteraction('floating-workspace') - rememberFloatingTerminalReturnFocus() - } else if (!resolvedOpen && currentOpen) { - restoreFloatingTerminalReturnFocus() - } - return resolvedOpen - }) + const currentOpen = floatingTerminalOpenRef.current + const resolvedOpen = + typeof nextOpen === 'function' ? nextOpen(floatingTerminalOpenRef.current) : nextOpen + floatingTerminalOpenRef.current = resolvedOpen + if (resolvedOpen && !currentOpen) { + useAppStore.getState().recordFeatureInteraction('floating-workspace') + rememberFloatingTerminalReturnFocus() + } else if (!resolvedOpen && currentOpen) { + restoreFloatingTerminalReturnFocus() + } + setFloatingTerminalOpen(resolvedOpen) }, [rememberFloatingTerminalReturnFocus, restoreFloatingTerminalReturnFocus] ) diff --git a/src/renderer/src/components/contextual-tours/use-contextual-tour.ts b/src/renderer/src/components/contextual-tours/use-contextual-tour.ts index 6a58ecedd71..a7c3f1b9fdf 100644 --- a/src/renderer/src/components/contextual-tours/use-contextual-tour.ts +++ b/src/renderer/src/components/contextual-tours/use-contextual-tour.ts @@ -33,6 +33,7 @@ export function useContextualTour( ) const enabledInteractionSnapshotRef = useRef<{ id: ContextualTourId + source: string wasPreviouslyInteracted: boolean } | null>(null) @@ -41,14 +42,21 @@ export function useContextualTour( enabledInteractionSnapshotRef.current = null return } - if (enabledInteractionSnapshotRef.current?.id !== id) { - enabledInteractionSnapshotRef.current = { - id, - wasPreviouslyInteracted: hasFeatureInteraction(featureInteractions, id) - } + if ( + enabledInteractionSnapshotRef.current?.id === id && + enabledInteractionSnapshotRef.current.source === source + ) { + return + } + enabledInteractionSnapshotRef.current = { + id, + source, + // Why: recording writes featureInteractions; subscribing here would retrigger + // this effect and repeatedly persist the same enabled source. + wasPreviouslyInteracted: hasFeatureInteraction(useAppStore.getState().featureInteractions, id) } recordFeatureInteraction(id) - }, [enabled, featureInteractions, id, persistedUIReady, recordFeatureInteraction]) + }, [enabled, id, persistedUIReady, recordFeatureInteraction, source]) useEffect(() => { // Why: source disable should end through the overlay so a shown tour gets @@ -94,7 +102,7 @@ export function useContextualTour( requestContextualTour( id, source, - snapshot?.id === id + snapshot?.id === id && snapshot.source === source ? snapshot.wasPreviouslyInteracted : hasFeatureInteraction(featureInteractions, id) ) diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx index 32a0f70fbe1..dab0902b1a5 100644 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx @@ -65,15 +65,16 @@ export function OnboardingInlineCommandTerminal({ useEffect(() => { const tab = createTab(worktreeId, undefined, undefined, { - activate: false + activate: false, + recordInteraction: false }) setActiveTabForWorktree(worktreeId, tab.id) - setTabCustomTitle(tab.id, title) + setTabCustomTitle(tab.id, title, { recordInteraction: false }) setTabId(tab.id) return () => { // Why: inline setup panels can disappear after detection succeeds; close // the backing tab so installer shells do not keep running invisibly. - closeTab(tab.id) + closeTab(tab.id, { recordInteraction: false }) } }, [closeTab, createTab, setActiveTabForWorktree, setTabCustomTitle, title, worktreeId]) @@ -241,8 +242,8 @@ export function OnboardingInlineCommandTerminal({ cwd={cwd} isActive isVisible - onPtyExit={() => closeTab(tabId)} - onCloseTab={() => closeTab(tabId)} + onPtyExit={() => closeTab(tabId, { recordInteraction: false })} + onCloseTab={() => closeTab(tabId, { recordInteraction: false })} /> ) : (
diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts index 4c7252d6274..ebeea53417c 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts @@ -129,9 +129,9 @@ const ONBOARDING_FEATURE_INTERACTIONS: Record< keyof OnboardingFeatureSetupSelection, FeatureInteractionId > = { - browserUse: 'agent-browser-use', - computerUse: 'computer-use', - orchestration: 'agent-orchestration' + browserUse: 'agent-browser-setup', + computerUse: 'computer-use-setup', + orchestration: 'agent-orchestration-setup' } export type PersistCurrentStepResult = { diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index a660a834739..6ca7fd3b4dd 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -1996,6 +1996,9 @@ function SourceControlInner(): React.JSX.Element { if (result.branchChangedByPreparation) { await refreshGitStatusAfterPullRequestGeneration(context) } + if (result.success) { + useAppStore.getState().recordFeatureInteraction('ai-pr-generation') + } setPrGenerationRecords((prev) => { const record = prev[generationKey] if (!result.success) { @@ -2031,7 +2034,6 @@ function SourceControlInner(): React.JSX.Element { if (!nextRecord) { return prev } - useAppStore.getState().recordFeatureInteraction('ai-pr-generation') return { ...prev, [generationKey]: nextRecord diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 9bc30a14c9c..450eb9ee2ae 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -3,7 +3,7 @@ add/select/reauth/remove flow is tightly coupled to the provider-specific error handling and restart prompts below; splitting them into separate files would scatter those flows without a meaningful abstraction boundary. */ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState, @@ -115,6 +115,7 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R const fetchSettings = useAppStore((s) => s.fetchSettings) const localPreflightContext = useAppStore(getLocalPreflightContext) const activeWslDistro = localPreflightContext?.wslDistro?.trim() || null + const recordedOpenCodeSettingEditsRef = useRef>(new Set()) const [codexAccounts, setCodexAccounts] = useState({ accounts: [], @@ -133,6 +134,14 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R const [removeAccountId, setRemoveAccountId] = useState(null) const [removeClaudeAccountId, setRemoveClaudeAccountId] = useState(null) + const recordOpenCodeSettingEdit = (field: 'cookie' | 'workspaceId'): void => { + if (recordedOpenCodeSettingEditsRef.current.has(field)) { + return + } + recordedOpenCodeSettingEditsRef.current.add(field) + recordFeatureInteraction('usage-tracking') + } + useEffect(() => { let stale = false @@ -701,7 +710,7 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R type="password" value={settings.opencodeSessionCookie} onChange={(e) => { - recordFeatureInteraction('usage-tracking') + recordOpenCodeSettingEdit('cookie') updateSettings({ opencodeSessionCookie: e.target.value }) }} placeholder="Fe26.2**… token or auth=Fe26.2**… header" @@ -741,7 +750,7 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R type="text" value={settings.opencodeWorkspaceId} onChange={(e) => { - recordFeatureInteraction('usage-tracking') + recordOpenCodeSettingEdit('workspaceId') updateSettings({ opencodeWorkspaceId: e.target.value }) }} placeholder="wrk_… (leave blank for automatic lookup)" diff --git a/src/renderer/src/components/settings/BrowserUsePane.tsx b/src/renderer/src/components/settings/BrowserUsePane.tsx index 23eb7a8cac3..f2677f52958 100644 --- a/src/renderer/src/components/settings/BrowserUsePane.tsx +++ b/src/renderer/src/components/settings/BrowserUsePane.tsx @@ -71,7 +71,7 @@ export function BrowserUseSetup({ setBrowserUseEnabled(value) localStorage.setItem(BROWSER_USE_ENABLED_STORAGE_KEY, value ? '1' : '0') if (value) { - useAppStore.getState().recordFeatureInteraction('agent-browser-use') + useAppStore.getState().recordFeatureInteraction('agent-browser-setup') } } @@ -140,7 +140,6 @@ export function BrowserUseSetup({ .getState() .importCookiesFromBrowser(profileId, browserFamily, browserProfile) if (result.ok) { - useAppStore.getState().recordFeatureInteraction('agent-browser-use') const browser = detectedBrowsers.find((b) => b.family === browserFamily) toast.success( `Imported ${result.summary.importedCookies} cookies from ${browser?.label ?? browserFamily}${browserProfile ? ` (${browserProfile})` : ''}.` @@ -153,7 +152,6 @@ export function BrowserUseSetup({ const handleImportFromFile = async (): Promise => { const result = await useAppStore.getState().importCookiesToProfile('default') if (result.ok) { - useAppStore.getState().recordFeatureInteraction('agent-browser-use') toast.success(`Imported ${result.summary.importedCookies} cookies from file.`) } else if (result.reason !== 'canceled') { toast.error(result.reason) @@ -329,7 +327,7 @@ export function BrowserUseSetup({ disabled={!cliEnabled} preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE} onBeforeOpenTerminal={async () => { - useAppStore.getState().recordFeatureInteraction('agent-browser-use') + useAppStore.getState().recordFeatureInteraction('agent-browser-setup') await ensureOrcaCliAvailableForAgentSkillTerminal({ onStatusChange: setCliStatus }) }} onRecheck={refreshSkill} diff --git a/src/renderer/src/components/settings/ComputerUsePane.tsx b/src/renderer/src/components/settings/ComputerUsePane.tsx index 4ebba32f2b3..b43982cf766 100644 --- a/src/renderer/src/components/settings/ComputerUsePane.tsx +++ b/src/renderer/src/components/settings/ComputerUsePane.tsx @@ -137,7 +137,7 @@ export function ComputerUsePane(): React.JSX.Element { }, [refresh]) const openPermission = async (id: ComputerUsePermissionId): Promise => { - useAppStore.getState().recordFeatureInteraction('computer-use') + useAppStore.getState().recordFeatureInteraction('computer-use-setup') setPendingId(id) try { const result = await window.api.computerUsePermissions.openSetup({ id }) @@ -241,7 +241,7 @@ export function ComputerUsePane(): React.JSX.Element { icon={} preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE} onBeforeOpenTerminal={async () => { - useAppStore.getState().recordFeatureInteraction('computer-use') + useAppStore.getState().recordFeatureInteraction('computer-use-setup') await ensureOrcaCliAvailableForAgentSkillTerminal() }} onRecheck={refreshComputerUseSkill} diff --git a/src/renderer/src/components/settings/OrchestrationPane.tsx b/src/renderer/src/components/settings/OrchestrationPane.tsx index b95b3fa0100..23ab6371724 100644 --- a/src/renderer/src/components/settings/OrchestrationPane.tsx +++ b/src/renderer/src/components/settings/OrchestrationPane.tsx @@ -55,7 +55,7 @@ export function OrchestrationPane(): React.JSX.Element { setOrchestrationEnabled(value) localStorage.setItem(ORCHESTRATION_ENABLED_STORAGE_KEY, value ? '1' : '0') if (value) { - useAppStore.getState().recordFeatureInteraction('agent-orchestration') + useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup') } notifyOrchestrationSetupStateChanged() } @@ -109,7 +109,7 @@ export function OrchestrationPane(): React.JSX.Element { icon={} preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE} onBeforeOpenTerminal={async () => { - useAppStore.getState().recordFeatureInteraction('agent-orchestration') + useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup') await ensureOrcaCliAvailableForAgentSkillTerminal() }} onRecheck={refreshOrchestrationSkill} diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts b/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts index eb0727419d6..0bfe5f89aa2 100644 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts +++ b/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts @@ -1,13 +1,26 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' const setTabGroupSplitRatioMock = vi.fn() +const recordFeatureInteractionMock = vi.fn() const useAppStoreMock = vi.fn( - (selector: (state: { setTabGroupSplitRatio: () => void }) => unknown) => - selector({ setTabGroupSplitRatio: setTabGroupSplitRatioMock }) + ( + selector: (state: { + recordFeatureInteraction: typeof recordFeatureInteractionMock + setTabGroupSplitRatio: typeof setTabGroupSplitRatioMock + }) => unknown + ) => + selector({ + recordFeatureInteraction: recordFeatureInteractionMock, + setTabGroupSplitRatio: setTabGroupSplitRatioMock + }) ) vi.mock('../../store', () => ({ - useAppStore: (selector: (state: { setTabGroupSplitRatio: () => void }) => unknown) => - useAppStoreMock(selector) + useAppStore: ( + selector: (state: { + recordFeatureInteraction: typeof recordFeatureInteractionMock + setTabGroupSplitRatio: typeof setTabGroupSplitRatioMock + }) => unknown + ) => useAppStoreMock(selector) })) vi.mock('./TabGroupPanel', () => ({ @@ -31,6 +44,12 @@ vi.mock('./useTabDragSplit', () => ({ import TabGroupSplitLayout from './TabGroupSplitLayout' describe('TabGroupSplitLayout', () => { + beforeEach(() => { + setTabGroupSplitRatioMock.mockClear() + recordFeatureInteractionMock.mockClear() + useAppStoreMock.mockClear() + }) + function getLeafPanelProps(isWorktreeActive: boolean) { const element = TabGroupSplitLayout({ layout: { type: 'leaf', groupId: 'group-1' }, @@ -124,4 +143,29 @@ describe('TabGroupSplitLayout', () => { }) ) }) + + it('records pane resizing at the start of the gesture', () => { + const element = TabGroupSplitLayout({ + layout: { + type: 'split', + direction: 'horizontal', + ratio: 0.5, + first: { type: 'leaf', groupId: 'left-group' }, + second: { type: 'leaf', groupId: 'right-group' } + }, + worktreeId: 'wt-1', + focusedGroupId: 'right-group', + isWorktreeActive: true + }) + + const layoutWrapper = element.props.children[0] + const splitBody = layoutWrapper.props.children[1] + const splitNodeElement = splitBody.props.children + const rootElement = splitNodeElement.type(splitNodeElement.props) + const resizeHandle = rootElement.props.children[1] + + resizeHandle.props.onResizeStart() + + expect(recordFeatureInteractionMock).toHaveBeenCalledWith('terminal-panes') + }) }) diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx index 7659b114c5b..5cc911cad95 100644 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx +++ b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx @@ -11,9 +11,11 @@ const MAX_RATIO = 0.85 function ResizeHandle({ direction, + onResizeStart, onRatioChange }: { direction: 'horizontal' | 'vertical' + onResizeStart: () => void onRatioChange: (ratio: number) => void }): React.JSX.Element { const isHorizontal = direction === 'horizontal' @@ -27,6 +29,7 @@ function ResizeHandle({ if (!container) { return } + onResizeStart() setDragging(true) handle.setPointerCapture(event.pointerId) @@ -69,7 +72,7 @@ function ResizeHandle({ handle.addEventListener('pointercancel', onPointerCancel) handle.addEventListener('lostpointercapture', onLostPointerCapture) }, - [isHorizontal, onRatioChange] + [isHorizontal, onRatioChange, onResizeStart] ) return ( @@ -112,6 +115,7 @@ function SplitNode({ hoveredTabInsertion: HoveredTabInsertion | null }): React.JSX.Element { const setTabGroupSplitRatio = useAppStore((state) => state.setTabGroupSplitRatio) + const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) if (node.type === 'leaf') { return ( @@ -164,6 +168,7 @@ function SplitNode({
recordFeatureInteraction('terminal-panes')} onRatioChange={(nextRatio) => setTabGroupSplitRatio(worktreeId, nodePath, nextRatio)} />
diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 25a073dad68..a1044388ecb 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -811,7 +811,9 @@ describe('useIpcEvents updater integration', () => { expect(setActiveTabType).toHaveBeenCalledWith('terminal') expect(setActiveTab).toHaveBeenCalledWith('tab-new') expect(revealWorktreeInSidebar).toHaveBeenCalledWith('wt-2') - expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Runner') + expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Runner', { + recordInteraction: false + }) expect(queueTabStartupCommand).toHaveBeenCalledWith('tab-new', { command: 'opencode' }) if (typeof requestTerminalCreateListenerRef.current !== 'function') { @@ -835,7 +837,10 @@ describe('useIpcEvents updater integration', () => { activate: false }) - expect(createTab).toHaveBeenCalledWith('wt-2', 'group-left', undefined, { activate: false }) + expect(createTab).toHaveBeenCalledWith('wt-2', 'group-left', undefined, { + activate: false, + recordInteraction: false + }) expect(setActiveView).not.toHaveBeenCalled() expect(setActiveWorktree).not.toHaveBeenCalled() expect(setActiveTabType).not.toHaveBeenCalled() @@ -847,7 +852,9 @@ describe('useIpcEvents updater integration', () => { detail: { worktreeId: 'wt-2' } }) ) - expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Codex') + expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Codex', { + recordInteraction: false + }) expect(queueTabStartupCommand).toHaveBeenCalledWith('tab-new', { command: 'codex' }) expect(replyTerminalCreate).toHaveBeenCalledWith({ requestId: 'req-renderer-backed', diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 1db0c4867ee..5255a2baff8 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -872,7 +872,7 @@ export function useIpcEvents(): void { // the runtime's stored title would otherwise silently overwrite on // every focus. if (title && !reusedTab) { - store.setTabCustomTitle(tab.id, title) + store.setTabCustomTitle(tab.id, title, { recordInteraction: false }) } if (leafId && ptyId) { if (splitFromLeafId) { @@ -989,7 +989,7 @@ export function useIpcEvents(): void { worktreeId, data.targetGroupId, undefined, - shouldActivate ? undefined : { activate: false } + shouldActivate ? undefined : { activate: false, recordInteraction: false } ) if (data.afterTabId) { const createdUnifiedTab = useAppStore @@ -1013,7 +1013,9 @@ export function useIpcEvents(): void { 0, createdUnifiedTab.id ) - useAppStore.getState().reorderUnifiedTabs(createdUnifiedTab.groupId, order) + useAppStore.getState().reorderUnifiedTabs(createdUnifiedTab.groupId, order, { + recordInteraction: false + }) } } if (shouldActivate) { @@ -1022,7 +1024,7 @@ export function useIpcEvents(): void { store.revealWorktreeInSidebar(worktreeId) } if (data.title) { - store.setTabCustomTitle(tab.id, data.title) + store.setTabCustomTitle(tab.id, data.title, { recordInteraction: false }) } if (data.command) { store.queueTabStartupCommand(tab.id, { command: data.command }) diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 82cadca7fb5..52981ff1c41 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: local/runtime launch tests share a mock harness. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createCompatibleRuntimeStatusResponseIfNeeded, @@ -124,7 +125,10 @@ describe('launchAgentBackgroundSession', () => { title: 'Nightly audit' }) - expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { activate: false }) + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + activate: false, + recordInteraction: false + }) expect(mockSpawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/repo/worktree', @@ -149,7 +153,9 @@ describe('launchAgentBackgroundSession', () => { }) ) expect(mockSetTabLayout.mock.calls.at(-1)?.[1]).not.toHaveProperty('titlesByLeafId') - expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Nightly audit') + expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Nightly audit', { + recordInteraction: false + }) expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-1') expect(mockRegisterEagerPtyBuffer).toHaveBeenCalledWith('pty-1', expect.any(Function)) expect(mockSubscribeToPtyData).toHaveBeenCalledWith('pty-1', expect.any(Function)) @@ -231,7 +237,7 @@ describe('launchAgentBackgroundSession', () => { }) ).rejects.toThrow('spawn failed') - expect(mockCloseTab).toHaveBeenCalledWith('tab-1') + expect(mockCloseTab).toHaveBeenCalledWith('tab-1', { recordInteraction: false }) expect(mockUpdateTabPtyId).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 03b0659d73b..9e2542d7860 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -93,9 +93,12 @@ export async function launchAgentBackgroundSession( // Why: automation runs should start without revealing the workspace. // Spawn the PTY immediately, then attach an inactive tab to the live session. - const tab = store.createTab(worktreeId, undefined, undefined, { activate: false }) + const tab = store.createTab(worktreeId, undefined, undefined, { + activate: false, + recordInteraction: false + }) if (title) { - store.setTabCustomTitle(tab.id, title) + store.setTabCustomTitle(tab.id, title, { recordInteraction: false }) } // Why: agent hook callbacks are keyed by pane, and background automation // tabs never mount a TerminalPane to inject this env for us. @@ -179,7 +182,7 @@ export async function launchAgentBackgroundSession( ptyId = result.id } } catch (error) { - store.closeTab(tab.id) + store.closeTab(tab.id, { recordInteraction: false }) throw error } store.updateTabPtyId(tab.id, ptyId) diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index 9ef579b9ad6..d417a5ee423 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -54,7 +54,9 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(createTab).toHaveBeenCalledTimes(2) expect(store.setActiveTab).toHaveBeenNthCalledWith(1, 'tab-1') expect(store.setActiveTab).toHaveBeenLastCalledWith('tab-1') - expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup') + expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', { + recordInteraction: false + }) expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-2', { command: 'bash /tmp/repo/.git/orca/setup-runner.sh', env: { @@ -285,7 +287,9 @@ describe('ensureWorktreeHasInitialTerminal', () => { // and the helper re-activates the main tab so focus stays on tab-1. expect(store.setActiveTab).toHaveBeenNthCalledWith(1, 'tab-1') expect(store.setActiveTab).toHaveBeenLastCalledWith('tab-1') - expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup') + expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', { + recordInteraction: false + }) expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-2', { command: 'bash /tmp/repo/.git/orca/setup-runner.sh', env: { ORCA_ROOT_PATH: '/tmp/repo' } diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index d5d5b8c3bbe..668f7916cd0 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -36,10 +36,14 @@ type WorktreeActivationStore = { worktreeId: string, targetGroupId?: string, shellOverride?: string, - options?: { pendingActivationSpawn?: boolean } + options?: { pendingActivationSpawn?: boolean; recordInteraction?: boolean } ) => { id: string } setActiveTab: (tabId: string) => void - setTabCustomTitle: (tabId: string, title: string | null) => void + setTabCustomTitle: ( + tabId: string, + title: string | null, + opts?: { recordInteraction?: boolean } + ) => void reconcileWorktreeTabModel: (worktreeId: string) => { renderableTabCount: number } queueTabStartupCommand: ( tabId: string, @@ -242,7 +246,9 @@ export function ensureWorktreeHasInitialTerminal( env: setup.envVars } if (mode === 'new-tab') { - const setupTab = store.createTab(worktreeId) + const setupTab = store.createTab(worktreeId, undefined, undefined, { + recordInteraction: false + }) // Why: createTab auto-activates the new tab. Revert activation so the // user's focus stays on the primary terminal — per the design, the // Setup tab runs unattended in the background. @@ -250,7 +256,7 @@ export function ensureWorktreeHasInitialTerminal( // Why: customTitle wins over the auto-generated "Terminal N" label // everywhere the tab is rendered (tab bar, switcher, session snapshots), // so labeling via customTitle is the single authoritative source. - store.setTabCustomTitle(setupTab.id, 'Setup') + store.setTabCustomTitle(setupTab.id, 'Setup', { recordInteraction: false }) store.queueTabStartupCommand(setupTab.id, setupCommand) } else { store.queueTabSetupSplit(terminalTab.id, { diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 8176fbeaf8e..5ac18f83991 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -1538,7 +1538,8 @@ export const createBrowserSlice: StateCreator = if (!exists) { state.createUnifiedTab(worktreeId, 'browser', { entityId: bt.id, - label: bt.title + label: bt.title, + recordInteraction: false }) } } @@ -1687,6 +1688,7 @@ export const createBrowserSlice: StateCreator = profileId })) as BrowserCookieImportResult if (result.ok) { + get().recordFeatureInteraction('cookie-import') set({ browserSessionImportState: { profileId, @@ -1830,6 +1832,7 @@ export const createBrowserSlice: StateCreator = browserProfile })) as BrowserCookieImportResult if (result.ok) { + get().recordFeatureInteraction('cookie-import') set({ browserSessionImportState: { profileId, @@ -1886,6 +1889,7 @@ export const createBrowserSlice: StateCreator = try { const ok = await window.api.browser.sessionClearDefaultCookies() if (ok) { + get().recordFeatureInteraction('cookie-import') await get().fetchBrowserSessionProfiles() } return ok diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index 53a456d8b17..a33cb9eeb10 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -4,7 +4,7 @@ import { create } from 'zustand' import type { AppState } from '../types' import type { Tab, TabGroup } from '../../../../shared/types' import type * as AgentStatusModule from '@/lib/agent-status' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultUIState } from '../../../../shared/constants' // Mock sonner (imported by repos.ts) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) @@ -44,6 +44,9 @@ const mockApi = { get: vi.fn().mockResolvedValue({}), set: vi.fn().mockResolvedValue(undefined) }, + ui: { + set: vi.fn().mockResolvedValue(undefined) + }, cache: { getGitHub: vi.fn().mockResolvedValue(null), setGitHub: vi.fn().mockResolvedValue(undefined) @@ -238,6 +241,24 @@ describe('TabsSlice', () => { }) }) + describe('terminal tab creation tracking', () => { + it('records normal terminal tab creation without recording activation fallback tabs', () => { + const setMock = vi.mocked(window.api.ui.set) + store.getState().hydratePersistedUI(getDefaultUIState()) + setMock.mockClear() + + store.getState().createTab(WT) + store.getState().createTab(WT, undefined, undefined, { pendingActivationSpawn: true }) + + expect(setMock).toHaveBeenCalledTimes(1) + expect(setMock).toHaveBeenCalledWith({ + featureInteractions: { + 'terminal-tabs': expect.objectContaining({ interactionCount: 1 }) + } + }) + }) + }) + // ─── closeUnifiedTab ──────────────────────────────────────────────── describe('closeUnifiedTab', () => { @@ -863,6 +884,7 @@ describe('TabsSlice', () => { }) it('merges a group into its sibling', () => { + const setMock = vi.mocked(window.api.ui.set) const t1 = store.getState().createUnifiedTab(WT, 'editor', { id: 'file-a.ts', label: 'file-a.ts' @@ -875,6 +897,8 @@ describe('TabsSlice', () => { label: 'file-b.ts', targetGroupId: targetGroupId! }) + store.getState().hydratePersistedUI(getDefaultUIState()) + setMock.mockClear() const mergedInto = store.getState().mergeGroupIntoSibling(WT, targetGroupId!) @@ -883,6 +907,12 @@ describe('TabsSlice', () => { expect(state.groupsByWorktree[WT]).toHaveLength(1) expect(state.groupsByWorktree[WT][0].tabOrder).toEqual([t1.id, 'file-b.ts']) expect(state.layoutByWorktree[WT]).toEqual({ type: 'leaf', groupId: sourceGroupId }) + expect(setMock).toHaveBeenCalledTimes(1) + expect(setMock).toHaveBeenCalledWith({ + featureInteractions: { + 'terminal-panes': expect.objectContaining({ interactionCount: 1 }) + } + }) }) it('drops a unified tab into another group and collapses an emptied source group', () => { diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index bfd4e45e619..a4dd9471746 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -46,6 +46,7 @@ export type TabsSlice = { 'id' | 'entityId' | 'label' | 'customLabel' | 'color' | 'isPreview' | 'isPinned' > & { targetGroupId: string + recordInteraction: boolean } > ) => Tab @@ -59,11 +60,20 @@ export type TabsSlice = { ) => Tab | null activateTab: (tabId: string) => void closeUnifiedTab: ( - tabId: string + tabId: string, + opts?: { recordInteraction?: boolean } ) => { closedTabId: string; wasLastTab: boolean; worktreeId: string } | null - reorderUnifiedTabs: (groupId: string, tabIds: string[]) => void + reorderUnifiedTabs: ( + groupId: string, + tabIds: string[], + opts?: { recordInteraction?: boolean } + ) => void setTabLabel: (tabId: string, label: string) => void - setTabCustomLabel: (tabId: string, label: string | null) => void + setTabCustomLabel: ( + tabId: string, + label: string | null, + opts?: { recordInteraction?: boolean } + ) => void setUnifiedTabColor: (tabId: string, color: string | null) => void pinTab: (tabId: string) => void unpinTab: (tabId: string) => void @@ -80,7 +90,7 @@ export type TabsSlice = { moveUnifiedTabToGroup: ( tabId: string, targetGroupId: string, - opts?: { index?: number; activate?: boolean } + opts?: { index?: number; activate?: boolean; recordInteraction?: boolean } ) => boolean dropUnifiedTab: ( tabId: string, @@ -465,7 +475,9 @@ export const createTabsSlice: StateCreator = (set, } } }) - get().recordFeatureInteraction('terminal-tabs') + if (init?.recordInteraction !== false) { + get().recordFeatureInteraction('terminal-tabs') + } return created }, @@ -554,7 +566,7 @@ export const createTabsSlice: StateCreator = (set, }) }, - closeUnifiedTab: (tabId) => { + closeUnifiedTab: (tabId, opts) => { const state = get() const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) if (!found) { @@ -695,11 +707,13 @@ export const createTabsSlice: StateCreator = (set, } }) - get().recordFeatureInteraction('terminal-tabs') + if (opts?.recordInteraction !== false) { + get().recordFeatureInteraction('terminal-tabs') + } return { closedTabId: tabId, wasLastTab, worktreeId } }, - reorderUnifiedTabs: (groupId, tabIds) => { + reorderUnifiedTabs: (groupId, tabIds, opts) => { let reordered = false set((state) => { for (const [worktreeId, groups] of Object.entries(state.groupsByWorktree)) { @@ -729,23 +743,19 @@ export const createTabsSlice: StateCreator = (set, } return {} }) - if (reordered) { + if (reordered && opts?.recordInteraction !== false) { get().recordFeatureInteraction('terminal-tabs') } }, setTabLabel: (tabId, label) => { - const exists = get().getTab(tabId) !== null set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? {}) - if (exists) { - get().recordFeatureInteraction('terminal-tabs') - } }, - setTabCustomLabel: (tabId, label) => { + setTabCustomLabel: (tabId, label, opts) => { const exists = get().getTab(tabId) !== null set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {}) - if (exists) { + if (exists && opts?.recordInteraction !== false) { get().recordFeatureInteraction('terminal-tabs') } }, @@ -1076,8 +1086,8 @@ export const createTabsSlice: StateCreator = (set, activeGroupIdByWorktree: nextActiveGroupIdByWorktree } }) - if (moved) { - get().recordFeatureInteraction('terminal-panes') + if (moved && opts?.recordInteraction !== false) { + get().recordFeatureInteraction('tab-splits') } return moved }, @@ -1248,9 +1258,7 @@ export const createTabsSlice: StateCreator = (set, }) if (moved) { get().recordFeatureInteraction('terminal-tabs') - if (target.splitDirection) { - get().recordFeatureInteraction('terminal-panes') - } + get().recordFeatureInteraction('tab-splits') } return moved }, @@ -1294,7 +1302,7 @@ export const createTabsSlice: StateCreator = (set, if (!item) { continue } - get().moveUnifiedTabToGroup(item.id, targetGroupId) + get().moveUnifiedTabToGroup(item.id, targetGroupId, { recordInteraction: false }) } get().closeEmptyGroup(worktreeId, groupId) get().recordFeatureInteraction('terminal-panes') @@ -1302,10 +1310,6 @@ export const createTabsSlice: StateCreator = (set, }, setTabGroupSplitRatio: (worktreeId, nodePath, ratio) => { - const currentLayout = get().layoutByWorktree[worktreeId] - if (currentLayout) { - get().recordFeatureInteraction('terminal-panes') - } set((state) => { const currentLayout = state.layoutByWorktree[worktreeId] if (!currentLayout) { diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 170c580d244..961c0ec9740 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -256,6 +256,7 @@ export type TerminalSlice = { pendingActivationSpawn?: boolean initialPtyId?: string activate?: boolean + recordInteraction?: boolean /** Pre-allocated tab id (e.g. minted by main for CLI/runtime-spawned * terminals whose PTY env already carries a pane key). Falls back to * minting a fresh id when omitted or when the supplied id collides @@ -264,7 +265,7 @@ export type TerminalSlice = { id?: string } ) => TerminalTab - closeTab: (tabId: string) => void + closeTab: (tabId: string, opts?: { recordInteraction?: boolean }) => void reorderTabs: (worktreeId: string, tabIds: string[]) => void setTabBarOrder: (worktreeId: string, order: string[]) => void setActiveTab: (tabId: string) => void @@ -283,7 +284,11 @@ export type TerminalSlice = { * model where the bell stays visible until the user engages with the * surface that raised it. */ clearTerminalTabUnread: (tabId: string) => void - setTabCustomTitle: (tabId: string, title: string | null) => void + setTabCustomTitle: ( + tabId: string, + title: string | null, + opts?: { recordInteraction?: boolean } + ) => void setTabColor: (tabId: string, color: string | null) => void updateTabPtyId: (tabId: string, ptyId: string) => void clearTabPtyId: (tabId: string, ptyId?: string) => void @@ -616,10 +621,15 @@ export const createTerminalSlice: StateCreator } } }) + const shouldRecordInteraction = + options?.recordInteraction ?? (!options?.pendingActivationSpawn && !options?.initialPtyId) + if (shouldRecordInteraction) { + get().recordFeatureInteraction('terminal-tabs') + } return tab }, - closeTab: (tabId) => { + closeTab: (tabId, opts) => { set((s) => { const next = { ...s.tabsByWorktree } let closingPtyId: string | null = null @@ -743,7 +753,7 @@ export const createTerminalSlice: StateCreator (entry) => entry.contentType === 'terminal' && entry.entityId === tabId ) if (workspaceItem) { - get().closeUnifiedTab(workspaceItem.id) + get().closeUnifiedTab(workspaceItem.id, opts) } } }, @@ -1033,7 +1043,7 @@ export const createTerminalSlice: StateCreator }) }, - setTabCustomTitle: (tabId, title) => { + setTabCustomTitle: (tabId, title, opts) => { set((s) => { const next = { ...s.tabsByWorktree } for (const wId of Object.keys(next)) { @@ -1046,7 +1056,7 @@ export const createTerminalSlice: StateCreator .flat() .find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId) if (item) { - get().setTabCustomLabel(item.id, title) + get().setTabCustomLabel(item.id, title, opts) } }, diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 1707eb63830..1b136cac2bd 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -816,6 +816,91 @@ describe('createUISlice feature interactions', () => { } }) + it('uses the main-owned feature interaction increment API when available', async () => { + const recordFeatureInteractionMock = vi.fn(() => + Promise.resolve( + makePersistedUI({ + featureInteractions: { + tasks: { firstInteractedAt: 100, interactionCount: 3 } + } + }) + ) + ) + const setMock = vi.fn(() => Promise.resolve()) + vi.stubGlobal('window', { + api: { + ui: { + recordFeatureInteraction: recordFeatureInteractionMock, + set: setMock + } + } + }) + const store = createUIStore() + store.getState().hydratePersistedUI( + makePersistedUI({ + featureInteractions: { + tasks: { firstInteractedAt: 100, interactionCount: 2 } + } + }) + ) + setMock.mockClear() + + store.getState().recordFeatureInteraction('tasks') + await Promise.resolve() + + expect(recordFeatureInteractionMock).toHaveBeenCalledWith('tasks') + expect(setMock).not.toHaveBeenCalled() + expect(store.getState().featureInteractions.tasks).toEqual({ + firstInteractedAt: 100, + interactionCount: 3 + }) + }) + + it('keeps newer optimistic interaction counts when persistence responses resolve out of order', async () => { + const pending: ((ui: PersistedUIState) => void)[] = [] + const recordFeatureInteractionMock = vi.fn( + () => + new Promise((resolve) => { + pending.push(resolve) + }) + ) + vi.stubGlobal('window', { + api: { + ui: { + recordFeatureInteraction: recordFeatureInteractionMock, + set: vi.fn(() => Promise.resolve()) + } + } + }) + const store = createUIStore() + store.getState().hydratePersistedUI(makePersistedUI()) + + store.getState().recordFeatureInteraction('tasks') + store.getState().recordFeatureInteraction('tasks') + + pending[1]( + makePersistedUI({ + featureInteractions: { + tasks: { firstInteractedAt: 100, interactionCount: 2 } + } + }) + ) + await Promise.resolve() + pending[0]( + makePersistedUI({ + featureInteractions: { + tasks: { firstInteractedAt: 100, interactionCount: 1 } + } + }) + ) + await Promise.resolve() + + expect(store.getState().featureInteractions.tasks).toEqual({ + firstInteractedAt: 100, + interactionCount: 2 + }) + }) + it('does not record interactions before persisted UI has hydrated', () => { const setMock = vi.fn(() => Promise.resolve()) vi.stubGlobal('window', { diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 119f2e933b6..fe02f3acdf1 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -77,6 +77,32 @@ export type PendingSidebarWorktreeReveal = { behavior: 'auto' | 'smooth' } +function mergeFeatureInteractionState( + current: FeatureInteractionState, + incoming: PersistedUIState['featureInteractions'] +): FeatureInteractionState { + const currentNormalized = normalizeFeatureInteractions(current) + const incomingNormalized = normalizeFeatureInteractions(incoming) + const merged: FeatureInteractionState = { ...currentNormalized } + for (const [id, incomingRecord] of Object.entries(incomingNormalized)) { + const featureId = id as FeatureInteractionId + const currentRecord = currentNormalized[featureId] + merged[featureId] = currentRecord + ? { + firstInteractedAt: Math.min( + currentRecord.firstInteractedAt, + incomingRecord.firstInteractedAt + ), + interactionCount: Math.max( + currentRecord.interactionCount, + incomingRecord.interactionCount + ) + } + : incomingRecord + } + return merged +} + function clampPetSize(size: number): number { if (!Number.isFinite(size)) { return PET_SIZE_DEFAULT @@ -940,7 +966,18 @@ export const createUISlice: StateCreator = (set, get) } } if (typeof window !== 'undefined') { - window.api.ui.set({ featureInteractions: next }).catch(console.error) + const recordInteraction = window.api.ui.recordFeatureInteraction + const persist = recordInteraction + ? recordInteraction(id).then((ui) => { + set((current) => ({ + featureInteractions: mergeFeatureInteractionState( + current.featureInteractions, + ui.featureInteractions + ) + })) + }) + : window.api.ui.set({ featureInteractions: next }) + persist.catch(console.error) } return { featureInteractions: next } }), diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index ddf77eb3ae9..ff0c54092f0 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -2,6 +2,7 @@ global setup across namespaces so browser API installation stays realistic. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PreloadApi } from '../../../preload/api-types' +import type { FeatureInteractionState } from '../../../shared/feature-interactions' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' class MemoryStorage implements Storage { @@ -170,6 +171,136 @@ describe('web keybindings preload API', () => { }) }) +describe('web UI preload API', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') + }) + + it('keeps newer feature interaction counts when runtime responses resolve out of order', async () => { + const pending: ((response: RuntimeRpcResponse) => void)[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + return new Promise((resolve) => { + pending.push((response) => + resolve({ + ...response, + id: method, + _meta: { runtimeId: 'runtime-1' } + }) + ) + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const first = globals.window.api.ui.recordFeatureInteraction('tasks') + const second = globals.window.api.ui.recordFeatureInteraction('tasks') + + pending[1]({ + id: 'second', + ok: true, + result: { + ui: { + featureInteractions: { + tasks: { firstInteractedAt: 100, interactionCount: 2 } + } + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + await second + pending[0]({ + id: 'first', + ok: true, + result: { + ui: { + featureInteractions: { + tasks: { firstInteractedAt: 100, interactionCount: 1 } + } + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + await first + + const stored = JSON.parse(globals.storage.getItem('orca.web.ui.v1') ?? '{}') as { + featureInteractions?: FeatureInteractionState + } + expect(stored.featureInteractions?.tasks).toEqual({ + firstInteractedAt: 100, + interactionCount: 2 + }) + }) + + it('keeps newer local feature interactions when ui.get returns stale host state', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + return Promise.resolve({ + id: method, + ok: true, + result: { + ui: { + featureInteractions: { + tasks: { firstInteractedAt: 100, interactionCount: 1 }, + ports: { firstInteractedAt: 300, interactionCount: 1 } + } + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + globals.storage.setItem( + 'orca.web.ui.v1', + JSON.stringify({ + featureInteractions: { + tasks: { firstInteractedAt: 50, interactionCount: 3 } + } + }) + ) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const ui = await globals.window.api.ui.get() + const stored = JSON.parse(globals.storage.getItem('orca.web.ui.v1') ?? '{}') as { + featureInteractions?: FeatureInteractionState + } + + expect(ui.featureInteractions?.tasks).toEqual({ + firstInteractedAt: 50, + interactionCount: 3 + }) + expect(stored.featureInteractions?.tasks).toEqual({ + firstInteractedAt: 50, + interactionCount: 3 + }) + expect(stored.featureInteractions?.ports).toEqual({ + firstInteractedAt: 300, + interactionCount: 1 + }) + }) +}) + describe('web worktree preload API', () => { beforeEach(() => { vi.resetModules() diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 6610f21226d..f64304c08fa 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -54,6 +54,11 @@ import { parseWebPairingInput } from './web-pairing' import { WebRuntimeClient } from './web-runtime-client' import { RuntimeRpcCallQueuePool } from '../../../shared/runtime-rpc-call-queue' import { sanitizeWebRuntimeWorkspaceSession } from './web-workspace-session' +import { + normalizeFeatureInteractions, + type FeatureInteractionId, + type FeatureInteractionState +} from '../../../shared/feature-interactions' const SETTINGS_STORAGE_KEY = 'orca.web.settings.v1' const UI_STORAGE_KEY = 'orca.web.ui.v1' @@ -1459,7 +1464,14 @@ function createWebUiApi(): NonNullable['ui']> { undefined, 15_000 ) - const next = mergeWebUIState(readLocalWebUIState(), result.ui) + const local = readLocalWebUIState() + const next = { + ...mergeWebUIState(local, result.ui), + featureInteractions: mergeFeatureInteractionState( + local.featureInteractions, + result.ui.featureInteractions + ) + } writeJson(UI_STORAGE_KEY, next) zoomLevel = next.uiZoomLevel return next @@ -1477,6 +1489,41 @@ function createWebUiApi(): NonNullable['ui']> { // Why: unpaired/offline web clients still need local UI persistence. } }, + recordFeatureInteraction: async (id: FeatureInteractionId) => { + const current = readLocalWebUIState() + const featureInteractions = normalizeFeatureInteractions(current.featureInteractions) + const existing = featureInteractions[id] + const optimistic = mergeWebUIState(current, { + featureInteractions: { + ...featureInteractions, + [id]: { + firstInteractedAt: existing?.firstInteractedAt ?? Date.now(), + interactionCount: (existing?.interactionCount ?? 0) + 1 + } + } + }) + writeJson(UI_STORAGE_KEY, optimistic) + try { + const result = await callRuntimeResult<{ ui: PersistedUIState }>( + 'ui.recordFeatureInteraction', + id, + 15_000 + ) + const local = readLocalWebUIState() + const next = { + ...mergeWebUIState(local, result.ui), + featureInteractions: mergeFeatureInteractionState( + local.featureInteractions, + result.ui.featureInteractions + ) + } + writeJson(UI_STORAGE_KEY, next) + zoomLevel = next.uiZoomLevel + return next + } catch { + return optimistic + } + }, readClipboardText: () => navigator.clipboard?.readText?.() ?? Promise.resolve(''), readSelectionClipboardText: () => Promise.reject(new Error('Selection clipboard is unavailable in the web client')), @@ -2032,6 +2079,32 @@ function mergeWebUIState( } } +function mergeFeatureInteractionState( + current: PersistedUIState['featureInteractions'], + incoming: PersistedUIState['featureInteractions'] +): FeatureInteractionState { + const currentNormalized = normalizeFeatureInteractions(current) + const incomingNormalized = normalizeFeatureInteractions(incoming) + const merged: FeatureInteractionState = { ...currentNormalized } + for (const [id, incomingRecord] of Object.entries(incomingNormalized)) { + const featureId = id as FeatureInteractionId + const currentRecord = currentNormalized[featureId] + merged[featureId] = currentRecord + ? { + firstInteractedAt: Math.min( + currentRecord.firstInteractedAt, + incomingRecord.firstInteractedAt + ), + interactionCount: Math.max( + currentRecord.interactionCount, + incomingRecord.interactionCount + ) + } + : incomingRecord + } + return merged +} + function mergeSettings(base: GlobalSettings, updates: Partial): GlobalSettings { const defaults = getDefaultSettings('~') return { diff --git a/src/shared/feature-interactions.test.ts b/src/shared/feature-interactions.test.ts index daebcb088f3..ceeb01b4ccd 100644 --- a/src/shared/feature-interactions.test.ts +++ b/src/shared/feature-interactions.test.ts @@ -29,13 +29,17 @@ describe('feature interactions', () => { 'browser-annotations', 'browser-grab', 'workspace-creation', + 'agent-browser-setup', 'agent-browser-use', + 'agent-orchestration-setup', 'agent-orchestration', 'ai-commit-generation', 'ai-pr-generation', 'claude-account-switching', + 'computer-use-setup', 'computer-use', 'codex-account-switching', + 'cookie-import', 'floating-workspace', 'mobile-pairing', 'notifications', @@ -46,6 +50,7 @@ describe('feature interactions', () => { 'ssh', 'terminal-panes', 'terminal-tabs', + 'tab-splits', 'usage-tracking', 'voice-dictation', 'workspace-cleanup' diff --git a/src/shared/feature-interactions.ts b/src/shared/feature-interactions.ts index 26ac2432504..653c81a072b 100644 --- a/src/shared/feature-interactions.ts +++ b/src/shared/feature-interactions.ts @@ -9,13 +9,17 @@ export type FeatureInteractionId = | 'browser-annotations' | 'browser-grab' | 'workspace-creation' + | 'agent-browser-setup' | 'agent-browser-use' + | 'agent-orchestration-setup' | 'agent-orchestration' | 'ai-commit-generation' | 'ai-pr-generation' | 'claude-account-switching' + | 'computer-use-setup' | 'computer-use' | 'codex-account-switching' + | 'cookie-import' | 'floating-workspace' | 'mobile-pairing' | 'notifications' @@ -26,6 +30,7 @@ export type FeatureInteractionId = | 'ssh' | 'terminal-panes' | 'terminal-tabs' + | 'tab-splits' | 'usage-tracking' | 'voice-dictation' | 'workspace-cleanup' @@ -90,13 +95,21 @@ export const FEATURE_INTERACTIONS = [ id: 'workspace-creation', interaction: 'workspace creation flow opened' }, + { + id: 'agent-browser-setup', + interaction: 'Agent Browser Use setup enabled or opened' + }, { id: 'agent-browser-use', - interaction: 'Agent Browser Use setup enabled or used' + interaction: 'agent browser runtime method used' + }, + { + id: 'agent-orchestration-setup', + interaction: 'Agent Orchestration setup enabled or opened' }, { id: 'agent-orchestration', - interaction: 'Agent Orchestration setup enabled or used' + interaction: 'agent orchestration runtime method used' }, { id: 'ai-commit-generation', @@ -111,13 +124,21 @@ export const FEATURE_INTERACTIONS = [ interaction: 'Claude managed account added, selected, reauthenticated, or removed' }, { - id: 'computer-use', + id: 'computer-use-setup', interaction: 'Computer Use setup or permission flow opened' }, + { + id: 'computer-use', + interaction: 'computer-use runtime method used' + }, { id: 'codex-account-switching', interaction: 'Codex managed account added, selected, reauthenticated, or removed' }, + { + id: 'cookie-import', + interaction: 'browser cookies imported or cleared' + }, { id: 'floating-workspace', interaction: 'Floating Workspace opened or configured' @@ -152,11 +173,15 @@ export const FEATURE_INTERACTIONS = [ }, { id: 'terminal-panes', - interaction: 'terminal/editor/browser pane split, moved, resized, or merged' + interaction: 'terminal/editor/browser pane created, resized, or merged' }, { id: 'terminal-tabs', - interaction: 'workspace tab created, moved, reordered, pinned, renamed, or closed' + interaction: 'workspace tab created, moved, reordered, pinned, renamed, recolored, or closed' + }, + { + id: 'tab-splits', + interaction: 'workspace tab split into another pane' }, { id: 'usage-tracking',