mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
Add new-user parallel work activation tour
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
+125
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user