Add new-user parallel work activation tour

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-31 11:47:56 -07:00
co-authored by Orca
parent 096a63b4a0
commit 347179ea99
34 changed files with 1381 additions and 133 deletions
+15
View File
@@ -2161,6 +2161,19 @@ describe('Store', () => {
})
})
it('updateUI merges contextual tour seen ids instead of replacing stale snapshots', async () => {
const store = await createStore()
store.updateUI({
contextualToursSeenIds: ['browser']
})
store.updateUI({
contextualToursSeenIds: ['workspace-agent-sessions', 'unknown', 'browser'] as never
})
expect(store.getUI().contextualToursSeenIds).toEqual(['browser', 'workspace-agent-sessions'])
})
it('normalizes malformed persisted feature discovery state on read', async () => {
writeDataFile({
schemaVersion: 1,
@@ -2169,6 +2182,7 @@ describe('Store', () => {
settings: {},
ui: {
featureTipsSeenIds: ['voice-dictation', 'unknown-tip', 'voice-dictation'],
contextualToursSeenIds: ['tasks', 'unknown', 'tasks'] as never,
featureInteractions: {
tasks: { firstInteractedAt: 100 },
automations: { firstInteractedAt: 150, interactionCount: 4 },
@@ -2183,6 +2197,7 @@ describe('Store', () => {
const store = await createStore()
expect(store.getUI().featureTipsSeenIds).toEqual(['voice-dictation'])
expect(store.getUI().contextualToursSeenIds).toEqual(['tasks'])
expect(store.getUI().featureInteractions).toEqual({
tasks: { firstInteractedAt: 100, interactionCount: 1 },
automations: { firstInteractedAt: 150, interactionCount: 4 }
+22
View File
@@ -89,6 +89,7 @@ import {
normalizeFeatureInteractions,
type FeatureInteractionId
} from '../shared/feature-interactions'
import { normalizeContextualTourIds } from '../shared/contextual-tours'
import { normalizeFeatureTipIds } from '../shared/feature-tips'
import {
DEFAULT_WORKSPACE_STATUS_ID,
@@ -264,6 +265,17 @@ function mergeFeatureInteractions(
return merged
}
function mergeContextualTourSeenIds(
current: PersistedState['ui']['contextualToursSeenIds'],
incoming: PersistedState['ui']['contextualToursSeenIds']
): PersistedState['ui']['contextualToursSeenIds'] {
const merged = new Set(normalizeContextualTourIds(current))
for (const id of normalizeContextualTourIds(incoming)) {
merged.add(id)
}
return [...merged]
}
function normalizeSortBy(sortBy: unknown): PersistedState['ui']['sortBy'] {
if (
sortBy === 'smart' ||
@@ -2753,6 +2765,7 @@ export class Store {
this.state.ui?.workspaceBoardColumnWidth
),
featureTipsSeenIds: normalizeFeatureTipIds(this.state.ui?.featureTipsSeenIds),
contextualToursSeenIds: normalizeContextualTourIds(this.state.ui?.contextualToursSeenIds),
featureInteractions: normalizeFeatureInteractions(this.state.ui?.featureInteractions)
}
}
@@ -2792,6 +2805,15 @@ export class Store {
updates.featureTipsSeenIds !== undefined
? normalizeFeatureTipIds(updates.featureTipsSeenIds)
: normalizeFeatureTipIds(this.state.ui?.featureTipsSeenIds),
// Why: renderer and paired clients can mark different tours seen from
// stale UI snapshots; union them so completed tours stay suppressed.
contextualToursSeenIds:
updates.contextualToursSeenIds !== undefined
? mergeContextualTourSeenIds(
this.state.ui?.contextualToursSeenIds,
updates.contextualToursSeenIds
)
: normalizeContextualTourIds(this.state.ui?.contextualToursSeenIds),
// 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.
@@ -39,6 +39,7 @@ import type { SshConnectionStatus } from '../../../shared/ssh-types'
type RepoOption = React.ComponentProps<typeof RepoCombobox>['repos'][number]
type NewWorkspaceComposerCardProps = {
contextualTourSource?: string
containerClassName?: string
composerRef?: React.RefObject<HTMLDivElement | null>
nameInputRef?: React.RefObject<HTMLInputElement | null>
@@ -203,6 +204,7 @@ function useComposerFileDragOver(): {
}
export default function NewWorkspaceComposerCard({
contextualTourSource,
containerClassName,
composerRef,
nameInputRef,
@@ -293,9 +295,10 @@ export default function NewWorkspaceComposerCard({
useContextualTour(
'workspace-creation',
eligibleRepos.length > 0 && Boolean(repoId),
activeModal === 'new-workspace-composer'
? 'workspace_creation_modal'
: 'workspace_creation_visible'
contextualTourSource ??
(activeModal === 'new-workspace-composer'
? 'workspace_creation_modal'
: 'workspace_creation_visible')
)
return (
@@ -25,6 +25,8 @@ type ComposerModalData = {
* `workspace_created.source` carries the right value. Falls back to
* `unknown` when omitted. */
telemetrySource?: WorkspaceCreateTelemetrySource
contextualTourSource?: string
setupGuideTourRequestId?: string
}
export default function NewWorkspaceComposerModal(): React.JSX.Element | null {
@@ -207,6 +209,7 @@ function QuickTabBody({
<DialogTitle className="text-base font-semibold">{primaryActionLabel}</DialogTitle>
</DialogHeader>
<NewWorkspaceComposerCard
contextualTourSource={modalData.contextualTourSource}
composerRef={composerRef}
nameInputRef={nameInputRef}
quickAgent={quickAgent}
+12 -1
View File
@@ -204,10 +204,21 @@ function Terminal(): React.JSX.Element | null {
const activeWorktreeBrowserTabIdsKey = activeWorktreeId
? (browserTabsByWorktree[activeWorktreeId] ?? []).map((tab) => tab.id).join(',')
: ''
const activeContextualTourId = useAppStore((s) => s.activeContextualTourId)
const hasSplitTerminalPane = useAppStore((s) =>
Boolean(s.featureInteractions['terminal-pane-split']?.interactionCount)
)
useContextualTour(
'workspace-agent-sessions',
Boolean(activeWorktreeId && activeView === 'terminal' && workspaceSessionReady),
Boolean(
activeWorktreeId &&
activeView === 'terminal' &&
workspaceSessionReady &&
activeTabType === 'terminal' &&
Boolean(activeTabId) &&
(!hasSplitTerminalPane || activeContextualTourId === 'workspace-agent-sessions')
),
'workspace_agent_sessions_visible'
)
@@ -3,6 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ContextualTourId } from '../../../../shared/contextual-tours'
import { getContextualTourCleanupOutcome } from './ContextualTourOverlay'
import { performContextualTourStepAction } from './contextual-tour-step-actions'
import {
ContextualTourOverlaySurface,
handleContextualTourGlobalKeyDown,
@@ -49,6 +50,7 @@ function renderSurface(
onSkip?: (id: ContextualTourId) => void
onNext?: () => void
onBack?: () => void
onStepAction?: Parameters<typeof ContextualTourOverlaySurface>[0]['onStepAction']
} = {}
): ReactElement {
const renderState = { ...baseRenderState, ...overrides }
@@ -62,6 +64,7 @@ function renderSurface(
onSkip: callbacks.onSkip ?? vi.fn(),
onBack: callbacks.onBack ?? vi.fn(),
onNext: callbacks.onNext ?? vi.fn(),
onStepAction: callbacks.onStepAction ?? vi.fn(),
onOverlayKeyDownCapture: handleContextualTourOverlayKeyDown
})
}
@@ -224,6 +227,25 @@ describe('ContextualTourOverlaySurface', () => {
expect(onNext).toHaveBeenCalledTimes(1)
})
it('renders configured step action labels and wires them through the action callback', () => {
const onStepAction = vi.fn()
const primaryAction = { kind: 'split-terminal-pane' as const, label: 'Split terminal' }
const secondaryAction = { kind: 'next' as const, label: 'Skip' }
const element = renderSurface(
{
primaryAction,
secondaryAction
},
{ onStepAction }
)
findElementByText(element, 'Split terminal')?.props.onClick?.()
findElementByText(element, 'Skip')?.props.onClick?.()
expect(onStepAction).toHaveBeenCalledWith(primaryAction)
expect(onStepAction).toHaveBeenCalledWith(secondaryAction)
})
it('handles Escape by clicking Skip before page-level handlers see it', () => {
const click = vi.fn()
const preventDefault = vi.fn()
@@ -317,6 +339,57 @@ describe('ContextualTourOverlaySurface', () => {
})
})
describe('performContextualTourStepAction', () => {
it('opens Tasks after detaching the terminal-owned tour source', () => {
const finishTour = vi.fn()
const advanceContextualTour = vi.fn()
const detachContextualTourSource = vi.fn()
const openTaskPage = vi.fn()
performContextualTourStepAction({
action: { kind: 'open-tasks', label: 'Show tasks' },
activeTabId: 'tab-1',
isLastStep: false,
finishTour,
advanceContextualTour,
detachContextualTourSource,
setSidebarOpen: vi.fn(),
openTaskPage,
openModal: vi.fn(),
dispatchTerminalPaneSplit: vi.fn(),
schedule: vi.fn()
})
expect(detachContextualTourSource).toHaveBeenCalledTimes(1)
expect(openTaskPage).toHaveBeenCalledTimes(1)
expect(advanceContextualTour).toHaveBeenCalledTimes(1)
expect(finishTour).not.toHaveBeenCalled()
})
it('dispatches the terminal-pane split action against the active tab', () => {
const dispatchTerminalPaneSplit = vi.fn()
performContextualTourStepAction({
action: { kind: 'split-terminal-pane', label: 'Split terminal' },
activeTabId: 'tab-1',
isLastStep: false,
finishTour: vi.fn(),
advanceContextualTour: vi.fn(),
detachContextualTourSource: vi.fn(),
setSidebarOpen: vi.fn(),
openTaskPage: vi.fn(),
openModal: vi.fn(),
dispatchTerminalPaneSplit,
schedule: vi.fn()
})
expect(dispatchTerminalPaneSplit).toHaveBeenCalledWith({
tabId: 'tab-1',
direction: 'vertical'
})
})
})
describe('getContextualTourPanelHost', () => {
it('hosts controls inside Radix dialog and sheet content', () => {
const dialogHost = {} as HTMLElement
@@ -1,6 +1,10 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type JSX } from 'react'
import { useAppStore } from '@/store'
import { getContextualTour, type ContextualTourId } from '../../../../shared/contextual-tours'
import {
getContextualTour,
type ContextualTourId,
type ContextualTourStepAction
} from '../../../../shared/contextual-tours'
import type { ContextualTourOutcome } from '../../../../shared/feature-education-telemetry'
import {
trackContextualTourOutcome,
@@ -23,6 +27,11 @@ import {
handleContextualTourOverlayKeyDown,
type ActiveTourRenderState
} from './ContextualTourOverlaySurface'
import {
REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT,
type RequestActiveTerminalPaneSplitDetail
} from '@/constants/terminal'
import { performContextualTourStepAction } from './contextual-tour-step-actions'
export function ContextualTourOverlay(): JSX.Element | null {
const activeTourId = useAppStore((s) => s.activeContextualTourId)
@@ -36,12 +45,17 @@ export function ContextualTourOverlay(): JSX.Element | null {
const blockingSurfaceVisible = useAppStore((s) => s.contextualToursBlockingSurfaceVisible)
const activeTourSuppressed = useAppStore((s) => s.activeContextualTourSuppressed)
const keybindings = useAppStore((s) => s.keybindings)
const activeTabId = useAppStore((s) => s.activeTabId)
const markContextualToursSeen = useAppStore((s) => s.markContextualToursSeen)
const advanceContextualTour = useAppStore((s) => s.advanceContextualTour)
const regressContextualTour = useAppStore((s) => s.regressContextualTour)
const dismissContextualTour = useAppStore((s) => s.dismissContextualTour)
const completeContextualTour = useAppStore((s) => s.completeContextualTour)
const cancelContextualTour = useAppStore((s) => s.cancelContextualTour)
const detachContextualTourSource = useAppStore((s) => s.detachContextualTourSource)
const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
const openTaskPage = useAppStore((s) => s.openTaskPage)
const openModal = useAppStore((s) => s.openModal)
const [renderState, setRenderState] = useState<ActiveTourRenderState | null>(null)
const [measureVersion, setMeasureVersion] = useState(0)
const panelRef = useRef<HTMLElement | null>(null)
@@ -177,6 +191,8 @@ export function ContextualTourOverlay(): JSX.Element | null {
title: activeStep.title,
body: formatContextualTourStepCopy(getContextualTourStepCopy(activeStep), keybindings),
control: activeStep.control,
primaryAction: activeStep.primaryAction,
secondaryAction: activeStep.secondaryAction,
preferredPlacement: activeStep.preferredPlacement,
isLastStep: progress.current === progress.total,
isFirstStep: progress.current === 1,
@@ -284,6 +300,40 @@ export function ContextualTourOverlay(): JSX.Element | null {
return null
}
const finishTour = (): void => {
emitContextualTourOutcome('completed')
completeContextualTour(activeTourId)
}
const handleStepAction = (action: ContextualTourStepAction): void => {
performContextualTourStepAction({
action,
activeTabId,
isLastStep: renderState.isLastStep,
finishTour,
advanceContextualTour,
detachContextualTourSource: () => {
if (activeTourSource) {
detachContextualTourSource(activeTourId, activeTourSource)
}
},
setSidebarOpen,
openTaskPage,
openModal,
dispatchTerminalPaneSplit: (detail) => {
window.dispatchEvent(
new CustomEvent<RequestActiveTerminalPaneSplitDetail>(
REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT,
{ detail }
)
)
},
schedule: (callback) => {
window.setTimeout(callback, 0)
}
})
}
const viewport = {
width: typeof window === 'undefined' ? 1024 : window.innerWidth,
height: typeof window === 'undefined' ? 768 : window.innerHeight
@@ -311,12 +361,12 @@ export function ContextualTourOverlay(): JSX.Element | null {
onBack={regressContextualTour}
onNext={() => {
if (renderState.isLastStep) {
emitContextualTourOutcome('completed')
completeContextualTour(activeTourId)
finishTour()
} else {
advanceContextualTour()
}
}}
onStepAction={handleStepAction}
onOverlayKeyDownCapture={handleContextualTourOverlayKeyDown}
/>
)
@@ -7,7 +7,8 @@ import { useAppStore } from '@/store'
import type {
ContextualTourId,
ContextualTourStepPlacement,
ContextualTourStepControl
ContextualTourStepControl,
ContextualTourStepAction
} from '../../../../shared/contextual-tours'
import { ContextualTourArrow } from './ContextualTourArrow'
import { ContextualTourControl } from './ContextualTourControl'
@@ -25,6 +26,8 @@ export type ActiveTourRenderState = {
title: string
body: string
control?: ContextualTourStepControl
primaryAction?: ContextualTourStepAction
secondaryAction?: ContextualTourStepAction
preferredPlacement?: ContextualTourStepPlacement
isLastStep: boolean
isFirstStep: boolean
@@ -45,6 +48,7 @@ type ContextualTourOverlaySurfaceProps = {
onSkip: (id: ContextualTourId) => void
onBack: () => void
onNext: () => void
onStepAction: (action: ContextualTourStepAction) => void
onOverlayKeyDownCapture: (event: KeyboardEvent<HTMLDivElement>) => void
}
@@ -73,6 +77,7 @@ export function ContextualTourOverlaySurface({
onSkip,
onBack,
onNext,
onStepAction,
onOverlayKeyDownCapture
}: ContextualTourOverlaySurfaceProps): JSX.Element {
const panelHostSlot = panelHost?.getAttribute('data-slot')
@@ -90,6 +95,12 @@ export function ContextualTourOverlaySurface({
)
const stepKey = `${activeTourId}-${renderState.progress.current}`
const primaryAction =
renderState.primaryAction ??
({
kind: renderState.isLastStep ? 'complete' : 'next',
label: renderState.isLastStep ? 'Done' : 'Next'
} satisfies ContextualTourStepAction)
const panel = (
<section
@@ -132,9 +143,28 @@ export function ContextualTourOverlaySurface({
Back
</Button>
) : null}
<Button type="button" size="xs" onClick={onNext}>
{renderState.isLastStep ? 'Done' : 'Next'}
{!renderState.isLastStep ? <ArrowRight /> : null}
{renderState.secondaryAction ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onStepAction(renderState.secondaryAction!)}
>
{renderState.secondaryAction.label}
</Button>
) : null}
<Button
type="button"
size="xs"
onClick={
primaryAction.kind === (renderState.isLastStep ? 'complete' : 'next') &&
primaryAction.label === (renderState.isLastStep ? 'Done' : 'Next')
? onNext
: () => onStepAction(primaryAction)
}
>
{primaryAction.label}
{primaryAction.kind === 'next' && !renderState.isLastStep ? <ArrowRight /> : null}
</Button>
</div>
</div>
@@ -243,4 +243,35 @@ describe('contextual tour gate', () => {
expect(getContextualTourOutcomeStepTotal(visibleStepIndexes)).toBe(2)
expect(getContextualTourOutcomeStepTotal([])).toBe(1)
})
it('keeps the full workspace-agent-sessions tour defined while skipping unavailable later targets', () => {
const tour = getContextualTour('workspace-agent-sessions')
const visibleSelectors = new Set([
tour.steps[0]!.targetSelector,
tour.steps[1]!.targetSelector,
tour.steps[4]!.targetSelector
])
const targetExists = (selector: string): boolean => visibleSelectors.has(selector)
const visibleStepIndexes = getVisibleContextualTourStepIndexes(tour, targetExists)
expect(tour.steps.map((step) => step.title)).toEqual([
'Work side by side',
'Split the terminal',
'Keep separate tasks isolated',
'Start from real work',
'Orchestrate capable agents'
])
expect(visibleStepIndexes).toEqual([0, 1, 4])
expect(
getNextVisibleContextualTourStepIndex({
tour,
currentStepIndex: 1,
targetExists
})
).toBe(4)
expect(getContextualTourStepProgress({ visibleStepIndexes, stepIndex: 4 })).toEqual({
current: 3,
total: 3
})
})
})
@@ -0,0 +1,54 @@
import type { ContextualTourStepAction } from '../../../../shared/contextual-tours'
import type { RequestActiveTerminalPaneSplitDetail } from '@/constants/terminal'
export function performContextualTourStepAction(args: {
action: ContextualTourStepAction
activeTabId: string | null
isLastStep: boolean
finishTour: () => void
advanceContextualTour: () => void
detachContextualTourSource: () => void
setSidebarOpen: (open: boolean) => void
openTaskPage: () => void
openModal: (modal: 'setup-guide') => void
dispatchTerminalPaneSplit: (detail: RequestActiveTerminalPaneSplitDetail) => void
schedule: (callback: () => void) => void
}): void {
const advanceOrFinish = (): void => {
if (args.isLastStep) {
args.finishTour()
} else {
args.advanceContextualTour()
}
}
switch (args.action.kind) {
case 'next':
advanceOrFinish()
return
case 'complete':
args.finishTour()
return
case 'split-terminal-pane':
if (args.activeTabId) {
args.dispatchTerminalPaneSplit({ tabId: args.activeTabId, direction: 'vertical' })
}
return
case 'show-worktrees':
args.setSidebarOpen(true)
advanceOrFinish()
return
case 'open-tasks':
// Why: the auto tour starts from the terminal, but this CTA intentionally
// navigates to Tasks before the final setup-guide step.
args.detachContextualTourSource()
args.openTaskPage()
advanceOrFinish()
return
case 'open-getting-started':
args.finishTour()
args.schedule(() => {
args.openModal('setup-guide')
})
}
}
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { requestContextualTourWhenReady } from './request-contextual-tour-when-ready'
describe('requestContextualTourWhenReady', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
it('retries a forced tour request until the target starts', () => {
vi.useFakeTimers()
let activeContextualTourId: string | null = null
const requestContextualTour = vi.fn(() => {
if (requestContextualTour.mock.calls.length === 3) {
activeContextualTourId = 'workspace-agent-sessions'
}
})
vi.spyOn(useAppStore, 'getState').mockImplementation(
() =>
({
activeContextualTourId,
requestContextualTour
}) as unknown as ReturnType<typeof useAppStore.getState>
)
requestContextualTourWhenReady({
id: 'workspace-agent-sessions',
source: 'setup_guide_parallel_work',
retryDelayMs: 10,
maxAttempts: 5
})
vi.advanceTimersByTime(0)
vi.advanceTimersByTime(10)
vi.advanceTimersByTime(10)
vi.advanceTimersByTime(20)
expect(requestContextualTour).toHaveBeenCalledTimes(3)
expect(requestContextualTour).toHaveBeenLastCalledWith(
'workspace-agent-sessions',
'setup_guide_parallel_work',
undefined,
{ force: true }
)
})
it('stops when another tour becomes active before the target starts', () => {
vi.useFakeTimers()
let activeContextualTourId: string | null = null
const requestContextualTour = vi.fn(() => {
activeContextualTourId = 'tasks'
})
vi.spyOn(useAppStore, 'getState').mockImplementation(
() =>
({
activeContextualTourId,
requestContextualTour
}) as unknown as ReturnType<typeof useAppStore.getState>
)
requestContextualTourWhenReady({
id: 'workspace-agent-sessions',
source: 'setup_guide_parallel_work',
retryDelayMs: 10,
maxAttempts: 5
})
vi.advanceTimersByTime(0)
vi.advanceTimersByTime(50)
expect(requestContextualTour).toHaveBeenCalledTimes(1)
})
it('cancels pending retries when the caller unmounts or starts another action', () => {
vi.useFakeTimers()
const requestContextualTour = vi.fn()
vi.spyOn(useAppStore, 'getState').mockImplementation(
() =>
({
activeContextualTourId: null,
requestContextualTour
}) as unknown as ReturnType<typeof useAppStore.getState>
)
const cancel = requestContextualTourWhenReady({
id: 'workspace-agent-sessions',
source: 'setup_guide_parallel_work',
retryDelayMs: 10,
maxAttempts: 5
})
vi.advanceTimersByTime(0)
cancel()
vi.advanceTimersByTime(50)
expect(requestContextualTour).toHaveBeenCalledTimes(1)
})
it('stops before requesting when the destination surface is no longer current', () => {
vi.useFakeTimers()
const requestContextualTour = vi.fn()
const shouldContinue = vi.fn(() => false)
vi.spyOn(useAppStore, 'getState').mockImplementation(
() =>
({
activeContextualTourId: null,
requestContextualTour
}) as unknown as ReturnType<typeof useAppStore.getState>
)
requestContextualTourWhenReady({
id: 'workspace-creation',
source: 'setup_guide_parallel_work',
shouldContinue,
retryDelayMs: 10,
maxAttempts: 5
})
vi.advanceTimersByTime(50)
expect(shouldContinue).toHaveBeenCalledTimes(1)
expect(requestContextualTour).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,60 @@
import type { ContextualTourId } from '../../../../shared/contextual-tours'
import { useAppStore } from '@/store'
type RequestContextualTourWhenReadyArgs = {
id: ContextualTourId
source: string
wasFeaturePreviouslyInteracted?: boolean
maxAttempts?: number
retryDelayMs?: number
shouldContinue?: () => boolean
}
export type { RequestContextualTourWhenReadyArgs }
export function requestContextualTourWhenReady(
args: RequestContextualTourWhenReadyArgs
): () => void {
const maxAttempts = args.maxAttempts ?? 20
const retryDelayMs = args.retryDelayMs ?? 100
let attempts = 0
let timeoutId: ReturnType<typeof setTimeout> | null = null
let cancelled = false
const attempt = (): void => {
if (cancelled) {
return
}
if (args.shouldContinue && !args.shouldContinue()) {
cancelled = true
return
}
attempts += 1
const before = useAppStore.getState()
if (before.activeContextualTourId && before.activeContextualTourId !== args.id) {
return
}
before.requestContextualTour(args.id, args.source, args.wasFeaturePreviouslyInteracted, {
force: true
})
const after = useAppStore.getState()
if (after.activeContextualTourId === args.id || attempts >= maxAttempts) {
return
}
timeoutId = setTimeout(attempt, retryDelayMs)
}
// Why: setup-guide actions often reveal lazy surfaces first; retrying keeps
// explicit user-triggered education from depending on renderer mount timing.
timeoutId = setTimeout(attempt, 0)
return () => {
cancelled = true
if (timeoutId !== null) {
clearTimeout(timeoutId)
}
}
}
@@ -24,6 +24,9 @@ export function useContextualTour(
const activeModal = useAppStore((s) => s.activeModal)
const activeContextualTourId = useAppStore((s) => s.activeContextualTourId)
const activeContextualTourSource = useAppStore((s) => s.activeContextualTourSource)
const activeContextualTourSourceDetached = useAppStore(
(s) => s.activeContextualTourSourceDetached
)
const featureInteractions = useAppStore((s) => s.featureInteractions)
const contextualToursSeenIds = useAppStore((s) => s.contextualToursSeenIds)
const contextualToursAutoEligible = useAppStore((s) => s.contextualToursAutoEligible)
@@ -62,12 +65,18 @@ export function useContextualTour(
useEffect(() => {
// Why: source disable should end through the overlay so a shown tour gets
// a cancellation outcome; the store flag also lets pre-render attempts retry.
if (!enabled && activeContextualTourId === id && activeContextualTourSource === source) {
if (
!enabled &&
activeContextualTourId === id &&
activeContextualTourSource === source &&
!activeContextualTourSourceDetached
) {
suppressContextualTour(id, source)
}
}, [
activeContextualTourId,
activeContextualTourSource,
activeContextualTourSourceDetached,
enabled,
id,
source,
@@ -79,7 +88,11 @@ export function useContextualTour(
const state = useAppStore.getState()
// Why: surfaces like sheets can unmount without rendering an `enabled=false`
// pass, so suppress their active tour during cleanup too.
if (state.activeContextualTourId === id && state.activeContextualTourSource === source) {
if (
state.activeContextualTourId === id &&
state.activeContextualTourSource === source &&
!state.activeContextualTourSourceDetached
) {
state.suppressContextualTour(id, source)
}
}
@@ -4,7 +4,7 @@ import type {
FeatureWallSetupStep,
FeatureWallSetupStepId
} from '../../../../shared/feature-wall-setup-steps'
import { getFeatureWallSetupSteps } from '../../../../shared/feature-wall-setup-steps'
import { getFeatureWallSetupStepsForSection } from '../../../../shared/feature-wall-setup-steps'
import { cn } from '@/lib/utils'
import type { FeatureWallSetupProgress } from './feature-wall-setup-progress'
import { usePrefersReducedMotion } from './feature-wall-modal-helpers'
@@ -69,22 +69,24 @@ function SetupStepRow(props: {
function SetupSection(props: {
title: string
count: string
steps: readonly FeatureWallSetupStep[]
activeStepId: FeatureWallSetupStepId | null
progress: FeatureWallSetupProgress
onSelectStep: (id: FeatureWallSetupStepId) => void
}): React.JSX.Element {
const steps = getFeatureWallSetupSteps()
const doneCount = props.steps.filter((step) => props.progress.stepDone[step.id]).length
return (
<section className="space-y-2">
<div className="flex items-center justify-between gap-3">
<h4 className="text-xs font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{props.title}
</h4>
<span className="font-mono text-xs text-muted-foreground">{props.count}</span>
<span className="font-mono text-xs text-muted-foreground">
{doneCount}/{props.steps.length}
</span>
</div>
<div className="space-y-1.5">
{steps.map((step) => (
{props.steps.map((step) => (
<SetupStepRow
key={step.id}
step={step}
@@ -214,13 +216,22 @@ export function FeatureWallSetupChecklist(
): React.JSX.Element {
const { activeStep, progress, onSelectStep } = props
const activeDone = activeStep ? progress.stepDone[activeStep.id] : false
const parallelWorkSteps = getFeatureWallSetupStepsForSection('parallel-work')
const setupSteps = getFeatureWallSetupStepsForSection('setup')
return (
<div className="grid gap-5 lg:grid-cols-[minmax(280px,360px)_minmax(0,1fr)]">
<div className="space-y-5">
<SetupSection
title="Core"
count={`${progress.coreDoneCount}/${progress.coreTotal}`}
title="Parallel work"
steps={parallelWorkSteps}
activeStepId={activeStep?.id ?? null}
progress={progress}
onSelectStep={onSelectStep}
/>
<SetupSection
title="Setup"
steps={setupSteps}
activeStepId={activeStep?.id ?? null}
progress={progress}
onSelectStep={onSelectStep}
@@ -0,0 +1,159 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
activateWorktreeTerminalForSetupTour,
cancelPendingSetupGuideTourRequest,
isSetupGuideWorkspaceComposerRequestCurrent,
requestSetupGuideTourAfterFrame,
requestSetupGuideTourWhenReady
} from './FeatureWallSetupWorkflowActions'
import { useAppStore } from '@/store'
import * as worktreeActivation from '@/lib/worktree-activation'
import * as webRuntimeSession from '@/runtime/web-runtime-session'
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn()
}))
vi.mock('@/lib/focus-terminal-tab-surface', () => ({
focusTerminalTabSurface: vi.fn()
}))
vi.mock('@/runtime/web-runtime-session', () => ({
isWebRuntimeSessionActive: vi.fn(() => false)
}))
vi.mock('./FeatureWallSetupStepVisuals', () => ({
SetupTwoAgentsVisual: () => <div />,
SetupWorkspacesVisual: () => <div />
}))
vi.mock('./AddReposAnimatedVisual', () => ({
AddReposAnimatedVisual: () => <div />
}))
vi.mock('./SetupScriptAnimatedVisual', () => ({
SetupScriptAnimatedVisual: () => <div />
}))
describe('TwoAgentsAction', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
useAppStore.setState({
activeWorktreeId: null,
worktreesByRepo: {},
tabsByWorktree: {},
activeTabId: null,
activeGroupIdByWorktree: {},
terminalLayoutsByTabId: {}
})
})
it('creates a terminal tab before requesting the split tour when a worktree has only editor/browser tabs', () => {
const createTab = vi.fn(() => ({ id: 'terminal-tab-1' }))
const setActiveTab = vi.fn()
const setActiveTabType = vi.fn()
vi.mocked(worktreeActivation.activateAndRevealWorktree).mockReturnValue({ primaryTabId: null })
useAppStore.setState({
activeWorktreeId: 'worktree-1',
worktreesByRepo: {
'repo-1': [{ id: 'worktree-1', repoId: 'repo-1' }]
} as never,
tabsByWorktree: { 'worktree-1': [] },
activeTabId: 'editor-tab-1',
activeGroupIdByWorktree: { 'worktree-1': 'group-1' },
terminalLayoutsByTabId: {},
closeModal: vi.fn(),
createTab: createTab as never,
setActiveTabType: setActiveTabType as never,
setActiveTab: setActiveTab as never
})
const tabId = activateWorktreeTerminalForSetupTour('worktree-1')
expect(tabId).toBe('terminal-tab-1')
expect(createTab).toHaveBeenCalledWith('worktree-1', 'group-1')
expect(setActiveTabType).toHaveBeenCalledWith('terminal')
expect(setActiveTab).toHaveBeenCalledWith('terminal-tab-1')
})
it('does not create a local fallback terminal for paired web runtime sessions', () => {
const createTab = vi.fn(() => ({ id: 'terminal-tab-1' }))
const setActiveTab = vi.fn()
vi.mocked(webRuntimeSession.isWebRuntimeSessionActive).mockReturnValue(true)
vi.mocked(worktreeActivation.activateAndRevealWorktree).mockReturnValue({ primaryTabId: null })
useAppStore.setState({
settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never,
activeWorktreeId: 'worktree-1',
worktreesByRepo: {
'repo-1': [{ id: 'worktree-1', repoId: 'repo-1' }]
} as never,
tabsByWorktree: { 'worktree-1': [] },
activeTabId: 'editor-tab-1',
activeGroupIdByWorktree: { 'worktree-1': 'group-1' },
terminalLayoutsByTabId: {},
createTab: createTab as never,
setActiveTab: setActiveTab as never
})
const tabId = activateWorktreeTerminalForSetupTour('worktree-1')
expect(tabId).toBeNull()
expect(createTab).not.toHaveBeenCalled()
expect(setActiveTab).not.toHaveBeenCalled()
})
it('keeps setup-guide tour work alive across the modal transition that unmounts the action', () => {
const callbacks: FrameRequestCallback[] = []
const callback = vi.fn()
vi.stubGlobal('window', {
requestAnimationFrame: vi.fn((frameCallback: FrameRequestCallback) => {
callbacks.push(frameCallback)
return callbacks.length
}),
cancelAnimationFrame: vi.fn()
})
requestSetupGuideTourAfterFrame(callback)
callbacks[0]?.(1)
expect(callback).toHaveBeenCalledTimes(1)
cancelPendingSetupGuideTourRequest()
})
it('gates setup-guide tour retries on the expected destination surface', () => {
vi.useFakeTimers()
const requestContextualTour = vi.fn()
useAppStore.setState({
activeModal: 'none',
activeContextualTourId: null,
requestContextualTour: requestContextualTour as never
})
requestSetupGuideTourWhenReady({
id: 'workspace-creation',
source: 'setup_guide_parallel_work',
shouldContinue: () => useAppStore.getState().activeModal === 'new-workspace-composer',
retryDelayMs: 10,
maxAttempts: 5
})
vi.advanceTimersByTime(50)
expect(requestContextualTour).not.toHaveBeenCalled()
cancelPendingSetupGuideTourRequest()
vi.useRealTimers()
})
it('rejects a stale setup-guide composer request after close and reopen', () => {
useAppStore.setState({
activeModal: 'new-workspace-composer',
modalData: { setupGuideTourRequestId: 'new-request' }
})
expect(isSetupGuideWorkspaceComposerRequestCurrent('old-request')).toBe(false)
expect(isSetupGuideWorkspaceComposerRequestCurrent('new-request')).toBe(true)
})
})
@@ -4,15 +4,22 @@ import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { useAppStore } from '@/store'
import { useAllWorktrees } from '@/store/selectors'
import { getDefaultRepoHookSettings } from '../../../../shared/constants'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { RepoHookSettings, Worktree } from '../../../../shared/types'
import type { RepoHookSettings, TerminalPaneLayoutNode, Worktree } from '../../../../shared/types'
import { getRepositoryLocalCommandsSectionId } from '../settings/repository-settings-targets'
import { AddReposAnimatedVisual } from './AddReposAnimatedVisual'
import { SetupTwoAgentsVisual, SetupWorkspacesVisual } from './FeatureWallSetupStepVisuals'
import { SetupScriptAnimatedVisual } from './SetupScriptAnimatedVisual'
import {
requestContextualTourWhenReady,
type RequestContextualTourWhenReadyArgs
} from '../contextual-tours/request-contextual-tour-when-ready'
import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session'
export function AddReposAction(props: { reducedMotion: boolean }): React.JSX.Element {
const openModal = useAppStore((s) => s.openModal)
@@ -31,35 +38,85 @@ export function TwoAgentsAction(props: { reducedMotion: boolean }): React.JSX.El
const targetWorktree = useSetupTargetWorktree()
const openModal = useAppStore((s) => s.openModal)
const closeModal = useAppStore((s) => s.closeModal)
const requestContextualTour = useAppStore((s) => s.requestContextualTour)
const paneTarget = useSecondPaneTarget(targetWorktree?.id ?? null)
const handlePrimaryAction = useCallback(() => {
cancelPendingSetupGuideTourRequest()
if (!targetWorktree) {
openModal('new-workspace-composer', { telemetrySource: 'unknown' })
window.setTimeout(() => {
requestContextualTour('workspace-creation', 'setup_guide_try_it_out', false, {
force: true
const tourRequestId = createSetupGuideTourRequestId()
openModal('new-workspace-composer', {
telemetrySource: 'unknown',
contextualTourSource: 'setup_guide_parallel_work',
setupGuideTourRequestId: tourRequestId
})
requestSetupGuideTourWhenReady({
id: 'workspace-creation',
source: 'setup_guide_parallel_work',
wasFeaturePreviouslyInteracted: false,
shouldContinue: () => isSetupGuideWorkspaceComposerRequestCurrent(tourRequestId)
})
return
}
if (paneTarget) {
closeModal()
requestSetupGuideTourAfterFrame(() => {
activateAndRevealWorktree(targetWorktree.id)
useAppStore.getState().setActiveTabType('terminal')
activateTabAndFocusPane(paneTarget.tabId, paneTarget.leafId, {
flashFocusedPane: true
})
}, 80)
})
return
}
closeModal()
requestSetupGuideTourAfterFrame(() => {
activateWorktreeTerminalForSetupTour(targetWorktree.id)
requestSetupGuideTourWhenReady({
id: 'workspace-agent-sessions',
source: 'setup_guide_parallel_work',
wasFeaturePreviouslyInteracted: false,
shouldContinue: () => isWorktreeTerminalStillCurrent(targetWorktree.id)
})
})
}, [closeModal, openModal, paneTarget, targetWorktree])
const handleSecondaryAction = useCallback(() => {
cancelPendingSetupGuideTourRequest()
if (!targetWorktree) {
return
}
closeModal()
window.requestAnimationFrame(() => {
activateAndRevealWorktree(targetWorktree.id)
window.setTimeout(() => {
requestContextualTour('workspace-agent-sessions', 'setup_guide_try_it_out', false, {
force: true
})
}, 120)
})
}, [closeModal, openModal, requestContextualTour, targetWorktree])
}, [closeModal, targetWorktree])
return (
<div className="space-y-4">
<SetupTwoAgentsVisual reducedMotion={props.reducedMotion} />
<Button type="button" size="sm" className="w-fit gap-2" onClick={handlePrimaryAction}>
<ArrowUpRight className="size-3.5" />
Try it out
</Button>
<div className="space-y-2">
<p className="max-w-[48ch] text-xs text-muted-foreground">
{paneTarget
? 'You already have room for parallel work. Focus the second pane, then start another agent manually.'
: 'Split the terminal first, then start another agent manually in the new pane.'}
</p>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" className="w-fit gap-2" onClick={handlePrimaryAction}>
<ArrowUpRight className="size-3.5" />
{paneTarget ? 'Focus second pane' : 'Split terminal'}
</Button>
{targetWorktree && !paneTarget ? (
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
onClick={handleSecondaryAction}
>
Start from current workspace
</Button>
) : null}
</div>
</div>
</div>
)
}
@@ -67,7 +124,6 @@ export function TwoAgentsAction(props: { reducedMotion: boolean }): React.JSX.El
export function WorkspacesAction(props: { reducedMotion: boolean }): React.JSX.Element {
const openModal = useAppStore((s) => s.openModal)
const activeRepoId = useAppStore((s) => s.activeRepoId)
const requestContextualTour = useAppStore((s) => s.requestContextualTour)
return (
<div className="space-y-4">
<SetupWorkspacesVisual reducedMotion={props.reducedMotion} />
@@ -76,15 +132,20 @@ export function WorkspacesAction(props: { reducedMotion: boolean }): React.JSX.E
size="sm"
className="w-fit gap-2"
onClick={() => {
cancelPendingSetupGuideTourRequest()
const tourRequestId = createSetupGuideTourRequestId()
openModal('new-workspace-composer', {
...(activeRepoId ? { initialRepoId: activeRepoId } : {}),
telemetrySource: 'unknown'
telemetrySource: 'unknown',
contextualTourSource: 'setup_guide_parallel_work',
setupGuideTourRequestId: tourRequestId
})
requestSetupGuideTourWhenReady({
id: 'workspace-creation',
source: 'setup_guide_parallel_work',
wasFeaturePreviouslyInteracted: false,
shouldContinue: () => isSetupGuideWorkspaceComposerRequestCurrent(tourRequestId)
})
window.setTimeout(() => {
requestContextualTour('workspace-creation', 'setup_guide_try_it_out', false, {
force: true
})
}, 80)
}}
>
<ArrowUpRight className="size-3.5" />
@@ -211,3 +272,116 @@ function useSetupTargetWorktree(): Worktree | null {
[activeWorktreeId, allWorktrees]
)
}
export function activateWorktreeTerminalForSetupTour(worktreeId: string): string | null {
const activation = activateAndRevealWorktree(worktreeId)
if (!activation) {
return null
}
const state = useAppStore.getState()
const activeRuntimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId ?? null
const webRuntimeActive = isWebRuntimeSessionActive(activeRuntimeEnvironmentId)
const activeGroupId = state.activeGroupIdByWorktree[worktreeId]
const tabs = state.tabsByWorktree[worktreeId] ?? []
const activeTerminalTabId =
state.activeTabId && tabs.some((tab) => tab.id === state.activeTabId) ? state.activeTabId : null
const tabId =
activeTerminalTabId ??
activation.primaryTabId ??
tabs[0]?.id ??
(webRuntimeActive ? null : state.createTab(worktreeId, activeGroupId).id)
if (!tabId) {
return null
}
// Why: the forced tour's split action targets the visible terminal tab.
// Worktree activation can restore an editor/browser as the active surface.
state.setActiveTabType('terminal')
state.setActiveTab(tabId)
focusTerminalTabSurface(tabId)
return tabId
}
function useSecondPaneTarget(worktreeId: string | null): { tabId: string; leafId: string } | null {
const activeTabId = useAppStore((s) => s.activeTabId)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId)
return useMemo(() => {
if (!worktreeId) {
return null
}
const tabIds = (tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)
const orderedTabIds =
activeTabId && tabIds.includes(activeTabId)
? [activeTabId, ...tabIds.filter((tabId) => tabId !== activeTabId)]
: tabIds
for (const tabId of orderedTabIds) {
const root = terminalLayoutsByTabId[tabId]?.root
const secondLeafId = getSecondSplitLeafId(root)
if (secondLeafId) {
return { tabId, leafId: secondLeafId }
}
}
return null
}, [activeTabId, tabsByWorktree, terminalLayoutsByTabId, worktreeId])
}
function getSecondSplitLeafId(node: TerminalPaneLayoutNode | null | undefined): string | null {
if (!node || node.type === 'leaf') {
return null
}
return getLeftmostLeafId(node.second)
}
function getLeftmostLeafId(node: TerminalPaneLayoutNode): string {
return node.type === 'leaf' ? node.leafId : getLeftmostLeafId(node.first)
}
let pendingSetupGuideTourCancel: (() => void) | null = null
let pendingSetupGuideFrame: number | null = null
let setupGuideTourRequestSequence = 0
function createSetupGuideTourRequestId(): string {
setupGuideTourRequestSequence += 1
return `setup-guide-tour-${setupGuideTourRequestSequence}`
}
export function cancelPendingSetupGuideTourRequest(): void {
pendingSetupGuideTourCancel?.()
pendingSetupGuideTourCancel = null
if (pendingSetupGuideFrame !== null) {
window.cancelAnimationFrame(pendingSetupGuideFrame)
pendingSetupGuideFrame = null
}
}
export function requestSetupGuideTourWhenReady(args: RequestContextualTourWhenReadyArgs): void {
cancelPendingSetupGuideTourRequest()
pendingSetupGuideTourCancel = requestContextualTourWhenReady(args)
}
export function requestSetupGuideTourAfterFrame(callback: () => void): void {
cancelPendingSetupGuideTourRequest()
pendingSetupGuideFrame = window.requestAnimationFrame(() => {
pendingSetupGuideFrame = null
callback()
})
}
export function isSetupGuideWorkspaceComposerRequestCurrent(requestId: string): boolean {
const state = useAppStore.getState()
const modalData = state.modalData as { setupGuideTourRequestId?: unknown }
return (
state.activeModal === 'new-workspace-composer' &&
modalData.setupGuideTourRequestId === requestId
)
}
function isWorktreeTerminalStillCurrent(worktreeId: string): boolean {
const state = useAppStore.getState()
return (
state.activeModal === 'none' &&
state.activeWorktreeId === worktreeId &&
state.activeView === 'terminal' &&
state.activeTabType === 'terminal'
)
}
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest'
import type { FeatureWallSetupProgressInput } from './feature-wall-setup-progress'
import { getFeatureWallSetupProgress } from './feature-wall-setup-progress'
import { getFeatureWallSetupSteps } from '../../../../shared/feature-wall-setup-steps'
import {
getFeatureWallSetupSteps,
getFeatureWallSetupStepsForSection,
getFirstIncompleteFeatureWallSetupStepId
} from '../../../../shared/feature-wall-setup-steps'
import type { Worktree } from '../../../../shared/types'
function makeInput(
@@ -19,6 +23,7 @@ function makeInput(
worktreesByRepo: {},
tabsByWorktree: {},
agentStatusByPaneKey: {},
retainedAgentsByPaneKey: {},
hasSetupScript: false,
...overrides
}
@@ -40,19 +45,54 @@ describe('getFeatureWallSetupProgress', () => {
expect(progress.coreTotal).toBe(8)
})
it('keeps Add 2 projects as the final core setup task', () => {
it('orders visible parallel work before setup tasks', () => {
expect(getFeatureWallSetupSteps().map((step) => step.id)).toEqual([
'default-agent',
'notifications',
'two-agents',
'three-workspaces',
'notifications',
'default-agent',
'task-sources',
'agent-capabilities',
'setup-script',
'add-two-repos'
'add-two-repos',
'agent-capabilities'
])
})
it('groups setup guide steps into Parallel work and Setup sections', () => {
expect(getFeatureWallSetupStepsForSection('parallel-work').map((step) => step.id)).toEqual([
'two-agents',
'three-workspaces'
])
expect(getFeatureWallSetupStepsForSection('setup').map((step) => step.id)).toEqual([
'notifications',
'default-agent',
'task-sources',
'setup-script',
'add-two-repos',
'agent-capabilities'
])
})
it('auto-selects incomplete parallel work before setup steps', () => {
const progress = getFeatureWallSetupProgress(
makeInput({
settings: {
defaultTuiAgent: 'claude',
notifications: { enabled: true, agentTaskComplete: true }
} as never,
hasConnectedTaskSource: true,
hasSetupScript: true,
gitRepoCount: 2,
browserUseSkillInstalled: true,
computerUseSkillInstalled: true,
computerUsePermissionsReady: true,
orchestrationSkillInstalled: true
})
)
expect(getFirstIncompleteFeatureWallSetupStepId(progress.stepDone)).toBe('two-agents')
})
it('does not mark two agents complete from split-pane interaction alone', () => {
const progress = getFeatureWallSetupProgress(
makeInput({
@@ -117,6 +157,51 @@ describe('getFeatureWallSetupProgress', () => {
expect(progress.stepDone['two-agents']).toBe(true)
})
it('marks two agents complete across live and retained hook-reported sessions in one worktree', () => {
const retainedEntry = {
entry: {
paneKey: 'tab-2:00000000-0000-4000-8000-000000000002',
state: 'done',
prompt: 'second task',
updatedAt: 2,
stateStartedAt: 2,
agentType: 'codex',
stateHistory: []
},
worktreeId: 'worktree-1',
tab: { id: 'tab-2', title: 'Terminal' },
agentType: 'codex',
startedAt: 2
} as never
const progress = getFeatureWallSetupProgress(
makeInput({
worktreesByRepo: { 'repo-1': [makeWorktree('worktree-1')] },
tabsByWorktree: {
'worktree-1': [
{ id: 'tab-1', title: 'Terminal' },
{ id: 'tab-2', title: 'Terminal' }
] as never
},
agentStatusByPaneKey: {
'tab-1:00000000-0000-4000-8000-000000000001': {
paneKey: 'tab-1:00000000-0000-4000-8000-000000000001',
state: 'working',
prompt: 'first task',
updatedAt: 1,
stateStartedAt: 1,
agentType: 'claude',
stateHistory: []
}
},
retainedAgentsByPaneKey: {
'tab-2:00000000-0000-4000-8000-000000000002': retainedEntry
}
})
)
expect(progress.stepDone['two-agents']).toBe(true)
})
it('does not mark two agents complete when hook-reported agents are in separate worktrees', () => {
const progress = getFeatureWallSetupProgress(
makeInput({
@@ -6,6 +6,7 @@ import {
import type { GlobalSettings, TerminalTab, Worktree } from '../../../../shared/types'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
export type FeatureWallSetupProgressInput = {
settings: GlobalSettings | null
@@ -19,6 +20,7 @@ export type FeatureWallSetupProgressInput = {
worktreesByRepo: Record<string, Worktree[]>
tabsByWorktree: Record<string, TerminalTab[]>
agentStatusByPaneKey: Record<string, AgentStatusEntry>
retainedAgentsByPaneKey: Record<string, RetainedAgentEntry>
hasSetupScript: boolean
}
@@ -44,7 +46,13 @@ function hasTwoHookReportedAgentsInOneWorktree(input: FeatureWallSetupProgressIn
}
}
const agentCountsByWorktree = new Map<string, number>()
const paneKeysByWorktree = new Map<string, Set<string>>()
const addPaneKeyForWorktree = (worktreeId: string, paneKey: string): boolean => {
const paneKeys = paneKeysByWorktree.get(worktreeId) ?? new Set<string>()
paneKeys.add(paneKey)
paneKeysByWorktree.set(worktreeId, paneKeys)
return paneKeys.size >= 2
}
for (const paneKey of Object.keys(input.agentStatusByPaneKey)) {
const parsed = parsePaneKey(paneKey)
if (!parsed) {
@@ -54,11 +62,17 @@ function hasTwoHookReportedAgentsInOneWorktree(input: FeatureWallSetupProgressIn
if (!worktreeId) {
continue
}
const count = (agentCountsByWorktree.get(worktreeId) ?? 0) + 1
if (count >= 2) {
if (addPaneKeyForWorktree(worktreeId, paneKey)) {
return true
}
}
for (const [paneKey, retained] of Object.entries(input.retainedAgentsByPaneKey)) {
if (!validWorktreeIds.has(retained.worktreeId)) {
continue
}
if (addPaneKeyForWorktree(retained.worktreeId, paneKey)) {
return true
}
agentCountsByWorktree.set(worktreeId, count)
}
return false
}
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState, type JSX } from 'react'
import {
FEATURE_WALL_SETUP_STEP_IDS,
getFirstIncompleteFeatureWallSetupStepId,
getFeatureWallSetupSteps
} from '../../../../shared/feature-wall-setup-steps'
import type { FeatureWallSetupStepId } from '../../../../shared/feature-wall-setup-steps'
@@ -38,13 +39,24 @@ export default function SetupGuideModal(): JSX.Element | null {
const activeStep = setupSteps.find((step) => step.id === activeStepId) ?? setupSteps[0] ?? null
useEffect(() => {
if (!isOpen || requestedStepId === null) {
if (!isOpen) {
setUserSelectedStep(false)
return
}
if (requestedStepId === null) {
return
}
setUserSelectedStep(false)
setActiveStepId(requestedStepId)
}, [isOpen, requestedStepId])
useEffect(() => {
if (!isOpen || userSelectedStep || requestedStepId !== null) {
return
}
setActiveStepId(getFirstIncompleteFeatureWallSetupStepId(progress.stepDone))
}, [isOpen, progress.stepDone, requestedStepId, userSelectedStep])
useEffect(() => {
if (
!isOpen ||
@@ -55,11 +67,9 @@ export default function SetupGuideModal(): JSX.Element | null {
) {
return
}
const nextUnfinishedCoreStep = getFeatureWallSetupSteps().find(
(step) => !progress.stepDone[step.id]
)
if (nextUnfinishedCoreStep && nextUnfinishedCoreStep.id !== activeStep.id) {
setActiveStepId(nextUnfinishedCoreStep.id)
const nextUnfinishedCoreStepId = getFirstIncompleteFeatureWallSetupStepId(progress.stepDone)
if (nextUnfinishedCoreStepId !== activeStep.id) {
setActiveStepId(nextUnfinishedCoreStepId)
}
}, [activeStep, isOpen, progress.stepDone, requestedStepId, userSelectedStep])
@@ -27,6 +27,7 @@ export function useSetupGuideProgress(
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey)
const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey)
const preflightStatus = useAppStore((s) => s.preflightStatus)
const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked)
const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus)
@@ -169,6 +170,7 @@ export function useSetupGuideProgress(
worktreesByRepo,
tabsByWorktree,
agentStatusByPaneKey,
retainedAgentsByPaneKey,
hasSetupScript
}),
[
@@ -179,6 +181,7 @@ export function useSetupGuideProgress(
detectedOrchestrationSkillInstalled,
featureInteractions,
agentStatusByPaneKey,
retainedAgentsByPaneKey,
gitRepoCount,
hasConnectedTaskSource,
hasSetupScript,
@@ -109,6 +109,7 @@ const SidebarHeader = React.memo(function SidebarHeader() {
}}
aria-label="New workspace"
disabled={!canCreateWorkspace}
data-contextual-tour-target="workspace-create-control"
>
<Plus className="size-3.5" strokeWidth={2.25} />
</Button>
@@ -18,7 +18,8 @@ import type { GlobalSettings } from '../../../../shared/types'
import { getTaskPresetQuery, PER_REPO_FETCH_LIMIT } from '@/lib/new-workspace'
import { LinearIcon } from '@/components/icons/LinearIcon'
import {
getFeatureWallSetupSteps,
FEATURE_WALL_SETUP_PARALLEL_WORK_STEP_IDS,
getFirstIncompleteFeatureWallSetupStepId,
type FeatureWallSetupStepId
} from '../../../../shared/feature-wall-setup-steps'
import {
@@ -156,23 +157,28 @@ const SidebarNav = React.memo(function SidebarNav() {
// count instead of changing while the lazy modal is mounting.
const setupProgress = useSetupGuideProgress(true, false, false)
const setupComplete = setupProgress.coreDoneCount >= setupProgress.coreTotal
const firstUnfinishedSetupStepId = React.useMemo<FeatureWallSetupStepId>(() => {
const firstUnfinished = getFeatureWallSetupSteps().find(
(step) => !setupProgress.stepDone[step.id]
)
return firstUnfinished?.id ?? 'default-agent'
}, [setupProgress.stepDone])
const firstUnfinishedSetupStepId = React.useMemo<FeatureWallSetupStepId>(
() => getFirstIncompleteFeatureWallSetupStepId(setupProgress.stepDone),
[setupProgress.stepDone]
)
const hasIncompleteParallelWork = FEATURE_WALL_SETUP_PARALLEL_WORK_STEP_IDS.some(
(id) => !setupProgress.stepDone[id]
)
const hideMobileButton = React.useCallback(() => {
void updateSettings({ showMobileButton: false })
}, [updateSettings])
return (
<div className="flex flex-col gap-0.5 px-2 pt-2 pb-1">
<div
className="flex flex-col gap-0.5 px-2 pt-2 pb-1"
data-contextual-tour-target="sidebar-navigation"
>
{!setupComplete ? (
<button
type="button"
onClick={() => openModal('setup-guide', { setupStepId: firstUnfinishedSetupStepId })}
aria-current={setupActive ? 'page' : undefined}
data-contextual-tour-target="setup-guide-entry"
className={cn(
'flex w-full items-center gap-2 rounded-md border border-sidebar-border px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
setupActive
@@ -187,7 +193,14 @@ const SidebarNav = React.memo(function SidebarNav() {
)}
strokeWidth={setupActive ? 2.25 : 1.75}
/>
<span className="flex-1">Getting started with Orca</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate">Getting started with Orca</span>
{hasIncompleteParallelWork ? (
<span className="truncate text-[11px] font-normal leading-3 text-muted-foreground">
Try parallel work
</span>
) : null}
</span>
<span className="font-mono text-[11px] text-muted-foreground">
{setupProgress.coreDoneCount}/{setupProgress.coreTotal}
</span>
@@ -206,6 +219,7 @@ const SidebarNav = React.memo(function SidebarNav() {
onFocus={handlePrefetch}
disabled={!canBrowseTasks}
aria-current={tasksActive ? 'page' : undefined}
data-contextual-tour-target="sidebar-tasks"
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
tasksActive
@@ -1916,7 +1916,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
)
return (
<div data-worktree-sidebar-container className="relative min-h-0 flex-1">
<div
data-worktree-sidebar-container
data-contextual-tour-target="workspace-list"
className="relative min-h-0 flex-1"
>
<div
ref={scrollRef}
data-worktree-sidebar
@@ -3409,7 +3413,11 @@ const WorktreeList = React.memo(function WorktreeList({
if (worktrees.length === 0) {
return (
<div data-worktree-sidebar-container className="relative min-h-0 flex-1">
<div
data-worktree-sidebar-container
data-contextual-tour-target="workspace-list"
className="relative min-h-0 flex-1"
>
<div className="worktree-sidebar-scrollbar flex h-full flex-col overflow-y-scroll overflow-x-hidden pl-1 scrollbar-sleek pt-px">
<div className="flex flex-col items-center gap-2 px-4 py-6 text-center text-[11px] text-muted-foreground">
<span>No workspaces found</span>
@@ -1486,6 +1486,7 @@ export default function TerminalPane({
}, [cancelPendingRenameFrames, renamingPaneId])
const contextMenu = useTerminalPaneContextMenu({
tabId,
managerRef,
paneTransportsRef,
paneCwdRef,
@@ -1605,6 +1606,7 @@ export default function TerminalPane({
ref={containerRef}
className="absolute inset-0 min-h-0 min-w-0"
data-native-file-drop-target="terminal"
data-contextual-tour-target="terminal-pane-split-target"
data-terminal-tab-id={tabId}
data-pane-title-surface={titleUsesLightSurface ? 'light' : 'dark'}
style={terminalContainerStyle}
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
import type { PtyTransport } from './pty-transport'
import { getConnectionId } from '@/lib/connection-context'
@@ -8,10 +8,15 @@ import { sendTerminalQuickCommandToPane } from './terminal-quick-command-dispatc
import { splitWebRuntimeTerminal } from '@/runtime/web-runtime-session'
import { pasteTerminalText } from './terminal-bracketed-paste'
import { pasteTerminalClipboard } from './terminal-clipboard-paste'
import {
REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT,
type RequestActiveTerminalPaneSplitDetail
} from '@/constants/terminal'
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
type UseTerminalPaneContextMenuDeps = {
tabId: string
managerRef: React.RefObject<PaneManager | null>
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
paneCwdRef: React.RefObject<PaneCwdMap>
@@ -46,6 +51,7 @@ type TerminalMenuState = {
}
export function useTerminalPaneContextMenu({
tabId,
managerRef,
paneTransportsRef,
paneCwdRef,
@@ -74,7 +80,7 @@ export function useTerminalPaneContextMenu({
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
}, [])
const resolveMenuPane = (): ManagedPane | null => {
const resolveMenuPane = useCallback((): ManagedPane | null => {
const manager = managerRef.current
if (!manager) {
return null
@@ -87,7 +93,7 @@ export function useTerminalPaneContextMenu({
}
}
return manager.getActivePane() ?? panes[0] ?? null
}
}, [managerRef])
const onCopy = async (): Promise<void> => {
const pane = resolveMenuPane()
@@ -131,36 +137,55 @@ export function useTerminalPaneContextMenu({
// Split-pane CWD inheritance (docs/ssh-split-pane-inherit-cwd.md):
// mirror the Cmd+D path — sync split on confirmed OSC 7 cache hit,
// otherwise fall back to async resolveSplitCwd.
const splitWithInheritedCwd = (direction: 'vertical' | 'horizontal'): void => {
const pane = resolveMenuPane()
if (!pane) {
return
}
onSplitPaneCommand?.()
const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null
if (splitWebRuntimeTerminal(ptyId, direction)) {
return
}
const cached = paneCwdRef.current.get(pane.id)
if (cached?.confirmed && cached.cwd) {
managerRef.current?.splitPane(pane.id, direction, { cwd: cached.cwd })
return
}
const paneId = pane.id
void (async () => {
const cwd = await resolveSplitCwd({
paneCwdMap: paneCwdRef.current,
sourcePaneId: paneId,
sourcePtyId: ptyId,
fallbackCwd
})
managerRef.current?.splitPane(paneId, direction, { cwd })
})()
}
const splitWithInheritedCwd = useCallback(
(direction: 'vertical' | 'horizontal'): void => {
const pane = resolveMenuPane()
if (!pane) {
return
}
onSplitPaneCommand?.()
const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null
if (splitWebRuntimeTerminal(ptyId, direction)) {
return
}
const cached = paneCwdRef.current.get(pane.id)
if (cached?.confirmed && cached.cwd) {
managerRef.current?.splitPane(pane.id, direction, { cwd: cached.cwd })
return
}
const paneId = pane.id
void (async () => {
const cwd = await resolveSplitCwd({
paneCwdMap: paneCwdRef.current,
sourcePaneId: paneId,
sourcePtyId: ptyId,
fallbackCwd
})
managerRef.current?.splitPane(paneId, direction, { cwd })
})()
},
[fallbackCwd, managerRef, onSplitPaneCommand, paneCwdRef, paneTransportsRef, resolveMenuPane]
)
const onSplitRight = (): void => splitWithInheritedCwd('vertical')
const onSplitDown = (): void => splitWithInheritedCwd('horizontal')
useEffect(() => {
const onRequestSplit = (event: Event): void => {
const detail = (event as CustomEvent<RequestActiveTerminalPaneSplitDetail>).detail
if (detail?.tabId && detail.tabId !== tabId) {
return
}
contextPaneIdRef.current = null
splitWithInheritedCwd(detail?.direction ?? 'vertical')
}
window.addEventListener(REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT, onRequestSplit)
return () =>
window.removeEventListener(REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT, onRequestSplit)
// splitWithInheritedCwd closes over live refs; re-registering keeps the
// tour action aligned with the current focused pane and fallback cwd.
}, [tabId, splitWithInheritedCwd])
const onEqualizePaneSizes = (): void => {
const pane = resolveMenuPane()
const manager = managerRef.current
+6
View File
@@ -2,6 +2,7 @@ export const TOGGLE_TERMINAL_PANE_EXPAND_EVENT = 'orca-toggle-terminal-pane-expa
export const FOCUS_TERMINAL_PANE_EVENT = 'orca-focus-terminal-pane'
export const PASTE_TERMINAL_TEXT_EVENT = 'orca-paste-terminal-text'
export const SPLIT_TERMINAL_PANE_EVENT = 'orca-split-terminal-pane'
export const REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT = 'orca-request-active-terminal-pane-split'
export const CLOSE_TERMINAL_PANE_EVENT = 'orca-close-terminal-pane'
export const BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT = 'orca-background-mount-terminal-worktree'
@@ -49,6 +50,11 @@ export type SplitTerminalPaneDetail = {
ptyId?: string
}
export type RequestActiveTerminalPaneSplitDetail = {
tabId?: string | null
direction: 'horizontal' | 'vertical'
}
export type CloseTerminalPaneDetail = {
tabId: string
paneRuntimeId: number
+83 -8
View File
@@ -1142,15 +1142,35 @@ describe('createUISlice contextual tours', () => {
store
.getState()
.requestContextualTour('workspace-agent-sessions', 'setup_guide_try_it_out', false, {
.requestContextualTour('workspace-agent-sessions', 'setup_guide_parallel_work', false, {
force: true
})
expect(store.getState().activeContextualTourId).toBe('workspace-agent-sessions')
expect(store.getState().activeContextualTourSource).toBe('setup_guide_try_it_out')
expect(store.getState().activeContextualTourSource).toBe('setup_guide_parallel_work')
expect(store.getState().activeContextualTourWasFeaturePreviouslyInteracted).toBe(false)
})
it('preserves the bounded setup-guide parallel-work source on forced tour requests', () => {
const store = createUIStore()
stubContextualTourTargets(['[data-contextual-tour-target="workspace-agent-terminal-tip"]'])
store.getState().hydratePersistedUI(
makePersistedUI({
contextualToursAutoEligible: false,
contextualToursSeenIds: ['workspace-agent-sessions']
})
)
store
.getState()
.requestContextualTour('workspace-agent-sessions', 'setup_guide_parallel_work', false, {
force: true
})
expect(store.getState().activeContextualTourId).toBe('workspace-agent-sessions')
expect(store.getState().activeContextualTourSource).toBe('setup_guide_parallel_work')
})
it('allows only workspace creation over its workspace composer modal', () => {
const store = createUIStore()
stubContextualTourTargets([
@@ -1185,7 +1205,7 @@ describe('createUISlice contextual tours', () => {
expect(store.getState().activeContextualTourStepIndex).toBe(2)
})
it('completes the active split-pane tour when the split command interaction is recorded', () => {
it('advances the active split step when the split command interaction is recorded', () => {
const setMock = vi.fn(() => Promise.resolve())
vi.stubGlobal('window', {
api: {
@@ -1195,23 +1215,56 @@ describe('createUISlice contextual tours', () => {
}
})
const store = createUIStore()
stubContextualTourTargets(['[data-contextual-tour-target="workspace-agent-terminal-tip"]'])
stubContextualTourTargets([
'[data-contextual-tour-target="workspace-agent-terminal-tip"]',
'[data-contextual-tour-target="terminal-pane-split-target"], [data-contextual-tour-target="workspace-agent-terminal-tip"]',
'[data-contextual-tour-target="workspace-list"], [data-contextual-tour-target="workspace-create-control"], [data-contextual-tour-target="sidebar-workspaces"]'
])
store.getState().hydratePersistedUI(makeAutoTourEligibleUI())
setMock.mockClear()
store
.getState()
.requestContextualTour('workspace-agent-sessions', 'setup_guide_try_it_out', false, {
.requestContextualTour('workspace-agent-sessions', 'setup_guide_parallel_work', false, {
force: true
})
store.getState().advanceContextualTour()
store.getState().recordFeatureInteraction('terminal-pane-split')
expect(store.getState().activeContextualTourId).toBe('workspace-agent-sessions')
expect(store.getState().activeContextualTourStepIndex).toBe(2)
expect(store.getState().featureInteractions['terminal-pane-split']).toMatchObject({
interactionCount: 1
})
})
it('completes the split step when no later tour target is visible', () => {
const setMock = vi.fn(() => Promise.resolve())
vi.stubGlobal('window', {
api: {
ui: {
set: setMock
}
}
})
const store = createUIStore()
stubContextualTourTargets([
'[data-contextual-tour-target="workspace-agent-terminal-tip"]',
'[data-contextual-tour-target="terminal-pane-split-target"], [data-contextual-tour-target="workspace-agent-terminal-tip"]'
])
store.getState().hydratePersistedUI(makeAutoTourEligibleUI())
store
.getState()
.requestContextualTour('workspace-agent-sessions', 'setup_guide_parallel_work', false, {
force: true
})
store.getState().advanceContextualTour()
store.getState().recordFeatureInteraction('terminal-pane-split')
expect(store.getState().activeContextualTourId).toBeNull()
expect(store.getState().contextualToursSeenIds).toEqual(['workspace-agent-sessions'])
expect(store.getState().lastCompletedContextualTourId).toBe('workspace-agent-sessions')
expect(store.getState().featureInteractions['terminal-pane-split']).toMatchObject({
interactionCount: 1
})
})
it('marks the active contextual tour suppressed when its owning source disables', () => {
@@ -1231,6 +1284,28 @@ describe('createUISlice contextual tours', () => {
expect(store.getState().activeContextualTourSuppressed).toBe(true)
})
it('keeps an intentionally detached contextual tour active when its owning source disables', () => {
const store = createUIStore()
store.setState({
activeContextualTourId: 'workspace-agent-sessions',
activeContextualTourStepIndex: 3,
activeContextualTourSource: 'workspace_agent_sessions_visible',
activeContextualTourWasFeaturePreviouslyInteracted: false,
contextualTourShownThisSession: true
})
store
.getState()
.detachContextualTourSource('workspace-agent-sessions', 'workspace_agent_sessions_visible')
store
.getState()
.suppressContextualTour('workspace-agent-sessions', 'workspace_agent_sessions_visible')
expect(store.getState().activeContextualTourSourceDetached).toBe(true)
expect(store.getState().activeContextualTourSuppressed).toBe(false)
expect(store.getState().activeContextualTourId).toBe('workspace-agent-sessions')
})
it('cancels a not-yet-rendered tour without persistence churn', () => {
const setMock = vi.fn(() => Promise.resolve())
vi.stubGlobal('window', {
+19 -1
View File
@@ -539,6 +539,7 @@ export type UISlice = {
activeContextualTourId: ContextualTourId | null
activeContextualTourStepIndex: number
activeContextualTourSource: string | null
activeContextualTourSourceDetached: boolean
activeContextualTourWasFeaturePreviouslyInteracted: boolean
activeContextualTourSuppressed: boolean
contextualTourShownThisSession: boolean
@@ -555,6 +556,7 @@ export type UISlice = {
options?: { force?: boolean }
) => void
suppressContextualTour: (id: ContextualTourId, source: string) => void
detachContextualTourSource: (id: ContextualTourId, source: string) => void
advanceContextualTour: () => void
regressContextualTour: () => void
dismissContextualTour: (id?: ContextualTourId) => void
@@ -1048,6 +1050,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
activeContextualTourId: null,
activeContextualTourStepIndex: 0,
activeContextualTourSource: null,
activeContextualTourSourceDetached: false,
activeContextualTourWasFeaturePreviouslyInteracted: false,
activeContextualTourSuppressed: false,
contextualTourShownThisSession: false,
@@ -1098,6 +1101,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
activeContextualTourId: id,
activeContextualTourStepIndex: decision.stepIndex,
activeContextualTourSource: source,
activeContextualTourSourceDetached: false,
activeContextualTourWasFeaturePreviouslyInteracted:
wasFeaturePreviouslyInteracted ?? hasFeatureInteraction(s.featureInteractions, id),
activeContextualTourSuppressed: false,
@@ -1107,11 +1111,22 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
}),
suppressContextualTour: (id, source) =>
set((s) => {
if (s.activeContextualTourId !== id || s.activeContextualTourSource !== source) {
if (
s.activeContextualTourId !== id ||
s.activeContextualTourSource !== source ||
s.activeContextualTourSourceDetached
) {
return s
}
return s.activeContextualTourSuppressed ? s : { activeContextualTourSuppressed: true }
}),
detachContextualTourSource: (id, source) =>
set((s) => {
if (s.activeContextualTourId !== id || s.activeContextualTourSource !== source) {
return s
}
return s.activeContextualTourSourceDetached ? s : { activeContextualTourSourceDetached: true }
}),
advanceContextualTour: () =>
set((s) => {
if (!s.activeContextualTourId) {
@@ -1159,6 +1174,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
activeContextualTourId: null,
activeContextualTourStepIndex: 0,
activeContextualTourSource: null,
activeContextualTourSourceDetached: false,
activeContextualTourWasFeaturePreviouslyInteracted: false,
activeContextualTourSuppressed: false,
lastCompletedContextualTourId: null
@@ -1182,6 +1198,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
activeContextualTourId: null,
activeContextualTourStepIndex: 0,
activeContextualTourSource: null,
activeContextualTourSourceDetached: false,
activeContextualTourWasFeaturePreviouslyInteracted: false,
activeContextualTourSuppressed: false,
lastCompletedContextualTourId: tourId ?? null
@@ -1200,6 +1217,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
activeContextualTourId: null,
activeContextualTourStepIndex: 0,
activeContextualTourSource: null,
activeContextualTourSourceDetached: false,
activeContextualTourWasFeaturePreviouslyInteracted: false,
activeContextualTourSuppressed: false,
lastCompletedContextualTourId: null,
+46 -4
View File
@@ -20,12 +20,15 @@ describe('contextual tour definitions', () => {
expect(CONTEXTUAL_TOURS.map((tour) => tour.id)).toEqual(expectedIds)
for (const tour of CONTEXTUAL_TOURS) {
expect(tour.steps[0]?.requiredForStart).toBe(true)
if (tour.steps.length === 1) {
expect(tour.steps[0]?.advanceOnFeatureInteraction).toBeTruthy()
const stepCount = (tour.steps as readonly unknown[]).length
if (stepCount === 1) {
expect(
(tour.steps[0] as ContextualTour['steps'][number]).advanceOnFeatureInteraction
).toBeTruthy()
} else {
expect(tour.steps.length).toBeGreaterThanOrEqual(2)
expect(stepCount).toBeGreaterThanOrEqual(2)
}
expect(tour.steps.length).toBeLessThanOrEqual(3)
expect(stepCount).toBeLessThanOrEqual(tour.id === 'workspace-agent-sessions' ? 5 : 3)
for (const step of tour.steps) {
expect(step.title.length).toBeGreaterThan(0)
expect(step.body.length).toBeGreaterThan(0)
@@ -35,6 +38,45 @@ describe('contextual tour definitions', () => {
}
})
it('defines the workspace agent sessions value tour with split and navigation actions', () => {
const tour = CONTEXTUAL_TOURS.find((entry) => entry.id === 'workspace-agent-sessions') as
| ContextualTour
| undefined
expect(tour?.steps.map((step) => step.title)).toEqual([
'Work side by side',
'Split the terminal',
'Keep separate tasks isolated',
'Start from real work',
'Orchestrate capable agents'
])
expect(tour?.steps[0]).toMatchObject({
requiredForStart: true,
primaryAction: { kind: 'next', label: 'Show me' }
})
expect(tour?.steps[1]).toMatchObject({
primaryAction: { kind: 'split-terminal-pane', label: 'Split terminal' },
advanceOnFeatureInteraction: 'terminal-pane-split'
})
expect(tour?.steps[1]?.targetSelector).toContain('terminal-pane-split-target')
expect(tour?.steps[1]?.targetSelector).not.toContain('terminal-split-control')
expect(tour?.steps[1]?.secondaryAction).toBeUndefined()
expect(tour?.steps[2]).toMatchObject({
primaryAction: { kind: 'show-worktrees', label: 'Show worktrees' },
secondaryAction: { kind: 'next', label: 'Skip' }
})
expect(tour?.steps[2]?.targetSelector).toContain('workspace-list')
expect(tour?.steps[3]).toMatchObject({
primaryAction: { kind: 'open-tasks', label: 'Show tasks' },
secondaryAction: { kind: 'next', label: 'Skip' }
})
expect(tour?.steps[3]?.targetSelector).toContain('sidebar-tasks')
expect(tour?.steps[4]).toMatchObject({
primaryAction: { kind: 'open-getting-started', label: 'Open Getting started' },
secondaryAction: { kind: 'complete', label: 'Done' }
})
})
it('allows only workspace creation over its workspace composer modal', () => {
const modalTours = (CONTEXTUAL_TOURS as readonly ContextualTour[]).filter(
(tour) => tour.allowedActiveModals?.length
+53 -2
View File
@@ -12,6 +12,19 @@ export type ContextualTourStepControl = {
kind: 'auto-rename-branch-from-work'
}
export type ContextualTourStepActionKind =
| 'next'
| 'complete'
| 'split-terminal-pane'
| 'show-worktrees'
| 'open-tasks'
| 'open-getting-started'
export type ContextualTourStepAction = {
kind: ContextualTourStepActionKind
label: string
}
export type ContextualTourStepPlacement = 'top' | 'right' | 'bottom' | 'left'
export type ContextualTourStep = {
@@ -22,6 +35,8 @@ export type ContextualTourStep = {
fallbackCopy?: string
preferredPlacement?: ContextualTourStepPlacement
control?: ContextualTourStepControl
primaryAction?: ContextualTourStepAction
secondaryAction?: ContextualTourStepAction
advanceOnFeatureInteraction?: FeatureInteractionId
}
@@ -57,12 +72,48 @@ export const CONTEXTUAL_TOURS = [
id: 'workspace-agent-sessions',
steps: [
{
title: 'Split panes for agents',
body: 'Use the right-click menu or {terminal.splitRight} to split the terminal and keep multiple agent sessions visible at once.',
title: 'Work side by side',
body: 'Orca is built for running more than one thread of work at once.',
targetSelector: '[data-contextual-tour-target="workspace-agent-terminal-tip"]',
requiredForStart: true,
preferredPlacement: 'bottom',
primaryAction: { kind: 'next', label: 'Show me' }
},
{
title: 'Split the terminal',
body: 'Split right to make room for another agent, a server, or a follow-up.',
targetSelector:
'[data-contextual-tour-target="terminal-pane-split-target"], [data-contextual-tour-target="workspace-agent-terminal-tip"]',
preferredPlacement: 'bottom',
primaryAction: { kind: 'split-terminal-pane', label: 'Split terminal' },
advanceOnFeatureInteraction: 'terminal-pane-split'
},
{
title: 'Keep separate tasks isolated',
body: 'Use separate worktrees when you want different tasks or agents isolated from each other.',
targetSelector:
'[data-contextual-tour-target="workspace-list"], [data-contextual-tour-target="workspace-create-control"], [data-contextual-tour-target="sidebar-workspaces"]',
preferredPlacement: 'right',
primaryAction: { kind: 'show-worktrees', label: 'Show worktrees' },
secondaryAction: { kind: 'next', label: 'Skip' }
},
{
title: 'Start from real work',
body: 'Start from tasks, issues, reviews, or merge requests so agents begin with the right context.',
targetSelector:
'[data-contextual-tour-target="sidebar-tasks"], [data-contextual-tour-target="tasks-source-filters"], [data-contextual-tour-target="sidebar-navigation"]',
preferredPlacement: 'right',
primaryAction: { kind: 'open-tasks', label: 'Show tasks' },
secondaryAction: { kind: 'next', label: 'Skip' }
},
{
title: 'Orchestrate capable agents',
body: 'Use browser context, setup scripts, notifications, orchestration, and action skills so agents can coordinate beyond the terminal.',
targetSelector:
'[data-contextual-tour-target="browser-toolbar"], [data-contextual-tour-target="setup-guide-entry"], [data-contextual-tour-target="sidebar-navigation"]',
preferredPlacement: 'right',
primaryAction: { kind: 'open-getting-started', label: 'Open Getting started' },
secondaryAction: { kind: 'complete', label: 'Done' }
}
]
},
@@ -15,6 +15,9 @@ describe('feature education telemetry constants', () => {
expect(normalizeFeatureEducationSource('workspace_agent_sessions_visible')).toBe(
'workspace_agent_sessions_visible'
)
expect(normalizeFeatureEducationSource('setup_guide_parallel_work')).toBe(
'setup_guide_parallel_work'
)
expect(normalizeFeatureEducationSource('https://example.com/private')).toBe('unknown')
expect(normalizeFeatureEducationSource(null)).toBe('unknown')
})
@@ -17,6 +17,7 @@ export const FEATURE_EDUCATION_SOURCES = [
'automations_open',
'workspace_creation_visible',
'workspace_creation_modal',
'setup_guide_parallel_work',
'unknown'
] as const
+56 -18
View File
@@ -15,19 +15,14 @@ export type FeatureWallSetupStep = {
readonly description: string
}
export const FEATURE_WALL_SETUP_PARALLEL_WORK_STEP_IDS = [
'two-agents',
'three-workspaces'
] as const satisfies readonly FeatureWallSetupStepId[]
export type FeatureWallSetupSectionId = 'parallel-work' | 'setup'
export const FEATURE_WALL_SETUP_STEPS: readonly FeatureWallSetupStep[] = [
{
id: 'default-agent',
name: 'Pick a default agent',
subtitle: 'Pick a default agent',
description: 'Start new work with the agent you trust most, without choosing every time.'
},
{
id: 'notifications',
name: 'Configure notifications',
subtitle: 'Configure notifications',
description: 'Know when agents need attention, finish work, or get blocked.'
},
{
id: 'two-agents',
name: 'Start 2 agents in one worktree',
@@ -40,18 +35,24 @@ export const FEATURE_WALL_SETUP_STEPS: readonly FeatureWallSetupStep[] = [
subtitle: 'Create 2 worktrees',
description: 'Keep separate tasks in separate worktrees so agents can work independently.'
},
{
id: 'notifications',
name: 'Configure notifications',
subtitle: 'Configure notifications',
description: 'Know when agents need attention, finish work, or get blocked.'
},
{
id: 'default-agent',
name: 'Pick a default agent',
subtitle: 'Pick a default agent',
description: 'Start new work with the agent you trust most, without choosing every time.'
},
{
id: 'task-sources',
name: 'Enable task sources',
subtitle: 'Enable task sources',
description: 'Start work directly from your tasks and keep PR status in view.'
},
{
id: 'agent-capabilities',
name: 'Install CLI & Skills',
subtitle: 'Install CLI & Skills',
description: 'Enable the tools that let agents work more independently.'
},
{
id: 'setup-script',
name: 'Configure a setup script',
@@ -64,6 +65,12 @@ export const FEATURE_WALL_SETUP_STEPS: readonly FeatureWallSetupStep[] = [
name: 'Add 2 projects',
subtitle: 'Add 2 projects',
description: 'Bring your key repos into Orca and run agent work across them in parallel.'
},
{
id: 'agent-capabilities',
name: 'Install CLI & Skills',
subtitle: 'Install CLI & Skills',
description: 'Unlock browser, orchestration, and computer-use actions for agents.'
}
] as const
@@ -72,3 +79,34 @@ export const FEATURE_WALL_SETUP_STEP_IDS = FEATURE_WALL_SETUP_STEPS.map((step) =
export function getFeatureWallSetupSteps(): readonly FeatureWallSetupStep[] {
return FEATURE_WALL_SETUP_STEPS
}
export function getFeatureWallSetupSectionId(
stepId: FeatureWallSetupStepId
): FeatureWallSetupSectionId {
return FEATURE_WALL_SETUP_PARALLEL_WORK_STEP_IDS.includes(
stepId as (typeof FEATURE_WALL_SETUP_PARALLEL_WORK_STEP_IDS)[number]
)
? 'parallel-work'
: 'setup'
}
export function getFeatureWallSetupStepsForSection(
sectionId: FeatureWallSetupSectionId
): readonly FeatureWallSetupStep[] {
return FEATURE_WALL_SETUP_STEPS.filter(
(step) => getFeatureWallSetupSectionId(step.id) === sectionId
)
}
export function getFirstIncompleteFeatureWallSetupStepId(
stepDone: Partial<Record<FeatureWallSetupStepId, boolean>>
): FeatureWallSetupStepId {
const parallelStep = getFeatureWallSetupStepsForSection('parallel-work').find(
(step) => !stepDone[step.id]
)
if (parallelStep) {
return parallelStep.id
}
const setupStep = getFeatureWallSetupStepsForSection('setup').find((step) => !stepDone[step.id])
return setupStep?.id ?? FEATURE_WALL_SETUP_STEPS[0].id
}
@@ -24,6 +24,24 @@ describe('feature education telemetry event schemas', () => {
expect(parsed.success).toBe(true)
})
it('accepts setup-guide contextual tour sources for shown and outcome telemetry', () => {
const shown = eventSchemas.contextual_tour_shown.safeParse({
tour_id: 'workspace-agent-sessions',
source: 'setup_guide_parallel_work',
was_feature_previously_interacted: false
})
const outcome = eventSchemas.contextual_tour_outcome.safeParse({
tour_id: 'workspace-agent-sessions',
source: 'setup_guide_parallel_work',
outcome: 'completed',
steps_seen: 2,
total_steps: 5
})
expect(shown.success).toBe(true)
expect(outcome.success).toBe(true)
})
it('rejects contextual tour outcome payloads with impossible progress', () => {
const parsed = eventSchemas.contextual_tour_outcome.safeParse({
tour_id: 'tasks',