diff --git a/docs/reference/feature-discovery-interaction-tracking.md b/docs/reference/feature-discovery-interaction-tracking.md new file mode 100644 index 00000000000..539730b1b04 --- /dev/null +++ b/docs/reference/feature-discovery-interaction-tracking.md @@ -0,0 +1,66 @@ +# Feature Discovery Interaction Tracking + +This document defines local feature-interaction state used to decide whether Orca should still teach a feature with an education surface such as a tour or feature tip. + +## Decision + +Track first meaningful interaction plus local interaction count for education-targeted features in `PersistedUIState.featureInteractions`. + +Do not upload this state as broad analytics. Product analytics should continue to use bounded telemetry events and downstream product events. Local interaction state answers a different question: "Has this user already found enough of this feature that education would be redundant?" + +## Rules + +- Add a `FeatureInteractionId` in `src/shared/feature-interactions.ts` before using it. +- Preserve `firstInteractedAt`, and increment `interactionCount` on each later meaningful interaction. +- Prefer explicit actions over passive visibility. +- Passive visibility is acceptable only when opening the surface is itself the product use, such as opening Tasks. +- Record after persisted UI is hydrated by using `recordFeatureInteraction(...)`; it no-ops before hydration. +- Keep IDs stable. If the meaning changes materially, add a new ID. +- Do not include user text, paths, URLs, repo names, branch names, hostnames, commands, prompts, or tokens in this state. +- Old records without `interactionCount` hydrate as `interactionCount: 1`. + +## Feature Catalog + +| Feature | Interaction ID | Record when | Education use | +| --- | --- | --- | --- | +| Review notes to agent | `review-notes` | A diff or markdown review note is added, or review notes are marked sent to an agent. | Suppress or target a future review-notes tour/tip about adding line notes and sending focused feedback back to an agent. | +| AI commit generation | `ai-commit-generation` | AI commit-message generation is enabled or an AI commit message is generated. | Suppress future education about AI commit generation. No contextual tour is planned for this branch. | +| 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 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. | +| Resource Manager | `resource-manager` | The Resource Manager status-bar popover is opened, or its status-bar visibility is toggled in Appearance/status-bar controls. | Suppress future tips about CPU, memory, session, daemon, and workspace disk-scan controls after the user has found the manager. | +| Workspace cleanup / disk space | `workspace-cleanup` | The Space page opens, a workspace disk scan starts/cancels, or cleanup removes scanned workspace rows. | Suppress future tips about scanning workspace disk usage and reclaiming old workspace storage. | +| Ports | `ports` | The Ports popover opens, the Ports status-bar item is configured, external ports are expanded, or a port is opened/copied/stopped. | Suppress future tips about discovering and acting on workspace ports. | +| SSH | `ssh` | SSH status opens, SSH status-bar visibility changes, a target is added/imported/tested, or an SSH target is connected/disconnected. | Suppress future SSH setup/status tips after the user has interacted with remote target controls. | +| 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. 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. | + +## Surface-Level Features + +Orca also records surface-level interactions for feature areas where opening the surface is itself a meaningful discovery signal: + +- `workspace-board`: workspace board opened +- `browser`: non-blank browser page viewed +- `tasks`: Tasks page opened +- `automations`: Automations page opened +- `workspace-creation`: workspace creation flow opened + +These remain intentionally separate from action-level IDs such as `workspace-board-actions`, `automation-created`, and `automation-run`. Surface-level IDs answer "has the user entered the feature area?" Action-level IDs answer "has the user performed the deeper workflow?" 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 b3d36672063..d5bf1b61543 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -14,7 +14,13 @@ import { } from 'fs' import { join } from 'path' import { tmpdir } from 'os' -import type { Repo, TerminalTab, WorktreeLineage, WorkspaceSessionState } from '../shared/types' +import type { + PersistedState, + Repo, + TerminalTab, + WorktreeLineage, + WorkspaceSessionState +} from '../shared/types' import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser-history' @@ -616,6 +622,35 @@ describe('Store', () => { expect(second.title).toBe('Nightly run 2') }) + it('records feature interactions when automations are created or manually queued', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + const automation = store.createAutomation({ + name: 'Nightly', + prompt: 'Run checks', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + + store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime(), 'scheduled') + store.createAutomationRun(automation, new Date('2026-05-14T09:00:00Z').getTime(), 'manual') + + expect(store.getUI().featureInteractions?.['automation-created']?.interactionCount).toBe(1) + expect(store.getUI().featureInteractions?.['automation-run']?.interactionCount).toBe(1) + const persisted = readDataFile() as PersistedState + expect(persisted.ui?.featureInteractions?.['automation-created']).toMatchObject({ + interactionCount: 1 + }) + expect(persisted.ui?.featureInteractions?.['automation-run']).toMatchObject({ + interactionCount: 1 + }) + }) + it('snapshots automation run workspace names for deleted-workspace history', async () => { const store = await createStore() store.addRepo(makeRepo()) @@ -2106,6 +2141,85 @@ describe('Store', () => { expect(store.getUI().rightSidebarTab).toBe('explorer') }) + 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('normalizes malformed persisted feature discovery state on read', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: { + featureTipsSeenIds: ['voice-dictation', 'unknown-tip', 'voice-dictation'], + featureInteractions: { + tasks: { firstInteractedAt: 100 }, + automations: { firstInteractedAt: 150, interactionCount: 4 }, + browser: { firstInteractedAt: Number.NaN }, + unknown: { firstInteractedAt: 200 } + } + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + + expect(store.getUI().featureTipsSeenIds).toEqual(['voice-dictation']) + expect(store.getUI().featureInteractions).toEqual({ + tasks: { firstInteractedAt: 100, interactionCount: 1 }, + automations: { firstInteractedAt: 150, interactionCount: 4 } + }) + }) + + it('normalizes feature tip ids from direct UI writes', async () => { + const store = await createStore() + + store.updateUI({ + featureTipsSeenIds: ['voice-dictation', 'unknown-tip', 'voice-dictation'] as never + }) + + expect(store.getUI().featureTipsSeenIds).toEqual(['voice-dictation']) + }) + + 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 d3e260ded2e..947cbeab4b7 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -85,6 +85,11 @@ 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 { normalizeFeatureTipIds } from '../shared/feature-tips' import { DEFAULT_WORKSPACE_STATUS_ID, clampWorkspaceBoardColumnWidth, @@ -234,6 +239,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' || @@ -2385,6 +2415,7 @@ export class Store { updatedAt: now } this.state.automations = [...(this.state.automations ?? []), automation] + this.recordFeatureInteraction('automation-created') this.flush() return automation } @@ -2483,6 +2514,9 @@ export class Store { createdAt: now } this.state.automationRuns = [...(this.state.automationRuns ?? []), run] + if (trigger === 'manual') { + this.recordFeatureInteraction('automation-run') + } this.flush() return run } @@ -2717,7 +2751,9 @@ export class Store { workspaceBoardCompact: normalizeWorkspaceBoardCompact(this.state.ui?.workspaceBoardCompact), workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( this.state.ui?.workspaceBoardColumnWidth - ) + ), + featureTipsSeenIds: normalizeFeatureTipIds(this.state.ui?.featureTipsSeenIds), + featureInteractions: normalizeFeatureInteractions(this.state.ui?.featureInteractions) } } @@ -2751,11 +2787,40 @@ export class Store { ), workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( updates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth - ) + ), + featureTipsSeenIds: + updates.featureTipsSeenIds !== undefined + ? normalizeFeatureTipIds(updates.featureTipsSeenIds) + : normalizeFeatureTipIds(this.state.ui?.featureTipsSeenIds), + // 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 b317382d77c..55b45ce2f16 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 { lstat, mkdir, mkdtemp, rm, writeFile } 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() @@ -4971,6 +5007,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 345a6388dac..cb01bdb1189 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -56,6 +56,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 { getNextProjectGroupOrder } from '../../shared/project-groups' @@ -439,6 +440,7 @@ type RuntimeStore = { getWorkspaceSession?: Store['getWorkspaceSession'] getUI?: Store['getUI'] updateUI?: Store['updateUI'] + recordFeatureInteraction?: Store['recordFeatureInteraction'] listAutomations?: Store['listAutomations'] listAutomationRuns?: Store['listAutomationRuns'] createAutomation?: Store['createAutomation'] @@ -1330,6 +1332,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' @@ -9289,7 +9298,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..87c127bbb1f --- /dev/null +++ b/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import type { PersistedUIState } from '../../../shared/types' +import { getDefaultUIState } from '../../../shared/constants' +import { + ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE, + ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY +} from '../../../shared/runtime-rpc-feature-interaction-source' +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 }) + }), + defineMethod({ + name: 'browser.tabCreate', + params: z.object({}), + handler: () => ({ browserPageId: 'page-1' }) + }), + defineMethod({ + name: 'browser.tabShow', + params: z.object({}), + handler: () => ({ tab: { id: 'page-1' } }) + }), + defineMethod({ + name: 'browser.viewport', + params: z.object({}), + handler: () => ({ ok: true }) + }), + defineMethod({ + name: 'browser.eval', + params: z.object({}), + handler: () => ({ value: 'ok' }) + }), + 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 unmarked browser display RPCs as agent browser use', async () => { + const runtime = makeRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) + const replies: string[] = [] + + await dispatcher.dispatch(makeRequest('browser.tabCreate')) + await dispatcher.dispatch(makeRequest('browser.tabShow')) + await dispatcher.dispatch(makeRequest('browser.screencast.unsubscribe')) + expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(2) + expect(runtime.recordFeatureInteraction).toHaveBeenNthCalledWith(1, 'agent-browser-use') + expect(runtime.recordFeatureInteraction).toHaveBeenNthCalledWith(2, 'agent-browser-use') + + await dispatcher.dispatchStreaming(makeRequest('browser.screencast'), (response) => { + replies.push(response) + }) + + expect(replies).toHaveLength(2) + expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(3) + expect(runtime.recordFeatureInteraction).toHaveBeenLastCalledWith('agent-browser-use') + }) + + it('records binary-only browser display streams as agent browser use', 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('does not record browser pane UI-originated browser RPCs as agent browser use', async () => { + const runtime = makeRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) + const browserPaneUiParams = { + [ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY]: ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE + } + + await dispatcher.dispatch(makeRequest('browser.viewport', browserPaneUiParams)) + await dispatcher.dispatch(makeRequest('browser.eval', browserPaneUiParams)) + await dispatcher.dispatchStreaming( + makeRequest('browser.screencast', browserPaneUiParams), + () => {} + ) + await dispatcher.dispatch(makeRequest('browser.click')) + + 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..add223a2d02 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -14,6 +14,8 @@ import { type RpcResponse } from './core' import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol' +import type { FeatureInteractionId } from '../../../shared/feature-interactions' +import { isBrowserPaneUiRuntimeRpcParams } from '../../../shared/runtime-rpc-feature-interaction-source' import { errorResponse, mapBrowserError, mapRuntimeError, successResponse } from './errors' import { ALL_RPC_METHODS } from './methods' import type { OrcaRuntimeService } from '../orca-runtime' @@ -66,6 +68,7 @@ export class RpcDispatcher { runtime: this.runtime, signal: options?.signal }) + this.recordRuntimeFeatureInteraction(request.method, result, undefined, request.params) return successResponse(request.id, meta, result) } catch (error) { return this.mapError(request, meta, error) @@ -116,6 +119,7 @@ export class RpcDispatcher { sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler }) + this.recordRuntimeFeatureInteraction(request.method, result, undefined, request.params) reply(JSON.stringify(successResponse(request.id, meta, result))) } catch (error) { reply(JSON.stringify(this.mapError(request, meta, error))) @@ -123,14 +127,21 @@ export class RpcDispatcher { return } + const recordedStreamingFeatureInteractions = new Set() const emit = (result: unknown): void => { + this.recordRuntimeFeatureInteraction( + request.method, + result, + recordedStreamingFeatureInteractions, + request.params + ) 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 +153,12 @@ export class RpcDispatcher { }, emit ) + this.recordRuntimeFeatureInteraction( + request.method, + result, + recordedStreamingFeatureInteractions, + request.params + ) } catch (error) { reply(JSON.stringify(this.mapError(request, meta, error))) } @@ -182,4 +199,63 @@ export class RpcDispatcher { private meta(): RpcEnvelopeMeta { return { runtimeId: this.runtime.getRuntimeId() } } + + private recordRuntimeFeatureInteraction( + method: string, + result: unknown, + alreadyRecorded?: Set, + rawParams?: unknown + ): void { + const id = getRuntimeFeatureInteractionId(method, result, rawParams) + 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, + rawParams?: 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.') && isBrowserPaneUiRuntimeRpcParams(rawParams)) { + 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 b4fdf24b629..48162cd9330 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -160,6 +160,10 @@ describe('client UI RPC methods', () => { classifierVersion: 2 } } + }, + featureTipsSeenIds: ['voice-dictation'], + featureInteractions: { + tasks: { firstInteractedAt: 100, interactionCount: 2 } } } const runtime = { @@ -187,6 +191,10 @@ describe('client UI RPC methods', () => { classifierVersion: 2 } } + }, + featureTipsSeenIds: ['voice-dictation'], + featureInteractions: { + tasks: { firstInteractedAt: 100, interactionCount: 2 } } } const response = await dispatcher.dispatch(makeRequest('ui.set', payload)) @@ -195,6 +203,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', @@ -209,4 +236,53 @@ 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', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateUIState: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('ui.set', { + featureInteractions: { + unknown: { firstInteractedAt: 100 } + } + }) + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.updateUIState).not.toHaveBeenCalled() + }) + + it('rejects unknown feature tip ids', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateUIState: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('ui.set', { featureTipsSeenIds: ['voice-dictation', 'unknown-tip'] }) + ) + + 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 8f3601a43d7..2bfd8609759 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -1,10 +1,16 @@ import { z } from 'zod' +import { + isFeatureInteractionId, + type FeatureInteractionId +} from '../../../../shared/feature-interactions' +import { isFeatureTipId } from '../../../../shared/feature-tips' import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { PersistedUIState } from '../../../../shared/types' import { defineMethod, type RpcMethod } from '../core' const NullableString = z.string().nullable() const StringArray = z.array(z.string()) +const FeatureTipIds = z.array(z.custom(isFeatureTipId, { message: 'Unknown feature tip id' })) const UnknownRecord = z.record(z.string(), z.unknown()) const UnknownRecordArray = z.array(UnknownRecord) const WorktreeCardProperty = z.enum([ @@ -48,6 +54,28 @@ const WorkspaceCleanup = z dismissals: z.record(z.string(), WorkspaceCleanupDismissal) }) .strict() +const FeatureInteractionRecord = z + .object({ + firstInteractedAt: z.number().finite().nonnegative(), + interactionCount: z.number().int().positive().optional() + }) + .strict() +const FeatureInteractions = z + .record(z.string(), FeatureInteractionRecord) + .superRefine((value, ctx) => { + for (const id of Object.keys(value)) { + if (!isFeatureInteractionId(id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Unknown feature interaction id: ${id}`, + path: [id] + }) + } + } + }) +const FeatureInteractionIdParam = z.custom(isFeatureInteractionId, { + message: 'Unknown feature interaction id' +}) const GitHubProjectRef = z .object({ owner: z.string(), @@ -159,7 +187,9 @@ const UiUpdate = z customSidekicks: UnknownRecordArray.optional(), sidekickSize: z.number().finite().optional(), taskResumeState: TaskResumeState.optional(), - workspaceCleanup: WorkspaceCleanup.optional() + workspaceCleanup: WorkspaceCleanup.optional(), + featureTipsSeenIds: FeatureTipIds.optional(), + featureInteractions: FeatureInteractions.optional() }) .strict() .default({}) @@ -186,5 +216,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 f93950c7713..69a05660f80 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -270,6 +270,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 835c9e2786b..d0a63a30cc3 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -115,6 +115,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, @@ -1767,6 +1768,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 68be46b6143..d40637e19f1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2328,6 +2328,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 ff4a3268a94..0a6d9fdd1a0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -304,6 +304,7 @@ function App(): React.JSX.Element { const activeView = useAppStore((s) => s.activeView) const activeModal = useAppStore((s) => s.activeModal) const featureTipsSeenIds = useAppStore((s) => s.featureTipsSeenIds) + const featureInteractions = useAppStore((s) => s.featureInteractions) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) // Why: App swaps the sidebar between workspace and landing layouts when the // active workspace is slept/deleted. Keep virtualized scroll memory above @@ -366,6 +367,7 @@ function App(): React.JSX.Element { setFloatingTerminalOpen((currentOpen) => { const resolvedOpen = typeof nextOpen === 'function' ? nextOpen(currentOpen) : nextOpen if (resolvedOpen && !currentOpen) { + useAppStore.getState().recordFeatureInteraction('floating-workspace') rememberFloatingTerminalReturnFocus() } else if (!resolvedOpen && currentOpen) { restoreFloatingTerminalReturnFocus() @@ -455,6 +457,7 @@ function App(): React.JSX.Element { const featureTipsDecision = getFeatureTipsAppOpenDecision({ activeModal, featureTipsSeenIds, + featureInteractions, onboarding, persistedUIReady, promptedThisSession: featureTipsPromptedThisSessionRef.current, @@ -478,7 +481,15 @@ function App(): React.JSX.Element { // on the next launch just because the user never clicked a dismiss button. actions.markFeatureTipsSeen([featureTipsDecision.tipId]) actions.openModal('feature-tips', { source: 'app_open', tipId: featureTipsDecision.tipId }) - }, [activeModal, actions, featureTipsSeenIds, onboarding, persistedUIReady, settings]) + }, [ + activeModal, + actions, + featureInteractions, + featureTipsSeenIds, + onboarding, + persistedUIReady, + settings + ]) useEffect(() => { if (activeView !== 'settings' || !shouldShowOnboarding(onboarding)) { diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index b4ac8b2f8df..4e167934511 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -520,6 +520,10 @@ export default function AutomationsPage(): React.JSX.Element { } }, [setSelectedId]) + const hydratePersistedUIState = useCallback(async (): Promise => { + useAppStore.getState().hydratePersistedUI(await window.api.ui.get()) + }, []) + useEffect(() => { void fetchAllWorktrees() void refresh() @@ -903,6 +907,9 @@ export default function AutomationsPage(): React.JSX.Element { jobId: editingExternalTarget.job.id }) : window.api.automations.createExternal(input)) + if (!editingExternalTarget) { + useAppStore.getState().recordFeatureInteraction('automation-created') + } await refresh() setCreateOpen(false) setEditingExternalTarget(null) @@ -978,6 +985,9 @@ export default function AutomationsPage(): React.JSX.Element { dtstart: now, missedRunGraceMinutes }) + if (!editingAutomationId) { + await hydratePersistedUIState() + } setAutomations((current) => { const next = current.filter((entry) => entry.id !== automation.id) return [...next, automation].sort((left, right) => left.name.localeCompare(right.name)) @@ -1053,6 +1063,7 @@ export default function AutomationsPage(): React.JSX.Element { const runNow = async (automation: Automation): Promise => { await window.api.automations.runNow({ id: automation.id }) + await hydratePersistedUIState() await refresh() toast.message('Automation run queued.') } @@ -1068,6 +1079,7 @@ export default function AutomationsPage(): React.JSX.Element { setRerunRunIdsInFlight(new Set(rerunRunIdsInFlightRef.current)) try { await window.api.automations.runNow({ id: automationId }) + await hydratePersistedUIState() await refresh() toast.message('Automation run queued.') } catch (error) { @@ -1096,6 +1108,9 @@ export default function AutomationsPage(): React.JSX.Element { jobId: job.id, action }) + if (action === 'run') { + useAppStore.getState().recordFeatureInteraction('automation-run') + } await refresh() toast.success( action === 'delete' diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 088156246bb..d824a3303c7 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -138,6 +138,7 @@ import { decodeBrowserScreencastFrame, type BrowserScreencastFrameMetadata } from '../../../../shared/browser-screencast-protocol' +import { withBrowserPaneUiRuntimeRpcSource } from '../../../../shared/runtime-rpc-feature-interaction-source' import { formatByteCount, formatDownloadFinishedNotice, @@ -1078,7 +1079,7 @@ function RemoteBrowserPagePane({ deviceScaleFactor: getRemoteBrowserDeviceScaleFactor(), mobile: false }, - { timeoutMs: 15_000 } + { timeoutMs: 15_000, suppressFeatureInteraction: true } ) try { // Why: the streamed bitmap can include the host compositor surface, @@ -1091,7 +1092,7 @@ function RemoteBrowserPagePane({ page: pageId, expression: 'JSON.stringify({ width: window.innerWidth, height: window.innerHeight })' }, - { timeoutMs: 15_000 } + { timeoutMs: 15_000, suppressFeatureInteraction: true } ) remoteCssViewportSizeRef.current = readRemoteCssViewportSize(viewport) ?? size } catch { @@ -1303,7 +1304,7 @@ function RemoteBrowserPagePane({ { kind: 'environment', environmentId: removedHandle.environmentId }, 'browser.tabClose', { worktree: `id:${worktreeId}`, page: removedHandle.remotePageId }, - { timeoutMs: 15_000 } + { timeoutMs: 15_000, suppressFeatureInteraction: true } ).catch(() => {}) } }, [activeRuntimeEnvironmentId, browserTab.id, worktreeId]) @@ -1410,14 +1411,14 @@ function RemoteBrowserPagePane({ target, 'browser.tabCreate', { worktree: `id:${worktreeId}`, url: initialUrl }, - { timeoutMs: 30_000 } + { timeoutMs: 30_000, suppressFeatureInteraction: true } ) if (!isCurrentRemoteOperationToken(token)) { void callRuntimeRpc( target, 'browser.tabClose', { worktree: `id:${worktreeId}`, page: created.browserPageId }, - { timeoutMs: 15_000 } + { timeoutMs: 15_000, suppressFeatureInteraction: true } ).catch(() => {}) return null } @@ -1476,7 +1477,7 @@ function RemoteBrowserPagePane({ { kind: 'environment', environmentId: token.environmentId }, 'browser.tabShow', { worktree: `id:${worktreeId}`, page: token.remotePageId }, - { timeoutMs: 15_000 } + { timeoutMs: 15_000, suppressFeatureInteraction: true } ) return shown.tab }, @@ -1637,7 +1638,7 @@ function RemoteBrowserPagePane({ { selector: target.environmentId, method: 'browser.screencast', - params: { + params: withBrowserPaneUiRuntimeRpcSource({ worktree: `id:${worktreeId}`, page: pageId, format: 'jpeg', @@ -1648,7 +1649,7 @@ function RemoteBrowserPagePane({ viewportHeight: viewportSize?.height, deviceScaleFactor: getRemoteBrowserDeviceScaleFactor(), everyNthFrame: 2 - }, + }), timeoutMs: 15_000 }, { @@ -1910,7 +1911,7 @@ function RemoteBrowserPagePane({ : { worktree: `id:${worktreeId}`, page: pageId } const result = await callRuntimeRpc< BrowserGotoResult | BrowserBackResult | BrowserReloadResult - >(target, method, params, { timeoutMs: 30_000 }) + >(target, method, params, { timeoutMs: 30_000, suppressFeatureInteraction: true }) if (isCurrentRemoteOperationToken(pageToken)) { applyRemoteTabInfo(result) } @@ -2006,13 +2007,13 @@ function RemoteBrowserPagePane({ target, 'browser.mouseMove', { ...params, x: point.x, y: point.y }, - { timeoutMs: 15_000 } + { timeoutMs: 15_000, suppressFeatureInteraction: true } ) await callRuntimeRpc( target, 'browser.mouseDown', { ...params, button }, - { timeoutMs: 15_000 } + { timeoutMs: 15_000, suppressFeatureInteraction: true } ) } catch (error) { if (isCurrentRemoteOperationToken(operationToken)) { @@ -2053,13 +2054,13 @@ function RemoteBrowserPagePane({ target, 'browser.mouseMove', { ...params, x: point.x, y: point.y }, - { timeoutMs: 15_000 } + { timeoutMs: 15_000, suppressFeatureInteraction: true } ) await callRuntimeRpc( target, 'browser.mouseUp', { ...params, button }, - { timeoutMs: 15_000 } + { timeoutMs: 15_000, suppressFeatureInteraction: true } ) scheduleRemoteTabInfoRefresh(operationToken, 250) } catch (error) { @@ -2107,7 +2108,7 @@ function RemoteBrowserPagePane({ page: pageId, expression: buildRemoteContextMenuExpression(point.x, point.y) }, - { timeoutMs: 15_000 } + { timeoutMs: 15_000, suppressFeatureInteraction: true } ) const parsed = readRemoteContextMenuResult(result) if (parsed) { @@ -2155,7 +2156,12 @@ function RemoteBrowserPagePane({ return } try { - await callRuntimeRpc(target, 'browser.keypress', { ...params, key }, { timeoutMs: 15_000 }) + await callRuntimeRpc( + target, + 'browser.keypress', + { ...params, key }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) if (key === 'Enter' || key === 'Meta+r' || key === 'Control+r') { scheduleRemoteTabInfoRefresh(operationToken, 400) } @@ -2194,7 +2200,7 @@ function RemoteBrowserPagePane({ target, 'browser.mouseMove', { ...params, x: point.x, y: point.y }, - { timeoutMs: 15_000 } + { timeoutMs: 15_000, suppressFeatureInteraction: true } ) await callRuntimeRpc( target, @@ -2204,7 +2210,7 @@ function RemoteBrowserPagePane({ dx, dy }, - { timeoutMs: 15_000 } + { timeoutMs: 15_000, suppressFeatureInteraction: true } ) scheduleRemoteTabInfoRefresh(operationToken, 400) } catch (error) { @@ -2614,6 +2620,7 @@ function BrowserPagePane({ const addBrowserPageAnnotation = useAppStore((s) => s.addBrowserPageAnnotation) const deleteBrowserPageAnnotation = useAppStore((s) => s.deleteBrowserPageAnnotation) const clearBrowserPageAnnotations = useAppStore((s) => s.clearBrowserPageAnnotations) + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const clearBrowserPageAnnotationsRef = useRef(clearBrowserPageAnnotations) clearBrowserPageAnnotationsRef.current = clearBrowserPageAnnotations const createBrowserTab = useAppStore((s) => s.createBrowserTab) @@ -3727,6 +3734,10 @@ function BrowserPagePane({ const startGrabIntent = useCallback( (nextIntent: GrabIntent): void => { + recordFeatureInteraction('browser-grab') + if (nextIntent === 'annotate') { + recordFeatureInteraction('browser-annotations') + } setGrabIntent(nextIntent) if (nextIntent === 'copy') { setPendingAnnotationPayload(null) @@ -3737,7 +3748,7 @@ function BrowserPagePane({ grab.toggle() } }, - [grab, grabIntent] + [grab, grabIntent, recordFeatureInteraction] ) // CmdOrCtrl+C toggles grab mode @@ -3819,11 +3830,13 @@ function BrowserPagePane({ if (key === 'c') { const text = formatGrabPayloadAsText(payload) void window.api.ui.writeClipboardText(text) + recordFeatureInteraction('browser-grab') showGrabToast('Copied', 'success', payload) } else { const dataUrl = payload.screenshot?.dataUrl if (dataUrl?.startsWith('data:image/png;base64,')) { void window.api.ui.writeClipboardImage(dataUrl) + recordFeatureInteraction('browser-grab') showGrabToast('Screenshotted', 'success', payload) } else { showGrabToast('No screenshot available', 'error', payload) @@ -3877,7 +3890,7 @@ function BrowserPagePane({ })() } }, - [grab, grabIntent, showGrabToast] + [grab, grabIntent, recordFeatureInteraction, showGrabToast] ) useEffect(() => { @@ -3930,9 +3943,10 @@ function BrowserPagePane({ } const text = formatGrabPayloadAsText(payload) void window.api.ui.writeClipboardText(text) + recordFeatureInteraction('browser-grab') showGrabToast('Copied', 'success', payload) grab.rearm() - }, [grab, showGrabToast]) + }, [grab, recordFeatureInteraction, showGrabToast]) const handleGrabCopyScreenshot = useCallback(() => { grabMenuActionTakenRef.current = true @@ -3945,9 +3959,10 @@ function BrowserPagePane({ return } void window.api.ui.writeClipboardImage(dataUrl) + recordFeatureInteraction('browser-grab') showGrabToast('Screenshotted', 'success', payload) grab.rearm() - }, [grab, showGrabToast]) + }, [grab, recordFeatureInteraction, showGrabToast]) const handleAddBrowserAnnotation = useCallback( (comment: string, intent: BrowserAnnotationIntent): void => { @@ -3966,10 +3981,18 @@ function BrowserPagePane({ }) setPendingAnnotationPayload(null) setBrowserAnnotationTrayOpen(true) + recordFeatureInteraction('browser-annotations') showGrabToast('Annotation added', 'success', payload) grab.rearm() }, - [addBrowserPageAnnotation, browserTab.id, grab, pendingAnnotationPayload, showGrabToast] + [ + addBrowserPageAnnotation, + browserTab.id, + grab, + pendingAnnotationPayload, + recordFeatureInteraction, + showGrabToast + ] ) const handleCancelPendingBrowserAnnotation = useCallback((): void => { @@ -3984,16 +4007,29 @@ function BrowserPagePane({ return } void window.api.ui.writeClipboardText(browserAnnotationsPrompt) + recordFeatureInteraction('browser-annotations') clearTimeout(annotationCopyTimerRef.current) setBrowserAnnotationsCopied(true) annotationCopyTimerRef.current = setTimeout(() => setBrowserAnnotationsCopied(false), 1400) - }, [browserAnnotationsPrompt]) + }, [browserAnnotationsPrompt, recordFeatureInteraction]) const handleClearBrowserAnnotations = useCallback((): void => { + if (browserAnnotationsRef.current.length === 0) { + return + } clearTimeout(annotationCopyTimerRef.current) setBrowserAnnotationsCopied(false) clearBrowserPageAnnotations(browserTab.id) - }, [browserTab.id, clearBrowserPageAnnotations]) + recordFeatureInteraction('browser-annotations') + }, [browserTab.id, clearBrowserPageAnnotations, recordFeatureInteraction]) + + const handleDeleteBrowserAnnotation = useCallback( + (annotationId: string): void => { + deleteBrowserPageAnnotation(browserTab.id, annotationId) + recordFeatureInteraction('browser-annotations') + }, + [browserTab.id, deleteBrowserPageAnnotation, recordFeatureInteraction] + ) const navigateToUrl = useCallback( (url: string): void => { @@ -4827,7 +4863,7 @@ function BrowserPagePane({ size="icon-xs" variant="ghost" className="opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 group-focus-within:opacity-100" - onClick={() => deleteBrowserPageAnnotation(browserTab.id, annotation.id)} + onClick={() => handleDeleteBrowserAnnotation(annotation.id)} aria-label={`Delete annotation ${index + 1}`} > diff --git a/src/renderer/src/components/dictation/DictationController.tsx b/src/renderer/src/components/dictation/DictationController.tsx index 0ec44865397..47c5c14c41a 100644 --- a/src/renderer/src/components/dictation/DictationController.tsx +++ b/src/renderer/src/components/dictation/DictationController.tsx @@ -17,6 +17,7 @@ export function DictationController() { const dictationState = useAppStore((s) => s.dictationState) const setDictationState = useAppStore((s) => s.setDictationState) const setPartialTranscript = useAppStore((s) => s.setPartialTranscript) + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const settings = useAppStore((s) => s.settings) const keybindings = useAppStore((s) => s.keybindings) const { @@ -149,6 +150,7 @@ export function DictationController() { dictationStateRef.current = 'listening' setDictationState('listening') + recordFeatureInteraction('voice-dictation') } catch (err) { if (dictationRunRef.current !== runId) { return @@ -201,7 +203,8 @@ export function DictationController() { discardBufferedAudio, stopCapture, finishDictationSession, - setPartialTranscript + setPartialTranscript, + recordFeatureInteraction ]) const stopDictation = useCallback(async () => { diff --git a/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx b/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx index 3ca6a0a71a7..5f672dc3116 100644 --- a/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx +++ b/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx @@ -48,12 +48,14 @@ export default function FeatureTipsModal(): JSX.Element | null { const settings = useAppStore((s) => s.settings) const updateSettings = useAppStore((s) => s.updateSettings) const seenTipIds = useAppStore((s) => s.featureTipsSeenIds) + const featureInteractions = useAppStore((s) => s.featureInteractions) const markFeatureTipsSeen = useAppStore((s) => s.markFeatureTipsSeen) const modalData = useAppStore((s) => s.modalData) const isOpen = activeModal === 'feature-tips' const currentTip = getFeatureTipForModal({ modalData, seenTipIds, + featureInteractions, settings }) diff --git a/src/renderer/src/components/feature-tips/feature-tip-modal-state.test.ts b/src/renderer/src/components/feature-tips/feature-tip-modal-state.test.ts index 6f4a9bc8b64..5e7169a535a 100644 --- a/src/renderer/src/components/feature-tips/feature-tip-modal-state.test.ts +++ b/src/renderer/src/components/feature-tips/feature-tip-modal-state.test.ts @@ -17,6 +17,7 @@ describe('feature tip modal state', () => { const tip = getFeatureTipForModal({ modalData: { tipId: 'voice-dictation' }, seenTipIds: ['voice-dictation'], + featureInteractions: {}, settings: makeSettings() }) @@ -27,6 +28,7 @@ describe('feature tip modal state', () => { const tip = getFeatureTipForModal({ modalData: {}, seenTipIds: [], + featureInteractions: {}, settings: makeSettings() }) @@ -37,6 +39,20 @@ describe('feature tip modal state', () => { const tip = getFeatureTipForModal({ modalData: {}, seenTipIds: ['voice-dictation'], + featureInteractions: {}, + settings: makeSettings() + }) + + expect(tip).toBeNull() + }) + + it('returns no unpinned tip after the user already interacted with the feature', () => { + const tip = getFeatureTipForModal({ + modalData: {}, + seenTipIds: [], + featureInteractions: { + 'voice-dictation': { firstInteractedAt: 100, interactionCount: 1 } + }, settings: makeSettings() }) diff --git a/src/renderer/src/components/feature-tips/feature-tip-modal-state.ts b/src/renderer/src/components/feature-tips/feature-tip-modal-state.ts index 85aa298be04..1d3d2770636 100644 --- a/src/renderer/src/components/feature-tips/feature-tip-modal-state.ts +++ b/src/renderer/src/components/feature-tips/feature-tip-modal-state.ts @@ -1,4 +1,5 @@ import type { GlobalSettings } from '../../../../shared/types' +import type { FeatureInteractionState } from '../../../../shared/feature-interactions' import { FEATURE_TIPS, getCompletedFeatureTipIds, @@ -11,6 +12,7 @@ import { export function getFeatureTipForModal(args: { modalData: Record seenTipIds: readonly FeatureTipId[] + featureInteractions: FeatureInteractionState settings: { voice?: GlobalSettings['voice'] } | null | undefined }): FeatureTip | null { const modalTipId = isFeatureTipId(args.modalData.tipId) ? args.modalData.tipId : null @@ -21,7 +23,8 @@ export function getFeatureTipForModal(args: { const pendingTips = getOrderedUnseenFeatureTips({ seenTipIds: new Set(args.seenTipIds), completedTipIds: getCompletedFeatureTipIds({ - voiceDictationEnabled: args.settings?.voice?.enabled === true + voiceDictationEnabled: args.settings?.voice?.enabled === true, + featureInteractions: args.featureInteractions }) }) diff --git a/src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts b/src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts index a3b231615fb..6c3a96d6f07 100644 --- a/src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts +++ b/src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts @@ -27,6 +27,7 @@ describe('feature tip startup gate', () => { getFeatureTipsAppOpenDecision({ activeModal: 'none', featureTipsSeenIds: [], + featureInteractions: {}, onboarding: existingUserOnboarding, persistedUIReady: true, promptedThisSession: false, @@ -41,6 +42,7 @@ describe('feature tip startup gate', () => { getFeatureTipsAppOpenDecision({ activeModal: 'none', featureTipsSeenIds: [], + featureInteractions: {}, onboarding: firstTimeOnboarding, persistedUIReady: true, promptedThisSession: false, @@ -55,6 +57,7 @@ describe('feature tip startup gate', () => { getFeatureTipsAppOpenDecision({ activeModal: 'none', featureTipsSeenIds: [], + featureInteractions: {}, onboarding: existingUserOnboarding, persistedUIReady: true, promptedThisSession: false, @@ -69,6 +72,7 @@ describe('feature tip startup gate', () => { getFeatureTipsAppOpenDecision({ activeModal: 'none', featureTipsSeenIds: ['voice-dictation'], + featureInteractions: {}, onboarding: existingUserOnboarding, persistedUIReady: true, promptedThisSession: false, @@ -83,6 +87,7 @@ describe('feature tip startup gate', () => { getFeatureTipsAppOpenDecision({ activeModal: 'none', featureTipsSeenIds: [], + featureInteractions: {}, onboarding: existingUserOnboarding, persistedUIReady: true, promptedThisSession: false, @@ -91,4 +96,21 @@ describe('feature tip startup gate', () => { }) ).toEqual({ kind: 'skip' }) }) + + it('does not open after the user already interacted with the feature', () => { + expect( + getFeatureTipsAppOpenDecision({ + activeModal: 'none', + featureTipsSeenIds: [], + featureInteractions: { + 'voice-dictation': { firstInteractedAt: 100, interactionCount: 1 } + }, + onboarding: existingUserOnboarding, + persistedUIReady: true, + promptedThisSession: false, + settings: makeSettings(), + suppressedByOnboardingThisSession: false + }) + ).toEqual({ kind: 'skip' }) + }) }) diff --git a/src/renderer/src/components/feature-tips/feature-tip-startup-gate.ts b/src/renderer/src/components/feature-tips/feature-tip-startup-gate.ts index c24f62bccc8..354ba23db95 100644 --- a/src/renderer/src/components/feature-tips/feature-tip-startup-gate.ts +++ b/src/renderer/src/components/feature-tips/feature-tip-startup-gate.ts @@ -3,6 +3,7 @@ import { getCompletedFeatureTipIds, getOrderedUnseenFeatureTips } from '../../../../shared/feature-tips' +import type { FeatureInteractionState } from '../../../../shared/feature-interactions' import type { GlobalSettings, OnboardingState } from '../../../../shared/types' import { shouldShowOnboarding } from '../onboarding/should-show-onboarding' @@ -14,6 +15,7 @@ export type FeatureTipsAppOpenDecision = export function getFeatureTipsAppOpenDecision(args: { activeModal: string featureTipsSeenIds: readonly FeatureTipId[] + featureInteractions: FeatureInteractionState onboarding: OnboardingState | null persistedUIReady: boolean promptedThisSession: boolean @@ -39,7 +41,8 @@ export function getFeatureTipsAppOpenDecision(args: { const unseenTips = getOrderedUnseenFeatureTips({ seenTipIds: new Set(args.featureTipsSeenIds), completedTipIds: getCompletedFeatureTipIds({ - voiceDictationEnabled: args.settings.voice?.enabled === true + voiceDictationEnabled: args.settings.voice?.enabled === true, + featureInteractions: args.featureInteractions }) }) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 7b19b45abd9..41f996c7f15 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -1156,7 +1156,7 @@ export function FloatingTerminalPanel({ className={isActive ? 'absolute inset-0 flex' : 'absolute inset-0 hidden'} aria-hidden={!isActive} > - + ) })} 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 65f46f644d7..656057814f6 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts @@ -1,7 +1,9 @@ import { useCallback } from 'react' import { toast } from 'sonner' import { track } from '@/lib/telemetry' +import { useAppStore } from '@/store' import { ONBOARDING_FINAL_STEP } from '../../../../shared/constants' +import type { FeatureInteractionId } from '../../../../shared/feature-interactions' import type { EventProps } from '../../../../shared/telemetry-events' import type { GlobalSettings, OnboardingState, TuiAgent } from '../../../../shared/types' import { @@ -138,6 +140,15 @@ type PersistCurrentStepDeps = { setError: (msg: string | null) => void } +const ONBOARDING_FEATURE_INTERACTIONS: Record< + keyof OnboardingFeatureSetupSelection, + FeatureInteractionId +> = { + browserUse: 'agent-browser-setup', + computerUse: 'computer-use-setup', + orchestration: 'agent-orchestration-setup' +} + export type PersistCurrentStepResult = { ok: boolean featureSetupResult?: OnboardingFeatureSetupResult @@ -196,6 +207,7 @@ export function usePersistCurrentStep({ terminalBell: true } }) + useAppStore.getState().recordFeatureInteraction('notifications') onOnboardingChange(await persistStep(3)) return { ok: true } } @@ -210,6 +222,15 @@ export function usePersistCurrentStep({ ...onboardingFeatureSetupRunTelemetry(featureSetupSelection, setupResult) }) if (hasSelectedOnboardingFeatureSetup(featureSetupSelection)) { + const recordFeatureInteraction = useAppStore.getState().recordFeatureInteraction + for (const [id, selected] of Object.entries(featureSetupSelection) as [ + keyof OnboardingFeatureSetupSelection, + boolean + ][]) { + if (selected) { + recordFeatureInteraction(ONBOARDING_FEATURE_INTERACTIONS[id]) + } + } const firstWarning = setupResult.warnings[0] if (firstWarning) { toast.warning('Some feature setup needs attention', { diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 7a5006e997c..2cb7f97dba0 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -1955,6 +1955,7 @@ function SourceControlInner(): React.JSX.Element { } return writeCommitDraftForWorktree(prev, activeWorktreeId, result.message) }) + useAppStore.getState().recordFeatureInteraction('ai-commit-generation') setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) } catch (error) { setGenerateErrors((prev) => ({ @@ -2253,6 +2254,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) { diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts index 23ba2988e4f..25d943b89fc 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -357,6 +357,7 @@ export function useCreatePullRequestDialogFields({ return } applyGeneratedFields(result.fields, currentSeed.fieldRevisions) + useAppStore.getState().recordFeatureInteraction('ai-pr-generation') setGenerateError(null) } catch (error) { if (generationRequestIdRef.current !== requestId) { diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 148d14720f4..21518e22605 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, @@ -111,9 +111,11 @@ function getClaudeAccountErrorDescription(error: unknown): string { export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): React.JSX.Element { const searchQuery = useAppStore((s) => s.settingsSearchQuery) + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) 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: [], @@ -132,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 @@ -201,6 +211,7 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R try { const next = await operation() await syncCodexAccounts(next) + recordFeatureInteraction('codex-account-switching') const shouldPromptRestart = action === 'adding' || (action.startsWith('select:') && previousActiveAccountId !== next.activeAccountId) || @@ -232,6 +243,7 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R try { const next = await operation() await syncClaudeAccounts(next) + recordFeatureInteraction('claude-account-switching') if (previousActiveAccountId !== next.activeAccountId || action === 'adding') { toast.info('Claude account updated.', { description: `${getClaudeAccountLabel(claudeAccounts, previousActiveAccountId)} → ${getClaudeAccountLabel(next, next.activeAccountId)}. Restart live Claude terminals before continuing old sessions.` @@ -657,11 +669,12 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R @@ -92,6 +97,7 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element { const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing) + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process') const address = addressForPort(port) @@ -100,6 +106,7 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element { const handleOpen = useCallback( (event: React.MouseEvent) => { event.stopPropagation() + recordFeatureInteraction('ports') void openWorkspacePortInBrowser({ port, runtimeTarget, @@ -112,17 +119,25 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element { } }) }, - [createBrowserTab, port, runtimeTarget, setRemoteBrowserPageHandle, settings] + [ + createBrowserTab, + port, + recordFeatureInteraction, + runtimeTarget, + setRemoteBrowserPageHandle, + settings + ] ) const handleCopy = useCallback( (event: React.MouseEvent) => { event.stopPropagation() + recordFeatureInteraction('ports') const address = addressForPort(port) void window.api.ui.writeClipboardText(address) toast.success(`Copied ${address}`) }, - [port] + [port, recordFeatureInteraction] ) const handleStop = useCallback( @@ -131,6 +146,7 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element { if (!canStopWorkspacePort(port)) { return } + recordFeatureInteraction('ports') const run = async (): Promise => { const result = await killWorkspacePortForTarget(runtimeTarget, { repoId: port.owner.repoId, @@ -155,7 +171,13 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element { } void run() }, - [port, runtimeTarget, setWorkspacePortScan, setWorkspacePortScanRefreshing] + [ + port, + recordFeatureInteraction, + runtimeTarget, + setWorkspacePortScan, + setWorkspacePortScanRefreshing + ] ) return ( @@ -201,15 +223,17 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element { export function WorktreeCardPortsDetails({ ports }: WorktreeCardPortsProps): React.JSX.Element | null { + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const handleGoToWorktree = useCallback( (event: React.MouseEvent) => { event.stopPropagation() + recordFeatureInteraction('ports') const ownerPort = ports[0] if (!ownerPort || !goToWorkspacePortOwner(ownerPort)) { toast.error('Workspace unavailable') } }, - [ports] + [ports, recordFeatureInteraction] ) if (ports.length === 0) { diff --git a/src/renderer/src/components/stats/ClaudeUsagePane.tsx b/src/renderer/src/components/stats/ClaudeUsagePane.tsx index d09d8861019..38ce88c01a1 100644 --- a/src/renderer/src/components/stats/ClaudeUsagePane.tsx +++ b/src/renderer/src/components/stats/ClaudeUsagePane.tsx @@ -91,11 +91,17 @@ export function ClaudeUsagePane(): React.JSX.Element { const refreshClaudeUsage = useAppStore((state) => state.refreshClaudeUsage) const setClaudeUsageScope = useAppStore((state) => state.setClaudeUsageScope) const setClaudeUsageRange = useAppStore((state) => state.setClaudeUsageRange) + const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) useEffect(() => { void fetchClaudeUsage() }, [fetchClaudeUsage]) + const handleSetEnabled = (enabled: boolean): void => { + recordFeatureInteraction('usage-tracking') + void setClaudeUsageEnabled(enabled) + } + if (!scanState?.enabled) { return (
@@ -111,7 +117,7 @@ export function ClaudeUsagePane(): React.JSX.Element { role="switch" aria-checked={false} aria-label="Enable Claude usage analytics" - onClick={() => void setClaudeUsageEnabled(true)} + onClick={() => handleSetEnabled(true)} className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors" > @@ -205,7 +211,7 @@ export function ClaudeUsagePane(): React.JSX.Element { role="switch" aria-checked={true} aria-label="Enable Claude usage analytics" - onClick={() => void setClaudeUsageEnabled(false)} + onClick={() => handleSetEnabled(false)} className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors" > diff --git a/src/renderer/src/components/stats/CodexUsagePane.tsx b/src/renderer/src/components/stats/CodexUsagePane.tsx index 8c48cf745f0..f6dd7accd02 100644 --- a/src/renderer/src/components/stats/CodexUsagePane.tsx +++ b/src/renderer/src/components/stats/CodexUsagePane.tsx @@ -90,11 +90,17 @@ export function CodexUsagePane(): React.JSX.Element { const refreshCodexUsage = useAppStore((state) => state.refreshCodexUsage) const setCodexUsageScope = useAppStore((state) => state.setCodexUsageScope) const setCodexUsageRange = useAppStore((state) => state.setCodexUsageRange) + const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) useEffect(() => { void fetchCodexUsage() }, [fetchCodexUsage]) + const handleSetEnabled = (enabled: boolean): void => { + recordFeatureInteraction('usage-tracking') + void setCodexUsageEnabled(enabled) + } + if (!scanState?.enabled) { return (
@@ -110,7 +116,7 @@ export function CodexUsagePane(): React.JSX.Element { role="switch" aria-checked={false} aria-label="Enable Codex usage analytics" - onClick={() => void setCodexUsageEnabled(true)} + onClick={() => handleSetEnabled(true)} className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors" > @@ -210,7 +216,7 @@ export function CodexUsagePane(): React.JSX.Element { role="switch" aria-checked={true} aria-label="Enable Codex usage analytics" - onClick={() => void setCodexUsageEnabled(false)} + onClick={() => handleSetEnabled(false)} className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors" > diff --git a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx index a8718b62b1c..7a5302d8526 100644 --- a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx +++ b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx @@ -92,11 +92,17 @@ export function OpenCodeUsagePane(): React.JSX.Element { const refreshOpenCodeUsage = useAppStore((state) => state.refreshOpenCodeUsage) const setOpenCodeUsageScope = useAppStore((state) => state.setOpenCodeUsageScope) const setOpenCodeUsageRange = useAppStore((state) => state.setOpenCodeUsageRange) + const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) useEffect(() => { void fetchOpenCodeUsage() }, [fetchOpenCodeUsage]) + const handleSetEnabled = (enabled: boolean): void => { + recordFeatureInteraction('usage-tracking') + void setOpenCodeUsageEnabled(enabled) + } + if (!scanState?.enabled) { return (
@@ -112,7 +118,7 @@ export function OpenCodeUsagePane(): React.JSX.Element { role="switch" aria-checked={false} aria-label="Enable OpenCode usage analytics" - onClick={() => void setOpenCodeUsageEnabled(true)} + onClick={() => handleSetEnabled(true)} className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors" > @@ -209,7 +215,7 @@ export function OpenCodeUsagePane(): React.JSX.Element { role="switch" aria-checked={true} aria-label="Enable OpenCode usage analytics" - onClick={() => void setOpenCodeUsageEnabled(false)} + onClick={() => handleSetEnabled(false)} className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors" > diff --git a/src/renderer/src/components/stats/StatsPane.tsx b/src/renderer/src/components/stats/StatsPane.tsx index f8b3979f3a6..c023814345f 100644 --- a/src/renderer/src/components/stats/StatsPane.tsx +++ b/src/renderer/src/components/stats/StatsPane.tsx @@ -63,14 +63,16 @@ function UsageAnalyticsOptionIcon({ tab }: { tab: UsageTab }): React.JSX.Element export function StatsPane(): React.JSX.Element { const summary = useAppStore((s) => s.statsSummary) const fetchStatsSummary = useAppStore((s) => s.fetchStatsSummary) + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const [activeUsageTab, setActiveUsageTab] = useState('overview') const activeUsageOption = USAGE_ANALYTICS_OPTIONS.find((option) => option.id === activeUsageTab) ?? USAGE_ANALYTICS_OPTIONS[0] useEffect(() => { + recordFeatureInteraction('usage-tracking') void fetchStatsSummary() - }, [fetchStatsSummary]) + }, [fetchStatsSummary, recordFeatureInteraction]) return (
diff --git a/src/renderer/src/components/stats/UsageOverviewPane.tsx b/src/renderer/src/components/stats/UsageOverviewPane.tsx index f0f7c820975..7eba68597aa 100644 --- a/src/renderer/src/components/stats/UsageOverviewPane.tsx +++ b/src/renderer/src/components/stats/UsageOverviewPane.tsx @@ -262,6 +262,7 @@ export function UsageOverviewPane(): React.JSX.Element { const enableClaudeUsage = useAppStore((state) => state.enableClaudeUsage) const enableCodexUsage = useAppStore((state) => state.enableCodexUsage) const enableOpenCodeUsage = useAppStore((state) => state.enableOpenCodeUsage) + const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) useEffect(() => { void fetchClaudeUsage() @@ -353,13 +354,33 @@ export function UsageOverviewPane(): React.JSX.Element {

- - -
@@ -425,6 +446,7 @@ export function UsageOverviewPane(): React.JSX.Element { provider={provider} totalTokens={overview.totalTokens} onEnable={() => { + recordFeatureInteraction('usage-tracking') if (provider.id === 'claude') { void enableClaudeUsage() } else if (provider.id === 'codex') { diff --git a/src/renderer/src/components/status-bar/PortsStatusSegment.tsx b/src/renderer/src/components/status-bar/PortsStatusSegment.tsx index d9ac1ace9d8..c56aec644ea 100644 --- a/src/renderer/src/components/status-bar/PortsStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/PortsStatusSegment.tsx @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: PortsStatusSegment keeps port scanning, grouping, row actions, + and status-bar interaction tracking together so the popover state stays coherent. */ import React, { useCallback, useMemo, useState } from 'react' import { Plug, @@ -98,6 +100,7 @@ function PortRow({ const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing) + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process') const openInOrcaBrowser = shouldOpenWorkspacePortInOrcaBrowser(settings) @@ -107,6 +110,7 @@ function PortRow({ const handleOpen = useCallback( (event: React.MouseEvent) => { event.stopPropagation() + recordFeatureInteraction('ports') void openWorkspacePortInBrowser({ port, activeWorktreeId, @@ -125,6 +129,7 @@ function PortRow({ createBrowserTab, openInOrcaBrowser, port, + recordFeatureInteraction, runtimeTarget, setRemoteBrowserPageHandle ] @@ -133,11 +138,12 @@ function PortRow({ const handleCopy = useCallback( (event: React.MouseEvent) => { event.stopPropagation() + recordFeatureInteraction('ports') const address = addressForPort(port) void window.api.ui.writeClipboardText(address) toast.success(`Copied ${address}`) }, - [port] + [port, recordFeatureInteraction] ) const handleStop = useCallback( @@ -146,6 +152,7 @@ function PortRow({ if (!canStopWorkspacePort(port)) { return } + recordFeatureInteraction('ports') const run = async (): Promise => { const result = await killWorkspacePortForTarget(runtimeTarget, { repoId: port.owner.repoId, @@ -170,7 +177,13 @@ function PortRow({ } void run() }, - [port, runtimeTarget, setWorkspacePortScan, setWorkspacePortScanRefreshing] + [ + port, + recordFeatureInteraction, + runtimeTarget, + setWorkspacePortScan, + setWorkspacePortScanRefreshing + ] ) return ( @@ -262,6 +275,7 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React const refreshing = useAppStore((s) => s.workspacePortScanRefreshing) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const [open, setOpen] = useState(false) const [externalOpen, setExternalOpen] = useState(false) const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) @@ -277,6 +291,7 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React if (!nextOpen) { return } + recordFeatureInteraction('ports') // Why: the 30s background poll is intentionally quiet; opening the // popover should still collapse that stale window without flashing icons. void scanWorkspacePortsForTarget(runtimeTarget) @@ -296,7 +311,7 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React }) }) }, - [runtimeTarget, scanKey, setWorkspacePortScan] + [recordFeatureInteraction, runtimeTarget, scanKey, setWorkspacePortScan] ) return ( @@ -379,7 +394,10 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React type="button" className="sticky top-0 z-10 flex w-full items-center gap-1.5 border-b border-border/40 bg-popover px-3 py-2 text-left text-[11px] font-medium uppercase tracking-[0.05em] text-muted-foreground hover:bg-accent/50 hover:text-foreground" aria-expanded={externalOpen} - onClick={() => setExternalOpen((value) => !value)} + onClick={() => { + recordFeatureInteraction('ports') + setExternalOpen((value) => !value) + }} > {externalOpen ? ( diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index 9ca09515699..f1abd77a777 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -650,6 +650,7 @@ export function ResourceUsageStatusSegment({ const setActiveView = useAppStore((s) => s.setActiveView) const openModal = useAppStore((s) => s.openModal) const openSpacePage = useAppStore((s) => s.openSpacePage) + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const activeView = useAppStore((s) => s.activeView) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const workspaceSpaceScannedAt = useAppStore((s) => s.workspaceSpaceAnalysis?.scannedAt ?? null) @@ -1060,7 +1061,15 @@ export function ResourceUsageStatusSegment({ }, [openSpacePage]) return ( - + { + if (nextOpen) { + recordFeatureInteraction('resource-manager') + } + setOpen(nextOpen) + }} + > diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index 1a77fed3d58..31c129c6f2b 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -113,28 +113,31 @@ function TargetRow({ syncStatus: RemoteWorkspaceSyncStatus | undefined }): React.JSX.Element { const [busy, setBusy] = useState(false) + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const handleConnect = useCallback(async () => { setBusy(true) try { await window.api.ssh.connect({ targetId }) + recordFeatureInteraction('ssh') } catch (err) { toast.error(err instanceof Error ? err.message : 'Connection failed') } finally { setBusy(false) } - }, [targetId]) + }, [recordFeatureInteraction, targetId]) const handleDisconnect = useCallback(async () => { setBusy(true) try { await window.api.ssh.disconnect({ targetId }) + recordFeatureInteraction('ssh') } catch (err) { toast.error(err instanceof Error ? err.message : 'Disconnect failed') } finally { setBusy(false) } - }, [targetId]) + }, [recordFeatureInteraction, targetId]) return (
@@ -193,6 +196,7 @@ export function SshStatusSegment({ ) const setActiveView = useAppStore((s) => s.setActiveView) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const targets = Array.from(sshTargetLabels.entries()).map(([id, label]) => { const state = sshConnectionStates.get(id) @@ -221,7 +225,13 @@ export function SshStatusSegment({ : null return ( - + { + if (open) { + recordFeatureInteraction('ssh') + } + }} + >
recordFeatureInteraction('terminal-panes')} onRatioChange={(nextRatio) => setTabGroupSplitRatio(worktreeId, nodePath, nextRatio)} />
diff --git a/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx b/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx index 2060ce32724..7e9256f57ea 100644 --- a/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx +++ b/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx @@ -7,6 +7,11 @@ import { useAppStore } from '../../store' export default function WorkspaceSpacePage(): React.JSX.Element { const closeSpacePage = useAppStore((state) => state.closeSpacePage) + const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) + + useEffect(() => { + recordFeatureInteraction('workspace-cleanup') + }, [recordFeatureInteraction]) useEffect(() => { const hasVisibleOverlay = (): boolean => diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 42524681ead..04f86886202 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1226,7 +1226,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') { @@ -1250,7 +1252,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() @@ -1262,7 +1267,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 4eb18d488ff..9b629c0d251 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -940,7 +940,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) { @@ -1057,7 +1057,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 @@ -1081,7 +1081,9 @@ export function useIpcEvents(): void { 0, createdUnifiedTab.id ) - useAppStore.getState().reorderUnifiedTabs(createdUnifiedTab.groupId, order) + useAppStore.getState().reorderUnifiedTabs(createdUnifiedTab.groupId, order, { + recordInteraction: false + }) } } if (shouldActivate) { @@ -1090,7 +1092,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 99cf6c894c9..579c4f98bb3 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -1,4 +1,4 @@ -/* eslint-disable max-lines */ +/* eslint-disable max-lines -- Why: local/runtime launch tests share a mock harness. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createCompatibleRuntimeStatusResponseIfNeeded } from '@/runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' @@ -119,7 +119,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', @@ -144,7 +147,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)) @@ -243,7 +248,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 03e7cf53234..63ea50128e4 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 ece817baade..ea8b755468f 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -42,10 +42,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, @@ -260,7 +264,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. @@ -268,7 +274,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/runtime/runtime-rpc-client.test.ts b/src/renderer/src/runtime/runtime-rpc-client.test.ts index 21eec50c6c0..c5b67286b21 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.test.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.test.ts @@ -10,6 +10,10 @@ import { MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_PROTOCOL_VERSION } from '../../../shared/protocol-version' +import { + ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE, + ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY +} from '../../../shared/runtime-rpc-feature-interaction-source' const runtimeCall = vi.fn() const runtimeEnvironmentCall = vi.fn() @@ -55,6 +59,31 @@ describe('runtime RPC client routing', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('marks local UI-owned runtime calls so feature interaction tracking can ignore them', async () => { + runtimeCall.mockResolvedValue({ + id: 'local', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'local-runtime' } + }) + + await callRuntimeRpc( + { kind: 'local' }, + 'browser.viewport', + { page: 'page-1' }, + { suppressFeatureInteraction: true } + ) + + expect(runtimeCall).toHaveBeenCalledWith({ + method: 'browser.viewport', + params: { + page: 'page-1', + [ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY]: ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE + } + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + it('routes remote runtime calls through window.api.runtimeEnvironments.call', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'remote', @@ -106,6 +135,43 @@ describe('runtime RPC client routing', () => { ]) }) + it('marks remote UI-owned runtime calls so feature interaction tracking can ignore them', async () => { + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + const result = + method === 'status.get' + ? { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + : { ok: true } + return Promise.resolve({ + id: method, + ok: true, + result, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + await callRuntimeRpc( + { kind: 'environment', environmentId: 'env-1' }, + 'browser.viewport', + { page: 'page-1' }, + { suppressFeatureInteraction: true } + ) + + expect(runtimeEnvironmentCall).toHaveBeenLastCalledWith({ + selector: 'env-1', + method: 'browser.viewport', + params: { + page: 'page-1', + [ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY]: ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE + }, + timeoutMs: undefined + }) + }) + it('caches successful remote compatibility checks per environment', async () => { runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { const result = diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts index 46f0e9a1208..dd974e30363 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -1,6 +1,7 @@ import type { GlobalSettings } from '../../../shared/types' import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../../shared/runtime-types' +import { withBrowserPaneUiRuntimeRpcSource } from '../../../shared/runtime-rpc-feature-interaction-source' import { assertRuntimeStatusCompatible } from './runtime-protocol-compat' export type RuntimeClientTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string } @@ -44,23 +45,34 @@ export async function callRuntimeRpc( target: RuntimeClientTarget, method: string, params?: unknown, - options: { timeoutMs?: number } = {} + options: { timeoutMs?: number; suppressFeatureInteraction?: boolean } = {} ): Promise { if (target.kind === 'environment' && method !== 'status.get') { await ensureRuntimeEnvironmentCompatible(target.environmentId, options.timeoutMs) } + const nextParams = addFeatureInteractionSource(params, options) const response = target.kind === 'local' - ? await window.api.runtime.call({ method, params }) + ? await window.api.runtime.call({ method, params: nextParams }) : await window.api.runtimeEnvironments.call({ selector: target.environmentId, method, - params, + params: nextParams, timeoutMs: options.timeoutMs }) return unwrapRuntimeRpcResult(response as RuntimeRpcResponse) } +function addFeatureInteractionSource( + params: unknown, + options: { suppressFeatureInteraction?: boolean } +): unknown { + if (!options.suppressFeatureInteraction) { + return params + } + return withBrowserPaneUiRuntimeRpcSource(params) +} + async function ensureRuntimeEnvironmentCompatible( environmentId: string, timeoutMs?: number diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 1055aaa6a24..6fd9207bbf4 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -1252,7 +1252,14 @@ export const createBrowserSlice: StateCreator = setBrowserTabUrl: (pageId, url) => get().setBrowserPageUrl(pageId, url), - setBrowserPageUrl: (pageId, url) => + setBrowserPageUrl: (pageId, url) => { + const nextUrl = normalizeUrl(url) + if (nextUrl !== 'about:blank' && nextUrl !== ORCA_BROWSER_BLANK_URL) { + const currentPage = findPage(get().browserPagesByWorkspace, pageId) + if (currentPage) { + get().recordFeatureInteraction?.('browser') + } + } set((s) => { const page = findPage(s.browserPagesByWorkspace, pageId) if (!page) { @@ -1262,7 +1269,6 @@ export const createBrowserSlice: StateCreator = if (!workspace) { return s } - const nextUrl = normalizeUrl(url) // Why: annotations point at DOM coordinates from one loaded document. // A real URL change invalidates those markers and copied context. const shouldClearAnnotations = normalizeUrl(page.url) !== nextUrl @@ -1299,7 +1305,8 @@ export const createBrowserSlice: StateCreator = ? { browserAnnotationsByPageId: nextBrowserAnnotationsByPageId } : {}) } - }), + }) + }, setRemoteBrowserPageHandle: (pageId, handle) => { set((s) => ({ @@ -1568,7 +1575,8 @@ export const createBrowserSlice: StateCreator = if (!exists) { state.createUnifiedTab(worktreeId, 'browser', { entityId: bt.id, - label: bt.title + label: bt.title, + recordInteraction: false }) } } @@ -1717,6 +1725,7 @@ export const createBrowserSlice: StateCreator = profileId })) as BrowserCookieImportResult if (result.ok) { + get().recordFeatureInteraction?.('cookie-import') set({ browserSessionImportState: { profileId, @@ -1860,6 +1869,7 @@ export const createBrowserSlice: StateCreator = browserProfile })) as BrowserCookieImportResult if (result.ok) { + get().recordFeatureInteraction?.('cookie-import') set({ browserSessionImportState: { profileId, @@ -1916,6 +1926,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/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index b757e9b714a..a7bf5b3d4f0 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -29,6 +29,10 @@ const runtimeEnvironmentCall = vi.fn().mockResolvedValue({ }) const runtimeEnvironmentTransportCall = vi.fn() const mockApi = { + ui: { + recordFeatureInteraction: vi.fn().mockResolvedValue({ featureInteractions: {} }), + set: vi.fn().mockResolvedValue(undefined) + }, worktrees: { list: vi.fn().mockResolvedValue([]), create: vi.fn().mockResolvedValue({}), @@ -434,6 +438,7 @@ describe('markDiffCommentsSent', () => { it('marks selected notes as sent and persists once', async () => { const store = createTestStore() + store.setState({ persistedUIReady: true }) seed(store, [ makeComment({ id: 'c1', filePath: 'src/foo.ts' }), makeComment({ id: 'c2', filePath: 'src/bar.ts' }) @@ -453,6 +458,9 @@ describe('markDiffCommentsSent', () => { diffComments: [expect.objectContaining({ id: 'c1', sentAt: 3000 }), expect.any(Object)] } }) + expect(store.getState().featureInteractions['review-notes']).toEqual( + expect.objectContaining({ interactionCount: 1 }) + ) }) it('returns success without persisting when no selected notes match', async () => { diff --git a/src/renderer/src/store/slices/diffComments.ts b/src/renderer/src/store/slices/diffComments.ts index 01ff2dc6461..09c9afdc32e 100644 --- a/src/renderer/src/store/slices/diffComments.ts +++ b/src/renderer/src/store/slices/diffComments.ts @@ -280,6 +280,7 @@ export const createDiffCommentsSlice: StateCreator ({ 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) @@ -248,6 +251,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', () => { @@ -873,6 +894,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' @@ -885,6 +907,8 @@ describe('TabsSlice', () => { label: 'file-b.ts', targetGroupId: targetGroupId! }) + store.getState().hydratePersistedUI(getDefaultUIState()) + setMock.mockClear() const mergedInto = store.getState().mergeGroupIntoSibling(WT, targetGroupId!) @@ -893,6 +917,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 42929490413..51ef442e492 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -47,6 +47,7 @@ export type TabsSlice = { > & { targetGroupId: string activate: boolean + recordInteraction: boolean } > ) => Tab @@ -60,11 +61,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 @@ -81,7 +91,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, @@ -466,6 +476,9 @@ export const createTabsSlice: StateCreator = (set, } } }) + if (init?.recordInteraction !== false) { + get().recordFeatureInteraction?.('terminal-tabs') + } return created }, @@ -554,7 +567,7 @@ export const createTabsSlice: StateCreator = (set, }) }, - closeUnifiedTab: (tabId) => { + closeUnifiedTab: (tabId, opts) => { const state = get() const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) if (!found) { @@ -695,10 +708,14 @@ export const createTabsSlice: StateCreator = (set, } }) + 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)) { const group = groups.find((candidate) => candidate.id === groupId) @@ -709,6 +726,7 @@ export const createTabsSlice: StateCreator = (set, // each tab. Sanitizing here restores the invariant at the store // boundary so later group operations do not branch on duplicate ids. const nextTabOrder = dedupeTabOrder(tabIds) + reordered = true const orderMap = new Map(nextTabOrder.map((id, index) => [id, index])) return { groupsByWorktree: { @@ -726,25 +744,49 @@ export const createTabsSlice: StateCreator = (set, } return {} }) + if (reordered && opts?.recordInteraction !== false) { + get().recordFeatureInteraction?.('terminal-tabs') + } }, - setTabLabel: (tabId, label) => - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? {}), + setTabLabel: (tabId, label) => { + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? {}) + }, - setTabCustomLabel: (tabId, label) => - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {}), + setTabCustomLabel: (tabId, label, opts) => { + const exists = get().getTab(tabId) !== null + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {}) + if (exists && opts?.recordInteraction !== false) { + get().recordFeatureInteraction?.('terminal-tabs') + } + }, - setUnifiedTabColor: (tabId, color) => - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { color }) ?? {}), + setUnifiedTabColor: (tabId, color) => { + const exists = get().getTab(tabId) !== null + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { color }) ?? {}) + if (exists) { + get().recordFeatureInteraction?.('terminal-tabs') + } + }, - pinTab: (tabId) => + pinTab: (tabId) => { + const exists = get().getTab(tabId) !== null set( (state) => patchTab(state.unifiedTabsByWorktree, tabId, { isPinned: true, isPreview: false }) ?? {} - ), + ) + if (exists) { + get().recordFeatureInteraction?.('terminal-tabs') + } + }, - unpinTab: (tabId) => - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { isPinned: false }) ?? {}), + unpinTab: (tabId) => { + const exists = get().getTab(tabId) !== null + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { isPinned: false }) ?? {}) + if (exists) { + get().recordFeatureInteraction?.('terminal-tabs') + } + }, closeOtherTabs: (tabId) => { const state = get() @@ -961,6 +1003,7 @@ export const createTabsSlice: StateCreator = (set, activeGroupIdByWorktree: { ...state.activeGroupIdByWorktree, [worktreeId]: newGroupId } } }) + get().recordFeatureInteraction?.('terminal-panes') return newGroupId }, @@ -1044,6 +1087,9 @@ export const createTabsSlice: StateCreator = (set, activeGroupIdByWorktree: nextActiveGroupIdByWorktree } }) + if (moved && opts?.recordInteraction !== false) { + get().recordFeatureInteraction?.('tab-splits') + } return moved }, @@ -1211,6 +1257,10 @@ export const createTabsSlice: StateCreator = (set, : {}) } }) + if (moved) { + get().recordFeatureInteraction?.('terminal-tabs') + get().recordFeatureInteraction?.('tab-splits') + } return moved }, @@ -1253,13 +1303,14 @@ 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') return targetGroupId }, - setTabGroupSplitRatio: (worktreeId, nodePath, ratio) => + setTabGroupSplitRatio: (worktreeId, nodePath, ratio) => { set((state) => { const currentLayout = state.layoutByWorktree[worktreeId] if (!currentLayout) { @@ -1278,7 +1329,8 @@ export const createTabsSlice: StateCreator = (set, ) } } - }), + }) + }, reconcileWorktreeTabModel: (worktreeId) => { const state = get() diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 8c5a68e3e8d..9c0162db84a 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -263,6 +263,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 @@ -272,7 +273,7 @@ export type TerminalSlice = { } ) => TerminalTab openNewTerminalTabInActiveWorkspace: (groupId: string) => Promise - 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 @@ -293,7 +294,11 @@ export type TerminalSlice = { * surface that raised it. */ clearTerminalTabUnread: (tabId: string) => void clearTerminalPaneUnread: (paneKey: 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 @@ -629,6 +634,11 @@ export const createTerminalSlice: StateCreator } } }) + const shouldRecordInteraction = + options?.recordInteraction ?? (!options?.pendingActivationSpawn && !options?.initialPtyId) + if (shouldRecordInteraction) { + get().recordFeatureInteraction?.('terminal-tabs') + } return tab }, @@ -678,7 +688,7 @@ export const createTerminalSlice: StateCreator focusTerminalTabSurface(terminal.id) }, - closeTab: (tabId) => { + closeTab: (tabId, opts) => { set((s) => { const next = { ...s.tabsByWorktree } let closingPtyId: string | null = null @@ -814,7 +824,7 @@ export const createTerminalSlice: StateCreator (entry) => entry.contentType === 'terminal' && entry.entityId === tabId ) if (workspaceItem) { - get().closeUnifiedTab(workspaceItem.id) + get().closeUnifiedTab(workspaceItem.id, opts) } } }, @@ -1124,7 +1134,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)) { @@ -1137,7 +1147,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 9484edf86e8..d9b0ca73b85 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -11,6 +11,7 @@ import type { import { createUISlice } from './ui' import { createWorktreeNavHistorySlice } from './worktree-nav-history' import type { AppState } from '../types' +import type { FeatureInteractionState } from '../../../../shared/feature-interactions' afterEach(() => { vi.restoreAllMocks() @@ -802,6 +803,162 @@ describe('createUISlice feature tips', () => { }) }) +describe('createUISlice feature interactions', () => { + it('normalizes persisted feature interaction records during hydration', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI( + makePersistedUI({ + featureInteractions: { + tasks: { firstInteractedAt: 100 }, + automations: { firstInteractedAt: 150, interactionCount: 4 }, + browser: { firstInteractedAt: Number.NaN }, + unknown: { firstInteractedAt: 200 } + } as unknown as FeatureInteractionState + }) + ) + + expect(store.getState().featureInteractions).toEqual({ + tasks: { firstInteractedAt: 100, interactionCount: 1 }, + automations: { firstInteractedAt: 150, interactionCount: 4 } + }) + }) + + it('records feature interaction counts and persists each interaction', () => { + const setMock = vi.fn(() => Promise.resolve()) + vi.stubGlobal('window', { + api: { + ui: { + set: setMock + } + } + }) + const now = 1_700_000_000_000 + vi.useFakeTimers() + vi.setSystemTime(now) + + try { + const store = createUIStore() + store.getState().hydratePersistedUI(makePersistedUI()) + setMock.mockClear() + + store.getState().recordFeatureInteraction('tasks') + store.getState().recordFeatureInteraction('tasks') + + const expected: FeatureInteractionState = { + tasks: { firstInteractedAt: now, interactionCount: 2 } + } + expect(store.getState().featureInteractions).toEqual(expected) + expect(setMock).toHaveBeenCalledTimes(2) + expect(setMock).toHaveBeenCalledWith({ featureInteractions: expected }) + } finally { + vi.useRealTimers() + } + }) + + 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', { + api: { + ui: { + set: setMock + } + } + }) + const store = createUIStore() + + store.getState().recordFeatureInteraction('tasks') + + expect(store.getState().featureInteractions).toEqual({}) + expect(setMock).not.toHaveBeenCalled() + }) +}) + describe('createUISlice space navigation', () => { it('returns to the tasks page after opening Space from an in-progress draft', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 976c470d045..7b4ac2d0c8b 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -27,6 +27,11 @@ import { type WorkspaceCleanupDismissal } from '../../../../shared/workspace-cleanup' import { normalizeFeatureTipIds, type FeatureTipId } from '../../../../shared/feature-tips' +import { + normalizeFeatureInteractions, + type FeatureInteractionId, + type FeatureInteractionState +} from '../../../../shared/feature-interactions' import { PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items' import { normalizeVisibleTaskProviders, @@ -61,6 +66,32 @@ export type PendingSidebarWorktreeReveal = { highlight?: boolean } +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 @@ -467,6 +498,8 @@ export type UISlice = { closeModal: () => void featureTipsSeenIds: FeatureTipId[] markFeatureTipsSeen: (ids: FeatureTipId[]) => void + featureInteractions: FeatureInteractionState + recordFeatureInteraction: (id: FeatureInteractionId) => void trustedOrcaHooks: PersistedTrustedOrcaHooks markOrcaHookScriptConfirmed: ( repoId: string, @@ -639,6 +672,7 @@ export const createUISlice: StateCreator = (set, get) githubTaskDrawerWorkItem: null, newWorkspaceDraft: null, openTaskPage: (data = {}) => { + get().recordFeatureInteraction?.('tasks') // Why: record a Tasks visit in the shared back/forward history so the // titlebar Back/Forward buttons can return to Tasks. All task-source // variants (github/linear presets) collapse to a single 'tasks' entry; @@ -799,6 +833,7 @@ export const createUISlice: StateCreator = (set, get) selectedAutomationId: null, setSelectedAutomationId: (id) => set({ selectedAutomationId: id }), openAutomationsPage: () => { + get().recordFeatureInteraction?.('automations') get().recordViewVisit('automations') set((state) => ({ activeView: 'automations', @@ -879,6 +914,9 @@ export const createUISlice: StateCreator = (set, get) activeModal: 'none', modalData: {}, openModal: (modal, data = {}) => { + if (modal === 'new-workspace-composer' || modal === 'add-repo' || modal === 'create-worktree') { + get().recordFeatureInteraction?.('workspace-creation') + } set({ activeModal: modal, modalData: data @@ -906,6 +944,36 @@ export const createUISlice: StateCreator = (set, get) window.api.ui.set({ featureTipsSeenIds: next }).catch(console.error) return { featureTipsSeenIds: next } }), + featureInteractions: {}, + recordFeatureInteraction: (id) => + set((s) => { + if (!s.persistedUIReady) { + return s + } + const existing = s.featureInteractions[id] + const next: FeatureInteractionState = { + ...s.featureInteractions, + [id]: { + firstInteractedAt: existing?.firstInteractedAt ?? Date.now(), + interactionCount: (existing?.interactionCount ?? 0) + 1 + } + } + if (typeof window !== 'undefined') { + 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 } + }), trustedOrcaHooks: {}, markOrcaHookScriptConfirmed: (repoId, kind, contentHash) => set((s) => { @@ -1236,6 +1304,7 @@ export const createUISlice: StateCreator = (set, get) browserKagiSessionLink: normalizeKagiSessionLink(ui.browserKagiSessionLink ?? ''), taskResumeState: sanitizeTaskResumeState(ui.taskResumeState), featureTipsSeenIds: normalizeFeatureTipIds(ui.featureTipsSeenIds), + featureInteractions: normalizeFeatureInteractions(ui.featureInteractions), trustedOrcaHooks: filterTrustedOrcaHooksToValidRepos( ui.trustedOrcaHooks ?? {}, validRepoIds diff --git a/src/renderer/src/store/slices/workspace-space.ts b/src/renderer/src/store/slices/workspace-space.ts index 3f2ae9ca595..617dcd79195 100644 --- a/src/renderer/src/store/slices/workspace-space.ts +++ b/src/renderer/src/store/slices/workspace-space.ts @@ -65,7 +65,8 @@ function isWorkspaceSpaceScanCancelled(error: unknown): boolean { } export const createWorkspaceSpaceSlice: StateCreator = ( - set + set, + get ) => ({ workspaceSpaceAnalysis: null, workspaceSpaceScanProgress: null, @@ -86,6 +87,9 @@ export const createWorkspaceSpaceSlice: StateCreator { const cancelled = await window.api.workspaceSpace.cancel() + if (cancelled) { + get().recordFeatureInteraction?.('workspace-cleanup') + } if (cancelled) { set((state) => state.workspaceSpaceScanProgress @@ -105,6 +109,7 @@ export const createWorkspaceSpaceSlice: StateCreator + removeWorkspaceSpaceWorktrees: (worktreeIds) => { + if (worktreeIds.length > 0) { + get().recordFeatureInteraction?.('workspace-cleanup') + } set((state) => state.workspaceSpaceAnalysis ? { @@ -152,4 +160,5 @@ export const createWorkspaceSpaceSlice: StateCreator { afterEach(() => { vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') }) it('migrates missing right sidebar visibility from the effective web legacy default', async () => { @@ -195,6 +197,125 @@ describe('web UI preload API', () => { expect(ui.rightSidebarOpen).toBe(true) }) + + 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', () => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index d8e36f37890..f569d5e0769 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' @@ -1464,7 +1469,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 @@ -1482,6 +1494,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')), @@ -2056,6 +2103,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/constants.ts b/src/shared/constants.ts index fa4f4da634c..339d121486d 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -387,7 +387,8 @@ export function getDefaultUIState(): PersistedUIState { setupScriptPromptDismissedRepoIds: [], acknowledgedAgentsByPaneKey: {}, workspaceCleanup: { dismissals: {} }, - featureTipsSeenIds: [] + featureTipsSeenIds: [], + featureInteractions: {} } } diff --git a/src/shared/feature-interactions.test.ts b/src/shared/feature-interactions.test.ts new file mode 100644 index 00000000000..c094808dd7c --- /dev/null +++ b/src/shared/feature-interactions.test.ts @@ -0,0 +1,156 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + FEATURE_INTERACTIONS, + hasFeatureInteraction, + normalizeFeatureInteractions, + type FeatureInteractionId +} from './feature-interactions' + +type DefinedFeatureInteractionId = (typeof FEATURE_INTERACTIONS)[number]['id'] +type MissingFeatureInteractionId = Exclude +type ExtraFeatureInteractionId = Exclude + +const REPO_ROOT = join(__dirname, '..', '..') +const SOURCE_ROOTS = ['src/main', 'src/renderer/src', 'src/preload'] +const PRODUCTION_FILE_PATTERN = /\.(ts|tsx)$/ +const TEST_FILE_PATTERN = /(?:^|\.)(test|spec)\.(ts|tsx)$/ + +describe('feature interactions', () => { + it('defines local interaction semantics for product education features', () => { + const catalogMatchesPublicUnion: [ + MissingFeatureInteractionId, + ExtraFeatureInteractionId + ] extends [never, never] + ? true + : never = true + const expectedIds: FeatureInteractionId[] = [ + 'workspace-board', + 'workspace-board-actions', + 'browser', + 'tasks', + 'automations', + 'automation-created', + 'automation-run', + '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', + 'ports', + 'quick-commands', + 'resource-manager', + 'review-notes', + 'ssh', + 'terminal-panes', + 'terminal-tabs', + 'tab-splits', + 'usage-tracking', + 'voice-dictation', + 'workspace-cleanup' + ] + + expect(catalogMatchesPublicUnion).toBe(true) + expect(FEATURE_INTERACTIONS.map((feature) => feature.id)).toEqual(expectedIds) + for (const feature of FEATURE_INTERACTIONS) { + expect(feature.interaction.length).toBeGreaterThan(0) + } + }) + + it('normalizes persisted records by removing unknown ids and malformed values', () => { + expect( + normalizeFeatureInteractions({ + tasks: { firstInteractedAt: 100 }, + browser: { firstInteractedAt: Number.NaN }, + automations: { firstInteractedAt: 200, interactionCount: 3 }, + 'browser-grab': { firstInteractedAt: 250, interactionCount: 0 }, + unknown: { firstInteractedAt: 200 }, + 'voice-dictation': { firstInteractedAt: 300 } + }) + ).toEqual({ + tasks: { firstInteractedAt: 100, interactionCount: 1 }, + automations: { firstInteractedAt: 200, interactionCount: 3 }, + 'browser-grab': { firstInteractedAt: 250, interactionCount: 1 }, + 'voice-dictation': { firstInteractedAt: 300, interactionCount: 1 } + }) + }) + + it('treats only valid known records as interacted', () => { + expect( + hasFeatureInteraction({ tasks: { firstInteractedAt: 100, interactionCount: 1 } }, 'tasks') + ).toBe(true) + expect( + hasFeatureInteraction({ tasks: { firstInteractedAt: 100, interactionCount: 1 } }, 'browser') + ).toBe(false) + expect( + hasFeatureInteraction( + { tasks: { firstInteractedAt: Number.POSITIVE_INFINITY, interactionCount: 1 } }, + 'tasks' + ) + ).toBe(false) + }) + + it('keeps every catalog id wired to a production writer', () => { + const productionText = collectProductionSourceText() + const missingWriters = FEATURE_INTERACTIONS.map((feature) => feature.id).filter((id) => { + const escaped = escapeRegExp(id) + const directRecord = new RegExp( + `recordFeatureInteraction(?:\\?\\.)?\\(\\s*['"]${escaped}['"]` + ) + const runtimeMappingReturn = new RegExp(`return[^\\n]*['"]${escaped}['"]`) + return !directRecord.test(productionText) && !runtimeMappingReturn.test(productionText) + }) + + expect(missingWriters).toEqual([]) + }) +}) + +function collectProductionSourceText(): string { + const files = SOURCE_ROOTS.flatMap((root) => collectSourceFiles(join(REPO_ROOT, root))) + return files + .sort() + .map((file) => readFileSync(file, 'utf8')) + .join('\n') +} + +function collectSourceFiles(directory: string): string[] { + const files: string[] = [] + for (const entry of readdirSync(directory)) { + const path = join(directory, entry) + const stats = statSync(path) + if (stats.isDirectory()) { + if (entry === 'node_modules' || entry === 'dist' || entry === 'out') { + continue + } + files.push(...collectSourceFiles(path)) + continue + } + const repoRelativePath = relative(REPO_ROOT, path) + if (!PRODUCTION_FILE_PATTERN.test(entry) || TEST_FILE_PATTERN.test(entry)) { + continue + } + // Why: the catalog itself proves the id exists, not that runtime code writes it. + if (repoRelativePath === 'src/shared/feature-interactions.ts') { + continue + } + files.push(path) + } + return files +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/src/shared/feature-interactions.ts b/src/shared/feature-interactions.ts new file mode 100644 index 00000000000..d95f194c4fe --- /dev/null +++ b/src/shared/feature-interactions.ts @@ -0,0 +1,252 @@ +export type FeatureInteractionId = + | 'workspace-board' + | 'workspace-board-actions' + | 'browser' + | 'tasks' + | 'automations' + | 'automation-created' + | 'automation-run' + | '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' + | 'ports' + | 'quick-commands' + | 'resource-manager' + | 'review-notes' + | 'ssh' + | 'terminal-panes' + | 'terminal-tabs' + | 'tab-splits' + | 'usage-tracking' + | 'voice-dictation' + | 'workspace-cleanup' + +export type FeatureInteractionDefinition = { + id: FeatureInteractionId + /** The product action that counts as "the user has interacted with this feature." */ + interaction: string +} + +export type FeatureInteractionRecord = { + /** Unix timestamp in milliseconds for the first local interaction. */ + firstInteractedAt: number + /** Number of local interactions recorded for this feature. */ + interactionCount: number +} + +export type FeatureInteractionState = Partial< + Record +> + +// Why: these ids become persisted product state; see +// docs/reference/feature-discovery-interaction-tracking.md before changing them. +export const FEATURE_INTERACTIONS = [ + { + id: 'workspace-board', + interaction: 'workspace board opened' + }, + { + id: 'workspace-board-actions', + interaction: 'workspace board card, lane, density, or status action used' + }, + { + id: 'browser', + interaction: 'non-blank browser page viewed' + }, + { + id: 'tasks', + interaction: 'Tasks page opened' + }, + { + id: 'automations', + interaction: 'Automations page opened' + }, + { + id: 'automation-created', + interaction: 'automation created' + }, + { + id: 'automation-run', + interaction: 'automation run queued' + }, + { + id: 'browser-annotations', + interaction: 'browser annotation added, copied, or cleared' + }, + { + id: 'browser-grab', + interaction: 'browser element grab or screenshot used' + }, + { + 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 runtime method used' + }, + { + id: 'agent-orchestration-setup', + interaction: 'Agent Orchestration setup enabled or opened' + }, + { + id: 'agent-orchestration', + interaction: 'agent orchestration runtime method used' + }, + { + id: 'ai-commit-generation', + interaction: 'AI commit message generation enabled or used' + }, + { + id: 'ai-pr-generation', + interaction: 'AI pull request generation used' + }, + { + id: 'claude-account-switching', + interaction: 'Claude managed account added, selected, reauthenticated, or removed' + }, + { + 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' + }, + { + id: 'mobile-pairing', + interaction: 'mobile pairing enabled or QR code generated' + }, + { + id: 'notifications', + interaction: 'desktop notifications enabled or tested' + }, + { + id: 'ports', + interaction: 'Ports popover opened, configured, or port action used' + }, + { + id: 'quick-commands', + interaction: 'terminal quick command created or edited' + }, + { + id: 'resource-manager', + interaction: 'Resource Manager opened or configured' + }, + { + id: 'review-notes', + interaction: 'review note added or sent to an agent' + }, + { + id: 'ssh', + interaction: 'SSH target added, imported, tested, connected, disconnected, or configured' + }, + { + id: 'terminal-panes', + interaction: 'terminal/editor/browser pane created, resized, or merged' + }, + { + id: 'terminal-tabs', + interaction: 'workspace tab created, moved, reordered, pinned, renamed, recolored, or closed' + }, + { + id: 'tab-splits', + interaction: 'workspace tab split into another pane' + }, + { + id: 'usage-tracking', + interaction: 'Stats & Usage or provider usage details opened or configured' + }, + { + id: 'voice-dictation', + interaction: 'dictation session started' + }, + { + id: 'workspace-cleanup', + interaction: 'workspace disk space scan, review, or cleanup action used' + } +] as const satisfies readonly FeatureInteractionDefinition[] + +export const FEATURE_INTERACTION_IDS = FEATURE_INTERACTIONS.map((feature) => feature.id) + +export function isFeatureInteractionId(value: unknown): value is FeatureInteractionId { + return ( + typeof value === 'string' && FEATURE_INTERACTION_IDS.includes(value as FeatureInteractionId) + ) +} + +export function hasFeatureInteraction( + state: FeatureInteractionState | null | undefined, + id: FeatureInteractionId +): boolean { + return normalizeFeatureInteractionRecord(state?.[id]) !== null +} + +export function normalizeFeatureInteractions(value: unknown): FeatureInteractionState { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + + const input = value as Record + const out: FeatureInteractionState = {} + for (const id of FEATURE_INTERACTION_IDS) { + const record = normalizeFeatureInteractionRecord(input[id]) + if (record) { + out[id] = record + } + } + return out +} + +function normalizeFeatureInteractionRecord(value: unknown): FeatureInteractionRecord | null { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const input = value as Record + const firstInteractedAt = input.firstInteractedAt + if ( + typeof firstInteractedAt !== 'number' || + !Number.isFinite(firstInteractedAt) || + firstInteractedAt < 0 + ) { + return null + } + const rawInteractionCount = input.interactionCount + const interactionCount = + typeof rawInteractionCount === 'number' && + Number.isInteger(rawInteractionCount) && + rawInteractionCount > 0 + ? rawInteractionCount + : 1 + return { firstInteractedAt, interactionCount } +} diff --git a/src/shared/feature-tips.test.ts b/src/shared/feature-tips.test.ts index 802cfdb76ef..56365ab72fd 100644 --- a/src/shared/feature-tips.test.ts +++ b/src/shared/feature-tips.test.ts @@ -30,6 +30,20 @@ describe('feature tips', () => { expect(tips.map((tip) => tip.id)).toEqual([]) }) + it('skips tips for features the user has already interacted with', () => { + const tips = getOrderedUnseenFeatureTips({ + seenTipIds: new Set(), + completedTipIds: getCompletedFeatureTipIds({ + voiceDictationEnabled: false, + featureInteractions: { + 'voice-dictation': { firstInteractedAt: 100, interactionCount: 1 } + } + }) + }) + + expect(tips.map((tip) => tip.id)).toEqual([]) + }) + it('normalizes persisted tip ids', () => { expect(normalizeFeatureTipIds(['feature-tour', 'bogus', 'voice-dictation'])).toEqual([ 'voice-dictation' diff --git a/src/shared/feature-tips.ts b/src/shared/feature-tips.ts index 9aec74e6ef9..a4b8bc3713b 100644 --- a/src/shared/feature-tips.ts +++ b/src/shared/feature-tips.ts @@ -1,3 +1,9 @@ +import { + hasFeatureInteraction, + type FeatureInteractionId, + type FeatureInteractionState +} from './feature-interactions' + export type FeatureTipId = 'voice-dictation' export type FeatureTipPriority = 'new' | 'unseen' @@ -12,10 +18,13 @@ export type FeatureTip = { description: string action: FeatureTipAction ctaLabel: string + /** Feature interactions that mean this tip is no longer useful to show. */ + completedByFeatureInteractions?: readonly FeatureInteractionId[] } export type CompletedFeatureTipState = { voiceDictationEnabled: boolean + featureInteractions?: FeatureInteractionState } export const FEATURE_TIPS = [ @@ -27,7 +36,8 @@ export const FEATURE_TIPS = [ description: 'Speak into any focused pane and Orca will transcribe it. Press the dictation shortcut to start and stop.', action: 'enable-voice', - ctaLabel: 'Set Up Voice' + ctaLabel: 'Set Up Voice', + completedByFeatureInteractions: ['voice-dictation'] } ] as const satisfies readonly FeatureTip[] @@ -56,6 +66,15 @@ export function getCompletedFeatureTipIds(state: CompletedFeatureTipState): Set< if (state.voiceDictationEnabled) { completedIds.add('voice-dictation') } + for (const tip of FEATURE_TIPS) { + if ( + tip.completedByFeatureInteractions?.some((id) => + hasFeatureInteraction(state.featureInteractions, id) + ) + ) { + completedIds.add(tip.id) + } + } return completedIds } diff --git a/src/shared/runtime-rpc-feature-interaction-source.ts b/src/shared/runtime-rpc-feature-interaction-source.ts new file mode 100644 index 00000000000..ce481338885 --- /dev/null +++ b/src/shared/runtime-rpc-feature-interaction-source.ts @@ -0,0 +1,25 @@ +export const ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY = '__orcaFeatureInteractionSource' + +export const ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE = 'browser-pane-ui' + +export function withBrowserPaneUiRuntimeRpcSource(value: unknown): unknown { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return { + [ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY]: ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE + } + } + return { + ...value, + [ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY]: ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE + } +} + +export function isBrowserPaneUiRuntimeRpcParams(value: unknown): boolean { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + (value as Record)[ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY] === + ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE + ) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 6a6b425db6f..0b50a74094b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -13,6 +13,7 @@ import type { WorkspaceCleanupUIState } from './workspace-cleanup' import type { GitLabProjectSettings } from './gitlab-types' import type { TaskProvider } from './task-providers' import type { FeatureTipId } from './feature-tips' +import type { FeatureInteractionState } from './feature-interactions' import type { GitBranchChangeStatus } from './git-status-types' import type { KeybindingOverrides, TerminalShortcutPolicy } from './keybindings' import type { RepoIcon } from './repo-icon' @@ -2309,6 +2310,9 @@ export type PersistedUIState = { /** Feature tips already surfaced to the user. Startup only opens the tips * modal when this list is missing one of the current tip ids. */ featureTipsSeenIds?: FeatureTipId[] + /** Local product-state facts: feature ids the user has actually used. + * Used by education surfaces to avoid teaching already-discovered features. */ + featureInteractions?: FeatureInteractionState } export const PET_SIZE_MIN = 60