mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
Preserve split-terminal setup milestone (#4546)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, Worktree[]>): 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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<React.SetStateAction<boolean>>
|
||||
onSearchSelectedText: (text: string) => void
|
||||
onRequestClosePane: (paneId: number) => void
|
||||
onSplitPaneCommand?: () => void
|
||||
searchOpenRef: React.RefObject<boolean>
|
||||
searchStateRef: React.RefObject<SearchState>
|
||||
macOptionAsAltRef: React.RefObject<MacOptionAsAlt>
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<PaneManager | null>
|
||||
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -1092,7 +1092,6 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
activeGroupIdByWorktree: { ...state.activeGroupIdByWorktree, [worktreeId]: newGroupId }
|
||||
}
|
||||
})
|
||||
get().recordFeatureInteraction?.('terminal-pane-split')
|
||||
get().recordFeatureInteraction?.('terminal-panes')
|
||||
return newGroupId
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user