From 065c620c10d875aed9123f2821e2fc7b2b809aed Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:11:29 -0700 Subject: [PATCH] Preserve split-terminal setup milestone (#4546) Co-authored-by: Orca --- src/renderer/src/components/Terminal.tsx | 3 +- .../feature-wall-setup-progress.test.ts | 60 +++++++++++- .../feature-wall-setup-progress.ts | 28 +----- .../useTabGroupWorkspaceModel.focus.test.ts | 17 ++++ .../tab-group/useTabGroupWorkspaceModel.ts | 8 ++ .../components/terminal-pane/TerminalPane.tsx | 7 -- .../terminal-pane/keyboard-handlers.ts | 36 +++---- .../terminal-pane-split-completion.test.ts | 72 ++++++++++++++ .../terminal-pane-split-completion.ts | 26 ++++++ .../terminal-pane-split-writer-paths.test.ts | 93 +++++++++++++++++++ .../use-terminal-pane-context-menu.ts | 25 ++--- .../use-terminal-pane-lifecycle.ts | 43 +++++---- src/renderer/src/store/slices/tabs.test.ts | 6 +- src/renderer/src/store/slices/tabs.ts | 1 - 14 files changed, 339 insertions(+), 86 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-split-completion.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-split-completion.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-split-writer-paths.test.ts diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 71f1cc9f0dd..48238f2ef77 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -34,6 +34,7 @@ import { import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload' import EditorAutosaveController from './editor/EditorAutosaveController' import type { Tab, TabContentType, TabGroupLayoutNode } from '../../../shared/types' +import { hasFeatureInteraction } from '../../../shared/feature-interactions' import BrowserPane from './browser-pane/BrowserPane' import BrowserPaneOverlayLayer from './browser-pane/BrowserPaneOverlayLayer' import { useBrowserAutomationVisibilityForAny } from './browser-pane/browser-automation-visibility' @@ -283,7 +284,7 @@ function Terminal(): React.JSX.Element | null { : '' const activeContextualTourId = useAppStore((s) => s.activeContextualTourId) const hasSplitTerminalPane = useAppStore((s) => - Boolean(s.featureInteractions['terminal-pane-split']?.interactionCount) + hasFeatureInteraction(s.featureInteractions, 'terminal-pane-split') ) useContextualTour( diff --git a/src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts b/src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts index 8ad5336be84..e5718677ace 100644 --- a/src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts +++ b/src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts @@ -115,7 +115,7 @@ describe('getFeatureWallSetupProgress', () => { expect(getFirstIncompleteFeatureWallSetupStepId(progress.stepDone)).toBe('split-terminal') }) - it('does not mark the step complete from split-pane interaction count alone', () => { + it('marks the step complete from durable terminal-pane split interaction state', () => { const progress = getFeatureWallSetupProgress( makeInput({ featureInteractions: { @@ -124,6 +124,30 @@ describe('getFeatureWallSetupProgress', () => { }) ) + expect(progress.stepDone['split-terminal']).toBe(true) + }) + + it('does not mark the step complete from malformed durable terminal-pane split state', () => { + const progress = getFeatureWallSetupProgress( + makeInput({ + featureInteractions: { + 'terminal-pane-split': { firstInteractedAt: Number.NaN, interactionCount: 1 } + } + }) + ) + + expect(progress.stepDone['split-terminal']).toBe(false) + }) + + it('does not mark the step complete from generic pane interaction state', () => { + const progress = getFeatureWallSetupProgress( + makeInput({ + featureInteractions: { + 'terminal-panes': { firstInteractedAt: 1_700_000_000_000, interactionCount: 1 } + } + }) + ) + expect(progress.stepDone['split-terminal']).toBe(false) }) @@ -141,7 +165,7 @@ describe('getFeatureWallSetupProgress', () => { expect(progress.stepDone['split-terminal']).toBe(false) }) - it('marks the step complete once a worktree terminal is split into two panes', () => { + it('does not mark the step complete from a live split layout without durable state', () => { const progress = getFeatureWallSetupProgress( makeInput({ worktreesByRepo: { 'repo-1': [makeWorktree('worktree-1')] }, @@ -152,7 +176,37 @@ describe('getFeatureWallSetupProgress', () => { }) ) - expect(progress.stepDone['split-terminal']).toBe(true) + expect(progress.stepDone['split-terminal']).toBe(false) + }) + + it('keeps the step complete after the split tab closes from durable state', () => { + const withSplit = getFeatureWallSetupProgress( + makeInput({ + featureInteractions: { + 'terminal-pane-split': { firstInteractedAt: 1_700_000_000_000, interactionCount: 1 } + }, + worktreesByRepo: { 'repo-1': [makeWorktree('worktree-1')] }, + tabsByWorktree: { + 'worktree-1': [{ id: 'tab-1', title: 'Terminal' }] as never + }, + terminalLayoutsByTabId: { 'tab-1': makeSplitLayout() } + }) + ) + + expect(withSplit.stepDone['split-terminal']).toBe(true) + + const afterClosingSplitTab = getFeatureWallSetupProgress( + makeInput({ + featureInteractions: { + 'terminal-pane-split': { firstInteractedAt: 1_700_000_000_000, interactionCount: 1 } + }, + worktreesByRepo: { 'repo-1': [makeWorktree('worktree-1')] }, + tabsByWorktree: { 'worktree-1': [] }, + terminalLayoutsByTabId: {} + }) + ) + + expect(afterClosingSplitTab.stepDone['split-terminal']).toBe(true) }) it('ignores split layouts for tabs that do not belong to a known worktree', () => { diff --git a/src/renderer/src/components/feature-wall/feature-wall-setup-progress.ts b/src/renderer/src/components/feature-wall/feature-wall-setup-progress.ts index f41bf8b26b0..7f9dca850a6 100644 --- a/src/renderer/src/components/feature-wall/feature-wall-setup-progress.ts +++ b/src/renderer/src/components/feature-wall/feature-wall-setup-progress.ts @@ -1,4 +1,5 @@ import type { FeatureInteractionState } from '../../../../shared/feature-interactions' +import { hasFeatureInteraction } from '../../../../shared/feature-interactions' import { FEATURE_WALL_SETUP_STEPS, type FeatureWallSetupStepId @@ -6,7 +7,6 @@ import { import type { GlobalSettings, TerminalLayoutSnapshot, - TerminalPaneLayoutNode, TerminalTab, Worktree } from '../../../../shared/types' @@ -35,30 +35,6 @@ export type FeatureWallSetupProgress = { coreTotal: number } -function isSplitLayout(node: TerminalPaneLayoutNode | null | undefined): boolean { - // A split node means the tab holds 2+ panes, regardless of what runs in them. - return Boolean(node) && node!.type === 'split' -} - -function hasSplitTerminalInAnyWorktree(input: FeatureWallSetupProgressInput): boolean { - const validWorktreeIds = new Set( - Object.values(input.worktreesByRepo) - .flat() - .map((worktree) => worktree.id) - ) - for (const [worktreeId, tabs] of Object.entries(input.tabsByWorktree)) { - if (!validWorktreeIds.has(worktreeId)) { - continue - } - for (const tab of tabs) { - if (isSplitLayout(input.terminalLayoutsByTabId[tab.id]?.root)) { - return true - } - } - } - return false -} - function countAvailableNonMainWorktrees(worktreesByRepo: Record): number { // Why: imported git worktrees count as real parallel-work capacity, but // partially hydrated placeholders can appear before a worktree path is known. @@ -87,7 +63,7 @@ export function getFeatureWallSetupProgress( notifications: input.settings?.notifications.enabled === true && input.settings.notifications.agentTaskComplete === true, - 'split-terminal': hasSplitTerminalInAnyWorktree(input), + 'split-terminal': hasFeatureInteraction(input.featureInteractions, 'terminal-pane-split'), 'two-worktrees': countAvailableNonMainWorktrees(input.worktreesByRepo) >= 1, 'task-sources': input.hasConnectedTaskSource, 'agent-capabilities': agentCapabilitiesDone, diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts index 1d9560fa511..0dcbe8912aa 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({ isWebRuntimeSessionActive: vi.fn(() => false), openFile: vi.fn(), pinFile: vi.fn(), + recordFeatureInteraction: vi.fn(), setActiveBrowserTab: vi.fn(), setActiveFile: vi.fn(), setActiveTab: vi.fn(), @@ -143,6 +144,7 @@ function resetStore(): void { focusGroup: mocks.focusGroup, openFile: mocks.openFile, pinFile: mocks.pinFile, + recordFeatureInteraction: mocks.recordFeatureInteraction, setActiveBrowserTab: mocks.setActiveBrowserTab, setActiveFile: mocks.setActiveFile, setActiveTab: mocks.setActiveTab, @@ -197,4 +199,19 @@ describe('useTabGroupWorkspaceModel terminal activation focus', () => { expect(event.type).toBe(TOGGLE_TERMINAL_PANE_EXPAND_EVENT) expect(event.detail).toEqual({ tabId: 'terminal-1' }) }) + + it('records terminal split completion when splitting a single terminal tab group', async () => { + mocks.createEmptySplitGroup.mockReturnValue('group-2') + mocks.createTab.mockReturnValue({ id: 'terminal-2' }) + const { useTabGroupWorkspaceModel } = await import('./useTabGroupWorkspaceModel') + const model = useTabGroupWorkspaceModel({ groupId: 'group-1', worktreeId: 'wt-1' }) + + model.commands.createSplitGroup('right') + + expect(mocks.createEmptySplitGroup).toHaveBeenCalledWith('wt-1', 'group-1', 'right') + expect(mocks.createTab).toHaveBeenCalledWith('wt-1', 'group-2') + expect(mocks.recordFeatureInteraction).toHaveBeenCalledWith('terminal-pane-split') + expect(mocks.setActiveTab).toHaveBeenCalledWith('terminal-2') + expect(mocks.setActiveTabType).toHaveBeenCalledWith('terminal') + }) }) diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index 988fefc54ad..b1fba881573 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -25,6 +25,13 @@ import { } from '../../runtime/web-runtime-session' import { openTabBarEntry, type TabCreateEntryArgs } from '../tab-bar/tab-create-entry-action' +export function recordTerminalTabGroupSplit(createdTerminal: TerminalTab | null | undefined): void { + if (!createdTerminal) { + return + } + useAppStore.getState().recordFeatureInteraction('terminal-pane-split') +} + export type GroupEditorItem = OpenFile & { tabId: string } export type GroupBrowserItem = BrowserTabState & { tabId: string } @@ -420,6 +427,7 @@ export function useTabGroupWorkspaceModel({ return } const terminal = createTab(worktreeId, newGroupId) + recordTerminalTabGroupSplit(terminal) setActiveTab(terminal.id) setActiveTabType('terminal') return diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 20523deccc4..501f25dac7e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -388,7 +388,6 @@ export default function TerminalPane({ const clearWorktreeUnread = useAppStore((store) => store.clearWorktreeUnread) const clearTerminalTabUnread = useAppStore((store) => store.clearTerminalTabUnread) const clearTerminalPaneUnread = useAppStore((store) => store.clearTerminalPaneUnread) - const recordFeatureInteraction = useAppStore((store) => store.recordFeatureInteraction) const openSpacePage = useAppStore((store) => store.openSpacePage) const refreshWorkspaceSpace = useAppStore((store) => store.refreshWorkspaceSpace) const settings = useAppStore((store) => store.settings) @@ -408,10 +407,6 @@ export default function TerminalPane({ () => useAppStore.getState().pendingIssueCommandSplitByTabId[tabId] ) const consumeTabIssueCommandSplit = useAppStore((store) => store.consumeTabIssueCommandSplit) - const handleSplitPaneCommand = useCallback(() => { - recordFeatureInteraction('terminal-pane-split') - }, [recordFeatureInteraction]) - useEffect(() => { if (startup) { consumeTabStartupCommand(tabId) @@ -1063,7 +1058,6 @@ export default function TerminalPane({ setSearchOpen, onSearchSelectedText: handleSearchSelectedText, onRequestClosePane: handleRequestClosePane, - onSplitPaneCommand: handleSplitPaneCommand, searchOpenRef, searchStateRef, macOptionAsAltRef, @@ -1588,7 +1582,6 @@ export default function TerminalPane({ fallbackCwd: cwd ?? '', toggleExpandPane, onRequestClosePane: handleRequestClosePane, - onSplitPaneCommand: handleSplitPaneCommand, onSetTitle: handleStartRename, onPasteError: setTerminalError, onAgentSessionForkReady: setAgentSessionFork, diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 9a2c53145e0..e1768b9fb19 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -17,9 +17,19 @@ import { keyboardEventBelongsToScope } from './terminal-keyboard-scope' import { normalizeSelectedTextForFileSearch } from '@/lib/file-search-selection' import { splitWebRuntimeTerminal } from '@/runtime/web-runtime-session' import { handleEmptyFloatingWorkspacePanelCloseShortcut } from '@/lib/floating-workspace-terminal-actions' -import { trackTerminalPaneSplit } from '@/lib/feature-education-telemetry' +import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' import { useAppStore } from '@/store' +export function recordKeyboardCreatedTerminalPaneSplit( + createdPane: unknown, + args: { + source: 'contextual_tour' | 'keyboard' + direction: 'vertical' | 'horizontal' + } +): boolean { + return recordCreatedTerminalPaneSplit(createdPane, args) +} + function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) { return false @@ -110,7 +120,6 @@ type KeyboardHandlersDeps = { setSearchOpen: React.Dispatch> onSearchSelectedText: (text: string) => void onRequestClosePane: (paneId: number) => void - onSplitPaneCommand?: () => void searchOpenRef: React.RefObject searchStateRef: React.RefObject macOptionAsAltRef: React.RefObject @@ -134,7 +143,6 @@ export function useTerminalKeyboardShortcuts({ setSearchOpen, onSearchSelectedText, onRequestClosePane, - onSplitPaneCommand, searchOpenRef, searchStateRef, macOptionAsAltRef, @@ -376,7 +384,6 @@ export function useTerminalKeyboardShortcuts({ if (!pane) { return } - onSplitPaneCommand?.() const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null const telemetrySource = getKeyboardSplitTelemetrySource() if (splitWebRuntimeTerminal(ptyId, action.direction, telemetrySource)) { @@ -389,12 +396,10 @@ export function useTerminalKeyboardShortcuts({ const cached = paneCwdRef.current.get(pane.id) if (cached?.confirmed && cached.cwd) { const createdPane = manager.splitPane(pane.id, action.direction, { cwd: cached.cwd }) - if (createdPane) { - trackTerminalPaneSplit({ - source: telemetrySource, - direction: action.direction - }) - } + recordKeyboardCreatedTerminalPaneSplit(createdPane, { + source: telemetrySource, + direction: action.direction + }) return } const paneIdAtDispatch = pane.id @@ -409,12 +414,10 @@ export function useTerminalKeyboardShortcuts({ const createdPane = managerRef.current?.splitPane(paneIdAtDispatch, directionAtDispatch, { cwd }) - if (createdPane) { - trackTerminalPaneSplit({ - source: telemetrySource, - direction: directionAtDispatch - }) - } + recordKeyboardCreatedTerminalPaneSplit(createdPane, { + source: telemetrySource, + direction: directionAtDispatch + }) })() } } @@ -443,7 +446,6 @@ export function useTerminalKeyboardShortcuts({ setSearchOpen, onSearchSelectedText, onRequestClosePane, - onSplitPaneCommand, searchOpenRef, searchStateRef, macOptionAsAltRef, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-completion.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-completion.test.ts new file mode 100644 index 00000000000..43569394582 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-completion.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' + +const mocks = vi.hoisted(() => ({ + recordFeatureInteraction: vi.fn(), + trackTerminalPaneSplit: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + recordFeatureInteraction: mocks.recordFeatureInteraction + }) + } +})) + +vi.mock('@/lib/feature-education-telemetry', () => ({ + trackTerminalPaneSplit: mocks.trackTerminalPaneSplit +})) + +describe('recordCreatedTerminalPaneSplit', () => { + beforeEach(() => { + mocks.recordFeatureInteraction.mockReset() + mocks.trackTerminalPaneSplit.mockReset() + }) + + it('does not record durable split completion when no pane was created', () => { + expect( + recordCreatedTerminalPaneSplit(null, { + source: 'keyboard', + direction: 'vertical' + }) + ).toBe(false) + + expect(mocks.recordFeatureInteraction).not.toHaveBeenCalled() + expect(mocks.trackTerminalPaneSplit).not.toHaveBeenCalled() + }) + + it('records durable split completion and telemetry after a pane is created', () => { + expect( + recordCreatedTerminalPaneSplit( + { id: 2 }, + { + source: 'context_menu', + direction: 'horizontal' + } + ) + ).toBe(true) + + expect(mocks.recordFeatureInteraction).toHaveBeenCalledWith('terminal-pane-split') + expect(mocks.trackTerminalPaneSplit).toHaveBeenCalledWith({ + source: 'context_menu', + direction: 'horizontal' + }) + }) + + it('keeps durable split completion when mirrored runtime telemetry is suppressed', () => { + expect( + recordCreatedTerminalPaneSplit( + { id: 2 }, + { + source: 'command', + direction: 'vertical', + telemetrySuppressed: true + } + ) + ).toBe(true) + + expect(mocks.recordFeatureInteraction).toHaveBeenCalledWith('terminal-pane-split') + expect(mocks.trackTerminalPaneSplit).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-completion.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-completion.ts new file mode 100644 index 00000000000..d20a34e3c85 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-completion.ts @@ -0,0 +1,26 @@ +import { trackTerminalPaneSplit } from '@/lib/feature-education-telemetry' +import { useAppStore } from '@/store' +import type { TerminalPaneSplitSource } from '../../../../shared/feature-education-telemetry' + +export type TerminalPaneSplitCompletion = { + source: TerminalPaneSplitSource + direction: 'vertical' | 'horizontal' + telemetrySuppressed?: boolean +} + +export function recordCreatedTerminalPaneSplit( + createdPane: unknown, + completion: TerminalPaneSplitCompletion +): boolean { + if (!createdPane) { + return false + } + useAppStore.getState().recordFeatureInteraction('terminal-pane-split') + if (!completion.telemetrySuppressed) { + trackTerminalPaneSplit({ + source: completion.source, + direction: completion.direction + }) + } + return true +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-writer-paths.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-writer-paths.test.ts new file mode 100644 index 00000000000..1e18395b894 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-writer-paths.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { recordKeyboardCreatedTerminalPaneSplit } from './keyboard-handlers' +import { recordContextMenuCreatedTerminalPaneSplit } from './use-terminal-pane-context-menu' +import { recordRuntimeCreatedTerminalPaneSplit } from './use-terminal-pane-lifecycle' + +const mocks = vi.hoisted(() => ({ + recordFeatureInteraction: vi.fn(), + trackTerminalPaneSplit: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + activeContextualTourId: null, + recordFeatureInteraction: mocks.recordFeatureInteraction + }) + } +})) + +vi.mock('@/lib/feature-education-telemetry', () => ({ + trackTerminalPaneSplit: mocks.trackTerminalPaneSplit +})) + +describe('terminal split writer paths', () => { + beforeEach(() => { + mocks.recordFeatureInteraction.mockReset() + mocks.trackTerminalPaneSplit.mockReset() + }) + + it('does not record keyboard split completion when the local split fails', () => { + expect( + recordKeyboardCreatedTerminalPaneSplit(null, { + source: 'keyboard', + direction: 'vertical' + }) + ).toBe(false) + + expect(mocks.recordFeatureInteraction).not.toHaveBeenCalled() + expect(mocks.trackTerminalPaneSplit).not.toHaveBeenCalled() + }) + + it('records context-menu split completion after the local split succeeds', () => { + expect( + recordContextMenuCreatedTerminalPaneSplit( + { id: 2 }, + { + source: 'context_menu', + direction: 'horizontal' + } + ) + ).toBe(true) + + expect(mocks.recordFeatureInteraction).toHaveBeenCalledWith('terminal-pane-split') + expect(mocks.trackTerminalPaneSplit).toHaveBeenCalledWith({ + source: 'context_menu', + direction: 'horizontal' + }) + }) + + it('records runtime split completion after SPLIT_TERMINAL_PANE_EVENT creates a pane', () => { + expect( + recordRuntimeCreatedTerminalPaneSplit( + { id: 2 }, + { + source: 'command', + direction: 'vertical' + } + ) + ).toBe(true) + + expect(mocks.recordFeatureInteraction).toHaveBeenCalledWith('terminal-pane-split') + expect(mocks.trackTerminalPaneSplit).toHaveBeenCalledWith({ + source: 'command', + direction: 'vertical' + }) + }) + + it('keeps runtime split completion when mirrored telemetry is suppressed', () => { + expect( + recordRuntimeCreatedTerminalPaneSplit( + { id: 2 }, + { + source: 'command', + direction: 'horizontal', + telemetrySuppressed: true + } + ) + ).toBe(true) + + expect(mocks.recordFeatureInteraction).toHaveBeenCalledWith('terminal-pane-split') + expect(mocks.trackTerminalPaneSplit).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 9c98dd30da3..14b8d48ca5f 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -22,11 +22,21 @@ import { prepareAgentSessionForkFromPane, type PreparedAgentSessionFork } from './terminal-agent-session-fork' -import { trackTerminalPaneSplit } from '@/lib/feature-education-telemetry' +import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' import { useAppStore } from '@/store' const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' +export function recordContextMenuCreatedTerminalPaneSplit( + createdPane: unknown, + args: { + source: 'contextual_tour' | 'context_menu' + direction: 'vertical' | 'horizontal' + } +): boolean { + return recordCreatedTerminalPaneSplit(createdPane, args) +} + type UseTerminalPaneContextMenuDeps = { managerRef: React.RefObject paneTransportsRef: React.RefObject> @@ -37,7 +47,6 @@ type UseTerminalPaneContextMenuDeps = { fallbackCwd: string toggleExpandPane: (paneId: number) => void onRequestClosePane: (paneId: number) => void - onSplitPaneCommand?: () => void onSetTitle: (paneId: number) => void onPasteError: (message: string) => void onAgentSessionForkReady: (fork: PreparedAgentSessionFork) => void @@ -76,7 +85,6 @@ export function useTerminalPaneContextMenu({ fallbackCwd, toggleExpandPane, onRequestClosePane, - onSplitPaneCommand, onSetTitle, onPasteError, onAgentSessionForkReady, @@ -176,7 +184,6 @@ export function useTerminalPaneContextMenu({ if (!pane) { return } - onSplitPaneCommand?.() const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null if (splitWebRuntimeTerminal(ptyId, direction, source)) { return @@ -184,9 +191,7 @@ export function useTerminalPaneContextMenu({ const cached = paneCwdRef.current.get(pane.id) if (cached?.confirmed && cached.cwd) { const createdPane = managerRef.current?.splitPane(pane.id, direction, { cwd: cached.cwd }) - if (createdPane) { - trackTerminalPaneSplit({ source, direction }) - } + recordContextMenuCreatedTerminalPaneSplit(createdPane, { source, direction }) return } const paneId = pane.id @@ -198,12 +203,10 @@ export function useTerminalPaneContextMenu({ fallbackCwd }) const createdPane = managerRef.current?.splitPane(paneId, direction, { cwd }) - if (createdPane) { - trackTerminalPaneSplit({ source, direction }) - } + recordContextMenuCreatedTerminalPaneSplit(createdPane, { source, direction }) })() }, - [fallbackCwd, managerRef, onSplitPaneCommand, paneCwdRef, paneTransportsRef, resolveMenuPane] + [fallbackCwd, managerRef, paneCwdRef, paneTransportsRef, resolveMenuPane] ) const onSplitRight = (): void => splitWithInheritedCwd('vertical') diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index be2417caf6d..792859b8133 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -3,7 +3,6 @@ import { useEffect, useRef } from 'react' import type { IDisposable, Terminal } from '@xterm/xterm' import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' import { PaneManager } from '@/lib/pane-manager/pane-manager' -import { trackTerminalPaneSplit } from '@/lib/feature-education-telemetry' import { consumePendingWebRuntimeSplitMirrorTelemetry } from '@/runtime/web-runtime-session' import { resolveTerminalCursorInactiveStyle } from '@/lib/pane-manager/pane-terminal-options' import { buildWindowsPtyCompatibilityOptions } from '@/lib/pane-manager/windows-pty-compatibility' @@ -23,6 +22,7 @@ import type { TerminalTab, TerminalLayoutSnapshot } from '../../../../shared/types' +import type { TerminalPaneSplitSource } from '../../../../shared/feature-education-telemetry' import type { EventProps } from '../../../../shared/telemetry-events' import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts' import { @@ -74,6 +74,18 @@ import { type CloseTerminalPaneDetail } from '@/constants/terminal' import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry' +import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' + +export function recordRuntimeCreatedTerminalPaneSplit( + createdPane: unknown, + args: { + source: TerminalPaneSplitSource + direction: 'vertical' | 'horizontal' + telemetrySuppressed?: boolean + } +): boolean { + return recordCreatedTerminalPaneSplit(createdPane, args) +} function extractUncHost(value: string | undefined): string | null { const match = /^(?:\\\\|\/\/)([^\\/]+)/.exec(value ?? '') @@ -1043,6 +1055,8 @@ export function useTerminalPaneLifecycle({ // than relying on getPanes()[0] which returns insertion order, not visual order. const initialPane = manager.getActivePane() ?? manager.getPanes()[0] + // Why: setup/issue automation panes are internal workspace bootstrap flows, + // not the user-visible split-terminal milestone recorded below. if (setupSplit) { if (initialPane) { const setupPane = splitPaneWithOneShotStartup( @@ -1124,23 +1138,20 @@ export function useTerminalPaneLifecycle({ const createdPane = splitPaneWithOneShotStartup(ptyDeps, { command: detail.command }, () => mgr.splitPane(sourcePaneId, detail.direction, splitOptions) ) - if (createdPane) { - trackTerminalPaneSplit({ - source: detail.telemetrySource ?? 'command', - direction: detail.direction - }) - } + recordRuntimeCreatedTerminalPaneSplit(createdPane, { + source: detail.telemetrySource ?? 'command', + direction: detail.direction + }) } else { const createdPane = mgr.splitPane(sourcePaneId, detail.direction, splitOptions) - if ( - createdPane && - !consumePendingWebRuntimeSplitMirrorTelemetry(detail.sourcePtyId, detail.direction) - ) { - trackTerminalPaneSplit({ - source: detail.telemetrySource ?? 'command', - direction: detail.direction - }) - } + const telemetrySuppressed = createdPane + ? consumePendingWebRuntimeSplitMirrorTelemetry(detail.sourcePtyId, detail.direction) + : false + recordRuntimeCreatedTerminalPaneSplit(createdPane, { + source: detail.telemetrySource ?? 'command', + direction: detail.direction, + telemetrySuppressed + }) } } window.addEventListener(SPLIT_TERMINAL_PANE_EVENT, onCliSplitPane) diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index c595c6406a4..d91b28f5e20 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -392,7 +392,7 @@ describe('TabsSlice', () => { expect(sourceGroup?.activeTabId).toBe(t1.id) }) - it('records the split-pane command interaction when creating an empty split group', () => { + it('records generic pane interaction when creating an empty split group', () => { const setMock = vi.mocked(window.api.ui.set) store.getState().hydratePersistedUI(getDefaultUIState()) setMock.mockClear() @@ -401,9 +401,7 @@ describe('TabsSlice', () => { store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right') - expect(store.getState().featureInteractions['terminal-pane-split']).toMatchObject({ - interactionCount: 1 - }) + expect(store.getState().featureInteractions['terminal-pane-split']).toBeUndefined() expect(store.getState().featureInteractions['terminal-panes']).toMatchObject({ interactionCount: 1 }) diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 47a01a93cea..7255e392733 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -1092,7 +1092,6 @@ export const createTabsSlice: StateCreator = (set, activeGroupIdByWorktree: { ...state.activeGroupIdByWorktree, [worktreeId]: newGroupId } } }) - get().recordFeatureInteraction?.('terminal-pane-split') get().recordFeatureInteraction?.('terminal-panes') return newGroupId },