mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 00:02:48 +00:00
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'] {
|
||||
|
||||
@@ -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: [] })
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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%` }}>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user