Track feature interactions (#2959)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-05-28 14:44:35 -07:00
committed by GitHub
co-authored by Orca
parent 638d565e5e
commit 4b32afeff2
79 changed files with 2497 additions and 151 deletions
@@ -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?"
+8
View File
@@ -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<PersistedUIState>) => {
store.updateUI(args)
})
ipcMain.handle('ui:recordFeatureInteraction', (_event, id: unknown) => {
if (!isFeatureInteractionId(id)) {
throw new Error('invalid_feature_interaction_id')
}
return store.recordFeatureInteraction(id)
})
}
+115 -1
View File
@@ -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'] })
+67 -2
View File
@@ -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'] {
+111
View File
@@ -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<string, Set<Listener>>()
const ipcMain = {
on: vi.fn((channel: string, listener: Listener) => {
const existing = listeners.get(channel) ?? new Set<Listener>()
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: [] })
+11 -1
View File
@@ -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
})
})
@@ -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<PersistedUIState>) => {
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')
})
})
+77 -1
View File
@@ -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<FeatureInteractionId>()
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<FeatureInteractionId>,
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<string, unknown>)[key] === true
)
}
@@ -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()
})
})
+38 -1
View File
@@ -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<FeatureInteractionId>(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<PersistedUIState>)
})
}),
defineMethod({
name: 'ui.recordFeatureInteraction',
params: FeatureInteractionIdParam,
handler: (params, { runtime }) => ({
ui: runtime.recordFeatureInteraction(params)
})
})
]
+1
View File
@@ -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',
+2
View File
@@ -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<PersistedUIState>
set: (args: Partial<PersistedUIState>) => Promise<void>
recordFeatureInteraction: (id: FeatureInteractionId) => Promise<PersistedUIState>
onOpenSettings: (callback: () => void) => () => void
onOpenFeatureTour: (callback: () => void) => () => void
onOpenCrashReport: (callback: () => void) => () => void
+2
View File
@@ -2328,6 +2328,8 @@ const api = {
ui: {
get: (): Promise<unknown> => ipcRenderer.invoke('ui:get'),
set: (args: Record<string, unknown>): Promise<void> => ipcRenderer.invoke('ui:set', args),
recordFeatureInteraction: (id: string): Promise<unknown> =>
ipcRenderer.invoke('ui:recordFeatureInteraction', id),
onOpenSettings: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('ui:openSettings', listener)
+12 -1
View File
@@ -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)) {
@@ -520,6 +520,10 @@ export default function AutomationsPage(): React.JSX.Element {
}
}, [setSelectedId])
const hydratePersistedUIState = useCallback(async (): Promise<void> => {
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<void> => {
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'
@@ -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}`}
>
<Trash2 className="size-3" />
@@ -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 () => {
@@ -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
})
@@ -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()
})
@@ -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<string, unknown>
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
})
})
@@ -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' })
})
})
@@ -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<FeatureTipId>(args.featureTipsSeenIds),
completedTipIds: getCompletedFeatureTipIds({
voiceDictationEnabled: args.settings.voice?.enabled === true
voiceDictationEnabled: args.settings.voice?.enabled === true,
featureInteractions: args.featureInteractions
})
})
@@ -1156,7 +1156,7 @@ export function FloatingTerminalPanel({
className={isActive ? 'absolute inset-0 flex' : 'absolute inset-0 hidden'}
aria-hidden={!isActive}
>
<BrowserPane browserTab={tab} isActive={isActive} />
<BrowserPane browserTab={tab} isActive={open && isActive} />
</div>
)
})}
@@ -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 })}
/>
) : (
<div className="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground">
@@ -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', {
@@ -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) {
@@ -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) {
@@ -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<Set<'cookie' | 'workspaceId'>>(new Set())
const [codexAccounts, setCodexAccounts] = useState<CodexRateLimitAccountsState>({
accounts: [],
@@ -132,6 +134,14 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
const [removeAccountId, setRemoveAccountId] = useState<string | null>(null)
const [removeClaudeAccountId, setRemoveClaudeAccountId] = useState<string | null>(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
<button
role="switch"
aria-checked={settings.geminiCliOAuthEnabled}
onClick={() =>
onClick={() => {
recordFeatureInteraction('usage-tracking')
updateSettings({
geminiCliOAuthEnabled: !settings.geminiCliOAuthEnabled
})
}
}}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.geminiCliOAuthEnabled ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
@@ -696,7 +709,10 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
<Input
type="password"
value={settings.opencodeSessionCookie}
onChange={(e) => updateSettings({ opencodeSessionCookie: e.target.value })}
onChange={(e) => {
recordOpenCodeSettingEdit('cookie')
updateSettings({ opencodeSessionCookie: e.target.value })
}}
placeholder="Fe26.2**… token or auth=Fe26.2**… header"
spellCheck={false}
className="flex-1 text-xs"
@@ -705,7 +721,10 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
<Button
variant="ghost"
size="xs"
onClick={() => updateSettings({ opencodeSessionCookie: '' })}
onClick={() => {
recordFeatureInteraction('usage-tracking')
updateSettings({ opencodeSessionCookie: '' })
}}
className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground"
>
Clear
@@ -730,7 +749,10 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
<Input
type="text"
value={settings.opencodeWorkspaceId}
onChange={(e) => updateSettings({ opencodeWorkspaceId: e.target.value })}
onChange={(e) => {
recordOpenCodeSettingEdit('workspaceId')
updateSettings({ opencodeWorkspaceId: e.target.value })
}}
placeholder="wrk_… (leave blank for automatic lookup)"
spellCheck={false}
className="flex-1 text-xs"
@@ -739,7 +761,10 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
<Button
variant="ghost"
size="xs"
onClick={() => updateSettings({ opencodeWorkspaceId: '' })}
onClick={() => {
recordFeatureInteraction('usage-tracking')
updateSettings({ opencodeWorkspaceId: '' })
}}
className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground"
>
Clear
@@ -1,3 +1,5 @@
/* eslint-disable max-lines -- Why: AppearancePane keeps theme, typography, zoom, and status-bar
visibility settings together so the searchable settings rows share one filtered surface. */
import type React from 'react'
import type { GlobalSettings } from '../../../../shared/types'
import { Separator } from '../ui/separator'
@@ -66,6 +68,7 @@ export function AppearancePane({
const zoomOutKeyCombos = useShortcutKeyCombos('zoom.out')
const statusBarItems = useAppStore((state) => state.statusBarItems)
const toggleStatusBarItem = useAppStore((state) => state.toggleStatusBarItem)
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
const visibleStatusBarToggles = useAvailableStatusBarToggles(STATUS_BAR_TOGGLES)
const visibleSections = [
@@ -214,7 +217,23 @@ export function AppearancePane({
label={toggle.title}
description={toggle.toggleDescription}
checked={enabled}
onChange={() => toggleStatusBarItem(toggle.id)}
onChange={() => {
if (toggle.id === 'resource-usage') {
recordFeatureInteraction('resource-manager')
} else if (toggle.id === 'ports') {
recordFeatureInteraction('ports')
} else if (toggle.id === 'ssh') {
recordFeatureInteraction('ssh')
} else if (
toggle.id === 'claude' ||
toggle.id === 'codex' ||
toggle.id === 'gemini' ||
toggle.id === 'opencode-go'
) {
recordFeatureInteraction('usage-tracking')
}
toggleStatusBarItem(toggle.id)
}}
ariaLabel={toggle.title}
/>
</SearchableSetting>
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: Browser Use setup keeps enablement, CLI registration, skill install, cookie import, examples, and interaction tracking in one pane so the three-step setup state stays coherent. */
import { useEffect, useState } from 'react'
import { Import, Loader2, MousePointerClick } from 'lucide-react'
import { toast } from 'sonner'
@@ -69,6 +70,9 @@ export function BrowserUseSetup({
const toggleBrowserUse = (value: boolean): void => {
setBrowserUseEnabled(value)
localStorage.setItem(BROWSER_USE_ENABLED_STORAGE_KEY, value ? '1' : '0')
if (value) {
useAppStore.getState().recordFeatureInteraction('agent-browser-setup')
}
}
const refreshCli = async (): Promise<void> => {
@@ -323,6 +327,7 @@ export function BrowserUseSetup({
disabled={!cliEnabled}
preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE}
onBeforeOpenTerminal={async () => {
useAppStore.getState().recordFeatureInteraction('agent-browser-setup')
await ensureOrcaCliAvailableForAgentSkillTerminal({ onStatusChange: setCliStatus })
}}
onRecheck={refreshSkill}
@@ -529,6 +529,7 @@ export function CommitMessageAiPane({
selectedThinkingByModel: nextSelectedThinkingByModel
}
})
useAppStore.getState().recordFeatureInteraction('ai-commit-generation')
}
const onAgentChange = (newAgentId: string): void => {
@@ -25,6 +25,7 @@ import {
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
useInstalledAgentSkill
} from '@/hooks/useInstalledAgentSkills'
import { useAppStore } from '@/store'
import { Button } from '../ui/button'
import { Badge } from '../ui/badge'
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
@@ -163,6 +164,7 @@ export function ComputerUsePane(): React.JSX.Element {
}, [refresh])
const openPermission = async (id: ComputerUsePermissionId): Promise<void> => {
useAppStore.getState().recordFeatureInteraction('computer-use-setup')
setPendingId(id)
try {
const result = await window.api.computerUsePermissions.openSetup({ id })
@@ -324,6 +326,7 @@ export function ComputerUsePane(): React.JSX.Element {
icon={<MonitorCog className="size-5" />}
preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE}
onBeforeOpenTerminal={async () => {
useAppStore.getState().recordFeatureInteraction('computer-use-setup')
await ensureOrcaCliAvailableForAgentSkillTerminal()
}}
onRecheck={refreshComputerUseSkill}
@@ -62,6 +62,7 @@ export function FloatingWorkspacePane({
if (!path) {
return
}
useAppStore.getState().recordFeatureInteraction('floating-workspace')
updateSettings({ floatingTerminalCwd: path })
}
@@ -96,11 +97,14 @@ export function FloatingWorkspacePane({
label="Enable Floating Workspace"
description="Shows the floating workspace button and panel."
checked={settings.floatingTerminalEnabled}
onChange={() =>
onChange={() => {
if (!settings.floatingTerminalEnabled) {
useAppStore.getState().recordFeatureInteraction('floating-workspace')
}
updateSettings({
floatingTerminalEnabled: !settings.floatingTerminalEnabled
})
}
}}
/>
<SettingsRow
@@ -142,6 +146,7 @@ export function FloatingWorkspacePane({
updateSettings({
floatingTerminalTriggerLocation: value as FloatingTerminalTriggerLocation
})
useAppStore.getState().recordFeatureInteraction('floating-workspace')
}}
>
<ToggleGroupItem value="floating-button">Floating Button</ToggleGroupItem>
@@ -91,6 +91,7 @@ export function MobilePane(): React.JSX.Element {
...(opts.rotate ? { rotate: true } : {})
})
if (result.available) {
useAppStore.getState().recordFeatureInteraction('mobile-pairing')
setQrDataUrl(result.qrDataUrl)
setPairingUrl(result.pairingUrl)
setEndpoint(result.endpoint)
@@ -66,11 +66,15 @@ export function MobileSettingsPane({
type="button"
role="switch"
aria-checked={settings.experimentalMobile}
onClick={() =>
onClick={() => {
const nextEnabled = !settings.experimentalMobile
if (nextEnabled) {
useAppStore.getState().recordFeatureInteraction('mobile-pairing')
}
updateSettings({
experimentalMobile: !settings.experimentalMobile
experimentalMobile: nextEnabled
})
}
}}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.experimentalMobile ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
@@ -16,6 +16,7 @@ import {
} from '../ui/select'
import { BellRing, Bot, FileAudio, Siren, Upload, Volume2 } from 'lucide-react'
import { getNotificationSoundOptions } from '@/components/notification-sound-options'
import { useAppStore } from '@/store'
export { NOTIFICATIONS_PANE_SEARCH_ENTRIES } from './notifications-search'
type NotificationsPaneProps = {
@@ -174,6 +175,7 @@ export function NotificationsPane({
}
const handleSendTestNotification = async (): Promise<void> => {
useAppStore.getState().recordFeatureInteraction('notifications')
await sendNotificationSettingsTestNotification(notificationSettings, volumeDraft)
}
@@ -223,7 +225,12 @@ export function NotificationsPane({
label="Enable Notifications"
description="Native system notifications for background events."
checked={notificationSettings.enabled}
onToggle={() => void updateNotificationSettings({ enabled: !notificationSettings.enabled })}
onToggle={() => {
if (!notificationSettings.enabled) {
useAppStore.getState().recordFeatureInteraction('notifications')
}
void updateNotificationSettings({ enabled: !notificationSettings.enabled })
}}
/>
<Separator />
@@ -54,6 +54,9 @@ export function OrchestrationPane(): React.JSX.Element {
const toggleOrchestration = (value: boolean): void => {
setOrchestrationEnabled(value)
localStorage.setItem(ORCHESTRATION_ENABLED_STORAGE_KEY, value ? '1' : '0')
if (value) {
useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup')
}
notifyOrchestrationSetupStateChanged()
}
@@ -106,6 +109,7 @@ export function OrchestrationPane(): React.JSX.Element {
icon={<Workflow className="size-5" />}
preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE}
onBeforeOpenTerminal={async () => {
useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup')
await ensureOrcaCliAvailableForAgentSkillTerminal()
}}
onRecheck={refreshOrchestrationSkill}
@@ -182,6 +182,7 @@ export function QuickCommandsPane({
const nextList = isEdit
? latest.map((command) => (command.id === next.id ? next : command))
: [...latest, next]
useAppStore.getState().recordFeatureInteraction('quick-commands')
updateSettings({ terminalQuickCommands: nextList })
}
@@ -24,6 +24,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
// global store (via useIpcEvents.ts). Reading from the store avoids
// duplicating the onStateChanged listener and per-target getState IPC calls.
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState<EditingTarget>(EMPTY_FORM)
@@ -103,6 +104,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
await window.api.ssh.addTarget({ target })
toast.success('Target added')
}
recordFeatureInteraction('ssh')
setShowForm(false)
setEditingId(null)
setForm(EMPTY_FORM)
@@ -164,6 +166,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
const handleConnect = async (targetId: string): Promise<void> => {
try {
await window.api.ssh.connect({ targetId })
recordFeatureInteraction('ssh')
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Connection failed')
}
@@ -172,6 +175,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
const handleDisconnect = async (targetId: string): Promise<void> => {
try {
await window.api.ssh.disconnect({ targetId })
recordFeatureInteraction('ssh')
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Disconnect failed')
}
@@ -200,6 +204,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
setTestingIds((prev) => new Set(prev).add(targetId))
try {
const result = await window.api.ssh.testConnection({ targetId })
recordFeatureInteraction('ssh')
if (result.success) {
toast.success('Connection successful')
} else {
@@ -219,6 +224,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
const handleImport = async (): Promise<void> => {
try {
const imported = (await window.api.ssh.importConfig()) as SshTarget[]
recordFeatureInteraction('ssh')
if (imported.length === 0) {
toast('No new hosts found in ~/.ssh/config')
} else {
@@ -120,6 +120,7 @@ export default function WorkspaceKanbanDrawer({
if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) {
return
}
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
void updateWorktreeMeta(worktreeId, { workspaceStatus: status })
},
[updateWorktreeMeta, workspaceStatuses, worktreeById]
@@ -211,6 +212,7 @@ export default function WorkspaceKanbanDrawer({
if (writeManualOrder && order.changed) {
setSortBy('manual')
}
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
void updateWorktreesMeta(updates)
},
[
@@ -244,6 +246,7 @@ export default function WorkspaceKanbanDrawer({
updates.set(worktreeId, { isPinned: true })
}
if (updates.size > 0) {
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
void updateWorktreesMeta(updates)
}
},
@@ -339,6 +342,7 @@ export default function WorkspaceKanbanDrawer({
status.id === statusId ? { ...status, label: trimmed } : status
)
)
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
},
[setWorkspaceStatuses, workspaceStatuses]
)
@@ -348,6 +352,7 @@ export default function WorkspaceKanbanDrawer({
setWorkspaceStatuses(
workspaceStatuses.map((status) => (status.id === statusId ? { ...status, color } : status))
)
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
},
[setWorkspaceStatuses, workspaceStatuses]
)
@@ -357,6 +362,7 @@ export default function WorkspaceKanbanDrawer({
setWorkspaceStatuses(
workspaceStatuses.map((status) => (status.id === statusId ? { ...status, icon } : status))
)
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
},
[setWorkspaceStatuses, workspaceStatuses]
)
@@ -372,6 +378,7 @@ export default function WorkspaceKanbanDrawer({
const [moved] = next.splice(index, 1)
next.splice(nextIndex, 0, moved)
setWorkspaceStatuses(next)
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
},
[setWorkspaceStatuses, workspaceStatuses]
)
@@ -382,6 +389,7 @@ export default function WorkspaceKanbanDrawer({
...workspaceStatuses,
{ id: makeWorkspaceStatusId(label, workspaceStatuses), label }
])
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
}, [setWorkspaceStatuses, workspaceStatuses])
const handleRemoveStatus = useCallback(
@@ -396,6 +404,7 @@ export default function WorkspaceKanbanDrawer({
const next = workspaceStatuses.filter((status) => status.id !== statusId)
const fallbackStatus = next[Math.min(index, next.length - 1)]?.id ?? next[0]!.id
setWorkspaceStatuses(next)
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
for (const worktree of allWorktrees) {
if (getWorkspaceStatus(worktree, workspaceStatuses) === statusId) {
void updateWorktreeMeta(worktree.id, { workspaceStatus: fallbackStatus })
@@ -420,6 +429,12 @@ export default function WorkspaceKanbanDrawer({
useWorkspaceKanbanShiftWheelScroll(boardRef, laneScrollerRef, open, isPointerDragActiveRef)
useWorkspaceKanbanOutsideDismiss({ open, boardRef, preserveOpenForMenu, onOpenChange })
useEffect(() => {
if (open) {
useAppStore.getState().recordFeatureInteraction('workspace-board')
}
}, [open])
useEffect(() => {
if (!open || selectedWorktreeIds.size === 0) {
return
@@ -498,7 +513,10 @@ export default function WorkspaceKanbanDrawer({
selectedCount={selectedWorktrees.length}
compact={workspaceBoardCompact}
workspaceStatuses={workspaceStatuses}
onCompactChange={setWorkspaceBoardCompact}
onCompactChange={(compact) => {
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
setWorkspaceBoardCompact(compact)
}}
onRenameStatus={handleRenameStatus}
onChangeStatusColor={handleChangeStatusColor}
onChangeStatusIcon={handleChangeStatusIcon}
@@ -30,6 +30,8 @@ type WorktreeCardPortsProps = {
export function WorktreeCardPortsTrigger({
ports
}: WorktreeCardPortsProps): React.JSX.Element | null {
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
if (ports.length === 0) {
return null
}
@@ -39,7 +41,10 @@ export function WorktreeCardPortsTrigger({
type="button"
className="inline-flex size-3.5 shrink-0 items-center justify-center rounded text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring"
aria-label={`${ports.length} live ${ports.length === 1 ? 'port' : 'ports'}`}
onClick={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
recordFeatureInteraction('ports')
}}
>
<Plug className="size-3.5" />
</button>
@@ -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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<void> => {
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<HTMLButtonElement>) => {
event.stopPropagation()
recordFeatureInteraction('ports')
const ownerPort = ports[0]
if (!ownerPort || !goToWorkspacePortOwner(ownerPort)) {
toast.error('Workspace unavailable')
}
},
[ports]
[ports, recordFeatureInteraction]
)
if (ports.length === 0) {
@@ -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 (
<div className="rounded-lg border border-border/60 bg-card/40 p-4">
@@ -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"
>
<span className="pointer-events-none block size-3.5 translate-x-0.5 rounded-full bg-background shadow-sm transition-transform" />
@@ -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"
>
<span className="pointer-events-none block size-3.5 translate-x-4 rounded-full bg-background shadow-sm transition-transform" />
@@ -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 (
<div className="rounded-lg border border-border/60 bg-card/40 p-4">
@@ -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"
>
<span className="pointer-events-none block size-3.5 translate-x-0.5 rounded-full bg-background shadow-sm transition-transform" />
@@ -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"
>
<span className="pointer-events-none block size-3.5 translate-x-4 rounded-full bg-background shadow-sm transition-transform" />
@@ -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 (
<div className="rounded-lg border border-border/60 bg-card/40 p-4">
@@ -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"
>
<span className="pointer-events-none block size-3.5 translate-x-0.5 rounded-full bg-background shadow-sm transition-transform" />
@@ -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"
>
<span className="pointer-events-none block size-3.5 translate-x-4 rounded-full bg-background shadow-sm transition-transform" />
@@ -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<UsageTab>('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 (
<div className="space-y-5">
@@ -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 {
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => void enableClaudeUsage()}>
<Button
size="sm"
onClick={() => {
recordFeatureInteraction('usage-tracking')
void enableClaudeUsage()
}}
>
Enable Claude
</Button>
<Button variant="secondary" size="sm" onClick={() => void enableCodexUsage()}>
<Button
variant="secondary"
size="sm"
onClick={() => {
recordFeatureInteraction('usage-tracking')
void enableCodexUsage()
}}
>
Enable Codex
</Button>
<Button variant="outline" size="sm" onClick={() => void enableOpenCodeUsage()}>
<Button
variant="outline"
size="sm"
onClick={() => {
recordFeatureInteraction('usage-tracking')
void enableOpenCodeUsage()
}}
>
Enable OpenCode
</Button>
</div>
@@ -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') {
@@ -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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<void> => {
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 ? (
<ChevronDown className="size-3" />
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<Popover
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) {
recordFeatureInteraction('resource-manager')
}
setOpen(nextOpen)
}}
>
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
@@ -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 (
<div className="flex items-center gap-2.5 px-2 py-1.5">
@@ -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 (
<DropdownMenu>
<DropdownMenu
onOpenChange={(open) => {
if (open) {
recordFeatureInteraction('ssh')
}
}}
>
<DropdownMenuTrigger asChild>
<button
type="button"
@@ -294,6 +304,7 @@ export function SshStatusSegment({
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
recordFeatureInteraction('ssh')
openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' })
setActiveView('settings')
}}
@@ -128,6 +128,7 @@ function ClaudeSwitcherMenu({
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const fetchSettings = useAppStore((s) => s.fetchSettings)
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
const fetchInactiveClaudeAccountUsage = useAppStore((s) => s.fetchInactiveClaudeAccountUsage)
const inactiveClaudeAccounts = useAppStore((s) => s.rateLimits.inactiveClaudeAccounts)
const claudeAccountSyncKey = useAppStore((s) => {
@@ -168,6 +169,7 @@ function ClaudeSwitcherMenu({
setIsSwitching(true)
try {
const next = await window.api.claudeAccounts.select({ accountId })
recordFeatureInteraction('claude-account-switching')
setAccounts(next)
await fetchSettings()
setAccountsExpanded(false)
@@ -483,6 +485,7 @@ function CodexSwitcherMenu({
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const fetchSettings = useAppStore((s) => s.fetchSettings)
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
const fetchInactiveCodexAccountUsage = useAppStore((s) => s.fetchInactiveCodexAccountUsage)
const inactiveCodexAccounts = useAppStore((s) => s.rateLimits.inactiveCodexAccounts)
const codexAccountSyncKey = useAppStore((s) => {
@@ -516,6 +519,7 @@ function CodexSwitcherMenu({
setIsSwitching(true)
try {
const next = await window.api.codexAccounts.select({ accountId })
recordFeatureInteraction('codex-account-switching')
setAccounts(next)
await fetchSettings()
if (previousActiveAccountId !== next.activeAccountId) {
@@ -671,8 +675,17 @@ function ProviderDetailsMenu({
onOpenChange?: (open: boolean) => void
children?: React.ReactNode
}): React.JSX.Element {
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
const handleOpenChange = (nextOpen: boolean): void => {
if (nextOpen) {
recordFeatureInteraction('usage-tracking')
}
onOpenChange?.(nextOpen)
}
return (
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
@@ -726,6 +739,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
const refreshRateLimits = useAppStore((s) => s.refreshRateLimits)
const statusBarVisible = useAppStore((s) => s.statusBarVisible)
const statusBarItems = useAppStore((s) => s.statusBarItems)
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
const floatingTerminalEnabled = useAppStore((s) => s.settings?.floatingTerminalEnabled === true)
const floatingTerminalTriggerLocation = useAppStore(
(s) => s.settings?.floatingTerminalTriggerLocation ?? 'floating-button'
@@ -953,7 +967,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
{isStatusBarItemAvailable('claude', detectedAgentIds) && (
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('claude')}
onCheckedChange={() => toggleStatusBarItem('claude')}
onCheckedChange={() => {
recordFeatureInteraction('usage-tracking')
toggleStatusBarItem('claude')
}}
>
<ClaudeIcon size={14} />
Claude Usage
@@ -962,7 +979,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
{isStatusBarItemAvailable('codex', detectedAgentIds) && (
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('codex')}
onCheckedChange={() => toggleStatusBarItem('codex')}
onCheckedChange={() => {
recordFeatureInteraction('usage-tracking')
toggleStatusBarItem('codex')
}}
>
<OpenAIIcon size={14} />
Codex Usage
@@ -971,7 +991,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
{isStatusBarItemAvailable('gemini', detectedAgentIds) && (
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('gemini')}
onCheckedChange={() => toggleStatusBarItem('gemini')}
onCheckedChange={() => {
recordFeatureInteraction('usage-tracking')
toggleStatusBarItem('gemini')
}}
>
<GeminiIcon size={14} />
Gemini Usage
@@ -979,28 +1002,40 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
)}
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('opencode-go')}
onCheckedChange={() => toggleStatusBarItem('opencode-go')}
onCheckedChange={() => {
recordFeatureInteraction('usage-tracking')
toggleStatusBarItem('opencode-go')
}}
>
<OpenCodeGoIcon size={14} />
OpenCode Go Usage
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('ssh')}
onCheckedChange={() => toggleStatusBarItem('ssh')}
onCheckedChange={() => {
recordFeatureInteraction('ssh')
toggleStatusBarItem('ssh')
}}
>
<Server className="size-3.5" />
SSH Status
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('resource-usage')}
onCheckedChange={() => toggleStatusBarItem('resource-usage')}
onCheckedChange={() => {
recordFeatureInteraction('resource-manager')
toggleStatusBarItem('resource-usage')
}}
>
<Activity className="size-3.5" />
Resource Manager
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('ports')}
onCheckedChange={() => toggleStatusBarItem('ports')}
onCheckedChange={() => {
recordFeatureInteraction('ports')
toggleStatusBarItem('ports')
}}
>
<Plug className="size-3.5" />
Ports
@@ -1,13 +1,26 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const setTabGroupSplitRatioMock = vi.fn()
const recordFeatureInteractionMock = vi.fn()
const useAppStoreMock = vi.fn(
(selector: (state: { setTabGroupSplitRatio: () => void }) => unknown) =>
selector({ setTabGroupSplitRatio: setTabGroupSplitRatioMock })
(
selector: (state: {
recordFeatureInteraction: typeof recordFeatureInteractionMock
setTabGroupSplitRatio: typeof setTabGroupSplitRatioMock
}) => unknown
) =>
selector({
recordFeatureInteraction: recordFeatureInteractionMock,
setTabGroupSplitRatio: setTabGroupSplitRatioMock
})
)
vi.mock('../../store', () => ({
useAppStore: (selector: (state: { setTabGroupSplitRatio: () => void }) => unknown) =>
useAppStoreMock(selector)
useAppStore: (
selector: (state: {
recordFeatureInteraction: typeof recordFeatureInteractionMock
setTabGroupSplitRatio: typeof setTabGroupSplitRatioMock
}) => unknown
) => useAppStoreMock(selector)
}))
vi.mock('./TabGroupPanel', () => ({
@@ -31,6 +44,12 @@ vi.mock('./useTabDragSplit', () => ({
import TabGroupSplitLayout from './TabGroupSplitLayout'
describe('TabGroupSplitLayout', () => {
beforeEach(() => {
setTabGroupSplitRatioMock.mockClear()
recordFeatureInteractionMock.mockClear()
useAppStoreMock.mockClear()
})
function getLeafPanelProps(isWorktreeActive: boolean) {
const element = TabGroupSplitLayout({
layout: { type: 'leaf', groupId: 'group-1' },
@@ -124,4 +143,29 @@ describe('TabGroupSplitLayout', () => {
})
)
})
it('records pane resizing at the start of the gesture', () => {
const element = TabGroupSplitLayout({
layout: {
type: 'split',
direction: 'horizontal',
ratio: 0.5,
first: { type: 'leaf', groupId: 'left-group' },
second: { type: 'leaf', groupId: 'right-group' }
},
worktreeId: 'wt-1',
focusedGroupId: 'right-group',
isWorktreeActive: true
})
const layoutWrapper = element.props.children[0]
const splitBody = layoutWrapper.props.children[1]
const splitNodeElement = splitBody.props.children
const rootElement = splitNodeElement.type(splitNodeElement.props)
const resizeHandle = rootElement.props.children[1]
resizeHandle.props.onResizeStart()
expect(recordFeatureInteractionMock).toHaveBeenCalledWith('terminal-panes')
})
})
@@ -11,9 +11,11 @@ const MAX_RATIO = 0.85
function ResizeHandle({
direction,
onResizeStart,
onRatioChange
}: {
direction: 'horizontal' | 'vertical'
onResizeStart: () => void
onRatioChange: (ratio: number) => void
}): React.JSX.Element {
const isHorizontal = direction === 'horizontal'
@@ -27,6 +29,7 @@ function ResizeHandle({
if (!container) {
return
}
onResizeStart()
setDragging(true)
handle.setPointerCapture(event.pointerId)
@@ -69,7 +72,7 @@ function ResizeHandle({
handle.addEventListener('pointercancel', onPointerCancel)
handle.addEventListener('lostpointercapture', onLostPointerCapture)
},
[isHorizontal, onRatioChange]
[isHorizontal, onRatioChange, onResizeStart]
)
return (
@@ -112,6 +115,7 @@ function SplitNode({
hoveredTabInsertion: HoveredTabInsertion | null
}): React.JSX.Element {
const setTabGroupSplitRatio = useAppStore((state) => state.setTabGroupSplitRatio)
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
if (node.type === 'leaf') {
return (
@@ -164,6 +168,7 @@ function SplitNode({
</div>
<ResizeHandle
direction={node.direction}
onResizeStart={() => recordFeatureInteraction('terminal-panes')}
onRatioChange={(nextRatio) => setTabGroupSplitRatio(worktreeId, nodePath, nextRatio)}
/>
<div className="flex min-w-0 min-h-0 overflow-hidden" style={{ flex: `${1 - ratio} 1 0%` }}>
@@ -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 =>
+10 -3
View File
@@ -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',
+6 -4
View File
@@ -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 })
@@ -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()
})
@@ -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)
@@ -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' }
+10 -4
View File
@@ -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, {
@@ -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 =
+15 -3
View File
@@ -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<TResult>(
target: RuntimeClientTarget,
method: string,
params?: unknown,
options: { timeoutMs?: number } = {}
options: { timeoutMs?: number; suppressFeatureInteraction?: boolean } = {}
): Promise<TResult> {
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<TResult>(response as RuntimeRpcResponse<TResult>)
}
function addFeatureInteractionSource(
params: unknown,
options: { suppressFeatureInteraction?: boolean }
): unknown {
if (!options.suppressFeatureInteraction) {
return params
}
return withBrowserPaneUiRuntimeRpcSource(params)
}
async function ensureRuntimeEnvironmentCompatible(
environmentId: string,
timeoutMs?: number
+15 -4
View File
@@ -1252,7 +1252,14 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
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<AppState, [], [], BrowserSlice> =
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<AppState, [], [], BrowserSlice> =
? { browserAnnotationsByPageId: nextBrowserAnnotationsByPageId }
: {})
}
}),
})
},
setRemoteBrowserPageHandle: (pageId, handle) => {
set((s) => ({
@@ -1568,7 +1575,8 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
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<AppState, [], [], BrowserSlice> =
profileId
})) as BrowserCookieImportResult
if (result.ok) {
get().recordFeatureInteraction?.('cookie-import')
set({
browserSessionImportState: {
profileId,
@@ -1860,6 +1869,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
browserProfile
})) as BrowserCookieImportResult
if (result.ok) {
get().recordFeatureInteraction?.('cookie-import')
set({
browserSessionImportState: {
profileId,
@@ -1916,6 +1926,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
try {
const ok = await window.api.browser.sessionClearDefaultCookies()
if (ok) {
get().recordFeatureInteraction?.('cookie-import')
await get().fetchBrowserSessionProfiles()
}
return ok
@@ -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 () => {
@@ -280,6 +280,7 @@ export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffComment
// latest store snapshot at dequeue time, so it will reflect any newer
// mutation that landed after this one was enqueued.
await enqueuePersist(input.worktreeId, get)
get().recordFeatureInteraction?.('review-notes')
return comment
} catch (err) {
console.error('Failed to persist diff comments:', err)
@@ -368,6 +369,7 @@ export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffComment
}
try {
await enqueuePersist(worktreeId, get)
get().recordFeatureInteraction?.('review-notes')
return true
} catch (err) {
console.error('Failed to persist diff comments:', err)
@@ -397,6 +399,7 @@ export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffComment
}
try {
await enqueuePersist(worktreeId, get)
get().recordFeatureInteraction?.('review-notes')
return true
} catch (err) {
console.error('Failed to persist diff comments:', err)
+31 -1
View File
@@ -4,7 +4,7 @@ import { create } from 'zustand'
import type { AppState } from '../types'
import type { Tab, TabGroup } from '../../../../shared/types'
import type * as AgentStatusModule from '@/lib/agent-status'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultUIState } from '../../../../shared/constants'
// Mock sonner (imported by repos.ts)
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
@@ -44,6 +44,9 @@ const mockApi = {
get: vi.fn().mockResolvedValue({}),
set: vi.fn().mockResolvedValue(undefined)
},
ui: {
set: vi.fn().mockResolvedValue(undefined)
},
cache: {
getGitHub: vi.fn().mockResolvedValue(null),
setGitHub: vi.fn().mockResolvedValue(undefined)
@@ -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', () => {
+71 -19
View File
@@ -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<AppState, [], [], TabsSlice> = (set,
}
}
})
if (init?.recordInteraction !== false) {
get().recordFeatureInteraction?.('terminal-tabs')
}
return created
},
@@ -554,7 +567,7 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (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<AppState, [], [], TabsSlice> = (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<AppState, [], [], TabsSlice> = (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<AppState, [], [], TabsSlice> = (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<AppState, [], [], TabsSlice> = (set,
activeGroupIdByWorktree: { ...state.activeGroupIdByWorktree, [worktreeId]: newGroupId }
}
})
get().recordFeatureInteraction?.('terminal-panes')
return newGroupId
},
@@ -1044,6 +1087,9 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
activeGroupIdByWorktree: nextActiveGroupIdByWorktree
}
})
if (moved && opts?.recordInteraction !== false) {
get().recordFeatureInteraction?.('tab-splits')
}
return moved
},
@@ -1211,6 +1257,10 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
: {})
}
})
if (moved) {
get().recordFeatureInteraction?.('terminal-tabs')
get().recordFeatureInteraction?.('tab-splits')
}
return moved
},
@@ -1253,13 +1303,14 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (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<AppState, [], [], TabsSlice> = (set,
)
}
}
}),
})
},
reconcileWorktreeTabModel: (worktreeId) => {
const state = get()
+16 -6
View File
@@ -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<void>
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<AppState, [], [], TerminalSlice>
}
}
})
const shouldRecordInteraction =
options?.recordInteraction ?? (!options?.pendingActivationSpawn && !options?.initialPtyId)
if (shouldRecordInteraction) {
get().recordFeatureInteraction?.('terminal-tabs')
}
return tab
},
@@ -678,7 +688,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
(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<AppState, [], [], TerminalSlice>
})
},
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<AppState, [], [], TerminalSlice>
.flat()
.find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId)
if (item) {
get().setTabCustomLabel(item.id, title)
get().setTabCustomLabel(item.id, title, opts)
}
},
+157
View File
@@ -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<PersistedUIState>((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()
+69
View File
@@ -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<AppState, [], [], UISlice> = (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<AppState, [], [], UISlice> = (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<AppState, [], [], UISlice> = (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<AppState, [], [], UISlice> = (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<AppState, [], [], UISlice> = (set, get)
browserKagiSessionLink: normalizeKagiSessionLink(ui.browserKagiSessionLink ?? ''),
taskResumeState: sanitizeTaskResumeState(ui.taskResumeState),
featureTipsSeenIds: normalizeFeatureTipIds(ui.featureTipsSeenIds),
featureInteractions: normalizeFeatureInteractions(ui.featureInteractions),
trustedOrcaHooks: filterTrustedOrcaHooksToValidRepos(
ui.trustedOrcaHooks ?? {},
validRepoIds
@@ -65,7 +65,8 @@ function isWorkspaceSpaceScanCancelled(error: unknown): boolean {
}
export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], WorkspaceSpaceSlice> = (
set
set,
get
) => ({
workspaceSpaceAnalysis: null,
workspaceSpaceScanProgress: null,
@@ -86,6 +87,9 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
}),
cancelWorkspaceSpaceScan: async () => {
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<AppState, [], [], Workspace
if (inFlightScan) {
return inFlightScan
}
get().recordFeatureInteraction?.('workspace-cleanup')
set({
workspaceSpaceScanning: true,
workspaceSpaceScanProgress: null,
@@ -141,7 +146,10 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
})
return inFlightScan
},
removeWorkspaceSpaceWorktrees: (worktreeIds) =>
removeWorkspaceSpaceWorktrees: (worktreeIds) => {
if (worktreeIds.length > 0) {
get().recordFeatureInteraction?.('workspace-cleanup')
}
set((state) =>
state.workspaceSpaceAnalysis
? {
@@ -152,4 +160,5 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
}
: state
)
}
})
@@ -2,6 +2,7 @@
global setup across namespaces so browser API installation stays realistic. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PreloadApi } from '../../../preload/api-types'
import type { FeatureInteractionState } from '../../../shared/feature-interactions'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
class MemoryStorage implements Storage {
@@ -177,6 +178,7 @@ describe('web UI preload API', () => {
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<unknown>) => void)[] = []
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string): Promise<RuntimeRpcResponse<unknown>> {
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<RuntimeRpcResponse<unknown>> {
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', () => {
+74 -1
View File
@@ -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<Partial<PreloadApi>['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<Partial<PreloadApi>['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>): GlobalSettings {
const defaults = getDefaultSettings('~')
return {
+2 -1
View File
@@ -387,7 +387,8 @@ export function getDefaultUIState(): PersistedUIState {
setupScriptPromptDismissedRepoIds: [],
acknowledgedAgentsByPaneKey: {},
workspaceCleanup: { dismissals: {} },
featureTipsSeenIds: []
featureTipsSeenIds: [],
featureInteractions: {}
}
}
+156
View File
@@ -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<FeatureInteractionId, DefinedFeatureInteractionId>
type ExtraFeatureInteractionId = Exclude<DefinedFeatureInteractionId, FeatureInteractionId>
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, '\\$&')
}
+252
View File
@@ -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<FeatureInteractionId, FeatureInteractionRecord>
>
// 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<string, unknown>
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<string, unknown>
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 }
}
+14
View File
@@ -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<FeatureTipId>(),
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'
+20 -1
View File
@@ -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
}
@@ -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<string, unknown>)[ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY] ===
ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE
)
}
+4
View File
@@ -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