Fix feature interaction tracking

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-27 17:43:45 -07:00
co-authored by Orca
parent 308bf25a6c
commit cd89577bff
40 changed files with 1145 additions and 111 deletions
@@ -28,10 +28,12 @@ Do not upload this state as broad analytics. Product analytics should continue t
| AI PR generation | `ai-pr-generation` | AI pull-request title/body/draft fields are generated. | Suppress future education about AI PR generation. No contextual tour is planned for this branch. |
| Floating Workspace | `floating-workspace` | The floating workspace opens, is enabled, or is configured. | Suppress future tips about the global terminal/browser/markdown workspace. No contextual tour is planned for this branch. |
| Quick Commands | `quick-commands` | A terminal quick command is created or edited. | Suppress future tips about saved terminal commands. No contextual tour is planned for this branch. |
| Computer Use | `computer-use` | Computer Use is selected in onboarding, a permission setup is opened, or the skill setup terminal opens. | Suppress future tips once the user has started setup. No contextual tour is planned until there is a better non-settings entry point. |
| Computer Use setup | `computer-use-setup` | Computer Use is selected in onboarding, a permission setup is opened, or the skill setup terminal opens. | Suppress setup-focused tips once the user has started setup. |
| Computer Use | `computer-use` | A successful `computer.*` runtime method other than capability probing is handled. | Suppress future usage tips once an agent has actually invoked Computer Use. |
| Mobile pairing | `mobile-pairing` | Mobile is enabled or a mobile pairing QR/code is generated. | Suppress future mobile-pairing tips. No contextual tour is planned for this branch. |
| Browser element grab | `browser-grab` | Browser grab mode is started, an element is copied, or an element screenshot is copied. | Suppress future tips about grabbing page context once the user has used the element picker. |
| Browser annotations | `browser-annotations` | Browser annotation mode is started, an annotation is added, browser annotations are copied, or browser annotations are cleared. | Suppress future tips about annotating local pages and sending concrete UI feedback to an agent. |
| Cookie import | `cookie-import` | Browser cookies are imported from another browser or file, imported through runtime browser profile methods, or default imported cookies are cleared. | Suppress future cookie-import education without conflating cookie setup with Agent Browser Use itself. |
| Workspace board actions | `workspace-board-actions` | A card/status action, lane configuration, density change, pin drop, or board drag action is used. | Keep the existing workspace-board tour, and avoid repeating deeper board-action education after real use. |
| Automation creation | `automation-created` | A local automation or external Hermes cron is created. | Keep the existing Automations tour, and suppress creation-focused education after the user creates one. |
| Automation run | `automation-run` | A local or external automation run is manually queued. | Keep the existing Automations tour, and suppress run/inspection education after the user queues a run. |
@@ -42,10 +44,13 @@ Do not upload this state as broad analytics. Product analytics should continue t
| Provider usage tracking | `usage-tracking` | Stats & Usage is opened, Claude/Codex/OpenCode usage analytics are enabled, provider usage details are opened from the status bar, Gemini usage/OAuth is configured, or provider usage status-bar toggles are changed. | Suppress future tips about where to find token/rate-limit/usage tracking for Claude, Codex, Gemini, OpenCode, and related providers. |
| Claude account switching | `claude-account-switching` | A Claude managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about using multiple Claude accounts once the user has started account management. |
| Codex account switching | `codex-account-switching` | A Codex managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about Codex account switching and restart follow-up flows. |
| Workspace tabs | `terminal-tabs` | A workspace tab is created, reordered, renamed, recolored, pinned/unpinned, moved, or closed. | Suppress future tips about tab-level workspace organization. |
| Split panes | `terminal-panes` | A split group is created, a tab is moved into another pane, a split drop happens, a split is resized, or panes are merged. | Suppress future tips about split-pane workflows once the user has used them. |
| Agent Browser Use setup | `agent-browser-use` | Browser Use is selected in onboarding, enabled in settings, its setup terminal opens, or cookies are imported. | Suppress future setup tips once the user has started Browser Use setup. |
| Agent Orchestration setup | `agent-orchestration` | Orchestration is selected in onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Orchestration setup. |
| Workspace tabs | `terminal-tabs` | A workspace tab is created, reordered, renamed, recolored, pinned/unpinned, moved, or closed. | Suppress future tips about tab-level workspace organization. These are real workspace tabs, not workspace board cards. |
| Split panes | `terminal-panes` | A split group is created from the pane shortcut/menu, a split is resized, or panes are merged. | Suppress future tips about pane-level split workflows. |
| Tab splits | `tab-splits` | A workspace tab is moved into another pane or split into a new pane from a tab split/drop action. | Suppress future tips about tab-level split workflows separately from pane creation/resizing. |
| Agent Browser Use setup | `agent-browser-setup` | Browser Use is selected in onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Browser Use setup. |
| Agent Browser Use | `agent-browser-use` | A successful non-profile `browser.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually driven Orca's browser. |
| Agent Orchestration setup | `agent-orchestration-setup` | Orchestration is selected in onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Orchestration setup. |
| Agent Orchestration | `agent-orchestration` | A successful `orchestration.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually used orchestration. |
| Notifications | `notifications` | Notifications are enabled in onboarding/settings or a test notification is sent. | Suppress future notification setup tips. |
## Existing Contextual Tour Features
+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)
})
}
+41
View File
@@ -1703,6 +1703,47 @@ describe('Store', () => {
expect(ui.dismissedUpdateVersion).toBeNull()
})
it('updateUI merges feature interactions instead of replacing stale snapshots', async () => {
const store = await createStore()
store.updateUI({
featureInteractions: {
'agent-browser-use': { firstInteractedAt: 100, interactionCount: 1 }
}
})
store.updateUI({
featureInteractions: {
tasks: { firstInteractedAt: 200, interactionCount: 1 }
}
})
expect(store.getUI().featureInteractions).toEqual({
'agent-browser-use': { firstInteractedAt: 100, interactionCount: 1 },
tasks: { firstInteractedAt: 200, interactionCount: 1 }
})
})
it('recordFeatureInteraction increments from the current persisted UI state', async () => {
const store = await createStore()
store.updateUI({
featureInteractions: {
tasks: { firstInteractedAt: 100, interactionCount: 2 }
}
})
const ui = store.recordFeatureInteraction('tasks')
expect(ui.featureInteractions?.tasks).toEqual({
firstInteractedAt: 100,
interactionCount: 3
})
expect(store.getUI().featureInteractions?.tasks).toEqual({
firstInteractedAt: 100,
interactionCount: 3
})
})
it('updateUI restores fixed card properties from direct UI writes', async () => {
const store = await createStore()
store.updateUI({ worktreeCardProperties: ['inline-agents'] })
+55 -1
View File
@@ -84,6 +84,10 @@ import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-command
import { normalizeTaskProviderSettings } from '../shared/task-providers'
import { normalizeOpenInApplications } from '../shared/open-in-applications'
import { normalizeTerminalShortcutPolicy } from '../shared/keybindings'
import {
normalizeFeatureInteractions,
type FeatureInteractionId
} from '../shared/feature-interactions'
import {
DEFAULT_WORKSPACE_STATUS_ID,
clampWorkspaceBoardColumnWidth,
@@ -218,6 +222,31 @@ function normalizeGroupBy(groupBy: unknown): PersistedState['ui']['groupBy'] {
return getDefaultUIState().groupBy
}
function mergeFeatureInteractions(
current: PersistedState['ui']['featureInteractions'],
incoming: PersistedState['ui']['featureInteractions']
): PersistedState['ui']['featureInteractions'] {
const currentNormalized = normalizeFeatureInteractions(current)
const incomingNormalized = normalizeFeatureInteractions(incoming)
const merged = { ...currentNormalized }
for (const [id, incomingRecord] of Object.entries(incomingNormalized)) {
const currentRecord = currentNormalized[id as keyof typeof currentNormalized]
merged[id as keyof typeof merged] = currentRecord
? {
firstInteractedAt: Math.min(
currentRecord.firstInteractedAt,
incomingRecord.firstInteractedAt
),
interactionCount: Math.max(
currentRecord.interactionCount,
incomingRecord.interactionCount
)
}
: incomingRecord
}
return merged
}
function normalizeSortBy(sortBy: unknown): PersistedState['ui']['sortBy'] {
if (
sortBy === 'smart' ||
@@ -2547,11 +2576,36 @@ export class Store {
),
workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(
updates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth
)
),
// Why: runtime RPCs and the renderer can both record education state.
// Merge instead of replacing so a stale renderer snapshot cannot erase
// runtime-only feature interactions.
featureInteractions:
updates.featureInteractions !== undefined
? mergeFeatureInteractions(
this.state.ui?.featureInteractions,
updates.featureInteractions
)
: normalizeFeatureInteractions(this.state.ui?.featureInteractions)
}
this.scheduleSave()
}
recordFeatureInteraction(id: FeatureInteractionId): PersistedState['ui'] {
const featureInteractions = normalizeFeatureInteractions(this.state.ui?.featureInteractions)
const existing = featureInteractions[id]
this.updateUI({
featureInteractions: {
...featureInteractions,
[id]: {
firstInteractedAt: existing?.firstInteractedAt ?? Date.now(),
interactionCount: (existing?.interactionCount ?? 0) + 1
}
}
})
return this.getUI()
}
// ── Onboarding ────────────────────────────────────────────────────
getOnboarding(): PersistedState['onboarding'] {
+111
View File
@@ -4,6 +4,7 @@ import { EventEmitter } from 'events'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { ipcMain } from 'electron'
import type { WorktreeLineage, WorktreeMeta } from '../../shared/types'
import {
addWorktree,
@@ -34,6 +35,36 @@ import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/s
import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
const electronMocks = vi.hoisted(() => {
type Listener = (...args: unknown[]) => void
const listeners = new Map<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()
@@ -4741,6 +4777,81 @@ describe('OrcaRuntimeService', () => {
])
})
it('forwards inactive mobile terminal creation to the renderer without focusing it', async () => {
const focusTerminal = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setNotifier({
focusTerminal,
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession: vi.fn(),
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
closeTerminal: vi.fn(),
closeSessionTab: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
const send = vi.fn((_channel: string, payload: { requestId: string; activate?: boolean }) => {
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: null,
activeTabType: null,
tabs: [
{
type: 'terminal',
id: 'tab-renderer::pane:1',
parentTabId: 'tab-renderer',
leafId: 'pane:1',
title: 'Terminal',
isActive: false
}
]
}
]
})
ipcMain.emit(
'terminal:tabCreateReply',
{},
{
requestId: payload.requestId,
tabId: 'tab-renderer',
title: 'Terminal'
}
)
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents: { send }
})
const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
activate: false
})
expect(send).toHaveBeenCalledWith(
'terminal:requestTabCreate',
expect.objectContaining({
worktreeId: TEST_WORKTREE_ID,
activate: false
})
)
expect(focusTerminal).not.toHaveBeenCalled()
expect(result.tab).toMatchObject({ parentTabId: 'tab-renderer', isActive: false })
})
it('reports browser tab creation as unsupported for headless runtime servers', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
+11 -1
View File
@@ -46,6 +46,7 @@ import type {
TabGroupLayoutNode,
TuiAgent
} from '../../shared/types'
import type { FeatureInteractionId } from '../../shared/feature-interactions'
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id'
import { isFolderRepo } from '../../shared/repo-kind'
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
@@ -406,6 +407,7 @@ type RuntimeStore = {
getWorkspaceSession?: Store['getWorkspaceSession']
getUI?: Store['getUI']
updateUI?: Store['updateUI']
recordFeatureInteraction?: Store['recordFeatureInteraction']
listAutomations?: Store['listAutomations']
listAutomationRuns?: Store['listAutomationRuns']
createAutomation?: Store['createAutomation']
@@ -1291,6 +1293,13 @@ export class OrcaRuntimeService {
return this.store.getUI()
}
recordFeatureInteraction(id: FeatureInteractionId): PersistedUIState {
if (!this.store?.recordFeatureInteraction) {
throw new Error('runtime_unavailable')
}
return this.store.recordFeatureInteraction(id)
}
getClientSettings(): Pick<
GlobalSettings,
| 'defaultTuiAgent'
@@ -8847,7 +8856,8 @@ export class OrcaRuntimeService {
worktreeId,
afterTabId: afterDesktopTabId,
targetGroupId: opts.targetGroupId,
command: opts.command
command: opts.command,
activate: opts.activate
})
})
@@ -0,0 +1,182 @@
import { describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import type { PersistedUIState } from '../../../shared/types'
import { getDefaultUIState } from '../../../shared/constants'
import { RpcDispatcher } from './dispatcher'
import { defineMethod, defineStreamingMethod, type RpcRequest } from './core'
import type { OrcaRuntimeService } from '../orca-runtime'
function makeRequest(method: string, params: unknown = {}): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
function makeRuntime(ui: PersistedUIState = getDefaultUIState()): OrcaRuntimeService {
let currentUI = ui
return {
getRuntimeId: () => 'test-runtime',
getUIState: vi.fn(() => currentUI),
recordFeatureInteraction: vi.fn((id) => {
const featureInteractions = currentUI.featureInteractions ?? {}
const existing = featureInteractions[id]
currentUI = {
...currentUI,
featureInteractions: {
...featureInteractions,
[id]: {
firstInteractedAt: existing?.firstInteractedAt ?? Date.now(),
interactionCount: (existing?.interactionCount ?? 0) + 1
}
}
}
return currentUI
}),
updateUIState: vi.fn((updates: Partial<PersistedUIState>) => {
currentUI = { ...currentUI, ...updates }
return currentUI
})
} as unknown as OrcaRuntimeService
}
const METHODS = [
defineMethod({
name: 'browser.click',
params: z.object({}),
handler: () => ({ clicked: true })
}),
defineStreamingMethod({
name: 'browser.screencast',
params: z.object({}),
handler: async (_params, _options, emit) => {
emit({ type: 'frame' })
emit({ type: 'end' })
}
}),
defineStreamingMethod({
name: 'browser.screencast.binaryOnly',
params: z.object({}),
handler: async () => {}
}),
defineMethod({
name: 'browser.screencast.unsubscribe',
params: z.object({}),
handler: () => ({ ok: true })
}),
defineMethod({
name: 'browser.profileImportFromBrowser',
params: z.object({}),
handler: () => ({ ok: true })
}),
defineMethod({
name: 'browser.profileList',
params: z.object({}),
handler: () => ({ profiles: [] })
}),
defineMethod({
name: 'browser.profileClearDefaultCookies',
params: z.object({}),
handler: () => ({ cleared: false })
}),
defineMethod({
name: 'computer.permissions',
params: z.object({}),
handler: () => ({ opened: true })
}),
defineMethod({
name: 'computer.click',
params: z.object({}),
handler: () => ({ clicked: true })
}),
defineMethod({
name: 'orchestration.send',
params: z.object({}),
handler: () => ({ id: 'msg-1' })
}),
defineMethod({
name: 'browser.fail',
params: z.object({}),
handler: () => {
throw new Error('nope')
}
})
]
describe('RpcDispatcher feature interactions', () => {
it('records runtime feature use after successful runtime tool methods', async () => {
const runtime = makeRuntime()
const dispatcher = new RpcDispatcher({ runtime, methods: METHODS })
await dispatcher.dispatch(makeRequest('browser.click'))
await dispatcher.dispatch(makeRequest('computer.click'))
await dispatcher.dispatch(makeRequest('orchestration.send'))
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('agent-browser-use')
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('computer-use')
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('agent-orchestration')
})
it('keeps setup and cookie import separate from actual runtime use', async () => {
const runtime = makeRuntime()
const dispatcher = new RpcDispatcher({ runtime, methods: METHODS })
await dispatcher.dispatch(makeRequest('computer.permissions'))
await dispatcher.dispatch(makeRequest('browser.profileImportFromBrowser'))
await dispatcher.dispatch(makeRequest('browser.profileList'))
await dispatcher.dispatch(makeRequest('browser.profileClearDefaultCookies'))
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('computer-use-setup')
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('cookie-import')
expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(2)
})
it('does not record failed runtime methods', async () => {
const runtime = makeRuntime()
const dispatcher = new RpcDispatcher({ runtime, methods: METHODS })
await dispatcher.dispatch(makeRequest('browser.fail'))
expect(runtime.recordFeatureInteraction).not.toHaveBeenCalled()
})
it('records screencast starts but not screencast cleanup', async () => {
const runtime = makeRuntime()
const dispatcher = new RpcDispatcher({ runtime, methods: METHODS })
const replies: string[] = []
await dispatcher.dispatch(makeRequest('browser.screencast.unsubscribe'))
expect(runtime.recordFeatureInteraction).not.toHaveBeenCalled()
await dispatcher.dispatchStreaming(makeRequest('browser.screencast'), (response) => {
replies.push(response)
})
expect(replies).toHaveLength(2)
expect(runtime.recordFeatureInteraction).toHaveBeenLastCalledWith('agent-browser-use')
expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(1)
})
it('records streaming runtime feature use when the stream only returns a start result', async () => {
const runtime = makeRuntime()
const dispatcher = new RpcDispatcher({ runtime, methods: METHODS })
const replies: string[] = []
await dispatcher.dispatchStreaming(makeRequest('browser.screencast.binaryOnly'), (response) => {
replies.push(response)
})
expect(replies).toHaveLength(0)
expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(1)
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('agent-browser-use')
})
it('records each successful non-streaming runtime feature interaction', async () => {
const runtime = makeRuntime()
const dispatcher = new RpcDispatcher({ runtime, methods: METHODS })
await dispatcher.dispatch(makeRequest('browser.click'))
await dispatcher.dispatch(makeRequest('browser.click'))
await dispatcher.dispatch(makeRequest('browser.screencast.unsubscribe'))
expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(2)
expect(runtime.recordFeatureInteraction).toHaveBeenLastCalledWith('agent-browser-use')
})
})
+69 -1
View File
@@ -14,6 +14,7 @@ import {
type RpcResponse
} from './core'
import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol'
import type { FeatureInteractionId } from '../../../shared/feature-interactions'
import { errorResponse, mapBrowserError, mapRuntimeError, successResponse } from './errors'
import { ALL_RPC_METHODS } from './methods'
import type { OrcaRuntimeService } from '../orca-runtime'
@@ -66,6 +67,7 @@ export class RpcDispatcher {
runtime: this.runtime,
signal: options?.signal
})
this.recordRuntimeFeatureInteraction(request.method, result)
return successResponse(request.id, meta, result)
} catch (error) {
return this.mapError(request, meta, error)
@@ -116,6 +118,7 @@ export class RpcDispatcher {
sendBinary: options?.sendBinary,
registerBinaryStreamHandler: options?.registerBinaryStreamHandler
})
this.recordRuntimeFeatureInteraction(request.method, result)
reply(JSON.stringify(successResponse(request.id, meta, result)))
} catch (error) {
reply(JSON.stringify(this.mapError(request, meta, error)))
@@ -123,14 +126,20 @@ export class RpcDispatcher {
return
}
const recordedStreamingFeatureInteractions = new Set<FeatureInteractionId>()
const emit = (result: unknown): void => {
this.recordRuntimeFeatureInteraction(
request.method,
result,
recordedStreamingFeatureInteractions
)
const response = successResponse(request.id, meta, result)
response.streaming = true
reply(JSON.stringify(response))
}
try {
await method.handler(
const result = await method.handler(
parsedParams.value,
{
runtime: this.runtime,
@@ -142,6 +151,11 @@ export class RpcDispatcher {
},
emit
)
this.recordRuntimeFeatureInteraction(
request.method,
result,
recordedStreamingFeatureInteractions
)
} catch (error) {
reply(JSON.stringify(this.mapError(request, meta, error)))
}
@@ -182,4 +196,58 @@ export class RpcDispatcher {
private meta(): RpcEnvelopeMeta {
return { runtimeId: this.runtime.getRuntimeId() }
}
private recordRuntimeFeatureInteraction(
method: string,
result: unknown,
alreadyRecorded?: Set<FeatureInteractionId>
): void {
const id = getRuntimeFeatureInteractionId(method, result)
if (!id) {
return
}
if (alreadyRecorded?.has(id)) {
return
}
try {
this.runtime.recordFeatureInteraction(id)
alreadyRecorded?.add(id)
} catch {
// Best-effort education state must not break runtime tools.
}
}
}
function getRuntimeFeatureInteractionId(
method: string,
result: unknown
): FeatureInteractionId | null {
if (method === 'browser.profileImportFromBrowser') {
return hasBooleanResult(result, 'ok') ? 'cookie-import' : null
}
if (method === 'browser.profileClearDefaultCookies') {
return hasBooleanResult(result, 'cleared') ? 'cookie-import' : null
}
if (method === 'browser.screencast.unsubscribe') {
return null
}
if (method.startsWith('browser.') && !method.startsWith('browser.profile')) {
return 'agent-browser-use'
}
if (method === 'computer.permissions') {
return 'computer-use-setup'
}
if (method.startsWith('computer.') && method !== 'computer.capabilities') {
return 'computer-use'
}
if (method.startsWith('orchestration.')) {
return 'agent-orchestration'
}
return null
}
function hasBooleanResult(value: unknown, key: string): boolean {
return (
value !== null && typeof value === 'object' && (value as Record<string, unknown>)[key] === true
)
}
@@ -201,6 +201,25 @@ describe('client UI RPC methods', () => {
expect(response).toMatchObject({ ok: true, result: { ui: updated } })
})
it('records a feature interaction through the runtime host', async () => {
const updated: PersistedUIState = {
...getDefaultUIState(),
featureInteractions: {
tasks: { firstInteractedAt: 100, interactionCount: 1 }
}
}
const runtime = {
getRuntimeId: () => 'test-runtime',
recordFeatureInteraction: vi.fn(() => updated)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
const response = await dispatcher.dispatch(makeRequest('ui.recordFeatureInteraction', 'tasks'))
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('tasks')
expect(response).toMatchObject({ ok: true, result: { ui: updated } })
})
it('rejects unknown and malformed UI update fields', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
@@ -234,4 +253,19 @@ describe('client UI RPC methods', () => {
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
expect(runtime.updateUIState).not.toHaveBeenCalled()
})
it('rejects unknown feature interaction ids for increment RPC', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
recordFeatureInteraction: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
const response = await dispatcher.dispatch(
makeRequest('ui.recordFeatureInteraction', 'unknown-feature')
)
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
expect(runtime.recordFeatureInteraction).not.toHaveBeenCalled()
})
})
+14 -1
View File
@@ -1,5 +1,8 @@
import { z } from 'zod'
import { isFeatureInteractionId } from '../../../../shared/feature-interactions'
import {
isFeatureInteractionId,
type FeatureInteractionId
} from '../../../../shared/feature-interactions'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import type { PersistedUIState } from '../../../../shared/types'
import { defineMethod, type RpcMethod } from '../core'
@@ -68,6 +71,9 @@ const FeatureInteractions = z
}
}
})
const FeatureInteractionIdParam = z.custom<FeatureInteractionId>(isFeatureInteractionId, {
message: 'Unknown feature interaction id'
})
const GitHubProjectRef = z
.object({
owner: z.string(),
@@ -207,5 +213,12 @@ export const CLIENT_UI_METHODS: RpcMethod[] = [
handler: (params, { runtime }) => ({
ui: runtime.updateUIState(params as Partial<PersistedUIState>)
})
}),
defineMethod({
name: 'ui.recordFeatureInteraction',
params: FeatureInteractionIdParam,
handler: (params, { runtime }) => ({
ui: runtime.recordFeatureInteraction(params)
})
})
]
+1
View File
@@ -268,6 +268,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'terminal.unsubscribe',
'terminal.updateViewport',
'ui.get',
'ui.recordFeatureInteraction',
'ui.set',
'worktree.activate',
'worktree.create',
+2
View File
@@ -111,6 +111,7 @@ import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history'
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
import type { RuntimeAccessGrant } from '../shared/runtime-access-grants'
import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope'
import type { FeatureInteractionId } from '../shared/feature-interactions'
import type {
AddIssueCommentBySlugArgs,
ClearProjectItemFieldArgs,
@@ -1710,6 +1711,7 @@ export type PreloadApi = {
ui: {
get: () => Promise<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
@@ -2243,6 +2243,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)
+14 -10
View File
@@ -257,6 +257,9 @@ function App(): React.JSX.Element {
useRadixBodyPointerEventsRecovery()
useWebSessionTabsSync()
const [floatingTerminalOpen, setFloatingTerminalOpen] = useState(false)
// Why: floating workspace toggles can batch before React re-renders; keep
// functional SetStateAction semantics without side effects inside the updater.
const floatingTerminalOpenRef = useRef(floatingTerminalOpen)
// Why: Zustand actions are referentially stable, but each individual
// useAppStore(s => s.someAction) still registers a subscription that React
@@ -366,16 +369,17 @@ function App(): React.JSX.Element {
const setFloatingTerminalOpenWithFocus = useCallback(
(nextOpen: SetStateAction<boolean>): void => {
setFloatingTerminalOpen((currentOpen) => {
const resolvedOpen = typeof nextOpen === 'function' ? nextOpen(currentOpen) : nextOpen
if (resolvedOpen && !currentOpen) {
useAppStore.getState().recordFeatureInteraction('floating-workspace')
rememberFloatingTerminalReturnFocus()
} else if (!resolvedOpen && currentOpen) {
restoreFloatingTerminalReturnFocus()
}
return resolvedOpen
})
const currentOpen = floatingTerminalOpenRef.current
const resolvedOpen =
typeof nextOpen === 'function' ? nextOpen(floatingTerminalOpenRef.current) : nextOpen
floatingTerminalOpenRef.current = resolvedOpen
if (resolvedOpen && !currentOpen) {
useAppStore.getState().recordFeatureInteraction('floating-workspace')
rememberFloatingTerminalReturnFocus()
} else if (!resolvedOpen && currentOpen) {
restoreFloatingTerminalReturnFocus()
}
setFloatingTerminalOpen(resolvedOpen)
},
[rememberFloatingTerminalReturnFocus, restoreFloatingTerminalReturnFocus]
)
@@ -33,6 +33,7 @@ export function useContextualTour(
)
const enabledInteractionSnapshotRef = useRef<{
id: ContextualTourId
source: string
wasPreviouslyInteracted: boolean
} | null>(null)
@@ -41,14 +42,21 @@ export function useContextualTour(
enabledInteractionSnapshotRef.current = null
return
}
if (enabledInteractionSnapshotRef.current?.id !== id) {
enabledInteractionSnapshotRef.current = {
id,
wasPreviouslyInteracted: hasFeatureInteraction(featureInteractions, id)
}
if (
enabledInteractionSnapshotRef.current?.id === id &&
enabledInteractionSnapshotRef.current.source === source
) {
return
}
enabledInteractionSnapshotRef.current = {
id,
source,
// Why: recording writes featureInteractions; subscribing here would retrigger
// this effect and repeatedly persist the same enabled source.
wasPreviouslyInteracted: hasFeatureInteraction(useAppStore.getState().featureInteractions, id)
}
recordFeatureInteraction(id)
}, [enabled, featureInteractions, id, persistedUIReady, recordFeatureInteraction])
}, [enabled, id, persistedUIReady, recordFeatureInteraction, source])
useEffect(() => {
// Why: source disable should end through the overlay so a shown tour gets
@@ -94,7 +102,7 @@ export function useContextualTour(
requestContextualTour(
id,
source,
snapshot?.id === id
snapshot?.id === id && snapshot.source === source
? snapshot.wasPreviouslyInteracted
: hasFeatureInteraction(featureInteractions, id)
)
@@ -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">
@@ -129,9 +129,9 @@ const ONBOARDING_FEATURE_INTERACTIONS: Record<
keyof OnboardingFeatureSetupSelection,
FeatureInteractionId
> = {
browserUse: 'agent-browser-use',
computerUse: 'computer-use',
orchestration: 'agent-orchestration'
browserUse: 'agent-browser-setup',
computerUse: 'computer-use-setup',
orchestration: 'agent-orchestration-setup'
}
export type PersistCurrentStepResult = {
@@ -1996,6 +1996,9 @@ function SourceControlInner(): React.JSX.Element {
if (result.branchChangedByPreparation) {
await refreshGitStatusAfterPullRequestGeneration(context)
}
if (result.success) {
useAppStore.getState().recordFeatureInteraction('ai-pr-generation')
}
setPrGenerationRecords((prev) => {
const record = prev[generationKey]
if (!result.success) {
@@ -2031,7 +2034,6 @@ function SourceControlInner(): React.JSX.Element {
if (!nextRecord) {
return prev
}
useAppStore.getState().recordFeatureInteraction('ai-pr-generation')
return {
...prev,
[generationKey]: nextRecord
@@ -3,7 +3,7 @@
add/select/reauth/remove flow is tightly coupled to the provider-specific
error handling and restart prompts below; splitting them into separate files
would scatter those flows without a meaningful abstraction boundary. */
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import type {
ClaudeRateLimitAccountsState,
CodexRateLimitAccountsState,
@@ -115,6 +115,7 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
const fetchSettings = useAppStore((s) => s.fetchSettings)
const localPreflightContext = useAppStore(getLocalPreflightContext)
const activeWslDistro = localPreflightContext?.wslDistro?.trim() || null
const recordedOpenCodeSettingEditsRef = useRef<Set<'cookie' | 'workspaceId'>>(new Set())
const [codexAccounts, setCodexAccounts] = useState<CodexRateLimitAccountsState>({
accounts: [],
@@ -133,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
@@ -701,7 +710,7 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
type="password"
value={settings.opencodeSessionCookie}
onChange={(e) => {
recordFeatureInteraction('usage-tracking')
recordOpenCodeSettingEdit('cookie')
updateSettings({ opencodeSessionCookie: e.target.value })
}}
placeholder="Fe26.2**… token or auth=Fe26.2**… header"
@@ -741,7 +750,7 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
type="text"
value={settings.opencodeWorkspaceId}
onChange={(e) => {
recordFeatureInteraction('usage-tracking')
recordOpenCodeSettingEdit('workspaceId')
updateSettings({ opencodeWorkspaceId: e.target.value })
}}
placeholder="wrk_… (leave blank for automatic lookup)"
@@ -71,7 +71,7 @@ export function BrowserUseSetup({
setBrowserUseEnabled(value)
localStorage.setItem(BROWSER_USE_ENABLED_STORAGE_KEY, value ? '1' : '0')
if (value) {
useAppStore.getState().recordFeatureInteraction('agent-browser-use')
useAppStore.getState().recordFeatureInteraction('agent-browser-setup')
}
}
@@ -140,7 +140,6 @@ export function BrowserUseSetup({
.getState()
.importCookiesFromBrowser(profileId, browserFamily, browserProfile)
if (result.ok) {
useAppStore.getState().recordFeatureInteraction('agent-browser-use')
const browser = detectedBrowsers.find((b) => b.family === browserFamily)
toast.success(
`Imported ${result.summary.importedCookies} cookies from ${browser?.label ?? browserFamily}${browserProfile ? ` (${browserProfile})` : ''}.`
@@ -153,7 +152,6 @@ export function BrowserUseSetup({
const handleImportFromFile = async (): Promise<void> => {
const result = await useAppStore.getState().importCookiesToProfile('default')
if (result.ok) {
useAppStore.getState().recordFeatureInteraction('agent-browser-use')
toast.success(`Imported ${result.summary.importedCookies} cookies from file.`)
} else if (result.reason !== 'canceled') {
toast.error(result.reason)
@@ -329,7 +327,7 @@ export function BrowserUseSetup({
disabled={!cliEnabled}
preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE}
onBeforeOpenTerminal={async () => {
useAppStore.getState().recordFeatureInteraction('agent-browser-use')
useAppStore.getState().recordFeatureInteraction('agent-browser-setup')
await ensureOrcaCliAvailableForAgentSkillTerminal({ onStatusChange: setCliStatus })
}}
onRecheck={refreshSkill}
@@ -137,7 +137,7 @@ export function ComputerUsePane(): React.JSX.Element {
}, [refresh])
const openPermission = async (id: ComputerUsePermissionId): Promise<void> => {
useAppStore.getState().recordFeatureInteraction('computer-use')
useAppStore.getState().recordFeatureInteraction('computer-use-setup')
setPendingId(id)
try {
const result = await window.api.computerUsePermissions.openSetup({ id })
@@ -241,7 +241,7 @@ export function ComputerUsePane(): React.JSX.Element {
icon={<MonitorCog className="size-5" />}
preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE}
onBeforeOpenTerminal={async () => {
useAppStore.getState().recordFeatureInteraction('computer-use')
useAppStore.getState().recordFeatureInteraction('computer-use-setup')
await ensureOrcaCliAvailableForAgentSkillTerminal()
}}
onRecheck={refreshComputerUseSkill}
@@ -55,7 +55,7 @@ export function OrchestrationPane(): React.JSX.Element {
setOrchestrationEnabled(value)
localStorage.setItem(ORCHESTRATION_ENABLED_STORAGE_KEY, value ? '1' : '0')
if (value) {
useAppStore.getState().recordFeatureInteraction('agent-orchestration')
useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup')
}
notifyOrchestrationSetupStateChanged()
}
@@ -109,7 +109,7 @@ export function OrchestrationPane(): React.JSX.Element {
icon={<Workflow className="size-5" />}
preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE}
onBeforeOpenTerminal={async () => {
useAppStore.getState().recordFeatureInteraction('agent-orchestration')
useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup')
await ensureOrcaCliAvailableForAgentSkillTerminal()
}}
onRecheck={refreshOrchestrationSkill}
@@ -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%` }}>
+10 -3
View File
@@ -811,7 +811,9 @@ describe('useIpcEvents updater integration', () => {
expect(setActiveTabType).toHaveBeenCalledWith('terminal')
expect(setActiveTab).toHaveBeenCalledWith('tab-new')
expect(revealWorktreeInSidebar).toHaveBeenCalledWith('wt-2')
expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Runner')
expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Runner', {
recordInteraction: false
})
expect(queueTabStartupCommand).toHaveBeenCalledWith('tab-new', { command: 'opencode' })
if (typeof requestTerminalCreateListenerRef.current !== 'function') {
@@ -835,7 +837,10 @@ describe('useIpcEvents updater integration', () => {
activate: false
})
expect(createTab).toHaveBeenCalledWith('wt-2', 'group-left', undefined, { activate: false })
expect(createTab).toHaveBeenCalledWith('wt-2', 'group-left', undefined, {
activate: false,
recordInteraction: false
})
expect(setActiveView).not.toHaveBeenCalled()
expect(setActiveWorktree).not.toHaveBeenCalled()
expect(setActiveTabType).not.toHaveBeenCalled()
@@ -847,7 +852,9 @@ describe('useIpcEvents updater integration', () => {
detail: { worktreeId: 'wt-2' }
})
)
expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Codex')
expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Codex', {
recordInteraction: false
})
expect(queueTabStartupCommand).toHaveBeenCalledWith('tab-new', { command: 'codex' })
expect(replyTerminalCreate).toHaveBeenCalledWith({
requestId: 'req-renderer-backed',
+6 -4
View File
@@ -872,7 +872,7 @@ export function useIpcEvents(): void {
// the runtime's stored title would otherwise silently overwrite on
// every focus.
if (title && !reusedTab) {
store.setTabCustomTitle(tab.id, title)
store.setTabCustomTitle(tab.id, title, { recordInteraction: false })
}
if (leafId && ptyId) {
if (splitFromLeafId) {
@@ -989,7 +989,7 @@ export function useIpcEvents(): void {
worktreeId,
data.targetGroupId,
undefined,
shouldActivate ? undefined : { activate: false }
shouldActivate ? undefined : { activate: false, recordInteraction: false }
)
if (data.afterTabId) {
const createdUnifiedTab = useAppStore
@@ -1013,7 +1013,9 @@ export function useIpcEvents(): void {
0,
createdUnifiedTab.id
)
useAppStore.getState().reorderUnifiedTabs(createdUnifiedTab.groupId, order)
useAppStore.getState().reorderUnifiedTabs(createdUnifiedTab.groupId, order, {
recordInteraction: false
})
}
}
if (shouldActivate) {
@@ -1022,7 +1024,7 @@ export function useIpcEvents(): void {
store.revealWorktreeInSidebar(worktreeId)
}
if (data.title) {
store.setTabCustomTitle(tab.id, data.title)
store.setTabCustomTitle(tab.id, data.title, { recordInteraction: false })
}
if (data.command) {
store.queueTabStartupCommand(tab.id, { command: data.command })
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: local/runtime launch tests share a mock harness. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
@@ -124,7 +125,10 @@ describe('launchAgentBackgroundSession', () => {
title: 'Nightly audit'
})
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { activate: false })
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
activate: false,
recordInteraction: false
})
expect(mockSpawn).toHaveBeenCalledWith(
expect.objectContaining({
cwd: '/repo/worktree',
@@ -149,7 +153,9 @@ describe('launchAgentBackgroundSession', () => {
})
)
expect(mockSetTabLayout.mock.calls.at(-1)?.[1]).not.toHaveProperty('titlesByLeafId')
expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Nightly audit')
expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Nightly audit', {
recordInteraction: false
})
expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-1')
expect(mockRegisterEagerPtyBuffer).toHaveBeenCalledWith('pty-1', expect.any(Function))
expect(mockSubscribeToPtyData).toHaveBeenCalledWith('pty-1', expect.any(Function))
@@ -231,7 +237,7 @@ describe('launchAgentBackgroundSession', () => {
})
).rejects.toThrow('spawn failed')
expect(mockCloseTab).toHaveBeenCalledWith('tab-1')
expect(mockCloseTab).toHaveBeenCalledWith('tab-1', { recordInteraction: false })
expect(mockUpdateTabPtyId).not.toHaveBeenCalled()
})
@@ -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
@@ -36,10 +36,14 @@ type WorktreeActivationStore = {
worktreeId: string,
targetGroupId?: string,
shellOverride?: string,
options?: { pendingActivationSpawn?: boolean }
options?: { pendingActivationSpawn?: boolean; recordInteraction?: boolean }
) => { id: string }
setActiveTab: (tabId: string) => void
setTabCustomTitle: (tabId: string, title: string | null) => void
setTabCustomTitle: (
tabId: string,
title: string | null,
opts?: { recordInteraction?: boolean }
) => void
reconcileWorktreeTabModel: (worktreeId: string) => { renderableTabCount: number }
queueTabStartupCommand: (
tabId: string,
@@ -242,7 +246,9 @@ export function ensureWorktreeHasInitialTerminal(
env: setup.envVars
}
if (mode === 'new-tab') {
const setupTab = store.createTab(worktreeId)
const setupTab = store.createTab(worktreeId, undefined, undefined, {
recordInteraction: false
})
// Why: createTab auto-activates the new tab. Revert activation so the
// user's focus stays on the primary terminal — per the design, the
// Setup tab runs unattended in the background.
@@ -250,7 +256,7 @@ export function ensureWorktreeHasInitialTerminal(
// Why: customTitle wins over the auto-generated "Terminal N" label
// everywhere the tab is rendered (tab bar, switcher, session snapshots),
// so labeling via customTitle is the single authoritative source.
store.setTabCustomTitle(setupTab.id, 'Setup')
store.setTabCustomTitle(setupTab.id, 'Setup', { recordInteraction: false })
store.queueTabStartupCommand(setupTab.id, setupCommand)
} else {
store.queueTabSetupSplit(terminalTab.id, {
+5 -1
View File
@@ -1538,7 +1538,8 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
if (!exists) {
state.createUnifiedTab(worktreeId, 'browser', {
entityId: bt.id,
label: bt.title
label: bt.title,
recordInteraction: false
})
}
}
@@ -1687,6 +1688,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
profileId
})) as BrowserCookieImportResult
if (result.ok) {
get().recordFeatureInteraction('cookie-import')
set({
browserSessionImportState: {
profileId,
@@ -1830,6 +1832,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
browserProfile
})) as BrowserCookieImportResult
if (result.ok) {
get().recordFeatureInteraction('cookie-import')
set({
browserSessionImportState: {
profileId,
@@ -1886,6 +1889,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
+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)
@@ -238,6 +241,24 @@ describe('TabsSlice', () => {
})
})
describe('terminal tab creation tracking', () => {
it('records normal terminal tab creation without recording activation fallback tabs', () => {
const setMock = vi.mocked(window.api.ui.set)
store.getState().hydratePersistedUI(getDefaultUIState())
setMock.mockClear()
store.getState().createTab(WT)
store.getState().createTab(WT, undefined, undefined, { pendingActivationSpawn: true })
expect(setMock).toHaveBeenCalledTimes(1)
expect(setMock).toHaveBeenCalledWith({
featureInteractions: {
'terminal-tabs': expect.objectContaining({ interactionCount: 1 })
}
})
})
})
// ─── closeUnifiedTab ────────────────────────────────────────────────
describe('closeUnifiedTab', () => {
@@ -863,6 +884,7 @@ describe('TabsSlice', () => {
})
it('merges a group into its sibling', () => {
const setMock = vi.mocked(window.api.ui.set)
const t1 = store.getState().createUnifiedTab(WT, 'editor', {
id: 'file-a.ts',
label: 'file-a.ts'
@@ -875,6 +897,8 @@ describe('TabsSlice', () => {
label: 'file-b.ts',
targetGroupId: targetGroupId!
})
store.getState().hydratePersistedUI(getDefaultUIState())
setMock.mockClear()
const mergedInto = store.getState().mergeGroupIntoSibling(WT, targetGroupId!)
@@ -883,6 +907,12 @@ describe('TabsSlice', () => {
expect(state.groupsByWorktree[WT]).toHaveLength(1)
expect(state.groupsByWorktree[WT][0].tabOrder).toEqual([t1.id, 'file-b.ts'])
expect(state.layoutByWorktree[WT]).toEqual({ type: 'leaf', groupId: sourceGroupId })
expect(setMock).toHaveBeenCalledTimes(1)
expect(setMock).toHaveBeenCalledWith({
featureInteractions: {
'terminal-panes': expect.objectContaining({ interactionCount: 1 })
}
})
})
it('drops a unified tab into another group and collapses an emptied source group', () => {
+29 -25
View File
@@ -46,6 +46,7 @@ export type TabsSlice = {
'id' | 'entityId' | 'label' | 'customLabel' | 'color' | 'isPreview' | 'isPinned'
> & {
targetGroupId: string
recordInteraction: boolean
}
>
) => Tab
@@ -59,11 +60,20 @@ export type TabsSlice = {
) => Tab | null
activateTab: (tabId: string) => void
closeUnifiedTab: (
tabId: string
tabId: string,
opts?: { recordInteraction?: boolean }
) => { closedTabId: string; wasLastTab: boolean; worktreeId: string } | null
reorderUnifiedTabs: (groupId: string, tabIds: string[]) => void
reorderUnifiedTabs: (
groupId: string,
tabIds: string[],
opts?: { recordInteraction?: boolean }
) => void
setTabLabel: (tabId: string, label: string) => void
setTabCustomLabel: (tabId: string, label: string | null) => void
setTabCustomLabel: (
tabId: string,
label: string | null,
opts?: { recordInteraction?: boolean }
) => void
setUnifiedTabColor: (tabId: string, color: string | null) => void
pinTab: (tabId: string) => void
unpinTab: (tabId: string) => void
@@ -80,7 +90,7 @@ export type TabsSlice = {
moveUnifiedTabToGroup: (
tabId: string,
targetGroupId: string,
opts?: { index?: number; activate?: boolean }
opts?: { index?: number; activate?: boolean; recordInteraction?: boolean }
) => boolean
dropUnifiedTab: (
tabId: string,
@@ -465,7 +475,9 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
}
}
})
get().recordFeatureInteraction('terminal-tabs')
if (init?.recordInteraction !== false) {
get().recordFeatureInteraction('terminal-tabs')
}
return created
},
@@ -554,7 +566,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,11 +707,13 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
}
})
get().recordFeatureInteraction('terminal-tabs')
if (opts?.recordInteraction !== false) {
get().recordFeatureInteraction('terminal-tabs')
}
return { closedTabId: tabId, wasLastTab, worktreeId }
},
reorderUnifiedTabs: (groupId, tabIds) => {
reorderUnifiedTabs: (groupId, tabIds, opts) => {
let reordered = false
set((state) => {
for (const [worktreeId, groups] of Object.entries(state.groupsByWorktree)) {
@@ -729,23 +743,19 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
}
return {}
})
if (reordered) {
if (reordered && opts?.recordInteraction !== false) {
get().recordFeatureInteraction('terminal-tabs')
}
},
setTabLabel: (tabId, label) => {
const exists = get().getTab(tabId) !== null
set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? {})
if (exists) {
get().recordFeatureInteraction('terminal-tabs')
}
},
setTabCustomLabel: (tabId, label) => {
setTabCustomLabel: (tabId, label, opts) => {
const exists = get().getTab(tabId) !== null
set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {})
if (exists) {
if (exists && opts?.recordInteraction !== false) {
get().recordFeatureInteraction('terminal-tabs')
}
},
@@ -1076,8 +1086,8 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
activeGroupIdByWorktree: nextActiveGroupIdByWorktree
}
})
if (moved) {
get().recordFeatureInteraction('terminal-panes')
if (moved && opts?.recordInteraction !== false) {
get().recordFeatureInteraction('tab-splits')
}
return moved
},
@@ -1248,9 +1258,7 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
})
if (moved) {
get().recordFeatureInteraction('terminal-tabs')
if (target.splitDirection) {
get().recordFeatureInteraction('terminal-panes')
}
get().recordFeatureInteraction('tab-splits')
}
return moved
},
@@ -1294,7 +1302,7 @@ export const createTabsSlice: StateCreator<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')
@@ -1302,10 +1310,6 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
},
setTabGroupSplitRatio: (worktreeId, nodePath, ratio) => {
const currentLayout = get().layoutByWorktree[worktreeId]
if (currentLayout) {
get().recordFeatureInteraction('terminal-panes')
}
set((state) => {
const currentLayout = state.layoutByWorktree[worktreeId]
if (!currentLayout) {
+16 -6
View File
@@ -256,6 +256,7 @@ export type TerminalSlice = {
pendingActivationSpawn?: boolean
initialPtyId?: string
activate?: boolean
recordInteraction?: boolean
/** Pre-allocated tab id (e.g. minted by main for CLI/runtime-spawned
* terminals whose PTY env already carries a pane key). Falls back to
* minting a fresh id when omitted or when the supplied id collides
@@ -264,7 +265,7 @@ export type TerminalSlice = {
id?: string
}
) => TerminalTab
closeTab: (tabId: string) => void
closeTab: (tabId: string, opts?: { recordInteraction?: boolean }) => void
reorderTabs: (worktreeId: string, tabIds: string[]) => void
setTabBarOrder: (worktreeId: string, order: string[]) => void
setActiveTab: (tabId: string) => void
@@ -283,7 +284,11 @@ export type TerminalSlice = {
* model where the bell stays visible until the user engages with the
* surface that raised it. */
clearTerminalTabUnread: (tabId: string) => void
setTabCustomTitle: (tabId: string, title: string | null) => void
setTabCustomTitle: (
tabId: string,
title: string | null,
opts?: { recordInteraction?: boolean }
) => void
setTabColor: (tabId: string, color: string | null) => void
updateTabPtyId: (tabId: string, ptyId: string) => void
clearTabPtyId: (tabId: string, ptyId?: string) => void
@@ -616,10 +621,15 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
}
}
})
const shouldRecordInteraction =
options?.recordInteraction ?? (!options?.pendingActivationSpawn && !options?.initialPtyId)
if (shouldRecordInteraction) {
get().recordFeatureInteraction('terminal-tabs')
}
return tab
},
closeTab: (tabId) => {
closeTab: (tabId, opts) => {
set((s) => {
const next = { ...s.tabsByWorktree }
let closingPtyId: string | null = null
@@ -743,7 +753,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
(entry) => entry.contentType === 'terminal' && entry.entityId === tabId
)
if (workspaceItem) {
get().closeUnifiedTab(workspaceItem.id)
get().closeUnifiedTab(workspaceItem.id, opts)
}
}
},
@@ -1033,7 +1043,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
})
},
setTabCustomTitle: (tabId, title) => {
setTabCustomTitle: (tabId, title, opts) => {
set((s) => {
const next = { ...s.tabsByWorktree }
for (const wId of Object.keys(next)) {
@@ -1046,7 +1056,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
.flat()
.find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId)
if (item) {
get().setTabCustomLabel(item.id, title)
get().setTabCustomLabel(item.id, title, opts)
}
},
+85
View File
@@ -816,6 +816,91 @@ describe('createUISlice feature interactions', () => {
}
})
it('uses the main-owned feature interaction increment API when available', async () => {
const recordFeatureInteractionMock = vi.fn(() =>
Promise.resolve(
makePersistedUI({
featureInteractions: {
tasks: { firstInteractedAt: 100, interactionCount: 3 }
}
})
)
)
const setMock = vi.fn(() => Promise.resolve())
vi.stubGlobal('window', {
api: {
ui: {
recordFeatureInteraction: recordFeatureInteractionMock,
set: setMock
}
}
})
const store = createUIStore()
store.getState().hydratePersistedUI(
makePersistedUI({
featureInteractions: {
tasks: { firstInteractedAt: 100, interactionCount: 2 }
}
})
)
setMock.mockClear()
store.getState().recordFeatureInteraction('tasks')
await Promise.resolve()
expect(recordFeatureInteractionMock).toHaveBeenCalledWith('tasks')
expect(setMock).not.toHaveBeenCalled()
expect(store.getState().featureInteractions.tasks).toEqual({
firstInteractedAt: 100,
interactionCount: 3
})
})
it('keeps newer optimistic interaction counts when persistence responses resolve out of order', async () => {
const pending: ((ui: PersistedUIState) => void)[] = []
const recordFeatureInteractionMock = vi.fn(
() =>
new Promise<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', {
+38 -1
View File
@@ -77,6 +77,32 @@ export type PendingSidebarWorktreeReveal = {
behavior: 'auto' | 'smooth'
}
function mergeFeatureInteractionState(
current: FeatureInteractionState,
incoming: PersistedUIState['featureInteractions']
): FeatureInteractionState {
const currentNormalized = normalizeFeatureInteractions(current)
const incomingNormalized = normalizeFeatureInteractions(incoming)
const merged: FeatureInteractionState = { ...currentNormalized }
for (const [id, incomingRecord] of Object.entries(incomingNormalized)) {
const featureId = id as FeatureInteractionId
const currentRecord = currentNormalized[featureId]
merged[featureId] = currentRecord
? {
firstInteractedAt: Math.min(
currentRecord.firstInteractedAt,
incomingRecord.firstInteractedAt
),
interactionCount: Math.max(
currentRecord.interactionCount,
incomingRecord.interactionCount
)
}
: incomingRecord
}
return merged
}
function clampPetSize(size: number): number {
if (!Number.isFinite(size)) {
return PET_SIZE_DEFAULT
@@ -940,7 +966,18 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
}
}
if (typeof window !== 'undefined') {
window.api.ui.set({ featureInteractions: next }).catch(console.error)
const recordInteraction = window.api.ui.recordFeatureInteraction
const persist = recordInteraction
? recordInteraction(id).then((ui) => {
set((current) => ({
featureInteractions: mergeFeatureInteractionState(
current.featureInteractions,
ui.featureInteractions
)
}))
})
: window.api.ui.set({ featureInteractions: next })
persist.catch(console.error)
}
return { featureInteractions: next }
}),
@@ -2,6 +2,7 @@
global setup across namespaces so browser API installation stays realistic. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PreloadApi } from '../../../preload/api-types'
import type { FeatureInteractionState } from '../../../shared/feature-interactions'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
class MemoryStorage implements Storage {
@@ -170,6 +171,136 @@ describe('web keybindings preload API', () => {
})
})
describe('web UI preload API', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.doUnmock('./web-runtime-client')
})
it('keeps newer feature interaction counts when runtime responses resolve out of order', async () => {
const pending: ((response: RuntimeRpcResponse<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', () => {
beforeEach(() => {
vi.resetModules()
+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'
@@ -1459,7 +1464,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
@@ -1477,6 +1489,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')),
@@ -2032,6 +2079,32 @@ function mergeWebUIState(
}
}
function mergeFeatureInteractionState(
current: PersistedUIState['featureInteractions'],
incoming: PersistedUIState['featureInteractions']
): FeatureInteractionState {
const currentNormalized = normalizeFeatureInteractions(current)
const incomingNormalized = normalizeFeatureInteractions(incoming)
const merged: FeatureInteractionState = { ...currentNormalized }
for (const [id, incomingRecord] of Object.entries(incomingNormalized)) {
const featureId = id as FeatureInteractionId
const currentRecord = currentNormalized[featureId]
merged[featureId] = currentRecord
? {
firstInteractedAt: Math.min(
currentRecord.firstInteractedAt,
incomingRecord.firstInteractedAt
),
interactionCount: Math.max(
currentRecord.interactionCount,
incomingRecord.interactionCount
)
}
: incomingRecord
}
return merged
}
function mergeSettings(base: GlobalSettings, updates: Partial<GlobalSettings>): GlobalSettings {
const defaults = getDefaultSettings('~')
return {
+5
View File
@@ -29,13 +29,17 @@ describe('feature interactions', () => {
'browser-annotations',
'browser-grab',
'workspace-creation',
'agent-browser-setup',
'agent-browser-use',
'agent-orchestration-setup',
'agent-orchestration',
'ai-commit-generation',
'ai-pr-generation',
'claude-account-switching',
'computer-use-setup',
'computer-use',
'codex-account-switching',
'cookie-import',
'floating-workspace',
'mobile-pairing',
'notifications',
@@ -46,6 +50,7 @@ describe('feature interactions', () => {
'ssh',
'terminal-panes',
'terminal-tabs',
'tab-splits',
'usage-tracking',
'voice-dictation',
'workspace-cleanup'
+30 -5
View File
@@ -9,13 +9,17 @@ export type FeatureInteractionId =
| 'browser-annotations'
| 'browser-grab'
| 'workspace-creation'
| 'agent-browser-setup'
| 'agent-browser-use'
| 'agent-orchestration-setup'
| 'agent-orchestration'
| 'ai-commit-generation'
| 'ai-pr-generation'
| 'claude-account-switching'
| 'computer-use-setup'
| 'computer-use'
| 'codex-account-switching'
| 'cookie-import'
| 'floating-workspace'
| 'mobile-pairing'
| 'notifications'
@@ -26,6 +30,7 @@ export type FeatureInteractionId =
| 'ssh'
| 'terminal-panes'
| 'terminal-tabs'
| 'tab-splits'
| 'usage-tracking'
| 'voice-dictation'
| 'workspace-cleanup'
@@ -90,13 +95,21 @@ export const FEATURE_INTERACTIONS = [
id: 'workspace-creation',
interaction: 'workspace creation flow opened'
},
{
id: 'agent-browser-setup',
interaction: 'Agent Browser Use setup enabled or opened'
},
{
id: 'agent-browser-use',
interaction: 'Agent Browser Use setup enabled or used'
interaction: 'agent browser runtime method used'
},
{
id: 'agent-orchestration-setup',
interaction: 'Agent Orchestration setup enabled or opened'
},
{
id: 'agent-orchestration',
interaction: 'Agent Orchestration setup enabled or used'
interaction: 'agent orchestration runtime method used'
},
{
id: 'ai-commit-generation',
@@ -111,13 +124,21 @@ export const FEATURE_INTERACTIONS = [
interaction: 'Claude managed account added, selected, reauthenticated, or removed'
},
{
id: 'computer-use',
id: 'computer-use-setup',
interaction: 'Computer Use setup or permission flow opened'
},
{
id: 'computer-use',
interaction: 'computer-use runtime method used'
},
{
id: 'codex-account-switching',
interaction: 'Codex managed account added, selected, reauthenticated, or removed'
},
{
id: 'cookie-import',
interaction: 'browser cookies imported or cleared'
},
{
id: 'floating-workspace',
interaction: 'Floating Workspace opened or configured'
@@ -152,11 +173,15 @@ export const FEATURE_INTERACTIONS = [
},
{
id: 'terminal-panes',
interaction: 'terminal/editor/browser pane split, moved, resized, or merged'
interaction: 'terminal/editor/browser pane created, resized, or merged'
},
{
id: 'terminal-tabs',
interaction: 'workspace tab created, moved, reordered, pinned, renamed, or closed'
interaction: 'workspace tab created, moved, reordered, pinned, renamed, recolored, or closed'
},
{
id: 'tab-splits',
interaction: 'workspace tab split into another pane'
},
{
id: 'usage-tracking',