mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Track feature interactions (#2959)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
# Feature Discovery Interaction Tracking
|
||||
|
||||
This document defines local feature-interaction state used to decide whether Orca should still teach a feature with an education surface such as a tour or feature tip.
|
||||
|
||||
## Decision
|
||||
|
||||
Track first meaningful interaction plus local interaction count for education-targeted features in `PersistedUIState.featureInteractions`.
|
||||
|
||||
Do not upload this state as broad analytics. Product analytics should continue to use bounded telemetry events and downstream product events. Local interaction state answers a different question: "Has this user already found enough of this feature that education would be redundant?"
|
||||
|
||||
## Rules
|
||||
|
||||
- Add a `FeatureInteractionId` in `src/shared/feature-interactions.ts` before using it.
|
||||
- Preserve `firstInteractedAt`, and increment `interactionCount` on each later meaningful interaction.
|
||||
- Prefer explicit actions over passive visibility.
|
||||
- Passive visibility is acceptable only when opening the surface is itself the product use, such as opening Tasks.
|
||||
- Record after persisted UI is hydrated by using `recordFeatureInteraction(...)`; it no-ops before hydration.
|
||||
- Keep IDs stable. If the meaning changes materially, add a new ID.
|
||||
- Do not include user text, paths, URLs, repo names, branch names, hostnames, commands, prompts, or tokens in this state.
|
||||
- Old records without `interactionCount` hydrate as `interactionCount: 1`.
|
||||
|
||||
## Feature Catalog
|
||||
|
||||
| Feature | Interaction ID | Record when | Education use |
|
||||
| --- | --- | --- | --- |
|
||||
| Review notes to agent | `review-notes` | A diff or markdown review note is added, or review notes are marked sent to an agent. | Suppress or target a future review-notes tour/tip about adding line notes and sending focused feedback back to an agent. |
|
||||
| AI commit generation | `ai-commit-generation` | AI commit-message generation is enabled or an AI commit message is generated. | Suppress future education about AI commit generation. No contextual tour is planned for this branch. |
|
||||
| AI PR generation | `ai-pr-generation` | AI pull-request title/body/draft fields are generated. | Suppress future education about AI PR generation. No contextual tour is planned for this branch. |
|
||||
| Floating Workspace | `floating-workspace` | The floating workspace opens, is enabled, or is configured. | Suppress future tips about the global terminal/browser/markdown workspace. No contextual tour is planned for this branch. |
|
||||
| Quick Commands | `quick-commands` | A terminal quick command is created or edited. | Suppress future tips about saved terminal commands. No contextual tour is planned for this branch. |
|
||||
| Computer Use setup | `computer-use-setup` | Computer Use is selected in onboarding, a permission setup is opened, or the skill setup terminal opens. | Suppress setup-focused tips once the user has started setup. |
|
||||
| Computer Use | `computer-use` | A successful `computer.*` runtime method other than capability probing is handled. | Suppress future usage tips once an agent has actually invoked Computer Use. |
|
||||
| Mobile pairing | `mobile-pairing` | Mobile is enabled or a mobile pairing QR/code is generated. | Suppress future mobile-pairing tips. No contextual tour is planned for this branch. |
|
||||
| Browser element grab | `browser-grab` | Browser grab mode is started, an element is copied, or an element screenshot is copied. | Suppress future tips about grabbing page context once the user has used the element picker. |
|
||||
| Browser annotations | `browser-annotations` | Browser annotation mode is started, an annotation is added, browser annotations are copied, or browser annotations are cleared. | Suppress future tips about annotating local pages and sending concrete UI feedback to an agent. |
|
||||
| Cookie import | `cookie-import` | Browser cookies are imported from another browser or file, imported through runtime browser profile methods, or default imported cookies are cleared. | Suppress future cookie-import education without conflating cookie setup with Agent Browser Use itself. |
|
||||
| Workspace board actions | `workspace-board-actions` | A card/status action, lane configuration, density change, pin drop, or board drag action is used. | Keep the existing workspace-board tour, and avoid repeating deeper board-action education after real use. |
|
||||
| Automation creation | `automation-created` | A local automation or external Hermes cron is created. | Keep the existing Automations tour, and suppress creation-focused education after the user creates one. |
|
||||
| Automation run | `automation-run` | A local or external automation run is manually queued. | Keep the existing Automations tour, and suppress run/inspection education after the user queues a run. |
|
||||
| Resource Manager | `resource-manager` | The Resource Manager status-bar popover is opened, or its status-bar visibility is toggled in Appearance/status-bar controls. | Suppress future tips about CPU, memory, session, daemon, and workspace disk-scan controls after the user has found the manager. |
|
||||
| Workspace cleanup / disk space | `workspace-cleanup` | The Space page opens, a workspace disk scan starts/cancels, or cleanup removes scanned workspace rows. | Suppress future tips about scanning workspace disk usage and reclaiming old workspace storage. |
|
||||
| Ports | `ports` | The Ports popover opens, the Ports status-bar item is configured, external ports are expanded, or a port is opened/copied/stopped. | Suppress future tips about discovering and acting on workspace ports. |
|
||||
| SSH | `ssh` | SSH status opens, SSH status-bar visibility changes, a target is added/imported/tested, or an SSH target is connected/disconnected. | Suppress future SSH setup/status tips after the user has interacted with remote target controls. |
|
||||
| Provider usage tracking | `usage-tracking` | Stats & Usage is opened, Claude/Codex/OpenCode usage analytics are enabled, provider usage details are opened from the status bar, Gemini usage/OAuth is configured, or provider usage status-bar toggles are changed. | Suppress future tips about where to find token/rate-limit/usage tracking for Claude, Codex, Gemini, OpenCode, and related providers. |
|
||||
| Claude account switching | `claude-account-switching` | A Claude managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about using multiple Claude accounts once the user has started account management. |
|
||||
| Codex account switching | `codex-account-switching` | A Codex managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about Codex account switching and restart follow-up flows. |
|
||||
| Workspace tabs | `terminal-tabs` | A workspace tab is created, reordered, renamed, recolored, pinned/unpinned, moved, or closed. | Suppress future tips about tab-level workspace organization. These are real workspace tabs, not workspace board cards. |
|
||||
| Split panes | `terminal-panes` | A split group is created from the pane shortcut/menu, a split is resized, or panes are merged. | Suppress future tips about pane-level split workflows. |
|
||||
| Tab splits | `tab-splits` | A workspace tab is moved into another pane or split into a new pane from a tab split/drop action. | Suppress future tips about tab-level split workflows separately from pane creation/resizing. |
|
||||
| Agent Browser Use setup | `agent-browser-setup` | Browser Use is selected in onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Browser Use setup. |
|
||||
| Agent Browser Use | `agent-browser-use` | A successful non-profile `browser.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually driven Orca's browser. |
|
||||
| Agent Orchestration setup | `agent-orchestration-setup` | Orchestration is selected in onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Orchestration setup. |
|
||||
| Agent Orchestration | `agent-orchestration` | A successful `orchestration.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually used orchestration. |
|
||||
| Notifications | `notifications` | Notifications are enabled in onboarding/settings or a test notification is sent. | Suppress future notification setup tips. |
|
||||
|
||||
## Surface-Level Features
|
||||
|
||||
Orca also records surface-level interactions for feature areas where opening the surface is itself a meaningful discovery signal:
|
||||
|
||||
- `workspace-board`: workspace board opened
|
||||
- `browser`: non-blank browser page viewed
|
||||
- `tasks`: Tasks page opened
|
||||
- `automations`: Automations page opened
|
||||
- `workspace-creation`: workspace creation flow opened
|
||||
|
||||
These remain intentionally separate from action-level IDs such as `workspace-board-actions`, `automation-created`, and `automation-run`. Surface-level IDs answer "has the user entered the feature area?" Action-level IDs answer "has the user performed the deeper workflow?"
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,7 +14,13 @@ import {
|
||||
} from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import type { Repo, TerminalTab, WorktreeLineage, WorkspaceSessionState } from '../shared/types'
|
||||
import type {
|
||||
PersistedState,
|
||||
Repo,
|
||||
TerminalTab,
|
||||
WorktreeLineage,
|
||||
WorkspaceSessionState
|
||||
} from '../shared/types'
|
||||
import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id'
|
||||
import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser-history'
|
||||
|
||||
@@ -616,6 +622,35 @@ describe('Store', () => {
|
||||
expect(second.title).toBe('Nightly run 2')
|
||||
})
|
||||
|
||||
it('records feature interactions when automations are created or manually queued', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const automation = store.createAutomation({
|
||||
name: 'Nightly',
|
||||
prompt: 'Run checks',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'wt1',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
|
||||
})
|
||||
|
||||
store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime(), 'scheduled')
|
||||
store.createAutomationRun(automation, new Date('2026-05-14T09:00:00Z').getTime(), 'manual')
|
||||
|
||||
expect(store.getUI().featureInteractions?.['automation-created']?.interactionCount).toBe(1)
|
||||
expect(store.getUI().featureInteractions?.['automation-run']?.interactionCount).toBe(1)
|
||||
const persisted = readDataFile() as PersistedState
|
||||
expect(persisted.ui?.featureInteractions?.['automation-created']).toMatchObject({
|
||||
interactionCount: 1
|
||||
})
|
||||
expect(persisted.ui?.featureInteractions?.['automation-run']).toMatchObject({
|
||||
interactionCount: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('snapshots automation run workspace names for deleted-workspace history', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
@@ -2106,6 +2141,85 @@ describe('Store', () => {
|
||||
expect(store.getUI().rightSidebarTab).toBe('explorer')
|
||||
})
|
||||
|
||||
it('updateUI merges feature interactions instead of replacing stale snapshots', async () => {
|
||||
const store = await createStore()
|
||||
|
||||
store.updateUI({
|
||||
featureInteractions: {
|
||||
'agent-browser-use': { firstInteractedAt: 100, interactionCount: 1 }
|
||||
}
|
||||
})
|
||||
store.updateUI({
|
||||
featureInteractions: {
|
||||
tasks: { firstInteractedAt: 200, interactionCount: 1 }
|
||||
}
|
||||
})
|
||||
|
||||
expect(store.getUI().featureInteractions).toEqual({
|
||||
'agent-browser-use': { firstInteractedAt: 100, interactionCount: 1 },
|
||||
tasks: { firstInteractedAt: 200, interactionCount: 1 }
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes malformed persisted feature discovery state on read', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {
|
||||
featureTipsSeenIds: ['voice-dictation', 'unknown-tip', 'voice-dictation'],
|
||||
featureInteractions: {
|
||||
tasks: { firstInteractedAt: 100 },
|
||||
automations: { firstInteractedAt: 150, interactionCount: 4 },
|
||||
browser: { firstInteractedAt: Number.NaN },
|
||||
unknown: { firstInteractedAt: 200 }
|
||||
}
|
||||
},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: {}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getUI().featureTipsSeenIds).toEqual(['voice-dictation'])
|
||||
expect(store.getUI().featureInteractions).toEqual({
|
||||
tasks: { firstInteractedAt: 100, interactionCount: 1 },
|
||||
automations: { firstInteractedAt: 150, interactionCount: 4 }
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes feature tip ids from direct UI writes', async () => {
|
||||
const store = await createStore()
|
||||
|
||||
store.updateUI({
|
||||
featureTipsSeenIds: ['voice-dictation', 'unknown-tip', 'voice-dictation'] as never
|
||||
})
|
||||
|
||||
expect(store.getUI().featureTipsSeenIds).toEqual(['voice-dictation'])
|
||||
})
|
||||
|
||||
it('recordFeatureInteraction increments from the current persisted UI state', async () => {
|
||||
const store = await createStore()
|
||||
|
||||
store.updateUI({
|
||||
featureInteractions: {
|
||||
tasks: { firstInteractedAt: 100, interactionCount: 2 }
|
||||
}
|
||||
})
|
||||
|
||||
const ui = store.recordFeatureInteraction('tasks')
|
||||
|
||||
expect(ui.featureInteractions?.tasks).toEqual({
|
||||
firstInteractedAt: 100,
|
||||
interactionCount: 3
|
||||
})
|
||||
expect(store.getUI().featureInteractions?.tasks).toEqual({
|
||||
firstInteractedAt: 100,
|
||||
interactionCount: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('updateUI restores fixed card properties from direct UI writes', async () => {
|
||||
const store = await createStore()
|
||||
store.updateUI({ worktreeCardProperties: ['inline-agents'] })
|
||||
|
||||
+67
-2
@@ -85,6 +85,11 @@ import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-command
|
||||
import { normalizeTaskProviderSettings } from '../shared/task-providers'
|
||||
import { normalizeOpenInApplications } from '../shared/open-in-applications'
|
||||
import { normalizeTerminalShortcutPolicy } from '../shared/keybindings'
|
||||
import {
|
||||
normalizeFeatureInteractions,
|
||||
type FeatureInteractionId
|
||||
} from '../shared/feature-interactions'
|
||||
import { normalizeFeatureTipIds } from '../shared/feature-tips'
|
||||
import {
|
||||
DEFAULT_WORKSPACE_STATUS_ID,
|
||||
clampWorkspaceBoardColumnWidth,
|
||||
@@ -234,6 +239,31 @@ function normalizeGroupBy(groupBy: unknown): PersistedState['ui']['groupBy'] {
|
||||
return getDefaultUIState().groupBy
|
||||
}
|
||||
|
||||
function mergeFeatureInteractions(
|
||||
current: PersistedState['ui']['featureInteractions'],
|
||||
incoming: PersistedState['ui']['featureInteractions']
|
||||
): PersistedState['ui']['featureInteractions'] {
|
||||
const currentNormalized = normalizeFeatureInteractions(current)
|
||||
const incomingNormalized = normalizeFeatureInteractions(incoming)
|
||||
const merged = { ...currentNormalized }
|
||||
for (const [id, incomingRecord] of Object.entries(incomingNormalized)) {
|
||||
const currentRecord = currentNormalized[id as keyof typeof currentNormalized]
|
||||
merged[id as keyof typeof merged] = currentRecord
|
||||
? {
|
||||
firstInteractedAt: Math.min(
|
||||
currentRecord.firstInteractedAt,
|
||||
incomingRecord.firstInteractedAt
|
||||
),
|
||||
interactionCount: Math.max(
|
||||
currentRecord.interactionCount,
|
||||
incomingRecord.interactionCount
|
||||
)
|
||||
}
|
||||
: incomingRecord
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function normalizeSortBy(sortBy: unknown): PersistedState['ui']['sortBy'] {
|
||||
if (
|
||||
sortBy === 'smart' ||
|
||||
@@ -2385,6 +2415,7 @@ export class Store {
|
||||
updatedAt: now
|
||||
}
|
||||
this.state.automations = [...(this.state.automations ?? []), automation]
|
||||
this.recordFeatureInteraction('automation-created')
|
||||
this.flush()
|
||||
return automation
|
||||
}
|
||||
@@ -2483,6 +2514,9 @@ export class Store {
|
||||
createdAt: now
|
||||
}
|
||||
this.state.automationRuns = [...(this.state.automationRuns ?? []), run]
|
||||
if (trigger === 'manual') {
|
||||
this.recordFeatureInteraction('automation-run')
|
||||
}
|
||||
this.flush()
|
||||
return run
|
||||
}
|
||||
@@ -2717,7 +2751,9 @@ export class Store {
|
||||
workspaceBoardCompact: normalizeWorkspaceBoardCompact(this.state.ui?.workspaceBoardCompact),
|
||||
workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(
|
||||
this.state.ui?.workspaceBoardColumnWidth
|
||||
)
|
||||
),
|
||||
featureTipsSeenIds: normalizeFeatureTipIds(this.state.ui?.featureTipsSeenIds),
|
||||
featureInteractions: normalizeFeatureInteractions(this.state.ui?.featureInteractions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2751,11 +2787,40 @@ export class Store {
|
||||
),
|
||||
workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(
|
||||
updates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth
|
||||
)
|
||||
),
|
||||
featureTipsSeenIds:
|
||||
updates.featureTipsSeenIds !== undefined
|
||||
? normalizeFeatureTipIds(updates.featureTipsSeenIds)
|
||||
: normalizeFeatureTipIds(this.state.ui?.featureTipsSeenIds),
|
||||
// Why: runtime RPCs and the renderer can both record education state.
|
||||
// Merge instead of replacing so a stale renderer snapshot cannot erase
|
||||
// runtime-only feature interactions.
|
||||
featureInteractions:
|
||||
updates.featureInteractions !== undefined
|
||||
? mergeFeatureInteractions(
|
||||
this.state.ui?.featureInteractions,
|
||||
updates.featureInteractions
|
||||
)
|
||||
: normalizeFeatureInteractions(this.state.ui?.featureInteractions)
|
||||
}
|
||||
this.scheduleSave()
|
||||
}
|
||||
|
||||
recordFeatureInteraction(id: FeatureInteractionId): PersistedState['ui'] {
|
||||
const featureInteractions = normalizeFeatureInteractions(this.state.ui?.featureInteractions)
|
||||
const existing = featureInteractions[id]
|
||||
this.updateUI({
|
||||
featureInteractions: {
|
||||
...featureInteractions,
|
||||
[id]: {
|
||||
firstInteractedAt: existing?.firstInteractedAt ?? Date.now(),
|
||||
interactionCount: (existing?.interactionCount ?? 0) + 1
|
||||
}
|
||||
}
|
||||
})
|
||||
return this.getUI()
|
||||
}
|
||||
|
||||
// ── Onboarding ────────────────────────────────────────────────────
|
||||
|
||||
getOnboarding(): PersistedState['onboarding'] {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { EventEmitter } from 'events'
|
||||
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { ipcMain } from 'electron'
|
||||
import type { WorktreeLineage, WorktreeMeta } from '../../shared/types'
|
||||
import {
|
||||
addWorktree,
|
||||
@@ -34,6 +35,36 @@ import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/s
|
||||
import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
|
||||
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
|
||||
|
||||
const electronMocks = vi.hoisted(() => {
|
||||
type Listener = (...args: unknown[]) => void
|
||||
const listeners = new Map<string, Set<Listener>>()
|
||||
const ipcMain = {
|
||||
on: vi.fn((channel: string, listener: Listener) => {
|
||||
const existing = listeners.get(channel) ?? new Set<Listener>()
|
||||
existing.add(listener)
|
||||
listeners.set(channel, existing)
|
||||
return ipcMain
|
||||
}),
|
||||
removeListener: vi.fn((channel: string, listener: Listener) => {
|
||||
listeners.get(channel)?.delete(listener)
|
||||
return ipcMain
|
||||
}),
|
||||
emit: vi.fn((channel: string, ...args: unknown[]) => {
|
||||
for (const listener of listeners.get(channel) ?? []) {
|
||||
listener(...args)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return {
|
||||
BrowserWindow: { fromId: vi.fn((_id: number): unknown => null) },
|
||||
ipcMain,
|
||||
app: { getPath: vi.fn(() => '/tmp') }
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('electron', () => electronMocks)
|
||||
|
||||
const {
|
||||
MOCK_GIT_WORKTREES,
|
||||
addWorktreeMock,
|
||||
@@ -273,6 +304,11 @@ vi.mock('../git/repo', async (importOriginal) => {
|
||||
|
||||
afterEach(() => {
|
||||
advertisedUrlWatcher.clear()
|
||||
electronMocks.BrowserWindow.fromId.mockReset()
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue(null)
|
||||
electronMocks.ipcMain.on.mockClear()
|
||||
electronMocks.ipcMain.removeListener.mockClear()
|
||||
electronMocks.ipcMain.emit.mockClear()
|
||||
vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES)
|
||||
vi.mocked(addWorktree).mockReset()
|
||||
vi.mocked(assertWorktreeCleanForRemoval).mockReset()
|
||||
@@ -4971,6 +5007,81 @@ describe('OrcaRuntimeService', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards inactive mobile terminal creation to the renderer without focusing it', async () => {
|
||||
const focusTerminal = vi.fn()
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setNotifier({
|
||||
focusTerminal,
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession: vi.fn(),
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
closeSessionTab: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
const send = vi.fn((_channel: string, payload: { requestId: string; activate?: boolean }) => {
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-renderer::pane:1',
|
||||
parentTabId: 'tab-renderer',
|
||||
leafId: 'pane:1',
|
||||
title: 'Terminal',
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
ipcMain.emit(
|
||||
'terminal:tabCreateReply',
|
||||
{},
|
||||
{
|
||||
requestId: payload.requestId,
|
||||
tabId: 'tab-renderer',
|
||||
title: 'Terminal'
|
||||
}
|
||||
)
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send }
|
||||
})
|
||||
|
||||
const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
activate: false
|
||||
})
|
||||
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'terminal:requestTabCreate',
|
||||
expect.objectContaining({
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
activate: false
|
||||
})
|
||||
)
|
||||
expect(focusTerminal).not.toHaveBeenCalled()
|
||||
expect(result.tab).toMatchObject({ parentTabId: 'tab-renderer', isActive: false })
|
||||
})
|
||||
|
||||
it('reports browser tab creation as unsupported for headless runtime servers', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
|
||||
@@ -56,6 +56,7 @@ import type {
|
||||
TabGroupLayoutNode,
|
||||
TuiAgent
|
||||
} from '../../shared/types'
|
||||
import type { FeatureInteractionId } from '../../shared/feature-interactions'
|
||||
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { getNextProjectGroupOrder } from '../../shared/project-groups'
|
||||
@@ -439,6 +440,7 @@ type RuntimeStore = {
|
||||
getWorkspaceSession?: Store['getWorkspaceSession']
|
||||
getUI?: Store['getUI']
|
||||
updateUI?: Store['updateUI']
|
||||
recordFeatureInteraction?: Store['recordFeatureInteraction']
|
||||
listAutomations?: Store['listAutomations']
|
||||
listAutomationRuns?: Store['listAutomationRuns']
|
||||
createAutomation?: Store['createAutomation']
|
||||
@@ -1330,6 +1332,13 @@ export class OrcaRuntimeService {
|
||||
return this.store.getUI()
|
||||
}
|
||||
|
||||
recordFeatureInteraction(id: FeatureInteractionId): PersistedUIState {
|
||||
if (!this.store?.recordFeatureInteraction) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
return this.store.recordFeatureInteraction(id)
|
||||
}
|
||||
|
||||
getClientSettings(): Pick<
|
||||
GlobalSettings,
|
||||
| 'defaultTuiAgent'
|
||||
@@ -9289,7 +9298,8 @@ export class OrcaRuntimeService {
|
||||
worktreeId,
|
||||
afterTabId: afterDesktopTabId,
|
||||
targetGroupId: opts.targetGroupId,
|
||||
command: opts.command
|
||||
command: opts.command,
|
||||
activate: opts.activate
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { PersistedUIState } from '../../../shared/types'
|
||||
import { getDefaultUIState } from '../../../shared/constants'
|
||||
import {
|
||||
ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE,
|
||||
ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY
|
||||
} from '../../../shared/runtime-rpc-feature-interaction-source'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import { defineMethod, defineStreamingMethod, type RpcRequest } from './core'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
|
||||
function makeRequest(method: string, params: unknown = {}): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
||||
function makeRuntime(ui: PersistedUIState = getDefaultUIState()): OrcaRuntimeService {
|
||||
let currentUI = ui
|
||||
return {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getUIState: vi.fn(() => currentUI),
|
||||
recordFeatureInteraction: vi.fn((id) => {
|
||||
const featureInteractions = currentUI.featureInteractions ?? {}
|
||||
const existing = featureInteractions[id]
|
||||
currentUI = {
|
||||
...currentUI,
|
||||
featureInteractions: {
|
||||
...featureInteractions,
|
||||
[id]: {
|
||||
firstInteractedAt: existing?.firstInteractedAt ?? Date.now(),
|
||||
interactionCount: (existing?.interactionCount ?? 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return currentUI
|
||||
}),
|
||||
updateUIState: vi.fn((updates: Partial<PersistedUIState>) => {
|
||||
currentUI = { ...currentUI, ...updates }
|
||||
return currentUI
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
}
|
||||
|
||||
const METHODS = [
|
||||
defineMethod({
|
||||
name: 'browser.click',
|
||||
params: z.object({}),
|
||||
handler: () => ({ clicked: true })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.tabCreate',
|
||||
params: z.object({}),
|
||||
handler: () => ({ browserPageId: 'page-1' })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.tabShow',
|
||||
params: z.object({}),
|
||||
handler: () => ({ tab: { id: 'page-1' } })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.viewport',
|
||||
params: z.object({}),
|
||||
handler: () => ({ ok: true })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.eval',
|
||||
params: z.object({}),
|
||||
handler: () => ({ value: 'ok' })
|
||||
}),
|
||||
defineStreamingMethod({
|
||||
name: 'browser.screencast',
|
||||
params: z.object({}),
|
||||
handler: async (_params, _options, emit) => {
|
||||
emit({ type: 'frame' })
|
||||
emit({ type: 'end' })
|
||||
}
|
||||
}),
|
||||
defineStreamingMethod({
|
||||
name: 'browser.screencast.binaryOnly',
|
||||
params: z.object({}),
|
||||
handler: async () => {}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.screencast.unsubscribe',
|
||||
params: z.object({}),
|
||||
handler: () => ({ ok: true })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.profileImportFromBrowser',
|
||||
params: z.object({}),
|
||||
handler: () => ({ ok: true })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.profileList',
|
||||
params: z.object({}),
|
||||
handler: () => ({ profiles: [] })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.profileClearDefaultCookies',
|
||||
params: z.object({}),
|
||||
handler: () => ({ cleared: false })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'computer.permissions',
|
||||
params: z.object({}),
|
||||
handler: () => ({ opened: true })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'computer.click',
|
||||
params: z.object({}),
|
||||
handler: () => ({ clicked: true })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'orchestration.send',
|
||||
params: z.object({}),
|
||||
handler: () => ({ id: 'msg-1' })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.fail',
|
||||
params: z.object({}),
|
||||
handler: () => {
|
||||
throw new Error('nope')
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
describe('RpcDispatcher feature interactions', () => {
|
||||
it('records runtime feature use after successful runtime tool methods', async () => {
|
||||
const runtime = makeRuntime()
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: METHODS })
|
||||
|
||||
await dispatcher.dispatch(makeRequest('browser.click'))
|
||||
await dispatcher.dispatch(makeRequest('computer.click'))
|
||||
await dispatcher.dispatch(makeRequest('orchestration.send'))
|
||||
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('agent-browser-use')
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('computer-use')
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('agent-orchestration')
|
||||
})
|
||||
|
||||
it('keeps setup and cookie import separate from actual runtime use', async () => {
|
||||
const runtime = makeRuntime()
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: METHODS })
|
||||
|
||||
await dispatcher.dispatch(makeRequest('computer.permissions'))
|
||||
await dispatcher.dispatch(makeRequest('browser.profileImportFromBrowser'))
|
||||
await dispatcher.dispatch(makeRequest('browser.profileList'))
|
||||
await dispatcher.dispatch(makeRequest('browser.profileClearDefaultCookies'))
|
||||
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('computer-use-setup')
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('cookie-import')
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not record failed runtime methods', async () => {
|
||||
const runtime = makeRuntime()
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: METHODS })
|
||||
|
||||
await dispatcher.dispatch(makeRequest('browser.fail'))
|
||||
|
||||
expect(runtime.recordFeatureInteraction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records unmarked browser display RPCs as agent browser use', async () => {
|
||||
const runtime = makeRuntime()
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: METHODS })
|
||||
const replies: string[] = []
|
||||
|
||||
await dispatcher.dispatch(makeRequest('browser.tabCreate'))
|
||||
await dispatcher.dispatch(makeRequest('browser.tabShow'))
|
||||
await dispatcher.dispatch(makeRequest('browser.screencast.unsubscribe'))
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(2)
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenNthCalledWith(1, 'agent-browser-use')
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenNthCalledWith(2, 'agent-browser-use')
|
||||
|
||||
await dispatcher.dispatchStreaming(makeRequest('browser.screencast'), (response) => {
|
||||
replies.push(response)
|
||||
})
|
||||
|
||||
expect(replies).toHaveLength(2)
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(3)
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenLastCalledWith('agent-browser-use')
|
||||
})
|
||||
|
||||
it('records binary-only browser display streams as agent browser use', async () => {
|
||||
const runtime = makeRuntime()
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: METHODS })
|
||||
const replies: string[] = []
|
||||
|
||||
await dispatcher.dispatchStreaming(makeRequest('browser.screencast.binaryOnly'), (response) => {
|
||||
replies.push(response)
|
||||
})
|
||||
|
||||
expect(replies).toHaveLength(0)
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('agent-browser-use')
|
||||
})
|
||||
|
||||
it('does not record browser pane UI-originated browser RPCs as agent browser use', async () => {
|
||||
const runtime = makeRuntime()
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: METHODS })
|
||||
const browserPaneUiParams = {
|
||||
[ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY]: ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE
|
||||
}
|
||||
|
||||
await dispatcher.dispatch(makeRequest('browser.viewport', browserPaneUiParams))
|
||||
await dispatcher.dispatch(makeRequest('browser.eval', browserPaneUiParams))
|
||||
await dispatcher.dispatchStreaming(
|
||||
makeRequest('browser.screencast', browserPaneUiParams),
|
||||
() => {}
|
||||
)
|
||||
await dispatcher.dispatch(makeRequest('browser.click'))
|
||||
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('agent-browser-use')
|
||||
})
|
||||
|
||||
it('records each successful non-streaming runtime feature interaction', async () => {
|
||||
const runtime = makeRuntime()
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: METHODS })
|
||||
|
||||
await dispatcher.dispatch(makeRequest('browser.click'))
|
||||
await dispatcher.dispatch(makeRequest('browser.click'))
|
||||
await dispatcher.dispatch(makeRequest('browser.screencast.unsubscribe'))
|
||||
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(2)
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenLastCalledWith('agent-browser-use')
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
type RpcResponse
|
||||
} from './core'
|
||||
import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol'
|
||||
import type { FeatureInteractionId } from '../../../shared/feature-interactions'
|
||||
import { isBrowserPaneUiRuntimeRpcParams } from '../../../shared/runtime-rpc-feature-interaction-source'
|
||||
import { errorResponse, mapBrowserError, mapRuntimeError, successResponse } from './errors'
|
||||
import { ALL_RPC_METHODS } from './methods'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
@@ -66,6 +68,7 @@ export class RpcDispatcher {
|
||||
runtime: this.runtime,
|
||||
signal: options?.signal
|
||||
})
|
||||
this.recordRuntimeFeatureInteraction(request.method, result, undefined, request.params)
|
||||
return successResponse(request.id, meta, result)
|
||||
} catch (error) {
|
||||
return this.mapError(request, meta, error)
|
||||
@@ -116,6 +119,7 @@ export class RpcDispatcher {
|
||||
sendBinary: options?.sendBinary,
|
||||
registerBinaryStreamHandler: options?.registerBinaryStreamHandler
|
||||
})
|
||||
this.recordRuntimeFeatureInteraction(request.method, result, undefined, request.params)
|
||||
reply(JSON.stringify(successResponse(request.id, meta, result)))
|
||||
} catch (error) {
|
||||
reply(JSON.stringify(this.mapError(request, meta, error)))
|
||||
@@ -123,14 +127,21 @@ export class RpcDispatcher {
|
||||
return
|
||||
}
|
||||
|
||||
const recordedStreamingFeatureInteractions = new Set<FeatureInteractionId>()
|
||||
const emit = (result: unknown): void => {
|
||||
this.recordRuntimeFeatureInteraction(
|
||||
request.method,
|
||||
result,
|
||||
recordedStreamingFeatureInteractions,
|
||||
request.params
|
||||
)
|
||||
const response = successResponse(request.id, meta, result)
|
||||
response.streaming = true
|
||||
reply(JSON.stringify(response))
|
||||
}
|
||||
|
||||
try {
|
||||
await method.handler(
|
||||
const result = await method.handler(
|
||||
parsedParams.value,
|
||||
{
|
||||
runtime: this.runtime,
|
||||
@@ -142,6 +153,12 @@ export class RpcDispatcher {
|
||||
},
|
||||
emit
|
||||
)
|
||||
this.recordRuntimeFeatureInteraction(
|
||||
request.method,
|
||||
result,
|
||||
recordedStreamingFeatureInteractions,
|
||||
request.params
|
||||
)
|
||||
} catch (error) {
|
||||
reply(JSON.stringify(this.mapError(request, meta, error)))
|
||||
}
|
||||
@@ -182,4 +199,63 @@ export class RpcDispatcher {
|
||||
private meta(): RpcEnvelopeMeta {
|
||||
return { runtimeId: this.runtime.getRuntimeId() }
|
||||
}
|
||||
|
||||
private recordRuntimeFeatureInteraction(
|
||||
method: string,
|
||||
result: unknown,
|
||||
alreadyRecorded?: Set<FeatureInteractionId>,
|
||||
rawParams?: unknown
|
||||
): void {
|
||||
const id = getRuntimeFeatureInteractionId(method, result, rawParams)
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
if (alreadyRecorded?.has(id)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.runtime.recordFeatureInteraction(id)
|
||||
alreadyRecorded?.add(id)
|
||||
} catch {
|
||||
// Best-effort education state must not break runtime tools.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getRuntimeFeatureInteractionId(
|
||||
method: string,
|
||||
result: unknown,
|
||||
rawParams?: unknown
|
||||
): FeatureInteractionId | null {
|
||||
if (method === 'browser.profileImportFromBrowser') {
|
||||
return hasBooleanResult(result, 'ok') ? 'cookie-import' : null
|
||||
}
|
||||
if (method === 'browser.profileClearDefaultCookies') {
|
||||
return hasBooleanResult(result, 'cleared') ? 'cookie-import' : null
|
||||
}
|
||||
if (method === 'browser.screencast.unsubscribe') {
|
||||
return null
|
||||
}
|
||||
if (method.startsWith('browser.') && isBrowserPaneUiRuntimeRpcParams(rawParams)) {
|
||||
return null
|
||||
}
|
||||
if (method.startsWith('browser.') && !method.startsWith('browser.profile')) {
|
||||
return 'agent-browser-use'
|
||||
}
|
||||
if (method === 'computer.permissions') {
|
||||
return 'computer-use-setup'
|
||||
}
|
||||
if (method.startsWith('computer.') && method !== 'computer.capabilities') {
|
||||
return 'computer-use'
|
||||
}
|
||||
if (method.startsWith('orchestration.')) {
|
||||
return 'agent-orchestration'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function hasBooleanResult(value: unknown, key: string): boolean {
|
||||
return (
|
||||
value !== null && typeof value === 'object' && (value as Record<string, unknown>)[key] === true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -160,6 +160,10 @@ describe('client UI RPC methods', () => {
|
||||
classifierVersion: 2
|
||||
}
|
||||
}
|
||||
},
|
||||
featureTipsSeenIds: ['voice-dictation'],
|
||||
featureInteractions: {
|
||||
tasks: { firstInteractedAt: 100, interactionCount: 2 }
|
||||
}
|
||||
}
|
||||
const runtime = {
|
||||
@@ -187,6 +191,10 @@ describe('client UI RPC methods', () => {
|
||||
classifierVersion: 2
|
||||
}
|
||||
}
|
||||
},
|
||||
featureTipsSeenIds: ['voice-dictation'],
|
||||
featureInteractions: {
|
||||
tasks: { firstInteractedAt: 100, interactionCount: 2 }
|
||||
}
|
||||
}
|
||||
const response = await dispatcher.dispatch(makeRequest('ui.set', payload))
|
||||
@@ -195,6 +203,25 @@ describe('client UI RPC methods', () => {
|
||||
expect(response).toMatchObject({ ok: true, result: { ui: updated } })
|
||||
})
|
||||
|
||||
it('records a feature interaction through the runtime host', async () => {
|
||||
const updated: PersistedUIState = {
|
||||
...getDefaultUIState(),
|
||||
featureInteractions: {
|
||||
tasks: { firstInteractedAt: 100, interactionCount: 1 }
|
||||
}
|
||||
}
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
recordFeatureInteraction: vi.fn(() => updated)
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('ui.recordFeatureInteraction', 'tasks'))
|
||||
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('tasks')
|
||||
expect(response).toMatchObject({ ok: true, result: { ui: updated } })
|
||||
})
|
||||
|
||||
it('rejects unknown and malformed UI update fields', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
@@ -209,4 +236,53 @@ describe('client UI RPC methods', () => {
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
|
||||
expect(runtime.updateUIState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects unknown feature interaction ids', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
updateUIState: vi.fn()
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('ui.set', {
|
||||
featureInteractions: {
|
||||
unknown: { firstInteractedAt: 100 }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
|
||||
expect(runtime.updateUIState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects unknown feature tip ids', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
updateUIState: vi.fn()
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('ui.set', { featureTipsSeenIds: ['voice-dictation', 'unknown-tip'] })
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
|
||||
expect(runtime.updateUIState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects unknown feature interaction ids for increment RPC', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
recordFeatureInteraction: vi.fn()
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('ui.recordFeatureInteraction', 'unknown-feature')
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
|
||||
expect(runtime.recordFeatureInteraction).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
isFeatureInteractionId,
|
||||
type FeatureInteractionId
|
||||
} from '../../../../shared/feature-interactions'
|
||||
import { isFeatureTipId } from '../../../../shared/feature-tips'
|
||||
import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
||||
import type { PersistedUIState } from '../../../../shared/types'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
|
||||
const NullableString = z.string().nullable()
|
||||
const StringArray = z.array(z.string())
|
||||
const FeatureTipIds = z.array(z.custom(isFeatureTipId, { message: 'Unknown feature tip id' }))
|
||||
const UnknownRecord = z.record(z.string(), z.unknown())
|
||||
const UnknownRecordArray = z.array(UnknownRecord)
|
||||
const WorktreeCardProperty = z.enum([
|
||||
@@ -48,6 +54,28 @@ const WorkspaceCleanup = z
|
||||
dismissals: z.record(z.string(), WorkspaceCleanupDismissal)
|
||||
})
|
||||
.strict()
|
||||
const FeatureInteractionRecord = z
|
||||
.object({
|
||||
firstInteractedAt: z.number().finite().nonnegative(),
|
||||
interactionCount: z.number().int().positive().optional()
|
||||
})
|
||||
.strict()
|
||||
const FeatureInteractions = z
|
||||
.record(z.string(), FeatureInteractionRecord)
|
||||
.superRefine((value, ctx) => {
|
||||
for (const id of Object.keys(value)) {
|
||||
if (!isFeatureInteractionId(id)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Unknown feature interaction id: ${id}`,
|
||||
path: [id]
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
const FeatureInteractionIdParam = z.custom<FeatureInteractionId>(isFeatureInteractionId, {
|
||||
message: 'Unknown feature interaction id'
|
||||
})
|
||||
const GitHubProjectRef = z
|
||||
.object({
|
||||
owner: z.string(),
|
||||
@@ -159,7 +187,9 @@ const UiUpdate = z
|
||||
customSidekicks: UnknownRecordArray.optional(),
|
||||
sidekickSize: z.number().finite().optional(),
|
||||
taskResumeState: TaskResumeState.optional(),
|
||||
workspaceCleanup: WorkspaceCleanup.optional()
|
||||
workspaceCleanup: WorkspaceCleanup.optional(),
|
||||
featureTipsSeenIds: FeatureTipIds.optional(),
|
||||
featureInteractions: FeatureInteractions.optional()
|
||||
})
|
||||
.strict()
|
||||
.default({})
|
||||
@@ -186,5 +216,12 @@ export const CLIENT_UI_METHODS: RpcMethod[] = [
|
||||
handler: (params, { runtime }) => ({
|
||||
ui: runtime.updateUIState(params as Partial<PersistedUIState>)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'ui.recordFeatureInteraction',
|
||||
params: FeatureInteractionIdParam,
|
||||
handler: (params, { runtime }) => ({
|
||||
ui: runtime.recordFeatureInteraction(params)
|
||||
})
|
||||
})
|
||||
]
|
||||
|
||||
@@ -270,6 +270,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'terminal.unsubscribe',
|
||||
'terminal.updateViewport',
|
||||
'ui.get',
|
||||
'ui.recordFeatureInteraction',
|
||||
'ui.set',
|
||||
'worktree.activate',
|
||||
'worktree.create',
|
||||
|
||||
@@ -115,6 +115,7 @@ import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
|
||||
import type { RuntimeAccessGrant } from '../shared/runtime-access-grants'
|
||||
import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope'
|
||||
import type { FeatureInteractionId } from '../shared/feature-interactions'
|
||||
import type {
|
||||
AddIssueCommentBySlugArgs,
|
||||
ClearProjectItemFieldArgs,
|
||||
@@ -1767,6 +1768,7 @@ export type PreloadApi = {
|
||||
ui: {
|
||||
get: () => Promise<PersistedUIState>
|
||||
set: (args: Partial<PersistedUIState>) => Promise<void>
|
||||
recordFeatureInteraction: (id: FeatureInteractionId) => Promise<PersistedUIState>
|
||||
onOpenSettings: (callback: () => void) => () => void
|
||||
onOpenFeatureTour: (callback: () => void) => () => void
|
||||
onOpenCrashReport: (callback: () => void) => () => void
|
||||
|
||||
@@ -2328,6 +2328,8 @@ const api = {
|
||||
ui: {
|
||||
get: (): Promise<unknown> => ipcRenderer.invoke('ui:get'),
|
||||
set: (args: Record<string, unknown>): Promise<void> => ipcRenderer.invoke('ui:set', args),
|
||||
recordFeatureInteraction: (id: string): Promise<unknown> =>
|
||||
ipcRenderer.invoke('ui:recordFeatureInteraction', id),
|
||||
onOpenSettings: (callback: () => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent) => callback()
|
||||
ipcRenderer.on('ui:openSettings', listener)
|
||||
|
||||
@@ -304,6 +304,7 @@ function App(): React.JSX.Element {
|
||||
const activeView = useAppStore((s) => s.activeView)
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
const featureTipsSeenIds = useAppStore((s) => s.featureTipsSeenIds)
|
||||
const featureInteractions = useAppStore((s) => s.featureInteractions)
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
// Why: App swaps the sidebar between workspace and landing layouts when the
|
||||
// active workspace is slept/deleted. Keep virtualized scroll memory above
|
||||
@@ -366,6 +367,7 @@ function App(): React.JSX.Element {
|
||||
setFloatingTerminalOpen((currentOpen) => {
|
||||
const resolvedOpen = typeof nextOpen === 'function' ? nextOpen(currentOpen) : nextOpen
|
||||
if (resolvedOpen && !currentOpen) {
|
||||
useAppStore.getState().recordFeatureInteraction('floating-workspace')
|
||||
rememberFloatingTerminalReturnFocus()
|
||||
} else if (!resolvedOpen && currentOpen) {
|
||||
restoreFloatingTerminalReturnFocus()
|
||||
@@ -455,6 +457,7 @@ function App(): React.JSX.Element {
|
||||
const featureTipsDecision = getFeatureTipsAppOpenDecision({
|
||||
activeModal,
|
||||
featureTipsSeenIds,
|
||||
featureInteractions,
|
||||
onboarding,
|
||||
persistedUIReady,
|
||||
promptedThisSession: featureTipsPromptedThisSessionRef.current,
|
||||
@@ -478,7 +481,15 @@ function App(): React.JSX.Element {
|
||||
// on the next launch just because the user never clicked a dismiss button.
|
||||
actions.markFeatureTipsSeen([featureTipsDecision.tipId])
|
||||
actions.openModal('feature-tips', { source: 'app_open', tipId: featureTipsDecision.tipId })
|
||||
}, [activeModal, actions, featureTipsSeenIds, onboarding, persistedUIReady, settings])
|
||||
}, [
|
||||
activeModal,
|
||||
actions,
|
||||
featureInteractions,
|
||||
featureTipsSeenIds,
|
||||
onboarding,
|
||||
persistedUIReady,
|
||||
settings
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView !== 'settings' || !shouldShowOnboarding(onboarding)) {
|
||||
|
||||
@@ -520,6 +520,10 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
}
|
||||
}, [setSelectedId])
|
||||
|
||||
const hydratePersistedUIState = useCallback(async (): Promise<void> => {
|
||||
useAppStore.getState().hydratePersistedUI(await window.api.ui.get())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchAllWorktrees()
|
||||
void refresh()
|
||||
@@ -903,6 +907,9 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
jobId: editingExternalTarget.job.id
|
||||
})
|
||||
: window.api.automations.createExternal(input))
|
||||
if (!editingExternalTarget) {
|
||||
useAppStore.getState().recordFeatureInteraction('automation-created')
|
||||
}
|
||||
await refresh()
|
||||
setCreateOpen(false)
|
||||
setEditingExternalTarget(null)
|
||||
@@ -978,6 +985,9 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
dtstart: now,
|
||||
missedRunGraceMinutes
|
||||
})
|
||||
if (!editingAutomationId) {
|
||||
await hydratePersistedUIState()
|
||||
}
|
||||
setAutomations((current) => {
|
||||
const next = current.filter((entry) => entry.id !== automation.id)
|
||||
return [...next, automation].sort((left, right) => left.name.localeCompare(right.name))
|
||||
@@ -1053,6 +1063,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
|
||||
const runNow = async (automation: Automation): Promise<void> => {
|
||||
await window.api.automations.runNow({ id: automation.id })
|
||||
await hydratePersistedUIState()
|
||||
await refresh()
|
||||
toast.message('Automation run queued.')
|
||||
}
|
||||
@@ -1068,6 +1079,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
setRerunRunIdsInFlight(new Set(rerunRunIdsInFlightRef.current))
|
||||
try {
|
||||
await window.api.automations.runNow({ id: automationId })
|
||||
await hydratePersistedUIState()
|
||||
await refresh()
|
||||
toast.message('Automation run queued.')
|
||||
} catch (error) {
|
||||
@@ -1096,6 +1108,9 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
jobId: job.id,
|
||||
action
|
||||
})
|
||||
if (action === 'run') {
|
||||
useAppStore.getState().recordFeatureInteraction('automation-run')
|
||||
}
|
||||
await refresh()
|
||||
toast.success(
|
||||
action === 'delete'
|
||||
|
||||
@@ -138,6 +138,7 @@ import {
|
||||
decodeBrowserScreencastFrame,
|
||||
type BrowserScreencastFrameMetadata
|
||||
} from '../../../../shared/browser-screencast-protocol'
|
||||
import { withBrowserPaneUiRuntimeRpcSource } from '../../../../shared/runtime-rpc-feature-interaction-source'
|
||||
import {
|
||||
formatByteCount,
|
||||
formatDownloadFinishedNotice,
|
||||
@@ -1078,7 +1079,7 @@ function RemoteBrowserPagePane({
|
||||
deviceScaleFactor: getRemoteBrowserDeviceScaleFactor(),
|
||||
mobile: false
|
||||
},
|
||||
{ timeoutMs: 15_000 }
|
||||
{ timeoutMs: 15_000, suppressFeatureInteraction: true }
|
||||
)
|
||||
try {
|
||||
// Why: the streamed bitmap can include the host compositor surface,
|
||||
@@ -1091,7 +1092,7 @@ function RemoteBrowserPagePane({
|
||||
page: pageId,
|
||||
expression: 'JSON.stringify({ width: window.innerWidth, height: window.innerHeight })'
|
||||
},
|
||||
{ timeoutMs: 15_000 }
|
||||
{ timeoutMs: 15_000, suppressFeatureInteraction: true }
|
||||
)
|
||||
remoteCssViewportSizeRef.current = readRemoteCssViewportSize(viewport) ?? size
|
||||
} catch {
|
||||
@@ -1303,7 +1304,7 @@ function RemoteBrowserPagePane({
|
||||
{ kind: 'environment', environmentId: removedHandle.environmentId },
|
||||
'browser.tabClose',
|
||||
{ worktree: `id:${worktreeId}`, page: removedHandle.remotePageId },
|
||||
{ timeoutMs: 15_000 }
|
||||
{ timeoutMs: 15_000, suppressFeatureInteraction: true }
|
||||
).catch(() => {})
|
||||
}
|
||||
}, [activeRuntimeEnvironmentId, browserTab.id, worktreeId])
|
||||
@@ -1410,14 +1411,14 @@ function RemoteBrowserPagePane({
|
||||
target,
|
||||
'browser.tabCreate',
|
||||
{ worktree: `id:${worktreeId}`, url: initialUrl },
|
||||
{ timeoutMs: 30_000 }
|
||||
{ timeoutMs: 30_000, suppressFeatureInteraction: true }
|
||||
)
|
||||
if (!isCurrentRemoteOperationToken(token)) {
|
||||
void callRuntimeRpc(
|
||||
target,
|
||||
'browser.tabClose',
|
||||
{ worktree: `id:${worktreeId}`, page: created.browserPageId },
|
||||
{ timeoutMs: 15_000 }
|
||||
{ timeoutMs: 15_000, suppressFeatureInteraction: true }
|
||||
).catch(() => {})
|
||||
return null
|
||||
}
|
||||
@@ -1476,7 +1477,7 @@ function RemoteBrowserPagePane({
|
||||
{ kind: 'environment', environmentId: token.environmentId },
|
||||
'browser.tabShow',
|
||||
{ worktree: `id:${worktreeId}`, page: token.remotePageId },
|
||||
{ timeoutMs: 15_000 }
|
||||
{ timeoutMs: 15_000, suppressFeatureInteraction: true }
|
||||
)
|
||||
return shown.tab
|
||||
},
|
||||
@@ -1637,7 +1638,7 @@ function RemoteBrowserPagePane({
|
||||
{
|
||||
selector: target.environmentId,
|
||||
method: 'browser.screencast',
|
||||
params: {
|
||||
params: withBrowserPaneUiRuntimeRpcSource({
|
||||
worktree: `id:${worktreeId}`,
|
||||
page: pageId,
|
||||
format: 'jpeg',
|
||||
@@ -1648,7 +1649,7 @@ function RemoteBrowserPagePane({
|
||||
viewportHeight: viewportSize?.height,
|
||||
deviceScaleFactor: getRemoteBrowserDeviceScaleFactor(),
|
||||
everyNthFrame: 2
|
||||
},
|
||||
}),
|
||||
timeoutMs: 15_000
|
||||
},
|
||||
{
|
||||
@@ -1910,7 +1911,7 @@ function RemoteBrowserPagePane({
|
||||
: { worktree: `id:${worktreeId}`, page: pageId }
|
||||
const result = await callRuntimeRpc<
|
||||
BrowserGotoResult | BrowserBackResult | BrowserReloadResult
|
||||
>(target, method, params, { timeoutMs: 30_000 })
|
||||
>(target, method, params, { timeoutMs: 30_000, suppressFeatureInteraction: true })
|
||||
if (isCurrentRemoteOperationToken(pageToken)) {
|
||||
applyRemoteTabInfo(result)
|
||||
}
|
||||
@@ -2006,13 +2007,13 @@ function RemoteBrowserPagePane({
|
||||
target,
|
||||
'browser.mouseMove',
|
||||
{ ...params, x: point.x, y: point.y },
|
||||
{ timeoutMs: 15_000 }
|
||||
{ timeoutMs: 15_000, suppressFeatureInteraction: true }
|
||||
)
|
||||
await callRuntimeRpc(
|
||||
target,
|
||||
'browser.mouseDown',
|
||||
{ ...params, button },
|
||||
{ timeoutMs: 15_000 }
|
||||
{ timeoutMs: 15_000, suppressFeatureInteraction: true }
|
||||
)
|
||||
} catch (error) {
|
||||
if (isCurrentRemoteOperationToken(operationToken)) {
|
||||
@@ -2053,13 +2054,13 @@ function RemoteBrowserPagePane({
|
||||
target,
|
||||
'browser.mouseMove',
|
||||
{ ...params, x: point.x, y: point.y },
|
||||
{ timeoutMs: 15_000 }
|
||||
{ timeoutMs: 15_000, suppressFeatureInteraction: true }
|
||||
)
|
||||
await callRuntimeRpc(
|
||||
target,
|
||||
'browser.mouseUp',
|
||||
{ ...params, button },
|
||||
{ timeoutMs: 15_000 }
|
||||
{ timeoutMs: 15_000, suppressFeatureInteraction: true }
|
||||
)
|
||||
scheduleRemoteTabInfoRefresh(operationToken, 250)
|
||||
} catch (error) {
|
||||
@@ -2107,7 +2108,7 @@ function RemoteBrowserPagePane({
|
||||
page: pageId,
|
||||
expression: buildRemoteContextMenuExpression(point.x, point.y)
|
||||
},
|
||||
{ timeoutMs: 15_000 }
|
||||
{ timeoutMs: 15_000, suppressFeatureInteraction: true }
|
||||
)
|
||||
const parsed = readRemoteContextMenuResult(result)
|
||||
if (parsed) {
|
||||
@@ -2155,7 +2156,12 @@ function RemoteBrowserPagePane({
|
||||
return
|
||||
}
|
||||
try {
|
||||
await callRuntimeRpc(target, 'browser.keypress', { ...params, key }, { timeoutMs: 15_000 })
|
||||
await callRuntimeRpc(
|
||||
target,
|
||||
'browser.keypress',
|
||||
{ ...params, key },
|
||||
{ timeoutMs: 15_000, suppressFeatureInteraction: true }
|
||||
)
|
||||
if (key === 'Enter' || key === 'Meta+r' || key === 'Control+r') {
|
||||
scheduleRemoteTabInfoRefresh(operationToken, 400)
|
||||
}
|
||||
@@ -2194,7 +2200,7 @@ function RemoteBrowserPagePane({
|
||||
target,
|
||||
'browser.mouseMove',
|
||||
{ ...params, x: point.x, y: point.y },
|
||||
{ timeoutMs: 15_000 }
|
||||
{ timeoutMs: 15_000, suppressFeatureInteraction: true }
|
||||
)
|
||||
await callRuntimeRpc(
|
||||
target,
|
||||
@@ -2204,7 +2210,7 @@ function RemoteBrowserPagePane({
|
||||
dx,
|
||||
dy
|
||||
},
|
||||
{ timeoutMs: 15_000 }
|
||||
{ timeoutMs: 15_000, suppressFeatureInteraction: true }
|
||||
)
|
||||
scheduleRemoteTabInfoRefresh(operationToken, 400)
|
||||
} catch (error) {
|
||||
@@ -2614,6 +2620,7 @@ function BrowserPagePane({
|
||||
const addBrowserPageAnnotation = useAppStore((s) => s.addBrowserPageAnnotation)
|
||||
const deleteBrowserPageAnnotation = useAppStore((s) => s.deleteBrowserPageAnnotation)
|
||||
const clearBrowserPageAnnotations = useAppStore((s) => s.clearBrowserPageAnnotations)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const clearBrowserPageAnnotationsRef = useRef(clearBrowserPageAnnotations)
|
||||
clearBrowserPageAnnotationsRef.current = clearBrowserPageAnnotations
|
||||
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
|
||||
@@ -3727,6 +3734,10 @@ function BrowserPagePane({
|
||||
|
||||
const startGrabIntent = useCallback(
|
||||
(nextIntent: GrabIntent): void => {
|
||||
recordFeatureInteraction('browser-grab')
|
||||
if (nextIntent === 'annotate') {
|
||||
recordFeatureInteraction('browser-annotations')
|
||||
}
|
||||
setGrabIntent(nextIntent)
|
||||
if (nextIntent === 'copy') {
|
||||
setPendingAnnotationPayload(null)
|
||||
@@ -3737,7 +3748,7 @@ function BrowserPagePane({
|
||||
grab.toggle()
|
||||
}
|
||||
},
|
||||
[grab, grabIntent]
|
||||
[grab, grabIntent, recordFeatureInteraction]
|
||||
)
|
||||
|
||||
// CmdOrCtrl+C toggles grab mode
|
||||
@@ -3819,11 +3830,13 @@ function BrowserPagePane({
|
||||
if (key === 'c') {
|
||||
const text = formatGrabPayloadAsText(payload)
|
||||
void window.api.ui.writeClipboardText(text)
|
||||
recordFeatureInteraction('browser-grab')
|
||||
showGrabToast('Copied', 'success', payload)
|
||||
} else {
|
||||
const dataUrl = payload.screenshot?.dataUrl
|
||||
if (dataUrl?.startsWith('data:image/png;base64,')) {
|
||||
void window.api.ui.writeClipboardImage(dataUrl)
|
||||
recordFeatureInteraction('browser-grab')
|
||||
showGrabToast('Screenshotted', 'success', payload)
|
||||
} else {
|
||||
showGrabToast('No screenshot available', 'error', payload)
|
||||
@@ -3877,7 +3890,7 @@ function BrowserPagePane({
|
||||
})()
|
||||
}
|
||||
},
|
||||
[grab, grabIntent, showGrabToast]
|
||||
[grab, grabIntent, recordFeatureInteraction, showGrabToast]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3930,9 +3943,10 @@ function BrowserPagePane({
|
||||
}
|
||||
const text = formatGrabPayloadAsText(payload)
|
||||
void window.api.ui.writeClipboardText(text)
|
||||
recordFeatureInteraction('browser-grab')
|
||||
showGrabToast('Copied', 'success', payload)
|
||||
grab.rearm()
|
||||
}, [grab, showGrabToast])
|
||||
}, [grab, recordFeatureInteraction, showGrabToast])
|
||||
|
||||
const handleGrabCopyScreenshot = useCallback(() => {
|
||||
grabMenuActionTakenRef.current = true
|
||||
@@ -3945,9 +3959,10 @@ function BrowserPagePane({
|
||||
return
|
||||
}
|
||||
void window.api.ui.writeClipboardImage(dataUrl)
|
||||
recordFeatureInteraction('browser-grab')
|
||||
showGrabToast('Screenshotted', 'success', payload)
|
||||
grab.rearm()
|
||||
}, [grab, showGrabToast])
|
||||
}, [grab, recordFeatureInteraction, showGrabToast])
|
||||
|
||||
const handleAddBrowserAnnotation = useCallback(
|
||||
(comment: string, intent: BrowserAnnotationIntent): void => {
|
||||
@@ -3966,10 +3981,18 @@ function BrowserPagePane({
|
||||
})
|
||||
setPendingAnnotationPayload(null)
|
||||
setBrowserAnnotationTrayOpen(true)
|
||||
recordFeatureInteraction('browser-annotations')
|
||||
showGrabToast('Annotation added', 'success', payload)
|
||||
grab.rearm()
|
||||
},
|
||||
[addBrowserPageAnnotation, browserTab.id, grab, pendingAnnotationPayload, showGrabToast]
|
||||
[
|
||||
addBrowserPageAnnotation,
|
||||
browserTab.id,
|
||||
grab,
|
||||
pendingAnnotationPayload,
|
||||
recordFeatureInteraction,
|
||||
showGrabToast
|
||||
]
|
||||
)
|
||||
|
||||
const handleCancelPendingBrowserAnnotation = useCallback((): void => {
|
||||
@@ -3984,16 +4007,29 @@ function BrowserPagePane({
|
||||
return
|
||||
}
|
||||
void window.api.ui.writeClipboardText(browserAnnotationsPrompt)
|
||||
recordFeatureInteraction('browser-annotations')
|
||||
clearTimeout(annotationCopyTimerRef.current)
|
||||
setBrowserAnnotationsCopied(true)
|
||||
annotationCopyTimerRef.current = setTimeout(() => setBrowserAnnotationsCopied(false), 1400)
|
||||
}, [browserAnnotationsPrompt])
|
||||
}, [browserAnnotationsPrompt, recordFeatureInteraction])
|
||||
|
||||
const handleClearBrowserAnnotations = useCallback((): void => {
|
||||
if (browserAnnotationsRef.current.length === 0) {
|
||||
return
|
||||
}
|
||||
clearTimeout(annotationCopyTimerRef.current)
|
||||
setBrowserAnnotationsCopied(false)
|
||||
clearBrowserPageAnnotations(browserTab.id)
|
||||
}, [browserTab.id, clearBrowserPageAnnotations])
|
||||
recordFeatureInteraction('browser-annotations')
|
||||
}, [browserTab.id, clearBrowserPageAnnotations, recordFeatureInteraction])
|
||||
|
||||
const handleDeleteBrowserAnnotation = useCallback(
|
||||
(annotationId: string): void => {
|
||||
deleteBrowserPageAnnotation(browserTab.id, annotationId)
|
||||
recordFeatureInteraction('browser-annotations')
|
||||
},
|
||||
[browserTab.id, deleteBrowserPageAnnotation, recordFeatureInteraction]
|
||||
)
|
||||
|
||||
const navigateToUrl = useCallback(
|
||||
(url: string): void => {
|
||||
@@ -4827,7 +4863,7 @@ function BrowserPagePane({
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 group-focus-within:opacity-100"
|
||||
onClick={() => deleteBrowserPageAnnotation(browserTab.id, annotation.id)}
|
||||
onClick={() => handleDeleteBrowserAnnotation(annotation.id)}
|
||||
aria-label={`Delete annotation ${index + 1}`}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
|
||||
@@ -17,6 +17,7 @@ export function DictationController() {
|
||||
const dictationState = useAppStore((s) => s.dictationState)
|
||||
const setDictationState = useAppStore((s) => s.setDictationState)
|
||||
const setPartialTranscript = useAppStore((s) => s.setPartialTranscript)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const keybindings = useAppStore((s) => s.keybindings)
|
||||
const {
|
||||
@@ -149,6 +150,7 @@ export function DictationController() {
|
||||
|
||||
dictationStateRef.current = 'listening'
|
||||
setDictationState('listening')
|
||||
recordFeatureInteraction('voice-dictation')
|
||||
} catch (err) {
|
||||
if (dictationRunRef.current !== runId) {
|
||||
return
|
||||
@@ -201,7 +203,8 @@ export function DictationController() {
|
||||
discardBufferedAudio,
|
||||
stopCapture,
|
||||
finishDictationSession,
|
||||
setPartialTranscript
|
||||
setPartialTranscript,
|
||||
recordFeatureInteraction
|
||||
])
|
||||
|
||||
const stopDictation = useCallback(async () => {
|
||||
|
||||
@@ -48,12 +48,14 @@ export default function FeatureTipsModal(): JSX.Element | null {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const seenTipIds = useAppStore((s) => s.featureTipsSeenIds)
|
||||
const featureInteractions = useAppStore((s) => s.featureInteractions)
|
||||
const markFeatureTipsSeen = useAppStore((s) => s.markFeatureTipsSeen)
|
||||
const modalData = useAppStore((s) => s.modalData)
|
||||
const isOpen = activeModal === 'feature-tips'
|
||||
const currentTip = getFeatureTipForModal({
|
||||
modalData,
|
||||
seenTipIds,
|
||||
featureInteractions,
|
||||
settings
|
||||
})
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('feature tip modal state', () => {
|
||||
const tip = getFeatureTipForModal({
|
||||
modalData: { tipId: 'voice-dictation' },
|
||||
seenTipIds: ['voice-dictation'],
|
||||
featureInteractions: {},
|
||||
settings: makeSettings()
|
||||
})
|
||||
|
||||
@@ -27,6 +28,7 @@ describe('feature tip modal state', () => {
|
||||
const tip = getFeatureTipForModal({
|
||||
modalData: {},
|
||||
seenTipIds: [],
|
||||
featureInteractions: {},
|
||||
settings: makeSettings()
|
||||
})
|
||||
|
||||
@@ -37,6 +39,20 @@ describe('feature tip modal state', () => {
|
||||
const tip = getFeatureTipForModal({
|
||||
modalData: {},
|
||||
seenTipIds: ['voice-dictation'],
|
||||
featureInteractions: {},
|
||||
settings: makeSettings()
|
||||
})
|
||||
|
||||
expect(tip).toBeNull()
|
||||
})
|
||||
|
||||
it('returns no unpinned tip after the user already interacted with the feature', () => {
|
||||
const tip = getFeatureTipForModal({
|
||||
modalData: {},
|
||||
seenTipIds: [],
|
||||
featureInteractions: {
|
||||
'voice-dictation': { firstInteractedAt: 100, interactionCount: 1 }
|
||||
},
|
||||
settings: makeSettings()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import type { FeatureInteractionState } from '../../../../shared/feature-interactions'
|
||||
import {
|
||||
FEATURE_TIPS,
|
||||
getCompletedFeatureTipIds,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
export function getFeatureTipForModal(args: {
|
||||
modalData: Record<string, unknown>
|
||||
seenTipIds: readonly FeatureTipId[]
|
||||
featureInteractions: FeatureInteractionState
|
||||
settings: { voice?: GlobalSettings['voice'] } | null | undefined
|
||||
}): FeatureTip | null {
|
||||
const modalTipId = isFeatureTipId(args.modalData.tipId) ? args.modalData.tipId : null
|
||||
@@ -21,7 +23,8 @@ export function getFeatureTipForModal(args: {
|
||||
const pendingTips = getOrderedUnseenFeatureTips({
|
||||
seenTipIds: new Set(args.seenTipIds),
|
||||
completedTipIds: getCompletedFeatureTipIds({
|
||||
voiceDictationEnabled: args.settings?.voice?.enabled === true
|
||||
voiceDictationEnabled: args.settings?.voice?.enabled === true,
|
||||
featureInteractions: args.featureInteractions
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('feature tip startup gate', () => {
|
||||
getFeatureTipsAppOpenDecision({
|
||||
activeModal: 'none',
|
||||
featureTipsSeenIds: [],
|
||||
featureInteractions: {},
|
||||
onboarding: existingUserOnboarding,
|
||||
persistedUIReady: true,
|
||||
promptedThisSession: false,
|
||||
@@ -41,6 +42,7 @@ describe('feature tip startup gate', () => {
|
||||
getFeatureTipsAppOpenDecision({
|
||||
activeModal: 'none',
|
||||
featureTipsSeenIds: [],
|
||||
featureInteractions: {},
|
||||
onboarding: firstTimeOnboarding,
|
||||
persistedUIReady: true,
|
||||
promptedThisSession: false,
|
||||
@@ -55,6 +57,7 @@ describe('feature tip startup gate', () => {
|
||||
getFeatureTipsAppOpenDecision({
|
||||
activeModal: 'none',
|
||||
featureTipsSeenIds: [],
|
||||
featureInteractions: {},
|
||||
onboarding: existingUserOnboarding,
|
||||
persistedUIReady: true,
|
||||
promptedThisSession: false,
|
||||
@@ -69,6 +72,7 @@ describe('feature tip startup gate', () => {
|
||||
getFeatureTipsAppOpenDecision({
|
||||
activeModal: 'none',
|
||||
featureTipsSeenIds: ['voice-dictation'],
|
||||
featureInteractions: {},
|
||||
onboarding: existingUserOnboarding,
|
||||
persistedUIReady: true,
|
||||
promptedThisSession: false,
|
||||
@@ -83,6 +87,7 @@ describe('feature tip startup gate', () => {
|
||||
getFeatureTipsAppOpenDecision({
|
||||
activeModal: 'none',
|
||||
featureTipsSeenIds: [],
|
||||
featureInteractions: {},
|
||||
onboarding: existingUserOnboarding,
|
||||
persistedUIReady: true,
|
||||
promptedThisSession: false,
|
||||
@@ -91,4 +96,21 @@ describe('feature tip startup gate', () => {
|
||||
})
|
||||
).toEqual({ kind: 'skip' })
|
||||
})
|
||||
|
||||
it('does not open after the user already interacted with the feature', () => {
|
||||
expect(
|
||||
getFeatureTipsAppOpenDecision({
|
||||
activeModal: 'none',
|
||||
featureTipsSeenIds: [],
|
||||
featureInteractions: {
|
||||
'voice-dictation': { firstInteractedAt: 100, interactionCount: 1 }
|
||||
},
|
||||
onboarding: existingUserOnboarding,
|
||||
persistedUIReady: true,
|
||||
promptedThisSession: false,
|
||||
settings: makeSettings(),
|
||||
suppressedByOnboardingThisSession: false
|
||||
})
|
||||
).toEqual({ kind: 'skip' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getCompletedFeatureTipIds,
|
||||
getOrderedUnseenFeatureTips
|
||||
} from '../../../../shared/feature-tips'
|
||||
import type { FeatureInteractionState } from '../../../../shared/feature-interactions'
|
||||
import type { GlobalSettings, OnboardingState } from '../../../../shared/types'
|
||||
import { shouldShowOnboarding } from '../onboarding/should-show-onboarding'
|
||||
|
||||
@@ -14,6 +15,7 @@ export type FeatureTipsAppOpenDecision =
|
||||
export function getFeatureTipsAppOpenDecision(args: {
|
||||
activeModal: string
|
||||
featureTipsSeenIds: readonly FeatureTipId[]
|
||||
featureInteractions: FeatureInteractionState
|
||||
onboarding: OnboardingState | null
|
||||
persistedUIReady: boolean
|
||||
promptedThisSession: boolean
|
||||
@@ -39,7 +41,8 @@ export function getFeatureTipsAppOpenDecision(args: {
|
||||
const unseenTips = getOrderedUnseenFeatureTips({
|
||||
seenTipIds: new Set<FeatureTipId>(args.featureTipsSeenIds),
|
||||
completedTipIds: getCompletedFeatureTipIds({
|
||||
voiceDictationEnabled: args.settings.voice?.enabled === true
|
||||
voiceDictationEnabled: args.settings.voice?.enabled === true,
|
||||
featureInteractions: args.featureInteractions
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1156,7 +1156,7 @@ export function FloatingTerminalPanel({
|
||||
className={isActive ? 'absolute inset-0 flex' : 'absolute inset-0 hidden'}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<BrowserPane browserTab={tab} isActive={isActive} />
|
||||
<BrowserPane browserTab={tab} isActive={open && isActive} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -65,15 +65,16 @@ export function OnboardingInlineCommandTerminal({
|
||||
|
||||
useEffect(() => {
|
||||
const tab = createTab(worktreeId, undefined, undefined, {
|
||||
activate: false
|
||||
activate: false,
|
||||
recordInteraction: false
|
||||
})
|
||||
setActiveTabForWorktree(worktreeId, tab.id)
|
||||
setTabCustomTitle(tab.id, title)
|
||||
setTabCustomTitle(tab.id, title, { recordInteraction: false })
|
||||
setTabId(tab.id)
|
||||
return () => {
|
||||
// Why: inline setup panels can disappear after detection succeeds; close
|
||||
// the backing tab so installer shells do not keep running invisibly.
|
||||
closeTab(tab.id)
|
||||
closeTab(tab.id, { recordInteraction: false })
|
||||
}
|
||||
}, [closeTab, createTab, setActiveTabForWorktree, setTabCustomTitle, title, worktreeId])
|
||||
|
||||
@@ -241,8 +242,8 @@ export function OnboardingInlineCommandTerminal({
|
||||
cwd={cwd}
|
||||
isActive
|
||||
isVisible
|
||||
onPtyExit={() => closeTab(tabId)}
|
||||
onCloseTab={() => closeTab(tabId)}
|
||||
onPtyExit={() => closeTab(tabId, { recordInteraction: false })}
|
||||
onCloseTab={() => closeTab(tabId, { recordInteraction: false })}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { track } from '@/lib/telemetry'
|
||||
import { useAppStore } from '@/store'
|
||||
import { ONBOARDING_FINAL_STEP } from '../../../../shared/constants'
|
||||
import type { FeatureInteractionId } from '../../../../shared/feature-interactions'
|
||||
import type { EventProps } from '../../../../shared/telemetry-events'
|
||||
import type { GlobalSettings, OnboardingState, TuiAgent } from '../../../../shared/types'
|
||||
import {
|
||||
@@ -138,6 +140,15 @@ type PersistCurrentStepDeps = {
|
||||
setError: (msg: string | null) => void
|
||||
}
|
||||
|
||||
const ONBOARDING_FEATURE_INTERACTIONS: Record<
|
||||
keyof OnboardingFeatureSetupSelection,
|
||||
FeatureInteractionId
|
||||
> = {
|
||||
browserUse: 'agent-browser-setup',
|
||||
computerUse: 'computer-use-setup',
|
||||
orchestration: 'agent-orchestration-setup'
|
||||
}
|
||||
|
||||
export type PersistCurrentStepResult = {
|
||||
ok: boolean
|
||||
featureSetupResult?: OnboardingFeatureSetupResult
|
||||
@@ -196,6 +207,7 @@ export function usePersistCurrentStep({
|
||||
terminalBell: true
|
||||
}
|
||||
})
|
||||
useAppStore.getState().recordFeatureInteraction('notifications')
|
||||
onOnboardingChange(await persistStep(3))
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -210,6 +222,15 @@ export function usePersistCurrentStep({
|
||||
...onboardingFeatureSetupRunTelemetry(featureSetupSelection, setupResult)
|
||||
})
|
||||
if (hasSelectedOnboardingFeatureSetup(featureSetupSelection)) {
|
||||
const recordFeatureInteraction = useAppStore.getState().recordFeatureInteraction
|
||||
for (const [id, selected] of Object.entries(featureSetupSelection) as [
|
||||
keyof OnboardingFeatureSetupSelection,
|
||||
boolean
|
||||
][]) {
|
||||
if (selected) {
|
||||
recordFeatureInteraction(ONBOARDING_FEATURE_INTERACTIONS[id])
|
||||
}
|
||||
}
|
||||
const firstWarning = setupResult.warnings[0]
|
||||
if (firstWarning) {
|
||||
toast.warning('Some feature setup needs attention', {
|
||||
|
||||
@@ -1955,6 +1955,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
}
|
||||
return writeCommitDraftForWorktree(prev, activeWorktreeId, result.message)
|
||||
})
|
||||
useAppStore.getState().recordFeatureInteraction('ai-commit-generation')
|
||||
setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
|
||||
} catch (error) {
|
||||
setGenerateErrors((prev) => ({
|
||||
@@ -2253,6 +2254,9 @@ function SourceControlInner(): React.JSX.Element {
|
||||
if (result.branchChangedByPreparation) {
|
||||
await refreshGitStatusAfterPullRequestGeneration(context)
|
||||
}
|
||||
if (result.success) {
|
||||
useAppStore.getState().recordFeatureInteraction('ai-pr-generation')
|
||||
}
|
||||
setPrGenerationRecords((prev) => {
|
||||
const record = prev[generationKey]
|
||||
if (!result.success) {
|
||||
|
||||
@@ -357,6 +357,7 @@ export function useCreatePullRequestDialogFields({
|
||||
return
|
||||
}
|
||||
applyGeneratedFields(result.fields, currentSeed.fieldRevisions)
|
||||
useAppStore.getState().recordFeatureInteraction('ai-pr-generation')
|
||||
setGenerateError(null)
|
||||
} catch (error) {
|
||||
if (generationRequestIdRef.current !== requestId) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
add/select/reauth/remove flow is tightly coupled to the provider-specific
|
||||
error handling and restart prompts below; splitting them into separate files
|
||||
would scatter those flows without a meaningful abstraction boundary. */
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type {
|
||||
ClaudeRateLimitAccountsState,
|
||||
CodexRateLimitAccountsState,
|
||||
@@ -111,9 +111,11 @@ function getClaudeAccountErrorDescription(error: unknown): string {
|
||||
|
||||
export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): React.JSX.Element {
|
||||
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const fetchSettings = useAppStore((s) => s.fetchSettings)
|
||||
const localPreflightContext = useAppStore(getLocalPreflightContext)
|
||||
const activeWslDistro = localPreflightContext?.wslDistro?.trim() || null
|
||||
const recordedOpenCodeSettingEditsRef = useRef<Set<'cookie' | 'workspaceId'>>(new Set())
|
||||
|
||||
const [codexAccounts, setCodexAccounts] = useState<CodexRateLimitAccountsState>({
|
||||
accounts: [],
|
||||
@@ -132,6 +134,14 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
|
||||
const [removeAccountId, setRemoveAccountId] = useState<string | null>(null)
|
||||
const [removeClaudeAccountId, setRemoveClaudeAccountId] = useState<string | null>(null)
|
||||
|
||||
const recordOpenCodeSettingEdit = (field: 'cookie' | 'workspaceId'): void => {
|
||||
if (recordedOpenCodeSettingEditsRef.current.has(field)) {
|
||||
return
|
||||
}
|
||||
recordedOpenCodeSettingEditsRef.current.add(field)
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
|
||||
@@ -201,6 +211,7 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
|
||||
try {
|
||||
const next = await operation()
|
||||
await syncCodexAccounts(next)
|
||||
recordFeatureInteraction('codex-account-switching')
|
||||
const shouldPromptRestart =
|
||||
action === 'adding' ||
|
||||
(action.startsWith('select:') && previousActiveAccountId !== next.activeAccountId) ||
|
||||
@@ -232,6 +243,7 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
|
||||
try {
|
||||
const next = await operation()
|
||||
await syncClaudeAccounts(next)
|
||||
recordFeatureInteraction('claude-account-switching')
|
||||
if (previousActiveAccountId !== next.activeAccountId || action === 'adding') {
|
||||
toast.info('Claude account updated.', {
|
||||
description: `${getClaudeAccountLabel(claudeAccounts, previousActiveAccountId)} → ${getClaudeAccountLabel(next, next.activeAccountId)}. Restart live Claude terminals before continuing old sessions.`
|
||||
@@ -657,11 +669,12 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={settings.geminiCliOAuthEnabled}
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
updateSettings({
|
||||
geminiCliOAuthEnabled: !settings.geminiCliOAuthEnabled
|
||||
})
|
||||
}
|
||||
}}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
|
||||
settings.geminiCliOAuthEnabled ? 'bg-foreground' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
@@ -696,7 +709,10 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
|
||||
<Input
|
||||
type="password"
|
||||
value={settings.opencodeSessionCookie}
|
||||
onChange={(e) => updateSettings({ opencodeSessionCookie: e.target.value })}
|
||||
onChange={(e) => {
|
||||
recordOpenCodeSettingEdit('cookie')
|
||||
updateSettings({ opencodeSessionCookie: e.target.value })
|
||||
}}
|
||||
placeholder="Fe26.2**… token or auth=Fe26.2**… header"
|
||||
spellCheck={false}
|
||||
className="flex-1 text-xs"
|
||||
@@ -705,7 +721,10 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => updateSettings({ opencodeSessionCookie: '' })}
|
||||
onClick={() => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
updateSettings({ opencodeSessionCookie: '' })
|
||||
}}
|
||||
className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear
|
||||
@@ -730,7 +749,10 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.opencodeWorkspaceId}
|
||||
onChange={(e) => updateSettings({ opencodeWorkspaceId: e.target.value })}
|
||||
onChange={(e) => {
|
||||
recordOpenCodeSettingEdit('workspaceId')
|
||||
updateSettings({ opencodeWorkspaceId: e.target.value })
|
||||
}}
|
||||
placeholder="wrk_… (leave blank for automatic lookup)"
|
||||
spellCheck={false}
|
||||
className="flex-1 text-xs"
|
||||
@@ -739,7 +761,10 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => updateSettings({ opencodeWorkspaceId: '' })}
|
||||
onClick={() => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
updateSettings({ opencodeWorkspaceId: '' })
|
||||
}}
|
||||
className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable max-lines -- Why: AppearancePane keeps theme, typography, zoom, and status-bar
|
||||
visibility settings together so the searchable settings rows share one filtered surface. */
|
||||
import type React from 'react'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { Separator } from '../ui/separator'
|
||||
@@ -66,6 +68,7 @@ export function AppearancePane({
|
||||
const zoomOutKeyCombos = useShortcutKeyCombos('zoom.out')
|
||||
const statusBarItems = useAppStore((state) => state.statusBarItems)
|
||||
const toggleStatusBarItem = useAppStore((state) => state.toggleStatusBarItem)
|
||||
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
|
||||
const visibleStatusBarToggles = useAvailableStatusBarToggles(STATUS_BAR_TOGGLES)
|
||||
|
||||
const visibleSections = [
|
||||
@@ -214,7 +217,23 @@ export function AppearancePane({
|
||||
label={toggle.title}
|
||||
description={toggle.toggleDescription}
|
||||
checked={enabled}
|
||||
onChange={() => toggleStatusBarItem(toggle.id)}
|
||||
onChange={() => {
|
||||
if (toggle.id === 'resource-usage') {
|
||||
recordFeatureInteraction('resource-manager')
|
||||
} else if (toggle.id === 'ports') {
|
||||
recordFeatureInteraction('ports')
|
||||
} else if (toggle.id === 'ssh') {
|
||||
recordFeatureInteraction('ssh')
|
||||
} else if (
|
||||
toggle.id === 'claude' ||
|
||||
toggle.id === 'codex' ||
|
||||
toggle.id === 'gemini' ||
|
||||
toggle.id === 'opencode-go'
|
||||
) {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
}
|
||||
toggleStatusBarItem(toggle.id)
|
||||
}}
|
||||
ariaLabel={toggle.title}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable max-lines -- Why: Browser Use setup keeps enablement, CLI registration, skill install, cookie import, examples, and interaction tracking in one pane so the three-step setup state stays coherent. */
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Import, Loader2, MousePointerClick } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
@@ -69,6 +70,9 @@ export function BrowserUseSetup({
|
||||
const toggleBrowserUse = (value: boolean): void => {
|
||||
setBrowserUseEnabled(value)
|
||||
localStorage.setItem(BROWSER_USE_ENABLED_STORAGE_KEY, value ? '1' : '0')
|
||||
if (value) {
|
||||
useAppStore.getState().recordFeatureInteraction('agent-browser-setup')
|
||||
}
|
||||
}
|
||||
|
||||
const refreshCli = async (): Promise<void> => {
|
||||
@@ -323,6 +327,7 @@ export function BrowserUseSetup({
|
||||
disabled={!cliEnabled}
|
||||
preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE}
|
||||
onBeforeOpenTerminal={async () => {
|
||||
useAppStore.getState().recordFeatureInteraction('agent-browser-setup')
|
||||
await ensureOrcaCliAvailableForAgentSkillTerminal({ onStatusChange: setCliStatus })
|
||||
}}
|
||||
onRecheck={refreshSkill}
|
||||
|
||||
@@ -529,6 +529,7 @@ export function CommitMessageAiPane({
|
||||
selectedThinkingByModel: nextSelectedThinkingByModel
|
||||
}
|
||||
})
|
||||
useAppStore.getState().recordFeatureInteraction('ai-commit-generation')
|
||||
}
|
||||
|
||||
const onAgentChange = (newAgentId: string): void => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
|
||||
useInstalledAgentSkill
|
||||
} from '@/hooks/useInstalledAgentSkills'
|
||||
import { useAppStore } from '@/store'
|
||||
import { Button } from '../ui/button'
|
||||
import { Badge } from '../ui/badge'
|
||||
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
|
||||
@@ -163,6 +164,7 @@ export function ComputerUsePane(): React.JSX.Element {
|
||||
}, [refresh])
|
||||
|
||||
const openPermission = async (id: ComputerUsePermissionId): Promise<void> => {
|
||||
useAppStore.getState().recordFeatureInteraction('computer-use-setup')
|
||||
setPendingId(id)
|
||||
try {
|
||||
const result = await window.api.computerUsePermissions.openSetup({ id })
|
||||
@@ -324,6 +326,7 @@ export function ComputerUsePane(): React.JSX.Element {
|
||||
icon={<MonitorCog className="size-5" />}
|
||||
preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE}
|
||||
onBeforeOpenTerminal={async () => {
|
||||
useAppStore.getState().recordFeatureInteraction('computer-use-setup')
|
||||
await ensureOrcaCliAvailableForAgentSkillTerminal()
|
||||
}}
|
||||
onRecheck={refreshComputerUseSkill}
|
||||
|
||||
@@ -62,6 +62,7 @@ export function FloatingWorkspacePane({
|
||||
if (!path) {
|
||||
return
|
||||
}
|
||||
useAppStore.getState().recordFeatureInteraction('floating-workspace')
|
||||
updateSettings({ floatingTerminalCwd: path })
|
||||
}
|
||||
|
||||
@@ -96,11 +97,14 @@ export function FloatingWorkspacePane({
|
||||
label="Enable Floating Workspace"
|
||||
description="Shows the floating workspace button and panel."
|
||||
checked={settings.floatingTerminalEnabled}
|
||||
onChange={() =>
|
||||
onChange={() => {
|
||||
if (!settings.floatingTerminalEnabled) {
|
||||
useAppStore.getState().recordFeatureInteraction('floating-workspace')
|
||||
}
|
||||
updateSettings({
|
||||
floatingTerminalEnabled: !settings.floatingTerminalEnabled
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<SettingsRow
|
||||
@@ -142,6 +146,7 @@ export function FloatingWorkspacePane({
|
||||
updateSettings({
|
||||
floatingTerminalTriggerLocation: value as FloatingTerminalTriggerLocation
|
||||
})
|
||||
useAppStore.getState().recordFeatureInteraction('floating-workspace')
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value="floating-button">Floating Button</ToggleGroupItem>
|
||||
|
||||
@@ -91,6 +91,7 @@ export function MobilePane(): React.JSX.Element {
|
||||
...(opts.rotate ? { rotate: true } : {})
|
||||
})
|
||||
if (result.available) {
|
||||
useAppStore.getState().recordFeatureInteraction('mobile-pairing')
|
||||
setQrDataUrl(result.qrDataUrl)
|
||||
setPairingUrl(result.pairingUrl)
|
||||
setEndpoint(result.endpoint)
|
||||
|
||||
@@ -66,11 +66,15 @@ export function MobileSettingsPane({
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={settings.experimentalMobile}
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
const nextEnabled = !settings.experimentalMobile
|
||||
if (nextEnabled) {
|
||||
useAppStore.getState().recordFeatureInteraction('mobile-pairing')
|
||||
}
|
||||
updateSettings({
|
||||
experimentalMobile: !settings.experimentalMobile
|
||||
experimentalMobile: nextEnabled
|
||||
})
|
||||
}
|
||||
}}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
|
||||
settings.experimentalMobile ? 'bg-foreground' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '../ui/select'
|
||||
import { BellRing, Bot, FileAudio, Siren, Upload, Volume2 } from 'lucide-react'
|
||||
import { getNotificationSoundOptions } from '@/components/notification-sound-options'
|
||||
import { useAppStore } from '@/store'
|
||||
export { NOTIFICATIONS_PANE_SEARCH_ENTRIES } from './notifications-search'
|
||||
|
||||
type NotificationsPaneProps = {
|
||||
@@ -174,6 +175,7 @@ export function NotificationsPane({
|
||||
}
|
||||
|
||||
const handleSendTestNotification = async (): Promise<void> => {
|
||||
useAppStore.getState().recordFeatureInteraction('notifications')
|
||||
await sendNotificationSettingsTestNotification(notificationSettings, volumeDraft)
|
||||
}
|
||||
|
||||
@@ -223,7 +225,12 @@ export function NotificationsPane({
|
||||
label="Enable Notifications"
|
||||
description="Native system notifications for background events."
|
||||
checked={notificationSettings.enabled}
|
||||
onToggle={() => void updateNotificationSettings({ enabled: !notificationSettings.enabled })}
|
||||
onToggle={() => {
|
||||
if (!notificationSettings.enabled) {
|
||||
useAppStore.getState().recordFeatureInteraction('notifications')
|
||||
}
|
||||
void updateNotificationSettings({ enabled: !notificationSettings.enabled })
|
||||
}}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -54,6 +54,9 @@ export function OrchestrationPane(): React.JSX.Element {
|
||||
const toggleOrchestration = (value: boolean): void => {
|
||||
setOrchestrationEnabled(value)
|
||||
localStorage.setItem(ORCHESTRATION_ENABLED_STORAGE_KEY, value ? '1' : '0')
|
||||
if (value) {
|
||||
useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup')
|
||||
}
|
||||
notifyOrchestrationSetupStateChanged()
|
||||
}
|
||||
|
||||
@@ -106,6 +109,7 @@ export function OrchestrationPane(): React.JSX.Element {
|
||||
icon={<Workflow className="size-5" />}
|
||||
preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE}
|
||||
onBeforeOpenTerminal={async () => {
|
||||
useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup')
|
||||
await ensureOrcaCliAvailableForAgentSkillTerminal()
|
||||
}}
|
||||
onRecheck={refreshOrchestrationSkill}
|
||||
|
||||
@@ -182,6 +182,7 @@ export function QuickCommandsPane({
|
||||
const nextList = isEdit
|
||||
? latest.map((command) => (command.id === next.id ? next : command))
|
||||
: [...latest, next]
|
||||
useAppStore.getState().recordFeatureInteraction('quick-commands')
|
||||
updateSettings({ terminalQuickCommands: nextList })
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
|
||||
// global store (via useIpcEvents.ts). Reading from the store avoids
|
||||
// duplicating the onStateChanged listener and per-target getState IPC calls.
|
||||
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<EditingTarget>(EMPTY_FORM)
|
||||
@@ -103,6 +104,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
|
||||
await window.api.ssh.addTarget({ target })
|
||||
toast.success('Target added')
|
||||
}
|
||||
recordFeatureInteraction('ssh')
|
||||
setShowForm(false)
|
||||
setEditingId(null)
|
||||
setForm(EMPTY_FORM)
|
||||
@@ -164,6 +166,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
|
||||
const handleConnect = async (targetId: string): Promise<void> => {
|
||||
try {
|
||||
await window.api.ssh.connect({ targetId })
|
||||
recordFeatureInteraction('ssh')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Connection failed')
|
||||
}
|
||||
@@ -172,6 +175,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
|
||||
const handleDisconnect = async (targetId: string): Promise<void> => {
|
||||
try {
|
||||
await window.api.ssh.disconnect({ targetId })
|
||||
recordFeatureInteraction('ssh')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Disconnect failed')
|
||||
}
|
||||
@@ -200,6 +204,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
|
||||
setTestingIds((prev) => new Set(prev).add(targetId))
|
||||
try {
|
||||
const result = await window.api.ssh.testConnection({ targetId })
|
||||
recordFeatureInteraction('ssh')
|
||||
if (result.success) {
|
||||
toast.success('Connection successful')
|
||||
} else {
|
||||
@@ -219,6 +224,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
|
||||
const handleImport = async (): Promise<void> => {
|
||||
try {
|
||||
const imported = (await window.api.ssh.importConfig()) as SshTarget[]
|
||||
recordFeatureInteraction('ssh')
|
||||
if (imported.length === 0) {
|
||||
toast('No new hosts found in ~/.ssh/config')
|
||||
} else {
|
||||
|
||||
@@ -120,6 +120,7 @@ export default function WorkspaceKanbanDrawer({
|
||||
if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) {
|
||||
return
|
||||
}
|
||||
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
|
||||
void updateWorktreeMeta(worktreeId, { workspaceStatus: status })
|
||||
},
|
||||
[updateWorktreeMeta, workspaceStatuses, worktreeById]
|
||||
@@ -211,6 +212,7 @@ export default function WorkspaceKanbanDrawer({
|
||||
if (writeManualOrder && order.changed) {
|
||||
setSortBy('manual')
|
||||
}
|
||||
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
|
||||
void updateWorktreesMeta(updates)
|
||||
},
|
||||
[
|
||||
@@ -244,6 +246,7 @@ export default function WorkspaceKanbanDrawer({
|
||||
updates.set(worktreeId, { isPinned: true })
|
||||
}
|
||||
if (updates.size > 0) {
|
||||
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
|
||||
void updateWorktreesMeta(updates)
|
||||
}
|
||||
},
|
||||
@@ -339,6 +342,7 @@ export default function WorkspaceKanbanDrawer({
|
||||
status.id === statusId ? { ...status, label: trimmed } : status
|
||||
)
|
||||
)
|
||||
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
|
||||
},
|
||||
[setWorkspaceStatuses, workspaceStatuses]
|
||||
)
|
||||
@@ -348,6 +352,7 @@ export default function WorkspaceKanbanDrawer({
|
||||
setWorkspaceStatuses(
|
||||
workspaceStatuses.map((status) => (status.id === statusId ? { ...status, color } : status))
|
||||
)
|
||||
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
|
||||
},
|
||||
[setWorkspaceStatuses, workspaceStatuses]
|
||||
)
|
||||
@@ -357,6 +362,7 @@ export default function WorkspaceKanbanDrawer({
|
||||
setWorkspaceStatuses(
|
||||
workspaceStatuses.map((status) => (status.id === statusId ? { ...status, icon } : status))
|
||||
)
|
||||
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
|
||||
},
|
||||
[setWorkspaceStatuses, workspaceStatuses]
|
||||
)
|
||||
@@ -372,6 +378,7 @@ export default function WorkspaceKanbanDrawer({
|
||||
const [moved] = next.splice(index, 1)
|
||||
next.splice(nextIndex, 0, moved)
|
||||
setWorkspaceStatuses(next)
|
||||
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
|
||||
},
|
||||
[setWorkspaceStatuses, workspaceStatuses]
|
||||
)
|
||||
@@ -382,6 +389,7 @@ export default function WorkspaceKanbanDrawer({
|
||||
...workspaceStatuses,
|
||||
{ id: makeWorkspaceStatusId(label, workspaceStatuses), label }
|
||||
])
|
||||
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
|
||||
}, [setWorkspaceStatuses, workspaceStatuses])
|
||||
|
||||
const handleRemoveStatus = useCallback(
|
||||
@@ -396,6 +404,7 @@ export default function WorkspaceKanbanDrawer({
|
||||
const next = workspaceStatuses.filter((status) => status.id !== statusId)
|
||||
const fallbackStatus = next[Math.min(index, next.length - 1)]?.id ?? next[0]!.id
|
||||
setWorkspaceStatuses(next)
|
||||
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
|
||||
for (const worktree of allWorktrees) {
|
||||
if (getWorkspaceStatus(worktree, workspaceStatuses) === statusId) {
|
||||
void updateWorktreeMeta(worktree.id, { workspaceStatus: fallbackStatus })
|
||||
@@ -420,6 +429,12 @@ export default function WorkspaceKanbanDrawer({
|
||||
useWorkspaceKanbanShiftWheelScroll(boardRef, laneScrollerRef, open, isPointerDragActiveRef)
|
||||
useWorkspaceKanbanOutsideDismiss({ open, boardRef, preserveOpenForMenu, onOpenChange })
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
useAppStore.getState().recordFeatureInteraction('workspace-board')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || selectedWorktreeIds.size === 0) {
|
||||
return
|
||||
@@ -498,7 +513,10 @@ export default function WorkspaceKanbanDrawer({
|
||||
selectedCount={selectedWorktrees.length}
|
||||
compact={workspaceBoardCompact}
|
||||
workspaceStatuses={workspaceStatuses}
|
||||
onCompactChange={setWorkspaceBoardCompact}
|
||||
onCompactChange={(compact) => {
|
||||
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
|
||||
setWorkspaceBoardCompact(compact)
|
||||
}}
|
||||
onRenameStatus={handleRenameStatus}
|
||||
onChangeStatusColor={handleChangeStatusColor}
|
||||
onChangeStatusIcon={handleChangeStatusIcon}
|
||||
|
||||
@@ -30,6 +30,8 @@ type WorktreeCardPortsProps = {
|
||||
export function WorktreeCardPortsTrigger({
|
||||
ports
|
||||
}: WorktreeCardPortsProps): React.JSX.Element | null {
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
|
||||
if (ports.length === 0) {
|
||||
return null
|
||||
}
|
||||
@@ -39,7 +41,10 @@ export function WorktreeCardPortsTrigger({
|
||||
type="button"
|
||||
className="inline-flex size-3.5 shrink-0 items-center justify-center rounded text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring"
|
||||
aria-label={`${ports.length} live ${ports.length === 1 ? 'port' : 'ports'}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
recordFeatureInteraction('ports')
|
||||
}}
|
||||
>
|
||||
<Plug className="size-3.5" />
|
||||
</button>
|
||||
@@ -92,6 +97,7 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element {
|
||||
const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle)
|
||||
const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan)
|
||||
const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings])
|
||||
const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process')
|
||||
const address = addressForPort(port)
|
||||
@@ -100,6 +106,7 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element {
|
||||
const handleOpen = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
recordFeatureInteraction('ports')
|
||||
void openWorkspacePortInBrowser({
|
||||
port,
|
||||
runtimeTarget,
|
||||
@@ -112,17 +119,25 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element {
|
||||
}
|
||||
})
|
||||
},
|
||||
[createBrowserTab, port, runtimeTarget, setRemoteBrowserPageHandle, settings]
|
||||
[
|
||||
createBrowserTab,
|
||||
port,
|
||||
recordFeatureInteraction,
|
||||
runtimeTarget,
|
||||
setRemoteBrowserPageHandle,
|
||||
settings
|
||||
]
|
||||
)
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
recordFeatureInteraction('ports')
|
||||
const address = addressForPort(port)
|
||||
void window.api.ui.writeClipboardText(address)
|
||||
toast.success(`Copied ${address}`)
|
||||
},
|
||||
[port]
|
||||
[port, recordFeatureInteraction]
|
||||
)
|
||||
|
||||
const handleStop = useCallback(
|
||||
@@ -131,6 +146,7 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element {
|
||||
if (!canStopWorkspacePort(port)) {
|
||||
return
|
||||
}
|
||||
recordFeatureInteraction('ports')
|
||||
const run = async (): Promise<void> => {
|
||||
const result = await killWorkspacePortForTarget(runtimeTarget, {
|
||||
repoId: port.owner.repoId,
|
||||
@@ -155,7 +171,13 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element {
|
||||
}
|
||||
void run()
|
||||
},
|
||||
[port, runtimeTarget, setWorkspacePortScan, setWorkspacePortScanRefreshing]
|
||||
[
|
||||
port,
|
||||
recordFeatureInteraction,
|
||||
runtimeTarget,
|
||||
setWorkspacePortScan,
|
||||
setWorkspacePortScanRefreshing
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -201,15 +223,17 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element {
|
||||
export function WorktreeCardPortsDetails({
|
||||
ports
|
||||
}: WorktreeCardPortsProps): React.JSX.Element | null {
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const handleGoToWorktree = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
recordFeatureInteraction('ports')
|
||||
const ownerPort = ports[0]
|
||||
if (!ownerPort || !goToWorkspacePortOwner(ownerPort)) {
|
||||
toast.error('Workspace unavailable')
|
||||
}
|
||||
},
|
||||
[ports]
|
||||
[ports, recordFeatureInteraction]
|
||||
)
|
||||
|
||||
if (ports.length === 0) {
|
||||
|
||||
@@ -91,11 +91,17 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
||||
const refreshClaudeUsage = useAppStore((state) => state.refreshClaudeUsage)
|
||||
const setClaudeUsageScope = useAppStore((state) => state.setClaudeUsageScope)
|
||||
const setClaudeUsageRange = useAppStore((state) => state.setClaudeUsageRange)
|
||||
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
|
||||
|
||||
useEffect(() => {
|
||||
void fetchClaudeUsage()
|
||||
}, [fetchClaudeUsage])
|
||||
|
||||
const handleSetEnabled = (enabled: boolean): void => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
void setClaudeUsageEnabled(enabled)
|
||||
}
|
||||
|
||||
if (!scanState?.enabled) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
@@ -111,7 +117,7 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
||||
role="switch"
|
||||
aria-checked={false}
|
||||
aria-label="Enable Claude usage analytics"
|
||||
onClick={() => void setClaudeUsageEnabled(true)}
|
||||
onClick={() => handleSetEnabled(true)}
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors"
|
||||
>
|
||||
<span className="pointer-events-none block size-3.5 translate-x-0.5 rounded-full bg-background shadow-sm transition-transform" />
|
||||
@@ -205,7 +211,7 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
||||
role="switch"
|
||||
aria-checked={true}
|
||||
aria-label="Enable Claude usage analytics"
|
||||
onClick={() => void setClaudeUsageEnabled(false)}
|
||||
onClick={() => handleSetEnabled(false)}
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors"
|
||||
>
|
||||
<span className="pointer-events-none block size-3.5 translate-x-4 rounded-full bg-background shadow-sm transition-transform" />
|
||||
|
||||
@@ -90,11 +90,17 @@ export function CodexUsagePane(): React.JSX.Element {
|
||||
const refreshCodexUsage = useAppStore((state) => state.refreshCodexUsage)
|
||||
const setCodexUsageScope = useAppStore((state) => state.setCodexUsageScope)
|
||||
const setCodexUsageRange = useAppStore((state) => state.setCodexUsageRange)
|
||||
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
|
||||
|
||||
useEffect(() => {
|
||||
void fetchCodexUsage()
|
||||
}, [fetchCodexUsage])
|
||||
|
||||
const handleSetEnabled = (enabled: boolean): void => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
void setCodexUsageEnabled(enabled)
|
||||
}
|
||||
|
||||
if (!scanState?.enabled) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
@@ -110,7 +116,7 @@ export function CodexUsagePane(): React.JSX.Element {
|
||||
role="switch"
|
||||
aria-checked={false}
|
||||
aria-label="Enable Codex usage analytics"
|
||||
onClick={() => void setCodexUsageEnabled(true)}
|
||||
onClick={() => handleSetEnabled(true)}
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors"
|
||||
>
|
||||
<span className="pointer-events-none block size-3.5 translate-x-0.5 rounded-full bg-background shadow-sm transition-transform" />
|
||||
@@ -210,7 +216,7 @@ export function CodexUsagePane(): React.JSX.Element {
|
||||
role="switch"
|
||||
aria-checked={true}
|
||||
aria-label="Enable Codex usage analytics"
|
||||
onClick={() => void setCodexUsageEnabled(false)}
|
||||
onClick={() => handleSetEnabled(false)}
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors"
|
||||
>
|
||||
<span className="pointer-events-none block size-3.5 translate-x-4 rounded-full bg-background shadow-sm transition-transform" />
|
||||
|
||||
@@ -92,11 +92,17 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
||||
const refreshOpenCodeUsage = useAppStore((state) => state.refreshOpenCodeUsage)
|
||||
const setOpenCodeUsageScope = useAppStore((state) => state.setOpenCodeUsageScope)
|
||||
const setOpenCodeUsageRange = useAppStore((state) => state.setOpenCodeUsageRange)
|
||||
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
|
||||
|
||||
useEffect(() => {
|
||||
void fetchOpenCodeUsage()
|
||||
}, [fetchOpenCodeUsage])
|
||||
|
||||
const handleSetEnabled = (enabled: boolean): void => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
void setOpenCodeUsageEnabled(enabled)
|
||||
}
|
||||
|
||||
if (!scanState?.enabled) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
@@ -112,7 +118,7 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
||||
role="switch"
|
||||
aria-checked={false}
|
||||
aria-label="Enable OpenCode usage analytics"
|
||||
onClick={() => void setOpenCodeUsageEnabled(true)}
|
||||
onClick={() => handleSetEnabled(true)}
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors"
|
||||
>
|
||||
<span className="pointer-events-none block size-3.5 translate-x-0.5 rounded-full bg-background shadow-sm transition-transform" />
|
||||
@@ -209,7 +215,7 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
||||
role="switch"
|
||||
aria-checked={true}
|
||||
aria-label="Enable OpenCode usage analytics"
|
||||
onClick={() => void setOpenCodeUsageEnabled(false)}
|
||||
onClick={() => handleSetEnabled(false)}
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors"
|
||||
>
|
||||
<span className="pointer-events-none block size-3.5 translate-x-4 rounded-full bg-background shadow-sm transition-transform" />
|
||||
|
||||
@@ -63,14 +63,16 @@ function UsageAnalyticsOptionIcon({ tab }: { tab: UsageTab }): React.JSX.Element
|
||||
export function StatsPane(): React.JSX.Element {
|
||||
const summary = useAppStore((s) => s.statsSummary)
|
||||
const fetchStatsSummary = useAppStore((s) => s.fetchStatsSummary)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const [activeUsageTab, setActiveUsageTab] = useState<UsageTab>('overview')
|
||||
const activeUsageOption =
|
||||
USAGE_ANALYTICS_OPTIONS.find((option) => option.id === activeUsageTab) ??
|
||||
USAGE_ANALYTICS_OPTIONS[0]
|
||||
|
||||
useEffect(() => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
void fetchStatsSummary()
|
||||
}, [fetchStatsSummary])
|
||||
}, [fetchStatsSummary, recordFeatureInteraction])
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
|
||||
@@ -262,6 +262,7 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
const enableClaudeUsage = useAppStore((state) => state.enableClaudeUsage)
|
||||
const enableCodexUsage = useAppStore((state) => state.enableCodexUsage)
|
||||
const enableOpenCodeUsage = useAppStore((state) => state.enableOpenCodeUsage)
|
||||
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
|
||||
|
||||
useEffect(() => {
|
||||
void fetchClaudeUsage()
|
||||
@@ -353,13 +354,33 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => void enableClaudeUsage()}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
void enableClaudeUsage()
|
||||
}}
|
||||
>
|
||||
Enable Claude
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => void enableCodexUsage()}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
void enableCodexUsage()
|
||||
}}
|
||||
>
|
||||
Enable Codex
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => void enableOpenCodeUsage()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
void enableOpenCodeUsage()
|
||||
}}
|
||||
>
|
||||
Enable OpenCode
|
||||
</Button>
|
||||
</div>
|
||||
@@ -425,6 +446,7 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
provider={provider}
|
||||
totalTokens={overview.totalTokens}
|
||||
onEnable={() => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
if (provider.id === 'claude') {
|
||||
void enableClaudeUsage()
|
||||
} else if (provider.id === 'codex') {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable max-lines -- Why: PortsStatusSegment keeps port scanning, grouping, row actions,
|
||||
and status-bar interaction tracking together so the popover state stays coherent. */
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
Plug,
|
||||
@@ -98,6 +100,7 @@ function PortRow({
|
||||
const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle)
|
||||
const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan)
|
||||
const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings])
|
||||
const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process')
|
||||
const openInOrcaBrowser = shouldOpenWorkspacePortInOrcaBrowser(settings)
|
||||
@@ -107,6 +110,7 @@ function PortRow({
|
||||
const handleOpen = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
recordFeatureInteraction('ports')
|
||||
void openWorkspacePortInBrowser({
|
||||
port,
|
||||
activeWorktreeId,
|
||||
@@ -125,6 +129,7 @@ function PortRow({
|
||||
createBrowserTab,
|
||||
openInOrcaBrowser,
|
||||
port,
|
||||
recordFeatureInteraction,
|
||||
runtimeTarget,
|
||||
setRemoteBrowserPageHandle
|
||||
]
|
||||
@@ -133,11 +138,12 @@ function PortRow({
|
||||
const handleCopy = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
recordFeatureInteraction('ports')
|
||||
const address = addressForPort(port)
|
||||
void window.api.ui.writeClipboardText(address)
|
||||
toast.success(`Copied ${address}`)
|
||||
},
|
||||
[port]
|
||||
[port, recordFeatureInteraction]
|
||||
)
|
||||
|
||||
const handleStop = useCallback(
|
||||
@@ -146,6 +152,7 @@ function PortRow({
|
||||
if (!canStopWorkspacePort(port)) {
|
||||
return
|
||||
}
|
||||
recordFeatureInteraction('ports')
|
||||
const run = async (): Promise<void> => {
|
||||
const result = await killWorkspacePortForTarget(runtimeTarget, {
|
||||
repoId: port.owner.repoId,
|
||||
@@ -170,7 +177,13 @@ function PortRow({
|
||||
}
|
||||
void run()
|
||||
},
|
||||
[port, runtimeTarget, setWorkspacePortScan, setWorkspacePortScanRefreshing]
|
||||
[
|
||||
port,
|
||||
recordFeatureInteraction,
|
||||
runtimeTarget,
|
||||
setWorkspacePortScan,
|
||||
setWorkspacePortScanRefreshing
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -262,6 +275,7 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React
|
||||
const refreshing = useAppStore((s) => s.workspacePortScanRefreshing)
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [externalOpen, setExternalOpen] = useState(false)
|
||||
const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings])
|
||||
@@ -277,6 +291,7 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React
|
||||
if (!nextOpen) {
|
||||
return
|
||||
}
|
||||
recordFeatureInteraction('ports')
|
||||
// Why: the 30s background poll is intentionally quiet; opening the
|
||||
// popover should still collapse that stale window without flashing icons.
|
||||
void scanWorkspacePortsForTarget(runtimeTarget)
|
||||
@@ -296,7 +311,7 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React
|
||||
})
|
||||
})
|
||||
},
|
||||
[runtimeTarget, scanKey, setWorkspacePortScan]
|
||||
[recordFeatureInteraction, runtimeTarget, scanKey, setWorkspacePortScan]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -379,7 +394,10 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React
|
||||
type="button"
|
||||
className="sticky top-0 z-10 flex w-full items-center gap-1.5 border-b border-border/40 bg-popover px-3 py-2 text-left text-[11px] font-medium uppercase tracking-[0.05em] text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
aria-expanded={externalOpen}
|
||||
onClick={() => setExternalOpen((value) => !value)}
|
||||
onClick={() => {
|
||||
recordFeatureInteraction('ports')
|
||||
setExternalOpen((value) => !value)
|
||||
}}
|
||||
>
|
||||
{externalOpen ? (
|
||||
<ChevronDown className="size-3" />
|
||||
|
||||
@@ -650,6 +650,7 @@ export function ResourceUsageStatusSegment({
|
||||
const setActiveView = useAppStore((s) => s.setActiveView)
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
const openSpacePage = useAppStore((s) => s.openSpacePage)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const activeView = useAppStore((s) => s.activeView)
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const workspaceSpaceScannedAt = useAppStore((s) => s.workspaceSpaceAnalysis?.scannedAt ?? null)
|
||||
@@ -1060,7 +1061,15 @@ export function ResourceUsageStatusSegment({
|
||||
}, [openSpacePage])
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen) {
|
||||
recordFeatureInteraction('resource-manager')
|
||||
}
|
||||
setOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<Tooltip delayDuration={150}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
|
||||
@@ -113,28 +113,31 @@ function TargetRow({
|
||||
syncStatus: RemoteWorkspaceSyncStatus | undefined
|
||||
}): React.JSX.Element {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await window.api.ssh.connect({ targetId })
|
||||
recordFeatureInteraction('ssh')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Connection failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [targetId])
|
||||
}, [recordFeatureInteraction, targetId])
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await window.api.ssh.disconnect({ targetId })
|
||||
recordFeatureInteraction('ssh')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Disconnect failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [targetId])
|
||||
}, [recordFeatureInteraction, targetId])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 px-2 py-1.5">
|
||||
@@ -193,6 +196,7 @@ export function SshStatusSegment({
|
||||
)
|
||||
const setActiveView = useAppStore((s) => s.setActiveView)
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
|
||||
const targets = Array.from(sshTargetLabels.entries()).map(([id, label]) => {
|
||||
const state = sshConnectionStates.get(id)
|
||||
@@ -221,7 +225,13 @@ export function SshStatusSegment({
|
||||
: null
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
recordFeatureInteraction('ssh')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -294,6 +304,7 @@ export function SshStatusSegment({
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
recordFeatureInteraction('ssh')
|
||||
openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' })
|
||||
setActiveView('settings')
|
||||
}}
|
||||
|
||||
@@ -128,6 +128,7 @@ function ClaudeSwitcherMenu({
|
||||
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
const fetchSettings = useAppStore((s) => s.fetchSettings)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const fetchInactiveClaudeAccountUsage = useAppStore((s) => s.fetchInactiveClaudeAccountUsage)
|
||||
const inactiveClaudeAccounts = useAppStore((s) => s.rateLimits.inactiveClaudeAccounts)
|
||||
const claudeAccountSyncKey = useAppStore((s) => {
|
||||
@@ -168,6 +169,7 @@ function ClaudeSwitcherMenu({
|
||||
setIsSwitching(true)
|
||||
try {
|
||||
const next = await window.api.claudeAccounts.select({ accountId })
|
||||
recordFeatureInteraction('claude-account-switching')
|
||||
setAccounts(next)
|
||||
await fetchSettings()
|
||||
setAccountsExpanded(false)
|
||||
@@ -483,6 +485,7 @@ function CodexSwitcherMenu({
|
||||
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
const fetchSettings = useAppStore((s) => s.fetchSettings)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const fetchInactiveCodexAccountUsage = useAppStore((s) => s.fetchInactiveCodexAccountUsage)
|
||||
const inactiveCodexAccounts = useAppStore((s) => s.rateLimits.inactiveCodexAccounts)
|
||||
const codexAccountSyncKey = useAppStore((s) => {
|
||||
@@ -516,6 +519,7 @@ function CodexSwitcherMenu({
|
||||
setIsSwitching(true)
|
||||
try {
|
||||
const next = await window.api.codexAccounts.select({ accountId })
|
||||
recordFeatureInteraction('codex-account-switching')
|
||||
setAccounts(next)
|
||||
await fetchSettings()
|
||||
if (previousActiveAccountId !== next.activeAccountId) {
|
||||
@@ -671,8 +675,17 @@ function ProviderDetailsMenu({
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children?: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean): void => {
|
||||
if (nextOpen) {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
}
|
||||
onOpenChange?.(nextOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -726,6 +739,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
|
||||
const refreshRateLimits = useAppStore((s) => s.refreshRateLimits)
|
||||
const statusBarVisible = useAppStore((s) => s.statusBarVisible)
|
||||
const statusBarItems = useAppStore((s) => s.statusBarItems)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const floatingTerminalEnabled = useAppStore((s) => s.settings?.floatingTerminalEnabled === true)
|
||||
const floatingTerminalTriggerLocation = useAppStore(
|
||||
(s) => s.settings?.floatingTerminalTriggerLocation ?? 'floating-button'
|
||||
@@ -953,7 +967,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
|
||||
{isStatusBarItemAvailable('claude', detectedAgentIds) && (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={statusBarItems.includes('claude')}
|
||||
onCheckedChange={() => toggleStatusBarItem('claude')}
|
||||
onCheckedChange={() => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
toggleStatusBarItem('claude')
|
||||
}}
|
||||
>
|
||||
<ClaudeIcon size={14} />
|
||||
Claude Usage
|
||||
@@ -962,7 +979,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
|
||||
{isStatusBarItemAvailable('codex', detectedAgentIds) && (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={statusBarItems.includes('codex')}
|
||||
onCheckedChange={() => toggleStatusBarItem('codex')}
|
||||
onCheckedChange={() => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
toggleStatusBarItem('codex')
|
||||
}}
|
||||
>
|
||||
<OpenAIIcon size={14} />
|
||||
Codex Usage
|
||||
@@ -971,7 +991,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
|
||||
{isStatusBarItemAvailable('gemini', detectedAgentIds) && (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={statusBarItems.includes('gemini')}
|
||||
onCheckedChange={() => toggleStatusBarItem('gemini')}
|
||||
onCheckedChange={() => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
toggleStatusBarItem('gemini')
|
||||
}}
|
||||
>
|
||||
<GeminiIcon size={14} />
|
||||
Gemini Usage
|
||||
@@ -979,28 +1002,40 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
|
||||
)}
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={statusBarItems.includes('opencode-go')}
|
||||
onCheckedChange={() => toggleStatusBarItem('opencode-go')}
|
||||
onCheckedChange={() => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
toggleStatusBarItem('opencode-go')
|
||||
}}
|
||||
>
|
||||
<OpenCodeGoIcon size={14} />
|
||||
OpenCode Go Usage
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={statusBarItems.includes('ssh')}
|
||||
onCheckedChange={() => toggleStatusBarItem('ssh')}
|
||||
onCheckedChange={() => {
|
||||
recordFeatureInteraction('ssh')
|
||||
toggleStatusBarItem('ssh')
|
||||
}}
|
||||
>
|
||||
<Server className="size-3.5" />
|
||||
SSH Status
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={statusBarItems.includes('resource-usage')}
|
||||
onCheckedChange={() => toggleStatusBarItem('resource-usage')}
|
||||
onCheckedChange={() => {
|
||||
recordFeatureInteraction('resource-manager')
|
||||
toggleStatusBarItem('resource-usage')
|
||||
}}
|
||||
>
|
||||
<Activity className="size-3.5" />
|
||||
Resource Manager
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={statusBarItems.includes('ports')}
|
||||
onCheckedChange={() => toggleStatusBarItem('ports')}
|
||||
onCheckedChange={() => {
|
||||
recordFeatureInteraction('ports')
|
||||
toggleStatusBarItem('ports')
|
||||
}}
|
||||
>
|
||||
<Plug className="size-3.5" />
|
||||
Ports
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const setTabGroupSplitRatioMock = vi.fn()
|
||||
const recordFeatureInteractionMock = vi.fn()
|
||||
const useAppStoreMock = vi.fn(
|
||||
(selector: (state: { setTabGroupSplitRatio: () => void }) => unknown) =>
|
||||
selector({ setTabGroupSplitRatio: setTabGroupSplitRatioMock })
|
||||
(
|
||||
selector: (state: {
|
||||
recordFeatureInteraction: typeof recordFeatureInteractionMock
|
||||
setTabGroupSplitRatio: typeof setTabGroupSplitRatioMock
|
||||
}) => unknown
|
||||
) =>
|
||||
selector({
|
||||
recordFeatureInteraction: recordFeatureInteractionMock,
|
||||
setTabGroupSplitRatio: setTabGroupSplitRatioMock
|
||||
})
|
||||
)
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: (selector: (state: { setTabGroupSplitRatio: () => void }) => unknown) =>
|
||||
useAppStoreMock(selector)
|
||||
useAppStore: (
|
||||
selector: (state: {
|
||||
recordFeatureInteraction: typeof recordFeatureInteractionMock
|
||||
setTabGroupSplitRatio: typeof setTabGroupSplitRatioMock
|
||||
}) => unknown
|
||||
) => useAppStoreMock(selector)
|
||||
}))
|
||||
|
||||
vi.mock('./TabGroupPanel', () => ({
|
||||
@@ -31,6 +44,12 @@ vi.mock('./useTabDragSplit', () => ({
|
||||
import TabGroupSplitLayout from './TabGroupSplitLayout'
|
||||
|
||||
describe('TabGroupSplitLayout', () => {
|
||||
beforeEach(() => {
|
||||
setTabGroupSplitRatioMock.mockClear()
|
||||
recordFeatureInteractionMock.mockClear()
|
||||
useAppStoreMock.mockClear()
|
||||
})
|
||||
|
||||
function getLeafPanelProps(isWorktreeActive: boolean) {
|
||||
const element = TabGroupSplitLayout({
|
||||
layout: { type: 'leaf', groupId: 'group-1' },
|
||||
@@ -124,4 +143,29 @@ describe('TabGroupSplitLayout', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('records pane resizing at the start of the gesture', () => {
|
||||
const element = TabGroupSplitLayout({
|
||||
layout: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
ratio: 0.5,
|
||||
first: { type: 'leaf', groupId: 'left-group' },
|
||||
second: { type: 'leaf', groupId: 'right-group' }
|
||||
},
|
||||
worktreeId: 'wt-1',
|
||||
focusedGroupId: 'right-group',
|
||||
isWorktreeActive: true
|
||||
})
|
||||
|
||||
const layoutWrapper = element.props.children[0]
|
||||
const splitBody = layoutWrapper.props.children[1]
|
||||
const splitNodeElement = splitBody.props.children
|
||||
const rootElement = splitNodeElement.type(splitNodeElement.props)
|
||||
const resizeHandle = rootElement.props.children[1]
|
||||
|
||||
resizeHandle.props.onResizeStart()
|
||||
|
||||
expect(recordFeatureInteractionMock).toHaveBeenCalledWith('terminal-panes')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,9 +11,11 @@ const MAX_RATIO = 0.85
|
||||
|
||||
function ResizeHandle({
|
||||
direction,
|
||||
onResizeStart,
|
||||
onRatioChange
|
||||
}: {
|
||||
direction: 'horizontal' | 'vertical'
|
||||
onResizeStart: () => void
|
||||
onRatioChange: (ratio: number) => void
|
||||
}): React.JSX.Element {
|
||||
const isHorizontal = direction === 'horizontal'
|
||||
@@ -27,6 +29,7 @@ function ResizeHandle({
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
onResizeStart()
|
||||
setDragging(true)
|
||||
handle.setPointerCapture(event.pointerId)
|
||||
|
||||
@@ -69,7 +72,7 @@ function ResizeHandle({
|
||||
handle.addEventListener('pointercancel', onPointerCancel)
|
||||
handle.addEventListener('lostpointercapture', onLostPointerCapture)
|
||||
},
|
||||
[isHorizontal, onRatioChange]
|
||||
[isHorizontal, onRatioChange, onResizeStart]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -112,6 +115,7 @@ function SplitNode({
|
||||
hoveredTabInsertion: HoveredTabInsertion | null
|
||||
}): React.JSX.Element {
|
||||
const setTabGroupSplitRatio = useAppStore((state) => state.setTabGroupSplitRatio)
|
||||
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
|
||||
|
||||
if (node.type === 'leaf') {
|
||||
return (
|
||||
@@ -164,6 +168,7 @@ function SplitNode({
|
||||
</div>
|
||||
<ResizeHandle
|
||||
direction={node.direction}
|
||||
onResizeStart={() => recordFeatureInteraction('terminal-panes')}
|
||||
onRatioChange={(nextRatio) => setTabGroupSplitRatio(worktreeId, nodePath, nextRatio)}
|
||||
/>
|
||||
<div className="flex min-w-0 min-h-0 overflow-hidden" style={{ flex: `${1 - ratio} 1 0%` }}>
|
||||
|
||||
@@ -7,6 +7,11 @@ import { useAppStore } from '../../store'
|
||||
|
||||
export default function WorkspaceSpacePage(): React.JSX.Element {
|
||||
const closeSpacePage = useAppStore((state) => state.closeSpacePage)
|
||||
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
|
||||
|
||||
useEffect(() => {
|
||||
recordFeatureInteraction('workspace-cleanup')
|
||||
}, [recordFeatureInteraction])
|
||||
|
||||
useEffect(() => {
|
||||
const hasVisibleOverlay = (): boolean =>
|
||||
|
||||
@@ -1226,7 +1226,9 @@ describe('useIpcEvents updater integration', () => {
|
||||
expect(setActiveTabType).toHaveBeenCalledWith('terminal')
|
||||
expect(setActiveTab).toHaveBeenCalledWith('tab-new')
|
||||
expect(revealWorktreeInSidebar).toHaveBeenCalledWith('wt-2')
|
||||
expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Runner')
|
||||
expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Runner', {
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(queueTabStartupCommand).toHaveBeenCalledWith('tab-new', { command: 'opencode' })
|
||||
|
||||
if (typeof requestTerminalCreateListenerRef.current !== 'function') {
|
||||
@@ -1250,7 +1252,10 @@ describe('useIpcEvents updater integration', () => {
|
||||
activate: false
|
||||
})
|
||||
|
||||
expect(createTab).toHaveBeenCalledWith('wt-2', 'group-left', undefined, { activate: false })
|
||||
expect(createTab).toHaveBeenCalledWith('wt-2', 'group-left', undefined, {
|
||||
activate: false,
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(setActiveView).not.toHaveBeenCalled()
|
||||
expect(setActiveWorktree).not.toHaveBeenCalled()
|
||||
expect(setActiveTabType).not.toHaveBeenCalled()
|
||||
@@ -1262,7 +1267,9 @@ describe('useIpcEvents updater integration', () => {
|
||||
detail: { worktreeId: 'wt-2' }
|
||||
})
|
||||
)
|
||||
expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Codex')
|
||||
expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Codex', {
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(queueTabStartupCommand).toHaveBeenCalledWith('tab-new', { command: 'codex' })
|
||||
expect(replyTerminalCreate).toHaveBeenCalledWith({
|
||||
requestId: 'req-renderer-backed',
|
||||
|
||||
@@ -940,7 +940,7 @@ export function useIpcEvents(): void {
|
||||
// the runtime's stored title would otherwise silently overwrite on
|
||||
// every focus.
|
||||
if (title && !reusedTab) {
|
||||
store.setTabCustomTitle(tab.id, title)
|
||||
store.setTabCustomTitle(tab.id, title, { recordInteraction: false })
|
||||
}
|
||||
if (leafId && ptyId) {
|
||||
if (splitFromLeafId) {
|
||||
@@ -1057,7 +1057,7 @@ export function useIpcEvents(): void {
|
||||
worktreeId,
|
||||
data.targetGroupId,
|
||||
undefined,
|
||||
shouldActivate ? undefined : { activate: false }
|
||||
shouldActivate ? undefined : { activate: false, recordInteraction: false }
|
||||
)
|
||||
if (data.afterTabId) {
|
||||
const createdUnifiedTab = useAppStore
|
||||
@@ -1081,7 +1081,9 @@ export function useIpcEvents(): void {
|
||||
0,
|
||||
createdUnifiedTab.id
|
||||
)
|
||||
useAppStore.getState().reorderUnifiedTabs(createdUnifiedTab.groupId, order)
|
||||
useAppStore.getState().reorderUnifiedTabs(createdUnifiedTab.groupId, order, {
|
||||
recordInteraction: false
|
||||
})
|
||||
}
|
||||
}
|
||||
if (shouldActivate) {
|
||||
@@ -1090,7 +1092,7 @@ export function useIpcEvents(): void {
|
||||
store.revealWorktreeInSidebar(worktreeId)
|
||||
}
|
||||
if (data.title) {
|
||||
store.setTabCustomTitle(tab.id, data.title)
|
||||
store.setTabCustomTitle(tab.id, data.title, { recordInteraction: false })
|
||||
}
|
||||
if (data.command) {
|
||||
store.queueTabStartupCommand(tab.id, { command: data.command })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable max-lines */
|
||||
/* eslint-disable max-lines -- Why: local/runtime launch tests share a mock harness. */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createCompatibleRuntimeStatusResponseIfNeeded } from '@/runtime/runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client'
|
||||
@@ -119,7 +119,10 @@ describe('launchAgentBackgroundSession', () => {
|
||||
title: 'Nightly audit'
|
||||
})
|
||||
|
||||
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { activate: false })
|
||||
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
|
||||
activate: false,
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: '/repo/worktree',
|
||||
@@ -144,7 +147,9 @@ describe('launchAgentBackgroundSession', () => {
|
||||
})
|
||||
)
|
||||
expect(mockSetTabLayout.mock.calls.at(-1)?.[1]).not.toHaveProperty('titlesByLeafId')
|
||||
expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Nightly audit')
|
||||
expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Nightly audit', {
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-1')
|
||||
expect(mockRegisterEagerPtyBuffer).toHaveBeenCalledWith('pty-1', expect.any(Function))
|
||||
expect(mockSubscribeToPtyData).toHaveBeenCalledWith('pty-1', expect.any(Function))
|
||||
@@ -243,7 +248,7 @@ describe('launchAgentBackgroundSession', () => {
|
||||
})
|
||||
).rejects.toThrow('spawn failed')
|
||||
|
||||
expect(mockCloseTab).toHaveBeenCalledWith('tab-1')
|
||||
expect(mockCloseTab).toHaveBeenCalledWith('tab-1', { recordInteraction: false })
|
||||
expect(mockUpdateTabPtyId).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -93,9 +93,12 @@ export async function launchAgentBackgroundSession(
|
||||
|
||||
// Why: automation runs should start without revealing the workspace.
|
||||
// Spawn the PTY immediately, then attach an inactive tab to the live session.
|
||||
const tab = store.createTab(worktreeId, undefined, undefined, { activate: false })
|
||||
const tab = store.createTab(worktreeId, undefined, undefined, {
|
||||
activate: false,
|
||||
recordInteraction: false
|
||||
})
|
||||
if (title) {
|
||||
store.setTabCustomTitle(tab.id, title)
|
||||
store.setTabCustomTitle(tab.id, title, { recordInteraction: false })
|
||||
}
|
||||
// Why: agent hook callbacks are keyed by pane, and background automation
|
||||
// tabs never mount a TerminalPane to inject this env for us.
|
||||
@@ -179,7 +182,7 @@ export async function launchAgentBackgroundSession(
|
||||
ptyId = result.id
|
||||
}
|
||||
} catch (error) {
|
||||
store.closeTab(tab.id)
|
||||
store.closeTab(tab.id, { recordInteraction: false })
|
||||
throw error
|
||||
}
|
||||
store.updateTabPtyId(tab.id, ptyId)
|
||||
|
||||
@@ -54,7 +54,9 @@ describe('ensureWorktreeHasInitialTerminal', () => {
|
||||
expect(createTab).toHaveBeenCalledTimes(2)
|
||||
expect(store.setActiveTab).toHaveBeenNthCalledWith(1, 'tab-1')
|
||||
expect(store.setActiveTab).toHaveBeenLastCalledWith('tab-1')
|
||||
expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup')
|
||||
expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', {
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-2', {
|
||||
command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
|
||||
env: {
|
||||
@@ -285,7 +287,9 @@ describe('ensureWorktreeHasInitialTerminal', () => {
|
||||
// and the helper re-activates the main tab so focus stays on tab-1.
|
||||
expect(store.setActiveTab).toHaveBeenNthCalledWith(1, 'tab-1')
|
||||
expect(store.setActiveTab).toHaveBeenLastCalledWith('tab-1')
|
||||
expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup')
|
||||
expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', {
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-2', {
|
||||
command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
|
||||
env: { ORCA_ROOT_PATH: '/tmp/repo' }
|
||||
|
||||
@@ -42,10 +42,14 @@ type WorktreeActivationStore = {
|
||||
worktreeId: string,
|
||||
targetGroupId?: string,
|
||||
shellOverride?: string,
|
||||
options?: { pendingActivationSpawn?: boolean }
|
||||
options?: { pendingActivationSpawn?: boolean; recordInteraction?: boolean }
|
||||
) => { id: string }
|
||||
setActiveTab: (tabId: string) => void
|
||||
setTabCustomTitle: (tabId: string, title: string | null) => void
|
||||
setTabCustomTitle: (
|
||||
tabId: string,
|
||||
title: string | null,
|
||||
opts?: { recordInteraction?: boolean }
|
||||
) => void
|
||||
reconcileWorktreeTabModel: (worktreeId: string) => { renderableTabCount: number }
|
||||
queueTabStartupCommand: (
|
||||
tabId: string,
|
||||
@@ -260,7 +264,9 @@ export function ensureWorktreeHasInitialTerminal(
|
||||
env: setup.envVars
|
||||
}
|
||||
if (mode === 'new-tab') {
|
||||
const setupTab = store.createTab(worktreeId)
|
||||
const setupTab = store.createTab(worktreeId, undefined, undefined, {
|
||||
recordInteraction: false
|
||||
})
|
||||
// Why: createTab auto-activates the new tab. Revert activation so the
|
||||
// user's focus stays on the primary terminal — per the design, the
|
||||
// Setup tab runs unattended in the background.
|
||||
@@ -268,7 +274,7 @@ export function ensureWorktreeHasInitialTerminal(
|
||||
// Why: customTitle wins over the auto-generated "Terminal N" label
|
||||
// everywhere the tab is rendered (tab bar, switcher, session snapshots),
|
||||
// so labeling via customTitle is the single authoritative source.
|
||||
store.setTabCustomTitle(setupTab.id, 'Setup')
|
||||
store.setTabCustomTitle(setupTab.id, 'Setup', { recordInteraction: false })
|
||||
store.queueTabStartupCommand(setupTab.id, setupCommand)
|
||||
} else {
|
||||
store.queueTabSetupSplit(terminalTab.id, {
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION
|
||||
} from '../../../shared/protocol-version'
|
||||
import {
|
||||
ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE,
|
||||
ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY
|
||||
} from '../../../shared/runtime-rpc-feature-interaction-source'
|
||||
|
||||
const runtimeCall = vi.fn()
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
@@ -55,6 +59,31 @@ describe('runtime RPC client routing', () => {
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks local UI-owned runtime calls so feature interaction tracking can ignore them', async () => {
|
||||
runtimeCall.mockResolvedValue({
|
||||
id: 'local',
|
||||
ok: true,
|
||||
result: { ok: true },
|
||||
_meta: { runtimeId: 'local-runtime' }
|
||||
})
|
||||
|
||||
await callRuntimeRpc(
|
||||
{ kind: 'local' },
|
||||
'browser.viewport',
|
||||
{ page: 'page-1' },
|
||||
{ suppressFeatureInteraction: true }
|
||||
)
|
||||
|
||||
expect(runtimeCall).toHaveBeenCalledWith({
|
||||
method: 'browser.viewport',
|
||||
params: {
|
||||
page: 'page-1',
|
||||
[ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY]: ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE
|
||||
}
|
||||
})
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes remote runtime calls through window.api.runtimeEnvironments.call', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'remote',
|
||||
@@ -106,6 +135,43 @@ describe('runtime RPC client routing', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('marks remote UI-owned runtime calls so feature interaction tracking can ignore them', async () => {
|
||||
runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => {
|
||||
const result =
|
||||
method === 'status.get'
|
||||
? {
|
||||
runtimeId: 'remote-runtime',
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
|
||||
}
|
||||
: { ok: true }
|
||||
return Promise.resolve({
|
||||
id: method,
|
||||
ok: true,
|
||||
result,
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
})
|
||||
|
||||
await callRuntimeRpc(
|
||||
{ kind: 'environment', environmentId: 'env-1' },
|
||||
'browser.viewport',
|
||||
{ page: 'page-1' },
|
||||
{ suppressFeatureInteraction: true }
|
||||
)
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenLastCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'browser.viewport',
|
||||
params: {
|
||||
page: 'page-1',
|
||||
[ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY]: ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE
|
||||
},
|
||||
timeoutMs: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('caches successful remote compatibility checks per environment', async () => {
|
||||
runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => {
|
||||
const result =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1252,7 +1252,14 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
|
||||
setBrowserTabUrl: (pageId, url) => get().setBrowserPageUrl(pageId, url),
|
||||
|
||||
setBrowserPageUrl: (pageId, url) =>
|
||||
setBrowserPageUrl: (pageId, url) => {
|
||||
const nextUrl = normalizeUrl(url)
|
||||
if (nextUrl !== 'about:blank' && nextUrl !== ORCA_BROWSER_BLANK_URL) {
|
||||
const currentPage = findPage(get().browserPagesByWorkspace, pageId)
|
||||
if (currentPage) {
|
||||
get().recordFeatureInteraction?.('browser')
|
||||
}
|
||||
}
|
||||
set((s) => {
|
||||
const page = findPage(s.browserPagesByWorkspace, pageId)
|
||||
if (!page) {
|
||||
@@ -1262,7 +1269,6 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
if (!workspace) {
|
||||
return s
|
||||
}
|
||||
const nextUrl = normalizeUrl(url)
|
||||
// Why: annotations point at DOM coordinates from one loaded document.
|
||||
// A real URL change invalidates those markers and copied context.
|
||||
const shouldClearAnnotations = normalizeUrl(page.url) !== nextUrl
|
||||
@@ -1299,7 +1305,8 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
? { browserAnnotationsByPageId: nextBrowserAnnotationsByPageId }
|
||||
: {})
|
||||
}
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
setRemoteBrowserPageHandle: (pageId, handle) => {
|
||||
set((s) => ({
|
||||
@@ -1568,7 +1575,8 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
if (!exists) {
|
||||
state.createUnifiedTab(worktreeId, 'browser', {
|
||||
entityId: bt.id,
|
||||
label: bt.title
|
||||
label: bt.title,
|
||||
recordInteraction: false
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1717,6 +1725,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
profileId
|
||||
})) as BrowserCookieImportResult
|
||||
if (result.ok) {
|
||||
get().recordFeatureInteraction?.('cookie-import')
|
||||
set({
|
||||
browserSessionImportState: {
|
||||
profileId,
|
||||
@@ -1860,6 +1869,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
browserProfile
|
||||
})) as BrowserCookieImportResult
|
||||
if (result.ok) {
|
||||
get().recordFeatureInteraction?.('cookie-import')
|
||||
set({
|
||||
browserSessionImportState: {
|
||||
profileId,
|
||||
@@ -1916,6 +1926,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
try {
|
||||
const ok = await window.api.browser.sessionClearDefaultCookies()
|
||||
if (ok) {
|
||||
get().recordFeatureInteraction?.('cookie-import')
|
||||
await get().fetchBrowserSessionProfiles()
|
||||
}
|
||||
return ok
|
||||
|
||||
@@ -29,6 +29,10 @@ const runtimeEnvironmentCall = vi.fn().mockResolvedValue({
|
||||
})
|
||||
const runtimeEnvironmentTransportCall = vi.fn()
|
||||
const mockApi = {
|
||||
ui: {
|
||||
recordFeatureInteraction: vi.fn().mockResolvedValue({ featureInteractions: {} }),
|
||||
set: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
worktrees: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
@@ -434,6 +438,7 @@ describe('markDiffCommentsSent', () => {
|
||||
|
||||
it('marks selected notes as sent and persists once', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({ persistedUIReady: true })
|
||||
seed(store, [
|
||||
makeComment({ id: 'c1', filePath: 'src/foo.ts' }),
|
||||
makeComment({ id: 'c2', filePath: 'src/bar.ts' })
|
||||
@@ -453,6 +458,9 @@ describe('markDiffCommentsSent', () => {
|
||||
diffComments: [expect.objectContaining({ id: 'c1', sentAt: 3000 }), expect.any(Object)]
|
||||
}
|
||||
})
|
||||
expect(store.getState().featureInteractions['review-notes']).toEqual(
|
||||
expect.objectContaining({ interactionCount: 1 })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns success without persisting when no selected notes match', async () => {
|
||||
|
||||
@@ -280,6 +280,7 @@ export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffComment
|
||||
// latest store snapshot at dequeue time, so it will reflect any newer
|
||||
// mutation that landed after this one was enqueued.
|
||||
await enqueuePersist(input.worktreeId, get)
|
||||
get().recordFeatureInteraction?.('review-notes')
|
||||
return comment
|
||||
} catch (err) {
|
||||
console.error('Failed to persist diff comments:', err)
|
||||
@@ -368,6 +369,7 @@ export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffComment
|
||||
}
|
||||
try {
|
||||
await enqueuePersist(worktreeId, get)
|
||||
get().recordFeatureInteraction?.('review-notes')
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to persist diff comments:', err)
|
||||
@@ -397,6 +399,7 @@ export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffComment
|
||||
}
|
||||
try {
|
||||
await enqueuePersist(worktreeId, get)
|
||||
get().recordFeatureInteraction?.('review-notes')
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to persist diff comments:', err)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { create } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type { Tab, TabGroup } from '../../../../shared/types'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultUIState } from '../../../../shared/constants'
|
||||
|
||||
// Mock sonner (imported by repos.ts)
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
@@ -44,6 +44,9 @@ const mockApi = {
|
||||
get: vi.fn().mockResolvedValue({}),
|
||||
set: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
ui: {
|
||||
set: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
cache: {
|
||||
getGitHub: vi.fn().mockResolvedValue(null),
|
||||
setGitHub: vi.fn().mockResolvedValue(undefined)
|
||||
@@ -248,6 +251,24 @@ describe('TabsSlice', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal tab creation tracking', () => {
|
||||
it('records normal terminal tab creation without recording activation fallback tabs', () => {
|
||||
const setMock = vi.mocked(window.api.ui.set)
|
||||
store.getState().hydratePersistedUI(getDefaultUIState())
|
||||
setMock.mockClear()
|
||||
|
||||
store.getState().createTab(WT)
|
||||
store.getState().createTab(WT, undefined, undefined, { pendingActivationSpawn: true })
|
||||
|
||||
expect(setMock).toHaveBeenCalledTimes(1)
|
||||
expect(setMock).toHaveBeenCalledWith({
|
||||
featureInteractions: {
|
||||
'terminal-tabs': expect.objectContaining({ interactionCount: 1 })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── closeUnifiedTab ────────────────────────────────────────────────
|
||||
|
||||
describe('closeUnifiedTab', () => {
|
||||
@@ -873,6 +894,7 @@ describe('TabsSlice', () => {
|
||||
})
|
||||
|
||||
it('merges a group into its sibling', () => {
|
||||
const setMock = vi.mocked(window.api.ui.set)
|
||||
const t1 = store.getState().createUnifiedTab(WT, 'editor', {
|
||||
id: 'file-a.ts',
|
||||
label: 'file-a.ts'
|
||||
@@ -885,6 +907,8 @@ describe('TabsSlice', () => {
|
||||
label: 'file-b.ts',
|
||||
targetGroupId: targetGroupId!
|
||||
})
|
||||
store.getState().hydratePersistedUI(getDefaultUIState())
|
||||
setMock.mockClear()
|
||||
|
||||
const mergedInto = store.getState().mergeGroupIntoSibling(WT, targetGroupId!)
|
||||
|
||||
@@ -893,6 +917,12 @@ describe('TabsSlice', () => {
|
||||
expect(state.groupsByWorktree[WT]).toHaveLength(1)
|
||||
expect(state.groupsByWorktree[WT][0].tabOrder).toEqual([t1.id, 'file-b.ts'])
|
||||
expect(state.layoutByWorktree[WT]).toEqual({ type: 'leaf', groupId: sourceGroupId })
|
||||
expect(setMock).toHaveBeenCalledTimes(1)
|
||||
expect(setMock).toHaveBeenCalledWith({
|
||||
featureInteractions: {
|
||||
'terminal-panes': expect.objectContaining({ interactionCount: 1 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a unified tab into another group and collapses an emptied source group', () => {
|
||||
|
||||
@@ -47,6 +47,7 @@ export type TabsSlice = {
|
||||
> & {
|
||||
targetGroupId: string
|
||||
activate: boolean
|
||||
recordInteraction: boolean
|
||||
}
|
||||
>
|
||||
) => Tab
|
||||
@@ -60,11 +61,20 @@ export type TabsSlice = {
|
||||
) => Tab | null
|
||||
activateTab: (tabId: string) => void
|
||||
closeUnifiedTab: (
|
||||
tabId: string
|
||||
tabId: string,
|
||||
opts?: { recordInteraction?: boolean }
|
||||
) => { closedTabId: string; wasLastTab: boolean; worktreeId: string } | null
|
||||
reorderUnifiedTabs: (groupId: string, tabIds: string[]) => void
|
||||
reorderUnifiedTabs: (
|
||||
groupId: string,
|
||||
tabIds: string[],
|
||||
opts?: { recordInteraction?: boolean }
|
||||
) => void
|
||||
setTabLabel: (tabId: string, label: string) => void
|
||||
setTabCustomLabel: (tabId: string, label: string | null) => void
|
||||
setTabCustomLabel: (
|
||||
tabId: string,
|
||||
label: string | null,
|
||||
opts?: { recordInteraction?: boolean }
|
||||
) => void
|
||||
setUnifiedTabColor: (tabId: string, color: string | null) => void
|
||||
pinTab: (tabId: string) => void
|
||||
unpinTab: (tabId: string) => void
|
||||
@@ -81,7 +91,7 @@ export type TabsSlice = {
|
||||
moveUnifiedTabToGroup: (
|
||||
tabId: string,
|
||||
targetGroupId: string,
|
||||
opts?: { index?: number; activate?: boolean }
|
||||
opts?: { index?: number; activate?: boolean; recordInteraction?: boolean }
|
||||
) => boolean
|
||||
dropUnifiedTab: (
|
||||
tabId: string,
|
||||
@@ -466,6 +476,9 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
}
|
||||
}
|
||||
})
|
||||
if (init?.recordInteraction !== false) {
|
||||
get().recordFeatureInteraction?.('terminal-tabs')
|
||||
}
|
||||
return created
|
||||
},
|
||||
|
||||
@@ -554,7 +567,7 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
})
|
||||
},
|
||||
|
||||
closeUnifiedTab: (tabId) => {
|
||||
closeUnifiedTab: (tabId, opts) => {
|
||||
const state = get()
|
||||
const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId)
|
||||
if (!found) {
|
||||
@@ -695,10 +708,14 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
}
|
||||
})
|
||||
|
||||
if (opts?.recordInteraction !== false) {
|
||||
get().recordFeatureInteraction?.('terminal-tabs')
|
||||
}
|
||||
return { closedTabId: tabId, wasLastTab, worktreeId }
|
||||
},
|
||||
|
||||
reorderUnifiedTabs: (groupId, tabIds) => {
|
||||
reorderUnifiedTabs: (groupId, tabIds, opts) => {
|
||||
let reordered = false
|
||||
set((state) => {
|
||||
for (const [worktreeId, groups] of Object.entries(state.groupsByWorktree)) {
|
||||
const group = groups.find((candidate) => candidate.id === groupId)
|
||||
@@ -709,6 +726,7 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
// each tab. Sanitizing here restores the invariant at the store
|
||||
// boundary so later group operations do not branch on duplicate ids.
|
||||
const nextTabOrder = dedupeTabOrder(tabIds)
|
||||
reordered = true
|
||||
const orderMap = new Map(nextTabOrder.map((id, index) => [id, index]))
|
||||
return {
|
||||
groupsByWorktree: {
|
||||
@@ -726,25 +744,49 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
}
|
||||
return {}
|
||||
})
|
||||
if (reordered && opts?.recordInteraction !== false) {
|
||||
get().recordFeatureInteraction?.('terminal-tabs')
|
||||
}
|
||||
},
|
||||
|
||||
setTabLabel: (tabId, label) =>
|
||||
set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? {}),
|
||||
setTabLabel: (tabId, label) => {
|
||||
set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? {})
|
||||
},
|
||||
|
||||
setTabCustomLabel: (tabId, label) =>
|
||||
set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {}),
|
||||
setTabCustomLabel: (tabId, label, opts) => {
|
||||
const exists = get().getTab(tabId) !== null
|
||||
set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {})
|
||||
if (exists && opts?.recordInteraction !== false) {
|
||||
get().recordFeatureInteraction?.('terminal-tabs')
|
||||
}
|
||||
},
|
||||
|
||||
setUnifiedTabColor: (tabId, color) =>
|
||||
set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { color }) ?? {}),
|
||||
setUnifiedTabColor: (tabId, color) => {
|
||||
const exists = get().getTab(tabId) !== null
|
||||
set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { color }) ?? {})
|
||||
if (exists) {
|
||||
get().recordFeatureInteraction?.('terminal-tabs')
|
||||
}
|
||||
},
|
||||
|
||||
pinTab: (tabId) =>
|
||||
pinTab: (tabId) => {
|
||||
const exists = get().getTab(tabId) !== null
|
||||
set(
|
||||
(state) =>
|
||||
patchTab(state.unifiedTabsByWorktree, tabId, { isPinned: true, isPreview: false }) ?? {}
|
||||
),
|
||||
)
|
||||
if (exists) {
|
||||
get().recordFeatureInteraction?.('terminal-tabs')
|
||||
}
|
||||
},
|
||||
|
||||
unpinTab: (tabId) =>
|
||||
set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { isPinned: false }) ?? {}),
|
||||
unpinTab: (tabId) => {
|
||||
const exists = get().getTab(tabId) !== null
|
||||
set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { isPinned: false }) ?? {})
|
||||
if (exists) {
|
||||
get().recordFeatureInteraction?.('terminal-tabs')
|
||||
}
|
||||
},
|
||||
|
||||
closeOtherTabs: (tabId) => {
|
||||
const state = get()
|
||||
@@ -961,6 +1003,7 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
activeGroupIdByWorktree: { ...state.activeGroupIdByWorktree, [worktreeId]: newGroupId }
|
||||
}
|
||||
})
|
||||
get().recordFeatureInteraction?.('terminal-panes')
|
||||
return newGroupId
|
||||
},
|
||||
|
||||
@@ -1044,6 +1087,9 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
activeGroupIdByWorktree: nextActiveGroupIdByWorktree
|
||||
}
|
||||
})
|
||||
if (moved && opts?.recordInteraction !== false) {
|
||||
get().recordFeatureInteraction?.('tab-splits')
|
||||
}
|
||||
return moved
|
||||
},
|
||||
|
||||
@@ -1211,6 +1257,10 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
: {})
|
||||
}
|
||||
})
|
||||
if (moved) {
|
||||
get().recordFeatureInteraction?.('terminal-tabs')
|
||||
get().recordFeatureInteraction?.('tab-splits')
|
||||
}
|
||||
return moved
|
||||
},
|
||||
|
||||
@@ -1253,13 +1303,14 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
if (!item) {
|
||||
continue
|
||||
}
|
||||
get().moveUnifiedTabToGroup(item.id, targetGroupId)
|
||||
get().moveUnifiedTabToGroup(item.id, targetGroupId, { recordInteraction: false })
|
||||
}
|
||||
get().closeEmptyGroup(worktreeId, groupId)
|
||||
get().recordFeatureInteraction?.('terminal-panes')
|
||||
return targetGroupId
|
||||
},
|
||||
|
||||
setTabGroupSplitRatio: (worktreeId, nodePath, ratio) =>
|
||||
setTabGroupSplitRatio: (worktreeId, nodePath, ratio) => {
|
||||
set((state) => {
|
||||
const currentLayout = state.layoutByWorktree[worktreeId]
|
||||
if (!currentLayout) {
|
||||
@@ -1278,7 +1329,8 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
)
|
||||
}
|
||||
}
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
reconcileWorktreeTabModel: (worktreeId) => {
|
||||
const state = get()
|
||||
|
||||
@@ -263,6 +263,7 @@ export type TerminalSlice = {
|
||||
pendingActivationSpawn?: boolean
|
||||
initialPtyId?: string
|
||||
activate?: boolean
|
||||
recordInteraction?: boolean
|
||||
/** Pre-allocated tab id (e.g. minted by main for CLI/runtime-spawned
|
||||
* terminals whose PTY env already carries a pane key). Falls back to
|
||||
* minting a fresh id when omitted or when the supplied id collides
|
||||
@@ -272,7 +273,7 @@ export type TerminalSlice = {
|
||||
}
|
||||
) => TerminalTab
|
||||
openNewTerminalTabInActiveWorkspace: (groupId: string) => Promise<void>
|
||||
closeTab: (tabId: string) => void
|
||||
closeTab: (tabId: string, opts?: { recordInteraction?: boolean }) => void
|
||||
reorderTabs: (worktreeId: string, tabIds: string[]) => void
|
||||
setTabBarOrder: (worktreeId: string, order: string[]) => void
|
||||
setActiveTab: (tabId: string) => void
|
||||
@@ -293,7 +294,11 @@ export type TerminalSlice = {
|
||||
* surface that raised it. */
|
||||
clearTerminalTabUnread: (tabId: string) => void
|
||||
clearTerminalPaneUnread: (paneKey: string) => void
|
||||
setTabCustomTitle: (tabId: string, title: string | null) => void
|
||||
setTabCustomTitle: (
|
||||
tabId: string,
|
||||
title: string | null,
|
||||
opts?: { recordInteraction?: boolean }
|
||||
) => void
|
||||
setTabColor: (tabId: string, color: string | null) => void
|
||||
updateTabPtyId: (tabId: string, ptyId: string) => void
|
||||
clearTabPtyId: (tabId: string, ptyId?: string) => void
|
||||
@@ -629,6 +634,11 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
}
|
||||
}
|
||||
})
|
||||
const shouldRecordInteraction =
|
||||
options?.recordInteraction ?? (!options?.pendingActivationSpawn && !options?.initialPtyId)
|
||||
if (shouldRecordInteraction) {
|
||||
get().recordFeatureInteraction?.('terminal-tabs')
|
||||
}
|
||||
return tab
|
||||
},
|
||||
|
||||
@@ -678,7 +688,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
focusTerminalTabSurface(terminal.id)
|
||||
},
|
||||
|
||||
closeTab: (tabId) => {
|
||||
closeTab: (tabId, opts) => {
|
||||
set((s) => {
|
||||
const next = { ...s.tabsByWorktree }
|
||||
let closingPtyId: string | null = null
|
||||
@@ -814,7 +824,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
(entry) => entry.contentType === 'terminal' && entry.entityId === tabId
|
||||
)
|
||||
if (workspaceItem) {
|
||||
get().closeUnifiedTab(workspaceItem.id)
|
||||
get().closeUnifiedTab(workspaceItem.id, opts)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1124,7 +1134,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
})
|
||||
},
|
||||
|
||||
setTabCustomTitle: (tabId, title) => {
|
||||
setTabCustomTitle: (tabId, title, opts) => {
|
||||
set((s) => {
|
||||
const next = { ...s.tabsByWorktree }
|
||||
for (const wId of Object.keys(next)) {
|
||||
@@ -1137,7 +1147,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
.flat()
|
||||
.find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId)
|
||||
if (item) {
|
||||
get().setTabCustomLabel(item.id, title)
|
||||
get().setTabCustomLabel(item.id, title, opts)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import { createUISlice } from './ui'
|
||||
import { createWorktreeNavHistorySlice } from './worktree-nav-history'
|
||||
import type { AppState } from '../types'
|
||||
import type { FeatureInteractionState } from '../../../../shared/feature-interactions'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
@@ -802,6 +803,162 @@ describe('createUISlice feature tips', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('createUISlice feature interactions', () => {
|
||||
it('normalizes persisted feature interaction records during hydration', () => {
|
||||
const store = createUIStore()
|
||||
|
||||
store.getState().hydratePersistedUI(
|
||||
makePersistedUI({
|
||||
featureInteractions: {
|
||||
tasks: { firstInteractedAt: 100 },
|
||||
automations: { firstInteractedAt: 150, interactionCount: 4 },
|
||||
browser: { firstInteractedAt: Number.NaN },
|
||||
unknown: { firstInteractedAt: 200 }
|
||||
} as unknown as FeatureInteractionState
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.getState().featureInteractions).toEqual({
|
||||
tasks: { firstInteractedAt: 100, interactionCount: 1 },
|
||||
automations: { firstInteractedAt: 150, interactionCount: 4 }
|
||||
})
|
||||
})
|
||||
|
||||
it('records feature interaction counts and persists each interaction', () => {
|
||||
const setMock = vi.fn(() => Promise.resolve())
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
ui: {
|
||||
set: setMock
|
||||
}
|
||||
}
|
||||
})
|
||||
const now = 1_700_000_000_000
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(now)
|
||||
|
||||
try {
|
||||
const store = createUIStore()
|
||||
store.getState().hydratePersistedUI(makePersistedUI())
|
||||
setMock.mockClear()
|
||||
|
||||
store.getState().recordFeatureInteraction('tasks')
|
||||
store.getState().recordFeatureInteraction('tasks')
|
||||
|
||||
const expected: FeatureInteractionState = {
|
||||
tasks: { firstInteractedAt: now, interactionCount: 2 }
|
||||
}
|
||||
expect(store.getState().featureInteractions).toEqual(expected)
|
||||
expect(setMock).toHaveBeenCalledTimes(2)
|
||||
expect(setMock).toHaveBeenCalledWith({ featureInteractions: expected })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the main-owned feature interaction increment API when available', async () => {
|
||||
const recordFeatureInteractionMock = vi.fn(() =>
|
||||
Promise.resolve(
|
||||
makePersistedUI({
|
||||
featureInteractions: {
|
||||
tasks: { firstInteractedAt: 100, interactionCount: 3 }
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
const setMock = vi.fn(() => Promise.resolve())
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
ui: {
|
||||
recordFeatureInteraction: recordFeatureInteractionMock,
|
||||
set: setMock
|
||||
}
|
||||
}
|
||||
})
|
||||
const store = createUIStore()
|
||||
store.getState().hydratePersistedUI(
|
||||
makePersistedUI({
|
||||
featureInteractions: {
|
||||
tasks: { firstInteractedAt: 100, interactionCount: 2 }
|
||||
}
|
||||
})
|
||||
)
|
||||
setMock.mockClear()
|
||||
|
||||
store.getState().recordFeatureInteraction('tasks')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(recordFeatureInteractionMock).toHaveBeenCalledWith('tasks')
|
||||
expect(setMock).not.toHaveBeenCalled()
|
||||
expect(store.getState().featureInteractions.tasks).toEqual({
|
||||
firstInteractedAt: 100,
|
||||
interactionCount: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps newer optimistic interaction counts when persistence responses resolve out of order', async () => {
|
||||
const pending: ((ui: PersistedUIState) => void)[] = []
|
||||
const recordFeatureInteractionMock = vi.fn(
|
||||
() =>
|
||||
new Promise<PersistedUIState>((resolve) => {
|
||||
pending.push(resolve)
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
ui: {
|
||||
recordFeatureInteraction: recordFeatureInteractionMock,
|
||||
set: vi.fn(() => Promise.resolve())
|
||||
}
|
||||
}
|
||||
})
|
||||
const store = createUIStore()
|
||||
store.getState().hydratePersistedUI(makePersistedUI())
|
||||
|
||||
store.getState().recordFeatureInteraction('tasks')
|
||||
store.getState().recordFeatureInteraction('tasks')
|
||||
|
||||
pending[1](
|
||||
makePersistedUI({
|
||||
featureInteractions: {
|
||||
tasks: { firstInteractedAt: 100, interactionCount: 2 }
|
||||
}
|
||||
})
|
||||
)
|
||||
await Promise.resolve()
|
||||
pending[0](
|
||||
makePersistedUI({
|
||||
featureInteractions: {
|
||||
tasks: { firstInteractedAt: 100, interactionCount: 1 }
|
||||
}
|
||||
})
|
||||
)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(store.getState().featureInteractions.tasks).toEqual({
|
||||
firstInteractedAt: 100,
|
||||
interactionCount: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('does not record interactions before persisted UI has hydrated', () => {
|
||||
const setMock = vi.fn(() => Promise.resolve())
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
ui: {
|
||||
set: setMock
|
||||
}
|
||||
}
|
||||
})
|
||||
const store = createUIStore()
|
||||
|
||||
store.getState().recordFeatureInteraction('tasks')
|
||||
|
||||
expect(store.getState().featureInteractions).toEqual({})
|
||||
expect(setMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createUISlice space navigation', () => {
|
||||
it('returns to the tasks page after opening Space from an in-progress draft', () => {
|
||||
const store = createUIStore()
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
type WorkspaceCleanupDismissal
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
import { normalizeFeatureTipIds, type FeatureTipId } from '../../../../shared/feature-tips'
|
||||
import {
|
||||
normalizeFeatureInteractions,
|
||||
type FeatureInteractionId,
|
||||
type FeatureInteractionState
|
||||
} from '../../../../shared/feature-interactions'
|
||||
import { PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items'
|
||||
import {
|
||||
normalizeVisibleTaskProviders,
|
||||
@@ -61,6 +66,32 @@ export type PendingSidebarWorktreeReveal = {
|
||||
highlight?: boolean
|
||||
}
|
||||
|
||||
function mergeFeatureInteractionState(
|
||||
current: FeatureInteractionState,
|
||||
incoming: PersistedUIState['featureInteractions']
|
||||
): FeatureInteractionState {
|
||||
const currentNormalized = normalizeFeatureInteractions(current)
|
||||
const incomingNormalized = normalizeFeatureInteractions(incoming)
|
||||
const merged: FeatureInteractionState = { ...currentNormalized }
|
||||
for (const [id, incomingRecord] of Object.entries(incomingNormalized)) {
|
||||
const featureId = id as FeatureInteractionId
|
||||
const currentRecord = currentNormalized[featureId]
|
||||
merged[featureId] = currentRecord
|
||||
? {
|
||||
firstInteractedAt: Math.min(
|
||||
currentRecord.firstInteractedAt,
|
||||
incomingRecord.firstInteractedAt
|
||||
),
|
||||
interactionCount: Math.max(
|
||||
currentRecord.interactionCount,
|
||||
incomingRecord.interactionCount
|
||||
)
|
||||
}
|
||||
: incomingRecord
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function clampPetSize(size: number): number {
|
||||
if (!Number.isFinite(size)) {
|
||||
return PET_SIZE_DEFAULT
|
||||
@@ -467,6 +498,8 @@ export type UISlice = {
|
||||
closeModal: () => void
|
||||
featureTipsSeenIds: FeatureTipId[]
|
||||
markFeatureTipsSeen: (ids: FeatureTipId[]) => void
|
||||
featureInteractions: FeatureInteractionState
|
||||
recordFeatureInteraction: (id: FeatureInteractionId) => void
|
||||
trustedOrcaHooks: PersistedTrustedOrcaHooks
|
||||
markOrcaHookScriptConfirmed: (
|
||||
repoId: string,
|
||||
@@ -639,6 +672,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
||||
githubTaskDrawerWorkItem: null,
|
||||
newWorkspaceDraft: null,
|
||||
openTaskPage: (data = {}) => {
|
||||
get().recordFeatureInteraction?.('tasks')
|
||||
// Why: record a Tasks visit in the shared back/forward history so the
|
||||
// titlebar Back/Forward buttons can return to Tasks. All task-source
|
||||
// variants (github/linear presets) collapse to a single 'tasks' entry;
|
||||
@@ -799,6 +833,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
||||
selectedAutomationId: null,
|
||||
setSelectedAutomationId: (id) => set({ selectedAutomationId: id }),
|
||||
openAutomationsPage: () => {
|
||||
get().recordFeatureInteraction?.('automations')
|
||||
get().recordViewVisit('automations')
|
||||
set((state) => ({
|
||||
activeView: 'automations',
|
||||
@@ -879,6 +914,9 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
||||
activeModal: 'none',
|
||||
modalData: {},
|
||||
openModal: (modal, data = {}) => {
|
||||
if (modal === 'new-workspace-composer' || modal === 'add-repo' || modal === 'create-worktree') {
|
||||
get().recordFeatureInteraction?.('workspace-creation')
|
||||
}
|
||||
set({
|
||||
activeModal: modal,
|
||||
modalData: data
|
||||
@@ -906,6 +944,36 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
||||
window.api.ui.set({ featureTipsSeenIds: next }).catch(console.error)
|
||||
return { featureTipsSeenIds: next }
|
||||
}),
|
||||
featureInteractions: {},
|
||||
recordFeatureInteraction: (id) =>
|
||||
set((s) => {
|
||||
if (!s.persistedUIReady) {
|
||||
return s
|
||||
}
|
||||
const existing = s.featureInteractions[id]
|
||||
const next: FeatureInteractionState = {
|
||||
...s.featureInteractions,
|
||||
[id]: {
|
||||
firstInteractedAt: existing?.firstInteractedAt ?? Date.now(),
|
||||
interactionCount: (existing?.interactionCount ?? 0) + 1
|
||||
}
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
const recordInteraction = window.api.ui.recordFeatureInteraction
|
||||
const persist = recordInteraction
|
||||
? recordInteraction(id).then((ui) => {
|
||||
set((current) => ({
|
||||
featureInteractions: mergeFeatureInteractionState(
|
||||
current.featureInteractions,
|
||||
ui.featureInteractions
|
||||
)
|
||||
}))
|
||||
})
|
||||
: window.api.ui.set({ featureInteractions: next })
|
||||
persist.catch(console.error)
|
||||
}
|
||||
return { featureInteractions: next }
|
||||
}),
|
||||
trustedOrcaHooks: {},
|
||||
markOrcaHookScriptConfirmed: (repoId, kind, contentHash) =>
|
||||
set((s) => {
|
||||
@@ -1236,6 +1304,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
||||
browserKagiSessionLink: normalizeKagiSessionLink(ui.browserKagiSessionLink ?? ''),
|
||||
taskResumeState: sanitizeTaskResumeState(ui.taskResumeState),
|
||||
featureTipsSeenIds: normalizeFeatureTipIds(ui.featureTipsSeenIds),
|
||||
featureInteractions: normalizeFeatureInteractions(ui.featureInteractions),
|
||||
trustedOrcaHooks: filterTrustedOrcaHooksToValidRepos(
|
||||
ui.trustedOrcaHooks ?? {},
|
||||
validRepoIds
|
||||
|
||||
@@ -65,7 +65,8 @@ function isWorkspaceSpaceScanCancelled(error: unknown): boolean {
|
||||
}
|
||||
|
||||
export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], WorkspaceSpaceSlice> = (
|
||||
set
|
||||
set,
|
||||
get
|
||||
) => ({
|
||||
workspaceSpaceAnalysis: null,
|
||||
workspaceSpaceScanProgress: null,
|
||||
@@ -86,6 +87,9 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
|
||||
}),
|
||||
cancelWorkspaceSpaceScan: async () => {
|
||||
const cancelled = await window.api.workspaceSpace.cancel()
|
||||
if (cancelled) {
|
||||
get().recordFeatureInteraction?.('workspace-cleanup')
|
||||
}
|
||||
if (cancelled) {
|
||||
set((state) =>
|
||||
state.workspaceSpaceScanProgress
|
||||
@@ -105,6 +109,7 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
|
||||
if (inFlightScan) {
|
||||
return inFlightScan
|
||||
}
|
||||
get().recordFeatureInteraction?.('workspace-cleanup')
|
||||
set({
|
||||
workspaceSpaceScanning: true,
|
||||
workspaceSpaceScanProgress: null,
|
||||
@@ -141,7 +146,10 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
|
||||
})
|
||||
return inFlightScan
|
||||
},
|
||||
removeWorkspaceSpaceWorktrees: (worktreeIds) =>
|
||||
removeWorkspaceSpaceWorktrees: (worktreeIds) => {
|
||||
if (worktreeIds.length > 0) {
|
||||
get().recordFeatureInteraction?.('workspace-cleanup')
|
||||
}
|
||||
set((state) =>
|
||||
state.workspaceSpaceAnalysis
|
||||
? {
|
||||
@@ -152,4 +160,5 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
|
||||
}
|
||||
: state
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
global setup across namespaces so browser API installation stays realistic. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PreloadApi } from '../../../preload/api-types'
|
||||
import type { FeatureInteractionState } from '../../../shared/feature-interactions'
|
||||
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
@@ -177,6 +178,7 @@ describe('web UI preload API', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.doUnmock('./web-runtime-client')
|
||||
})
|
||||
|
||||
it('migrates missing right sidebar visibility from the effective web legacy default', async () => {
|
||||
@@ -195,6 +197,125 @@ describe('web UI preload API', () => {
|
||||
|
||||
expect(ui.rightSidebarOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps newer feature interaction counts when runtime responses resolve out of order', async () => {
|
||||
const pending: ((response: RuntimeRpcResponse<unknown>) => void)[] = []
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
call(method: string): Promise<RuntimeRpcResponse<unknown>> {
|
||||
return new Promise((resolve) => {
|
||||
pending.push((response) =>
|
||||
resolve({
|
||||
...response,
|
||||
id: method,
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage)
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
const first = globals.window.api.ui.recordFeatureInteraction('tasks')
|
||||
const second = globals.window.api.ui.recordFeatureInteraction('tasks')
|
||||
|
||||
pending[1]({
|
||||
id: 'second',
|
||||
ok: true,
|
||||
result: {
|
||||
ui: {
|
||||
featureInteractions: {
|
||||
tasks: { firstInteractedAt: 100, interactionCount: 2 }
|
||||
}
|
||||
}
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
await second
|
||||
pending[0]({
|
||||
id: 'first',
|
||||
ok: true,
|
||||
result: {
|
||||
ui: {
|
||||
featureInteractions: {
|
||||
tasks: { firstInteractedAt: 100, interactionCount: 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
await first
|
||||
|
||||
const stored = JSON.parse(globals.storage.getItem('orca.web.ui.v1') ?? '{}') as {
|
||||
featureInteractions?: FeatureInteractionState
|
||||
}
|
||||
expect(stored.featureInteractions?.tasks).toEqual({
|
||||
firstInteractedAt: 100,
|
||||
interactionCount: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps newer local feature interactions when ui.get returns stale host state', async () => {
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
call(method: string): Promise<RuntimeRpcResponse<unknown>> {
|
||||
return Promise.resolve({
|
||||
id: method,
|
||||
ok: true,
|
||||
result: {
|
||||
ui: {
|
||||
featureInteractions: {
|
||||
tasks: { firstInteractedAt: 100, interactionCount: 1 },
|
||||
ports: { firstInteractedAt: 300, interactionCount: 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage)
|
||||
globals.storage.setItem(
|
||||
'orca.web.ui.v1',
|
||||
JSON.stringify({
|
||||
featureInteractions: {
|
||||
tasks: { firstInteractedAt: 50, interactionCount: 3 }
|
||||
}
|
||||
})
|
||||
)
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
const ui = await globals.window.api.ui.get()
|
||||
const stored = JSON.parse(globals.storage.getItem('orca.web.ui.v1') ?? '{}') as {
|
||||
featureInteractions?: FeatureInteractionState
|
||||
}
|
||||
|
||||
expect(ui.featureInteractions?.tasks).toEqual({
|
||||
firstInteractedAt: 50,
|
||||
interactionCount: 3
|
||||
})
|
||||
expect(stored.featureInteractions?.tasks).toEqual({
|
||||
firstInteractedAt: 50,
|
||||
interactionCount: 3
|
||||
})
|
||||
expect(stored.featureInteractions?.ports).toEqual({
|
||||
firstInteractedAt: 300,
|
||||
interactionCount: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('web worktree preload API', () => {
|
||||
|
||||
@@ -54,6 +54,11 @@ import { parseWebPairingInput } from './web-pairing'
|
||||
import { WebRuntimeClient } from './web-runtime-client'
|
||||
import { RuntimeRpcCallQueuePool } from '../../../shared/runtime-rpc-call-queue'
|
||||
import { sanitizeWebRuntimeWorkspaceSession } from './web-workspace-session'
|
||||
import {
|
||||
normalizeFeatureInteractions,
|
||||
type FeatureInteractionId,
|
||||
type FeatureInteractionState
|
||||
} from '../../../shared/feature-interactions'
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'orca.web.settings.v1'
|
||||
const UI_STORAGE_KEY = 'orca.web.ui.v1'
|
||||
@@ -1464,7 +1469,14 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
|
||||
undefined,
|
||||
15_000
|
||||
)
|
||||
const next = mergeWebUIState(readLocalWebUIState(), result.ui)
|
||||
const local = readLocalWebUIState()
|
||||
const next = {
|
||||
...mergeWebUIState(local, result.ui),
|
||||
featureInteractions: mergeFeatureInteractionState(
|
||||
local.featureInteractions,
|
||||
result.ui.featureInteractions
|
||||
)
|
||||
}
|
||||
writeJson(UI_STORAGE_KEY, next)
|
||||
zoomLevel = next.uiZoomLevel
|
||||
return next
|
||||
@@ -1482,6 +1494,41 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
|
||||
// Why: unpaired/offline web clients still need local UI persistence.
|
||||
}
|
||||
},
|
||||
recordFeatureInteraction: async (id: FeatureInteractionId) => {
|
||||
const current = readLocalWebUIState()
|
||||
const featureInteractions = normalizeFeatureInteractions(current.featureInteractions)
|
||||
const existing = featureInteractions[id]
|
||||
const optimistic = mergeWebUIState(current, {
|
||||
featureInteractions: {
|
||||
...featureInteractions,
|
||||
[id]: {
|
||||
firstInteractedAt: existing?.firstInteractedAt ?? Date.now(),
|
||||
interactionCount: (existing?.interactionCount ?? 0) + 1
|
||||
}
|
||||
}
|
||||
})
|
||||
writeJson(UI_STORAGE_KEY, optimistic)
|
||||
try {
|
||||
const result = await callRuntimeResult<{ ui: PersistedUIState }>(
|
||||
'ui.recordFeatureInteraction',
|
||||
id,
|
||||
15_000
|
||||
)
|
||||
const local = readLocalWebUIState()
|
||||
const next = {
|
||||
...mergeWebUIState(local, result.ui),
|
||||
featureInteractions: mergeFeatureInteractionState(
|
||||
local.featureInteractions,
|
||||
result.ui.featureInteractions
|
||||
)
|
||||
}
|
||||
writeJson(UI_STORAGE_KEY, next)
|
||||
zoomLevel = next.uiZoomLevel
|
||||
return next
|
||||
} catch {
|
||||
return optimistic
|
||||
}
|
||||
},
|
||||
readClipboardText: () => navigator.clipboard?.readText?.() ?? Promise.resolve(''),
|
||||
readSelectionClipboardText: () =>
|
||||
Promise.reject(new Error('Selection clipboard is unavailable in the web client')),
|
||||
@@ -2056,6 +2103,32 @@ function mergeWebUIState(
|
||||
}
|
||||
}
|
||||
|
||||
function mergeFeatureInteractionState(
|
||||
current: PersistedUIState['featureInteractions'],
|
||||
incoming: PersistedUIState['featureInteractions']
|
||||
): FeatureInteractionState {
|
||||
const currentNormalized = normalizeFeatureInteractions(current)
|
||||
const incomingNormalized = normalizeFeatureInteractions(incoming)
|
||||
const merged: FeatureInteractionState = { ...currentNormalized }
|
||||
for (const [id, incomingRecord] of Object.entries(incomingNormalized)) {
|
||||
const featureId = id as FeatureInteractionId
|
||||
const currentRecord = currentNormalized[featureId]
|
||||
merged[featureId] = currentRecord
|
||||
? {
|
||||
firstInteractedAt: Math.min(
|
||||
currentRecord.firstInteractedAt,
|
||||
incomingRecord.firstInteractedAt
|
||||
),
|
||||
interactionCount: Math.max(
|
||||
currentRecord.interactionCount,
|
||||
incomingRecord.interactionCount
|
||||
)
|
||||
}
|
||||
: incomingRecord
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function mergeSettings(base: GlobalSettings, updates: Partial<GlobalSettings>): GlobalSettings {
|
||||
const defaults = getDefaultSettings('~')
|
||||
return {
|
||||
|
||||
@@ -387,7 +387,8 @@ export function getDefaultUIState(): PersistedUIState {
|
||||
setupScriptPromptDismissedRepoIds: [],
|
||||
acknowledgedAgentsByPaneKey: {},
|
||||
workspaceCleanup: { dismissals: {} },
|
||||
featureTipsSeenIds: []
|
||||
featureTipsSeenIds: [],
|
||||
featureInteractions: {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
FEATURE_INTERACTIONS,
|
||||
hasFeatureInteraction,
|
||||
normalizeFeatureInteractions,
|
||||
type FeatureInteractionId
|
||||
} from './feature-interactions'
|
||||
|
||||
type DefinedFeatureInteractionId = (typeof FEATURE_INTERACTIONS)[number]['id']
|
||||
type MissingFeatureInteractionId = Exclude<FeatureInteractionId, DefinedFeatureInteractionId>
|
||||
type ExtraFeatureInteractionId = Exclude<DefinedFeatureInteractionId, FeatureInteractionId>
|
||||
|
||||
const REPO_ROOT = join(__dirname, '..', '..')
|
||||
const SOURCE_ROOTS = ['src/main', 'src/renderer/src', 'src/preload']
|
||||
const PRODUCTION_FILE_PATTERN = /\.(ts|tsx)$/
|
||||
const TEST_FILE_PATTERN = /(?:^|\.)(test|spec)\.(ts|tsx)$/
|
||||
|
||||
describe('feature interactions', () => {
|
||||
it('defines local interaction semantics for product education features', () => {
|
||||
const catalogMatchesPublicUnion: [
|
||||
MissingFeatureInteractionId,
|
||||
ExtraFeatureInteractionId
|
||||
] extends [never, never]
|
||||
? true
|
||||
: never = true
|
||||
const expectedIds: FeatureInteractionId[] = [
|
||||
'workspace-board',
|
||||
'workspace-board-actions',
|
||||
'browser',
|
||||
'tasks',
|
||||
'automations',
|
||||
'automation-created',
|
||||
'automation-run',
|
||||
'browser-annotations',
|
||||
'browser-grab',
|
||||
'workspace-creation',
|
||||
'agent-browser-setup',
|
||||
'agent-browser-use',
|
||||
'agent-orchestration-setup',
|
||||
'agent-orchestration',
|
||||
'ai-commit-generation',
|
||||
'ai-pr-generation',
|
||||
'claude-account-switching',
|
||||
'computer-use-setup',
|
||||
'computer-use',
|
||||
'codex-account-switching',
|
||||
'cookie-import',
|
||||
'floating-workspace',
|
||||
'mobile-pairing',
|
||||
'notifications',
|
||||
'ports',
|
||||
'quick-commands',
|
||||
'resource-manager',
|
||||
'review-notes',
|
||||
'ssh',
|
||||
'terminal-panes',
|
||||
'terminal-tabs',
|
||||
'tab-splits',
|
||||
'usage-tracking',
|
||||
'voice-dictation',
|
||||
'workspace-cleanup'
|
||||
]
|
||||
|
||||
expect(catalogMatchesPublicUnion).toBe(true)
|
||||
expect(FEATURE_INTERACTIONS.map((feature) => feature.id)).toEqual(expectedIds)
|
||||
for (const feature of FEATURE_INTERACTIONS) {
|
||||
expect(feature.interaction.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes persisted records by removing unknown ids and malformed values', () => {
|
||||
expect(
|
||||
normalizeFeatureInteractions({
|
||||
tasks: { firstInteractedAt: 100 },
|
||||
browser: { firstInteractedAt: Number.NaN },
|
||||
automations: { firstInteractedAt: 200, interactionCount: 3 },
|
||||
'browser-grab': { firstInteractedAt: 250, interactionCount: 0 },
|
||||
unknown: { firstInteractedAt: 200 },
|
||||
'voice-dictation': { firstInteractedAt: 300 }
|
||||
})
|
||||
).toEqual({
|
||||
tasks: { firstInteractedAt: 100, interactionCount: 1 },
|
||||
automations: { firstInteractedAt: 200, interactionCount: 3 },
|
||||
'browser-grab': { firstInteractedAt: 250, interactionCount: 1 },
|
||||
'voice-dictation': { firstInteractedAt: 300, interactionCount: 1 }
|
||||
})
|
||||
})
|
||||
|
||||
it('treats only valid known records as interacted', () => {
|
||||
expect(
|
||||
hasFeatureInteraction({ tasks: { firstInteractedAt: 100, interactionCount: 1 } }, 'tasks')
|
||||
).toBe(true)
|
||||
expect(
|
||||
hasFeatureInteraction({ tasks: { firstInteractedAt: 100, interactionCount: 1 } }, 'browser')
|
||||
).toBe(false)
|
||||
expect(
|
||||
hasFeatureInteraction(
|
||||
{ tasks: { firstInteractedAt: Number.POSITIVE_INFINITY, interactionCount: 1 } },
|
||||
'tasks'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps every catalog id wired to a production writer', () => {
|
||||
const productionText = collectProductionSourceText()
|
||||
const missingWriters = FEATURE_INTERACTIONS.map((feature) => feature.id).filter((id) => {
|
||||
const escaped = escapeRegExp(id)
|
||||
const directRecord = new RegExp(
|
||||
`recordFeatureInteraction(?:\\?\\.)?\\(\\s*['"]${escaped}['"]`
|
||||
)
|
||||
const runtimeMappingReturn = new RegExp(`return[^\\n]*['"]${escaped}['"]`)
|
||||
return !directRecord.test(productionText) && !runtimeMappingReturn.test(productionText)
|
||||
})
|
||||
|
||||
expect(missingWriters).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function collectProductionSourceText(): string {
|
||||
const files = SOURCE_ROOTS.flatMap((root) => collectSourceFiles(join(REPO_ROOT, root)))
|
||||
return files
|
||||
.sort()
|
||||
.map((file) => readFileSync(file, 'utf8'))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function collectSourceFiles(directory: string): string[] {
|
||||
const files: string[] = []
|
||||
for (const entry of readdirSync(directory)) {
|
||||
const path = join(directory, entry)
|
||||
const stats = statSync(path)
|
||||
if (stats.isDirectory()) {
|
||||
if (entry === 'node_modules' || entry === 'dist' || entry === 'out') {
|
||||
continue
|
||||
}
|
||||
files.push(...collectSourceFiles(path))
|
||||
continue
|
||||
}
|
||||
const repoRelativePath = relative(REPO_ROOT, path)
|
||||
if (!PRODUCTION_FILE_PATTERN.test(entry) || TEST_FILE_PATTERN.test(entry)) {
|
||||
continue
|
||||
}
|
||||
// Why: the catalog itself proves the id exists, not that runtime code writes it.
|
||||
if (repoRelativePath === 'src/shared/feature-interactions.ts') {
|
||||
continue
|
||||
}
|
||||
files.push(path)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
export type FeatureInteractionId =
|
||||
| 'workspace-board'
|
||||
| 'workspace-board-actions'
|
||||
| 'browser'
|
||||
| 'tasks'
|
||||
| 'automations'
|
||||
| 'automation-created'
|
||||
| 'automation-run'
|
||||
| 'browser-annotations'
|
||||
| 'browser-grab'
|
||||
| 'workspace-creation'
|
||||
| 'agent-browser-setup'
|
||||
| 'agent-browser-use'
|
||||
| 'agent-orchestration-setup'
|
||||
| 'agent-orchestration'
|
||||
| 'ai-commit-generation'
|
||||
| 'ai-pr-generation'
|
||||
| 'claude-account-switching'
|
||||
| 'computer-use-setup'
|
||||
| 'computer-use'
|
||||
| 'codex-account-switching'
|
||||
| 'cookie-import'
|
||||
| 'floating-workspace'
|
||||
| 'mobile-pairing'
|
||||
| 'notifications'
|
||||
| 'ports'
|
||||
| 'quick-commands'
|
||||
| 'resource-manager'
|
||||
| 'review-notes'
|
||||
| 'ssh'
|
||||
| 'terminal-panes'
|
||||
| 'terminal-tabs'
|
||||
| 'tab-splits'
|
||||
| 'usage-tracking'
|
||||
| 'voice-dictation'
|
||||
| 'workspace-cleanup'
|
||||
|
||||
export type FeatureInteractionDefinition = {
|
||||
id: FeatureInteractionId
|
||||
/** The product action that counts as "the user has interacted with this feature." */
|
||||
interaction: string
|
||||
}
|
||||
|
||||
export type FeatureInteractionRecord = {
|
||||
/** Unix timestamp in milliseconds for the first local interaction. */
|
||||
firstInteractedAt: number
|
||||
/** Number of local interactions recorded for this feature. */
|
||||
interactionCount: number
|
||||
}
|
||||
|
||||
export type FeatureInteractionState = Partial<
|
||||
Record<FeatureInteractionId, FeatureInteractionRecord>
|
||||
>
|
||||
|
||||
// Why: these ids become persisted product state; see
|
||||
// docs/reference/feature-discovery-interaction-tracking.md before changing them.
|
||||
export const FEATURE_INTERACTIONS = [
|
||||
{
|
||||
id: 'workspace-board',
|
||||
interaction: 'workspace board opened'
|
||||
},
|
||||
{
|
||||
id: 'workspace-board-actions',
|
||||
interaction: 'workspace board card, lane, density, or status action used'
|
||||
},
|
||||
{
|
||||
id: 'browser',
|
||||
interaction: 'non-blank browser page viewed'
|
||||
},
|
||||
{
|
||||
id: 'tasks',
|
||||
interaction: 'Tasks page opened'
|
||||
},
|
||||
{
|
||||
id: 'automations',
|
||||
interaction: 'Automations page opened'
|
||||
},
|
||||
{
|
||||
id: 'automation-created',
|
||||
interaction: 'automation created'
|
||||
},
|
||||
{
|
||||
id: 'automation-run',
|
||||
interaction: 'automation run queued'
|
||||
},
|
||||
{
|
||||
id: 'browser-annotations',
|
||||
interaction: 'browser annotation added, copied, or cleared'
|
||||
},
|
||||
{
|
||||
id: 'browser-grab',
|
||||
interaction: 'browser element grab or screenshot used'
|
||||
},
|
||||
{
|
||||
id: 'workspace-creation',
|
||||
interaction: 'workspace creation flow opened'
|
||||
},
|
||||
{
|
||||
id: 'agent-browser-setup',
|
||||
interaction: 'Agent Browser Use setup enabled or opened'
|
||||
},
|
||||
{
|
||||
id: 'agent-browser-use',
|
||||
interaction: 'agent browser runtime method used'
|
||||
},
|
||||
{
|
||||
id: 'agent-orchestration-setup',
|
||||
interaction: 'Agent Orchestration setup enabled or opened'
|
||||
},
|
||||
{
|
||||
id: 'agent-orchestration',
|
||||
interaction: 'agent orchestration runtime method used'
|
||||
},
|
||||
{
|
||||
id: 'ai-commit-generation',
|
||||
interaction: 'AI commit message generation enabled or used'
|
||||
},
|
||||
{
|
||||
id: 'ai-pr-generation',
|
||||
interaction: 'AI pull request generation used'
|
||||
},
|
||||
{
|
||||
id: 'claude-account-switching',
|
||||
interaction: 'Claude managed account added, selected, reauthenticated, or removed'
|
||||
},
|
||||
{
|
||||
id: 'computer-use-setup',
|
||||
interaction: 'Computer Use setup or permission flow opened'
|
||||
},
|
||||
{
|
||||
id: 'computer-use',
|
||||
interaction: 'computer-use runtime method used'
|
||||
},
|
||||
{
|
||||
id: 'codex-account-switching',
|
||||
interaction: 'Codex managed account added, selected, reauthenticated, or removed'
|
||||
},
|
||||
{
|
||||
id: 'cookie-import',
|
||||
interaction: 'browser cookies imported or cleared'
|
||||
},
|
||||
{
|
||||
id: 'floating-workspace',
|
||||
interaction: 'Floating Workspace opened or configured'
|
||||
},
|
||||
{
|
||||
id: 'mobile-pairing',
|
||||
interaction: 'mobile pairing enabled or QR code generated'
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
interaction: 'desktop notifications enabled or tested'
|
||||
},
|
||||
{
|
||||
id: 'ports',
|
||||
interaction: 'Ports popover opened, configured, or port action used'
|
||||
},
|
||||
{
|
||||
id: 'quick-commands',
|
||||
interaction: 'terminal quick command created or edited'
|
||||
},
|
||||
{
|
||||
id: 'resource-manager',
|
||||
interaction: 'Resource Manager opened or configured'
|
||||
},
|
||||
{
|
||||
id: 'review-notes',
|
||||
interaction: 'review note added or sent to an agent'
|
||||
},
|
||||
{
|
||||
id: 'ssh',
|
||||
interaction: 'SSH target added, imported, tested, connected, disconnected, or configured'
|
||||
},
|
||||
{
|
||||
id: 'terminal-panes',
|
||||
interaction: 'terminal/editor/browser pane created, resized, or merged'
|
||||
},
|
||||
{
|
||||
id: 'terminal-tabs',
|
||||
interaction: 'workspace tab created, moved, reordered, pinned, renamed, recolored, or closed'
|
||||
},
|
||||
{
|
||||
id: 'tab-splits',
|
||||
interaction: 'workspace tab split into another pane'
|
||||
},
|
||||
{
|
||||
id: 'usage-tracking',
|
||||
interaction: 'Stats & Usage or provider usage details opened or configured'
|
||||
},
|
||||
{
|
||||
id: 'voice-dictation',
|
||||
interaction: 'dictation session started'
|
||||
},
|
||||
{
|
||||
id: 'workspace-cleanup',
|
||||
interaction: 'workspace disk space scan, review, or cleanup action used'
|
||||
}
|
||||
] as const satisfies readonly FeatureInteractionDefinition[]
|
||||
|
||||
export const FEATURE_INTERACTION_IDS = FEATURE_INTERACTIONS.map((feature) => feature.id)
|
||||
|
||||
export function isFeatureInteractionId(value: unknown): value is FeatureInteractionId {
|
||||
return (
|
||||
typeof value === 'string' && FEATURE_INTERACTION_IDS.includes(value as FeatureInteractionId)
|
||||
)
|
||||
}
|
||||
|
||||
export function hasFeatureInteraction(
|
||||
state: FeatureInteractionState | null | undefined,
|
||||
id: FeatureInteractionId
|
||||
): boolean {
|
||||
return normalizeFeatureInteractionRecord(state?.[id]) !== null
|
||||
}
|
||||
|
||||
export function normalizeFeatureInteractions(value: unknown): FeatureInteractionState {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const input = value as Record<string, unknown>
|
||||
const out: FeatureInteractionState = {}
|
||||
for (const id of FEATURE_INTERACTION_IDS) {
|
||||
const record = normalizeFeatureInteractionRecord(input[id])
|
||||
if (record) {
|
||||
out[id] = record
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function normalizeFeatureInteractionRecord(value: unknown): FeatureInteractionRecord | null {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
const input = value as Record<string, unknown>
|
||||
const firstInteractedAt = input.firstInteractedAt
|
||||
if (
|
||||
typeof firstInteractedAt !== 'number' ||
|
||||
!Number.isFinite(firstInteractedAt) ||
|
||||
firstInteractedAt < 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const rawInteractionCount = input.interactionCount
|
||||
const interactionCount =
|
||||
typeof rawInteractionCount === 'number' &&
|
||||
Number.isInteger(rawInteractionCount) &&
|
||||
rawInteractionCount > 0
|
||||
? rawInteractionCount
|
||||
: 1
|
||||
return { firstInteractedAt, interactionCount }
|
||||
}
|
||||
@@ -30,6 +30,20 @@ describe('feature tips', () => {
|
||||
expect(tips.map((tip) => tip.id)).toEqual([])
|
||||
})
|
||||
|
||||
it('skips tips for features the user has already interacted with', () => {
|
||||
const tips = getOrderedUnseenFeatureTips({
|
||||
seenTipIds: new Set<FeatureTipId>(),
|
||||
completedTipIds: getCompletedFeatureTipIds({
|
||||
voiceDictationEnabled: false,
|
||||
featureInteractions: {
|
||||
'voice-dictation': { firstInteractedAt: 100, interactionCount: 1 }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(tips.map((tip) => tip.id)).toEqual([])
|
||||
})
|
||||
|
||||
it('normalizes persisted tip ids', () => {
|
||||
expect(normalizeFeatureTipIds(['feature-tour', 'bogus', 'voice-dictation'])).toEqual([
|
||||
'voice-dictation'
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
hasFeatureInteraction,
|
||||
type FeatureInteractionId,
|
||||
type FeatureInteractionState
|
||||
} from './feature-interactions'
|
||||
|
||||
export type FeatureTipId = 'voice-dictation'
|
||||
|
||||
export type FeatureTipPriority = 'new' | 'unseen'
|
||||
@@ -12,10 +18,13 @@ export type FeatureTip = {
|
||||
description: string
|
||||
action: FeatureTipAction
|
||||
ctaLabel: string
|
||||
/** Feature interactions that mean this tip is no longer useful to show. */
|
||||
completedByFeatureInteractions?: readonly FeatureInteractionId[]
|
||||
}
|
||||
|
||||
export type CompletedFeatureTipState = {
|
||||
voiceDictationEnabled: boolean
|
||||
featureInteractions?: FeatureInteractionState
|
||||
}
|
||||
|
||||
export const FEATURE_TIPS = [
|
||||
@@ -27,7 +36,8 @@ export const FEATURE_TIPS = [
|
||||
description:
|
||||
'Speak into any focused pane and Orca will transcribe it. Press the dictation shortcut to start and stop.',
|
||||
action: 'enable-voice',
|
||||
ctaLabel: 'Set Up Voice'
|
||||
ctaLabel: 'Set Up Voice',
|
||||
completedByFeatureInteractions: ['voice-dictation']
|
||||
}
|
||||
] as const satisfies readonly FeatureTip[]
|
||||
|
||||
@@ -56,6 +66,15 @@ export function getCompletedFeatureTipIds(state: CompletedFeatureTipState): Set<
|
||||
if (state.voiceDictationEnabled) {
|
||||
completedIds.add('voice-dictation')
|
||||
}
|
||||
for (const tip of FEATURE_TIPS) {
|
||||
if (
|
||||
tip.completedByFeatureInteractions?.some((id) =>
|
||||
hasFeatureInteraction(state.featureInteractions, id)
|
||||
)
|
||||
) {
|
||||
completedIds.add(tip.id)
|
||||
}
|
||||
}
|
||||
return completedIds
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export const ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY = '__orcaFeatureInteractionSource'
|
||||
|
||||
export const ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE = 'browser-pane-ui'
|
||||
|
||||
export function withBrowserPaneUiRuntimeRpcSource(value: unknown): unknown {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {
|
||||
[ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY]: ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE
|
||||
}
|
||||
}
|
||||
return {
|
||||
...value,
|
||||
[ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY]: ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE
|
||||
}
|
||||
}
|
||||
|
||||
export function isBrowserPaneUiRuntimeRpcParams(value: unknown): boolean {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
(value as Record<string, unknown>)[ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY] ===
|
||||
ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type { WorkspaceCleanupUIState } from './workspace-cleanup'
|
||||
import type { GitLabProjectSettings } from './gitlab-types'
|
||||
import type { TaskProvider } from './task-providers'
|
||||
import type { FeatureTipId } from './feature-tips'
|
||||
import type { FeatureInteractionState } from './feature-interactions'
|
||||
import type { GitBranchChangeStatus } from './git-status-types'
|
||||
import type { KeybindingOverrides, TerminalShortcutPolicy } from './keybindings'
|
||||
import type { RepoIcon } from './repo-icon'
|
||||
@@ -2309,6 +2310,9 @@ export type PersistedUIState = {
|
||||
/** Feature tips already surfaced to the user. Startup only opens the tips
|
||||
* modal when this list is missing one of the current tip ids. */
|
||||
featureTipsSeenIds?: FeatureTipId[]
|
||||
/** Local product-state facts: feature ids the user has actually used.
|
||||
* Used by education surfaces to avoid teaching already-discovered features. */
|
||||
featureInteractions?: FeatureInteractionState
|
||||
}
|
||||
|
||||
export const PET_SIZE_MIN = 60
|
||||
|
||||
Reference in New Issue
Block a user