Fix feature interaction state writes

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-28 13:10:22 -07:00
co-authored by Orca
parent 80b8cea544
commit df67e25691
14 changed files with 404 additions and 45 deletions
+74 -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'
@@ -615,6 +621,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())
@@ -2124,6 +2159,44 @@ describe('Store', () => {
})
})
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()
+12 -1
View File
@@ -89,6 +89,7 @@ import {
normalizeFeatureInteractions,
type FeatureInteractionId
} from '../shared/feature-interactions'
import { normalizeFeatureTipIds } from '../shared/feature-tips'
import {
DEFAULT_WORKSPACE_STATUS_ID,
clampWorkspaceBoardColumnWidth,
@@ -2414,6 +2415,7 @@ export class Store {
updatedAt: now
}
this.state.automations = [...(this.state.automations ?? []), automation]
this.recordFeatureInteraction('automation-created')
this.flush()
return automation
}
@@ -2512,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
}
@@ -2746,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)
}
}
@@ -2781,6 +2788,10 @@ 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.
@@ -2,6 +2,10 @@ 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'
@@ -43,6 +47,26 @@ const METHODS = [
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({}),
@@ -137,24 +161,28 @@ describe('RpcDispatcher feature interactions', () => {
expect(runtime.recordFeatureInteraction).not.toHaveBeenCalled()
})
it('records screencast starts but not screencast cleanup', async () => {
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).not.toHaveBeenCalled()
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')
expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(1)
})
it('records streaming runtime feature use when the stream only returns a start result', async () => {
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[] = []
@@ -168,6 +196,25 @@ describe('RpcDispatcher feature interactions', () => {
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 })
+15 -7
View File
@@ -15,6 +15,7 @@ import {
} 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'
@@ -67,7 +68,7 @@ export class RpcDispatcher {
runtime: this.runtime,
signal: options?.signal
})
this.recordRuntimeFeatureInteraction(request.method, result)
this.recordRuntimeFeatureInteraction(request.method, result, undefined, request.params)
return successResponse(request.id, meta, result)
} catch (error) {
return this.mapError(request, meta, error)
@@ -118,7 +119,7 @@ export class RpcDispatcher {
sendBinary: options?.sendBinary,
registerBinaryStreamHandler: options?.registerBinaryStreamHandler
})
this.recordRuntimeFeatureInteraction(request.method, result)
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)))
@@ -131,7 +132,8 @@ export class RpcDispatcher {
this.recordRuntimeFeatureInteraction(
request.method,
result,
recordedStreamingFeatureInteractions
recordedStreamingFeatureInteractions,
request.params
)
const response = successResponse(request.id, meta, result)
response.streaming = true
@@ -154,7 +156,8 @@ export class RpcDispatcher {
this.recordRuntimeFeatureInteraction(
request.method,
result,
recordedStreamingFeatureInteractions
recordedStreamingFeatureInteractions,
request.params
)
} catch (error) {
reply(JSON.stringify(this.mapError(request, meta, error)))
@@ -200,9 +203,10 @@ export class RpcDispatcher {
private recordRuntimeFeatureInteraction(
method: string,
result: unknown,
alreadyRecorded?: Set<FeatureInteractionId>
alreadyRecorded?: Set<FeatureInteractionId>,
rawParams?: unknown
): void {
const id = getRuntimeFeatureInteractionId(method, result)
const id = getRuntimeFeatureInteractionId(method, result, rawParams)
if (!id) {
return
}
@@ -220,7 +224,8 @@ export class RpcDispatcher {
function getRuntimeFeatureInteractionId(
method: string,
result: unknown
result: unknown,
rawParams?: unknown
): FeatureInteractionId | null {
if (method === 'browser.profileImportFromBrowser') {
return hasBooleanResult(result, 'ok') ? 'cookie-import' : null
@@ -231,6 +236,9 @@ function getRuntimeFeatureInteractionId(
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'
}
@@ -256,6 +256,21 @@ describe('client UI RPC methods', () => {
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',
+3 -1
View File
@@ -3,12 +3,14 @@ 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([
@@ -186,7 +188,7 @@ const UiUpdate = z
sidekickSize: z.number().finite().optional(),
taskResumeState: TaskResumeState.optional(),
workspaceCleanup: WorkspaceCleanup.optional(),
featureTipsSeenIds: StringArray.optional(),
featureTipsSeenIds: FeatureTipIds.optional(),
featureInteractions: FeatureInteractions.optional()
})
.strict()
@@ -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,7 @@ function BrowserPagePane({
const startGrabIntent = useCallback(
(nextIntent: GrabIntent): void => {
recordFeatureInteraction('browser-grab')
setGrabIntent(nextIntent)
if (nextIntent === 'copy') {
setPendingAnnotationPayload(null)
@@ -3737,7 +3745,7 @@ function BrowserPagePane({
grab.toggle()
}
},
[grab, grabIntent]
[grab, grabIntent, recordFeatureInteraction]
)
// CmdOrCtrl+C toggles grab mode
@@ -3819,11 +3827,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 +3887,7 @@ function BrowserPagePane({
})()
}
},
[grab, grabIntent, showGrabToast]
[grab, grabIntent, recordFeatureInteraction, showGrabToast]
)
useEffect(() => {
@@ -3930,9 +3940,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 +3956,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 +3978,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 +4004,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 +4860,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" />
@@ -206,12 +206,12 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
action: typeof codexAction,
operation: () => Promise<CodexRateLimitAccountsState>
): Promise<void> => {
recordFeatureInteraction('codex-account-switching')
const previousActiveAccountId = codexAccounts.activeAccountId
setCodexAction(action)
try {
const next = await operation()
await syncCodexAccounts(next)
recordFeatureInteraction('codex-account-switching')
const shouldPromptRestart =
action === 'adding' ||
(action.startsWith('select:') && previousActiveAccountId !== next.activeAccountId) ||
@@ -238,12 +238,12 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
action: typeof claudeAction,
operation: () => Promise<ClaudeRateLimitAccountsState>
): Promise<void> => {
recordFeatureInteraction('claude-account-switching')
const previousActiveAccountId = claudeAccounts.activeAccountId
setClaudeAction(action)
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.`
@@ -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
@@ -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 () => {
@@ -341,7 +341,6 @@ 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)
@@ -370,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)
@@ -399,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)
+58
View File
@@ -1,3 +1,5 @@
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, relative } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
FEATURE_INTERACTIONS,
@@ -10,6 +12,11 @@ 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: [
@@ -95,4 +102,55 @@ describe('feature interactions', () => {
)
).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, '\\$&')
}
@@ -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
)
}