Revert "Add contextual feature tours"

This reverts commit 77ae11fcc3.
This commit is contained in:
brennanb2025
2026-05-24 14:00:24 -07:00
parent 77ae11fcc3
commit b024b88356
29 changed files with 25 additions and 2030 deletions
-17
View File
@@ -113,7 +113,6 @@ import type { RemoteWorkspacePatchResult } from '../../shared/remote-workspace-t
import type { OnboardingState } from '../../shared/types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import { getFeatureTipsAppOpenDecision } from './components/feature-tips/feature-tip-startup-gate'
import { ContextualTourOverlay } from './components/contextual-tours/ContextualTourOverlay'
import {
keybindingMatchesAction,
type KeybindingActionId,
@@ -287,8 +286,6 @@ function App(): React.JSX.Element {
openModal: s.openModal,
closeModal: s.closeModal,
markFeatureTipsSeen: s.markFeatureTipsSeen,
setContextualToursOnboardingVisible: s.setContextualToursOnboardingVisible,
cancelContextualTour: s.cancelContextualTour,
toggleRightSidebar: s.toggleRightSidebar,
setRightSidebarOpen: s.setRightSidebarOpen,
setRightSidebarTab: s.setRightSidebarTab,
@@ -416,7 +413,6 @@ function App(): React.JSX.Element {
const [collapsedSidebarHeaderWidth, setCollapsedSidebarHeaderWidth] = useState(0)
const [mountedLazyModalIds, setMountedLazyModalIds] = useState(() => new Set<string>())
const [onboarding, setOnboarding] = useState<OnboardingState | null>(null)
const [onboardingLoaded, setOnboardingLoaded] = useState(false)
const featureTipsPromptedThisSessionRef = useRef(false)
const featureTipsSuppressedByOnboardingThisSessionRef = useRef(false)
const [onboardingSettingsDetour, setOnboardingSettingsDetour] = useState(false)
@@ -451,17 +447,6 @@ function App(): React.JSX.Element {
return onOnboardingReopened(setOnboarding)
}, [])
useEffect(() => {
// Why: `onboarding === null` is the startup loading state. Suppress
// contextual tours until the persisted onboarding state is known so a
// first-run user cannot have a tour marked seen before onboarding appears.
const suppressTours = !onboardingLoaded || shouldShowOnboarding(onboarding)
actions.setContextualToursOnboardingVisible(suppressTours)
if (suppressTours) {
actions.cancelContextualTour()
}
}, [actions, onboarding, onboardingLoaded])
useEffect(() => {
const featureTipsDecision = getFeatureTipsAppOpenDecision({
activeModal,
@@ -572,7 +557,6 @@ function App(): React.JSX.Element {
const onboardingState = await window.api.onboarding.get()
if (!cancelled) {
setOnboarding(onboardingState)
setOnboardingLoaded(true)
}
// Why: SSH connections must be re-established BEFORE terminal
@@ -1702,7 +1686,6 @@ function App(): React.JSX.Element {
{mountedLazyModalIds.has('feature-wall') ? <FeatureWallModal /> : null}
{mountedLazyModalIds.has('feature-tips') ? <FeatureTipsModal /> : null}
</Suspense>
<ContextualTourOverlay />
{/* Why: mount PetOverlay only when the experimental flag is on AND
the user hasn't hit "Hide pet" in the status-bar menu. Both
conditions must be true — see design doc (pet-overlay.md) on why
@@ -307,7 +307,7 @@ export default function NewWorkspaceComposerCard({
)}
>
<div className="min-w-0 space-y-4 pt-3">
<div className="space-y-1" data-contextual-tour-target="workspace-creation-source">
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-muted-foreground">Project</label>
<Tooltip>
@@ -452,7 +452,6 @@ export default function NewWorkspaceComposerCard({
size="sm"
onClick={onToggleAdvanced}
className="-ml-2 text-xs"
data-contextual-tour-target="workspace-creation-setup"
>
Advanced
<ChevronDown
@@ -649,7 +648,6 @@ export default function NewWorkspaceComposerCard({
disabled={createDisabled}
size="sm"
className="text-xs"
data-contextual-tour-target="workspace-creation-action"
>
{creating ? <LoaderCircle className="size-4 animate-spin" /> : null}
{primaryActionLabel}
@@ -8,7 +8,6 @@ import { AGENT_CATALOG } from '@/lib/agent-catalog'
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
import { shouldAllowComposerEnterSubmitTarget } from '@/lib/new-workspace-enter-guard'
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
import type {
TuiAgent,
WorkspaceCreateTelemetrySource,
@@ -99,7 +98,6 @@ function QuickTabBody({
onClose: () => void
active: boolean
}): React.JSX.Element {
useContextualTour('workspace-creation', active, 'workspace_creation_modal')
const settings = useAppStore((s) => s.settings)
const { cardProps, composerRef, nameInputRef, submitQuick, createDisabled } = useComposerState({
initialName: modalData.prefilledName ?? '',
+7 -40
View File
@@ -132,7 +132,6 @@ import type {
TaskViewPresetId
} from '../../../shared/types'
import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard'
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { useTeamStates } from '@/hooks/useIssueMetadata'
import {
@@ -3072,17 +3071,6 @@ export default function TaskPage(): React.JSX.Element {
'idle'
)
const [linearConnectError, setLinearConnectError] = useState<string | null>(null)
useContextualTour(
'tasks',
!dialogWorkItem &&
!gitlabDialogItem &&
!selectedLinearIssue &&
!newIssueOpen &&
!newLinearIssueOpen &&
!linearConnectOpen &&
activeModal === 'none',
'tasks_open'
)
const activeGithubTaskKind = getGitHubTaskKind(activeTaskPreset, appliedTaskSearch)
@@ -4159,10 +4147,7 @@ export default function TaskPage(): React.JSX.Element {
<section className="flex flex-col gap-3">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<div
className="flex min-w-0 flex-wrap items-center gap-2"
data-contextual-tour-target="tasks-source-filters"
>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{/* Why: Close is anchored left in the same row as the
source icons so the top chrome is one compact band.
Left-aligned keeps it clear of the app sidebar on the
@@ -4338,10 +4323,7 @@ export default function TaskPage(): React.JSX.Element {
) : null}
{taskSource === 'github' && githubMode === 'items' ? (
<div
className="min-w-0 rounded-md rounded-b-none border border-border/50 bg-muted/50 p-3 shadow-sm"
data-contextual-tour-target="tasks-search-presets"
>
<div className="min-w-0 rounded-md rounded-b-none border border-border/50 bg-muted/50 p-3 shadow-sm">
<div className="mb-3 flex flex-wrap gap-2">
{getGitHubTaskKindPresets(activeGithubTaskKind).map((option) => {
const active = activeTaskPreset === option.id
@@ -4411,10 +4393,7 @@ export default function TaskPage(): React.JSX.Element {
</button>
) : null}
</div>
<div
className="flex shrink-0 items-center gap-2"
data-contextual-tour-target="tasks-actions"
>
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -4537,10 +4516,7 @@ export default function TaskPage(): React.JSX.Element {
})()}
</div>
) : taskSource === 'linear' && linearStatus.connected ? (
<div
className="min-w-0 rounded-md rounded-b-none border border-border/50 bg-muted/50 p-3 shadow-sm"
data-contextual-tour-target="tasks-search-presets"
>
<div className="min-w-0 rounded-md rounded-b-none border border-border/50 bg-muted/50 p-3 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{LINEAR_PRESETS.map((preset) => {
@@ -4568,10 +4544,7 @@ export default function TaskPage(): React.JSX.Element {
)
})}
</div>
<div
className="flex shrink-0 items-center gap-2"
data-contextual-tour-target="tasks-actions"
>
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -4708,10 +4681,7 @@ export default function TaskPage(): React.JSX.Element {
/>
</div>
</div>
<div
className="min-w-0 rounded-md rounded-b-none border border-border/50 bg-muted/50 p-3 shadow-sm"
data-contextual-tour-target="tasks-search-presets"
>
<div className="min-w-0 rounded-md rounded-b-none border border-border/50 bg-muted/50 p-3 shadow-sm">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<div className="flex flex-wrap gap-2">
@@ -4743,10 +4713,7 @@ export default function TaskPage(): React.JSX.Element {
: null}
</div>
</div>
<div
className="flex shrink-0 items-center gap-2"
data-contextual-tour-target="tasks-actions"
>
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -83,7 +83,6 @@ import { AutomationRunHistory } from './AutomationRunHistory'
import { AUTOMATION_TEMPLATES, type AutomationTemplate } from './automation-templates'
import { ExternalAutomationManagers } from './ExternalAutomationManagers'
import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable'
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
const AGENTS = AGENT_CATALOG.map((agent) => agent.id)
const DEFAULT_TIME = '09:00'
@@ -275,11 +274,6 @@ export default function AutomationsPage(): React.JSX.Element {
manager: ExternalAutomationManager
job: ExternalAutomationJob
} | null>(null)
useContextualTour(
'automations',
!createOpen && !deleteTarget && !externalDeleteTarget,
'automations_open'
)
const [editingExternalTarget, setEditingExternalTarget] = useState<{
manager: ExternalAutomationManager
job: ExternalAutomationJob
@@ -1265,7 +1259,6 @@ export default function AutomationsPage(): React.JSX.Element {
aria-label="Add automation"
onClick={() => openCreateDialog()}
className="border border-border/50 bg-transparent hover:bg-muted/50"
data-contextual-tour-target="automations-create"
>
<Plus className="size-4" />
</Button>
@@ -1444,10 +1437,7 @@ export default function AutomationsPage(): React.JSX.Element {
</Dialog>
<div className="grid min-h-0 flex-1 grid-cols-[minmax(280px,360px)_1fr] overflow-hidden border-t border-border/50">
<section
className="flex min-h-0 flex-col border-r border-border/50 bg-muted/20"
data-contextual-tour-target="automations-list"
>
<section className="flex min-h-0 flex-col border-r border-border/50 bg-muted/20">
<div className="scrollbar-sleek min-h-0 flex-1 overflow-auto p-2">
{automations.length + externalAutomationEntries.length > 0 ? (
<div className="grid grid-cols-[1fr_auto] gap-2 px-2 pb-2 text-[11px] font-medium uppercase text-muted-foreground">
@@ -1819,10 +1809,7 @@ export default function AutomationsPage(): React.JSX.Element {
onValueChange={(value) => setActivePaneTab(value as AutomationPaneTab)}
className="min-h-0 flex-1 gap-0"
>
<div
className="flex shrink-0 items-center justify-between border-b border-border/50 px-5 py-2"
data-contextual-tour-target="automations-runs"
>
<div className="flex shrink-0 items-center justify-between border-b border-border/50 px-5 py-2">
<TabsList variant="line" className="h-8">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="runs" disabled={!selected}>
@@ -151,7 +151,6 @@ import {
type BrowserDriverState
} from '@/lib/pane-manager/browser-mobile-driver-state'
import { shouldPollChromiumErrorPage } from './chromium-error-page-polling'
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
type BrowserTabPageState = Partial<
Pick<
@@ -785,9 +784,6 @@ export default function BrowserPane({
const setBrowserPageUrl = useAppStore((s) => s.setBrowserPageUrl)
const runtimeEnvironmentActive = Boolean(activeRuntimeEnvironmentId?.trim())
const activeBrowserPageId = activeBrowserPage?.id ?? null
const activeBrowserPageIsBlank =
!runtimeEnvironmentActive &&
(activeBrowserPage?.url === 'about:blank' || activeBrowserPage?.url === ORCA_BROWSER_BLANK_URL)
const [activeBrowserDriver, setActiveBrowserDriver] = useState<BrowserDriverState>({
kind: 'idle'
})
@@ -814,15 +810,6 @@ export default function BrowserPane({
})
}, [activeBrowserPageId, runtimeEnvironmentActive])
useContextualTour(
'browser',
isActive &&
activeBrowserPage !== null &&
!runtimeEnvironmentActive &&
!activeBrowserPageIsBlank,
'browser_visible'
)
const reclaimActiveBrowserForDesktop = useCallback(async (): Promise<void> => {
if (!activeBrowserPageId) {
return
@@ -2408,10 +2395,7 @@ function RemoteBrowserPagePane({
document.body
)
: null}
<div
className="relative z-10 flex items-center gap-2 border-b border-border/70 bg-background/95 px-3 py-1.5"
data-contextual-tour-target="browser-toolbar"
>
<div className="relative z-10 flex items-center gap-2 border-b border-border/70 bg-background/95 px-3 py-1.5">
<Button
size="icon"
variant="ghost"
@@ -4317,10 +4301,7 @@ function BrowserPagePane({
)
: null}
<div
className="relative z-10 flex items-center gap-2 border-b border-border/70 bg-background/95 px-3 py-1.5"
data-contextual-tour-target="browser-toolbar"
>
<div className="relative z-10 flex items-center gap-2 border-b border-border/70 bg-background/95 px-3 py-1.5">
<Button
size="icon"
variant="ghost"
@@ -4387,7 +4368,6 @@ function BrowserPagePane({
onClick={() => startGrabIntent('copy')}
disabled={isBlankTab}
aria-label="Grab page element"
data-contextual-tour-target="browser-grab-control"
>
<Crosshair className="size-4" />
</Button>
@@ -4417,7 +4397,6 @@ function BrowserPagePane({
onClick={() => startGrabIntent('annotate')}
disabled={isBlankTab}
aria-label="Annotate page element"
data-contextual-tour-target="browser-annotation-control"
>
<MessageSquarePlus className="size-4" />
{browserAnnotations.length > 0 ? (
@@ -9,7 +9,6 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { useAppStore } from '@/store'
type ConfirmationDialogOptions = {
title: string
@@ -40,9 +39,6 @@ export function ConfirmationDialogProvider({
const [renderedRequest, setRenderedRequest] = useState<ConfirmationDialogRequest | null>(null)
const activeRequestRef = useRef<ConfirmationDialogRequest | null>(activeRequest)
const displayedRequest = activeRequest ?? renderedRequest
const setContextualToursBlockingSurfaceVisible = useAppStore(
(s) => s.setContextualToursBlockingSurfaceVisible
)
useEffect(() => {
activeRequestRef.current = activeRequest
@@ -51,13 +47,6 @@ export function ConfirmationDialogProvider({
}
}, [activeRequest])
useEffect(() => {
// Why: this provider's dialog is not represented by activeModal. Block
// contextual tours so they cannot appear behind confirmation prompts.
setContextualToursBlockingSurfaceVisible(activeRequest !== null)
return () => setContextualToursBlockingSurfaceVisible(false)
}, [activeRequest, setContextualToursBlockingSurfaceVisible])
const confirm = useCallback<ConfirmationDialogContextValue>((options) => {
return new Promise((resolve) => {
const request: ConfirmationDialogRequest = {
@@ -1,157 +0,0 @@
import { Children, isValidElement, type ReactElement, type ReactNode, type RefObject } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import type { ContextualTourId } from '../../../../shared/contextual-tours'
import {
ContextualTourOverlaySurface,
handleContextualTourOverlayKeyDown,
type ActiveTourRenderState
} from './ContextualTourOverlaySurface'
import { getContextualTourPanelHost } from './contextual-tour-gate'
type ClickableElementProps = {
children?: ReactNode
onClick?: () => void
}
const baseRenderState: ActiveTourRenderState = {
rect: {
left: 10,
top: 20,
right: 110,
bottom: 80,
width: 100,
height: 60
} as DOMRect,
targetElement: {
closest: () => null
} as unknown as Element,
progress: { current: 1, total: 3 },
title: 'Choose the work source',
body: 'Switch between connected providers and project filters without changing pages.',
isLastStep: false,
panelHost: null
}
function renderSurface(
overrides: Partial<ActiveTourRenderState> = {},
callbacks: { onSkip?: (id: ContextualTourId) => void; onNext?: () => void } = {}
): ReactElement {
return ContextualTourOverlaySurface({
activeTourId: 'tasks',
renderState: { ...baseRenderState, ...overrides },
panelRef: { current: null } as RefObject<HTMLElement | null>,
highlightStyle: { left: 6, top: 16, width: 108, height: 68 },
panelPosition: { left: 130, top: 20 },
panelHost: null,
onSkip: callbacks.onSkip ?? vi.fn(),
onNext: callbacks.onNext ?? vi.fn(),
onOverlayKeyDownCapture: handleContextualTourOverlayKeyDown
})
}
function findElementByText(
node: ReactNode,
text: string
): ReactElement<ClickableElementProps> | null {
if (Array.isArray(node)) {
for (const child of node) {
const match = findElementByText(child, text)
if (match) {
return match
}
}
return null
}
if (!isValidElement(node)) {
return null
}
const props = node.props as ClickableElementProps
if (props.children === text) {
return node as ReactElement<ClickableElementProps>
}
for (const child of Children.toArray(props.children)) {
const match = findElementByText(child, text)
if (match) {
return match
}
}
return null
}
describe('ContextualTourOverlaySurface', () => {
it('renders visible progress and step copy', () => {
const markup = renderToStaticMarkup(renderSurface())
expect(markup).toContain('1/3')
expect(markup).toContain('Choose the work source')
expect(markup).toContain('Switch between connected providers')
expect(markup).toContain('Skip')
expect(markup).toContain('Next')
})
it('renders later progress and Done on the final visible step', () => {
const markup = renderToStaticMarkup(
renderSurface({
progress: { current: 2, total: 2 },
title: 'Start from tracked work',
isLastStep: true
})
)
expect(markup).toContain('2/2')
expect(markup).toContain('Start from tracked work')
expect(markup).toContain('Done')
})
it('wires Skip and Next callbacks', () => {
const onSkip = vi.fn()
const onNext = vi.fn()
const element = renderSurface({}, { onSkip, onNext })
findElementByText(element, 'Skip')?.props.onClick?.()
findElementByText(element, 'Next')?.props.onClick?.()
expect(onSkip).toHaveBeenCalledWith('tasks')
expect(onNext).toHaveBeenCalledTimes(1)
})
it('handles Escape by clicking Skip before page-level handlers see it', () => {
const click = vi.fn()
const preventDefault = vi.fn()
const stopPropagation = vi.fn()
handleContextualTourOverlayKeyDown({
key: 'Escape',
preventDefault,
stopPropagation,
currentTarget: {
querySelector: () => ({ click })
}
} as unknown as Parameters<typeof handleContextualTourOverlayKeyDown>[0])
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(stopPropagation).toHaveBeenCalledTimes(1)
expect(click).toHaveBeenCalledTimes(1)
})
})
describe('getContextualTourPanelHost', () => {
it('hosts controls inside Radix dialog and sheet content', () => {
const dialogHost = {} as HTMLElement
const sheetHost = {} as HTMLElement
const dialogTarget = { closest: vi.fn(() => dialogHost) } as unknown as Element
const sheetTarget = { closest: vi.fn(() => sheetHost) } as unknown as Element
const pageTarget = { closest: vi.fn(() => null) } as unknown as Element
expect(getContextualTourPanelHost(dialogTarget)).toBe(dialogHost)
expect(getContextualTourPanelHost(sheetTarget)).toBe(sheetHost)
expect(getContextualTourPanelHost(pageTarget)).toBeNull()
expect(dialogTarget.closest).toHaveBeenCalledWith(
'[data-slot="dialog-content"], [data-slot="sheet-content"]'
)
})
})
@@ -1,254 +0,0 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type JSX } from 'react'
import { useAppStore } from '@/store'
import { getContextualTour } from '../../../../shared/contextual-tours'
import {
clampContextualTourPanelPosition,
getContextualTourStepCopy,
getContextualTourStepProgress,
getMeasurableContextualTourTarget,
getContextualTourPanelHost,
getVisibleContextualTourStepIndexes,
isContextualTourAllowedForModal
} from './contextual-tour-gate'
import {
ContextualTourOverlaySurface,
getContextualTourFocusableElements,
handleContextualTourOverlayKeyDown,
type ActiveTourRenderState
} from './ContextualTourOverlaySurface'
const PANEL_FALLBACK_SIZE = { width: 304, height: 172 }
export function ContextualTourOverlay(): JSX.Element | null {
const activeTourId = useAppStore((s) => s.activeContextualTourId)
const activeStepIndex = useAppStore((s) => s.activeContextualTourStepIndex)
const activeModal = useAppStore((s) => s.activeModal)
const blockingSurfaceVisible = useAppStore((s) => s.contextualToursBlockingSurfaceVisible)
const markContextualToursSeen = useAppStore((s) => s.markContextualToursSeen)
const advanceContextualTour = useAppStore((s) => s.advanceContextualTour)
const dismissContextualTour = useAppStore((s) => s.dismissContextualTour)
const completeContextualTour = useAppStore((s) => s.completeContextualTour)
const cancelContextualTour = useAppStore((s) => s.cancelContextualTour)
const [renderState, setRenderState] = useState<ActiveTourRenderState | null>(null)
const [measureVersion, setMeasureVersion] = useState(0)
const panelRef = useRef<HTMLElement | null>(null)
const markedTourIdRef = useRef<string | null>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
const focusedStepRef = useRef<string | null>(null)
const activeTour = useMemo(
() => (activeTourId ? getContextualTour(activeTourId) : null),
[activeTourId]
)
useEffect(() => {
markedTourIdRef.current = null
setRenderState(null)
}, [activeTourId])
useEffect(() => {
if (!activeTour || !activeTourId) {
return
}
if (blockingSurfaceVisible || !isContextualTourAllowedForModal(activeTour, activeModal)) {
cancelContextualTour(activeTourId)
}
}, [activeModal, activeTour, activeTourId, blockingSurfaceVisible, cancelContextualTour])
useEffect(() => {
if (!activeTourId) {
return
}
const scheduleMeasure = (): void => setMeasureVersion((version) => version + 1)
window.addEventListener('resize', scheduleMeasure)
window.addEventListener('scroll', scheduleMeasure, true)
const interval = window.setInterval(scheduleMeasure, 500)
return () => {
window.removeEventListener('resize', scheduleMeasure)
window.removeEventListener('scroll', scheduleMeasure, true)
window.clearInterval(interval)
}
}, [activeTourId])
useLayoutEffect(() => {
if (!activeTour || activeTourId === null) {
setRenderState(null)
return
}
const targetExists = (selector: string): boolean =>
getMeasurableContextualTourTarget(selector) !== null
const visibleStepIndexes = getVisibleContextualTourStepIndexes(activeTour, targetExists)
const activeStep = activeTour.steps[activeStepIndex]
const target = activeStep ? getMeasurableContextualTourTarget(activeStep.targetSelector) : null
const progress = getContextualTourStepProgress({
visibleStepIndexes,
stepIndex: activeStepIndex
})
if (visibleStepIndexes.length === 0) {
if (markedTourIdRef.current === activeTourId) {
completeContextualTour(activeTourId)
} else {
cancelContextualTour(activeTourId)
}
return
}
if (!activeStep || !target || !progress) {
const hasLaterStep = visibleStepIndexes.some((index) => index > activeStepIndex)
if (hasLaterStep) {
advanceContextualTour()
} else if (markedTourIdRef.current === activeTourId) {
completeContextualTour(activeTourId)
} else {
cancelContextualTour(activeTourId)
}
return
}
setRenderState({
rect: target.rect,
targetElement: target.element,
progress,
title: activeStep.title,
body: getContextualTourStepCopy(activeStep),
isLastStep: progress.current === progress.total,
panelHost: getContextualTourPanelHost(target.element)
})
}, [
activeStepIndex,
activeTour,
activeTourId,
advanceContextualTour,
cancelContextualTour,
completeContextualTour,
dismissContextualTour,
measureVersion
])
useEffect(() => {
if (!activeTourId || !renderState || markedTourIdRef.current === activeTourId) {
return
}
// Why: a tour is considered seen only after its first measured target
// paints, so missing or removed surfaces can retry on a later visit.
markedTourIdRef.current = activeTourId
markContextualToursSeen([activeTourId])
}, [activeTourId, markContextualToursSeen, renderState])
useEffect(() => {
if (!activeTourId || !renderState) {
return
}
const focusKey = `${activeTourId}:${activeStepIndex}`
if (focusedStepRef.current === focusKey) {
return
}
focusedStepRef.current = focusKey
const currentFocus = document.activeElement
if (
!previousFocusRef.current &&
currentFocus instanceof HTMLElement &&
!panelRef.current?.contains(currentFocus)
) {
previousFocusRef.current = currentFocus
}
const timeout = window.setTimeout(() => {
const panel = panelRef.current
const firstFocusable = panel ? getContextualTourFocusableElements(panel)[0] : null
;(firstFocusable ?? panel)?.focus({ preventScroll: true })
}, 0)
return () => window.clearTimeout(timeout)
}, [activeStepIndex, activeTourId, renderState])
useEffect(() => {
if (activeTourId) {
return
}
focusedStepRef.current = null
const previousFocus = previousFocusRef.current
previousFocusRef.current = null
if (previousFocus?.isConnected) {
previousFocus.focus({ preventScroll: true })
}
}, [activeTourId])
useEffect(() => {
const panelHost = renderState?.panelHost
if (!activeTourId || !panelHost) {
return
}
// Why: Radix dialog/sheet content owns focus and z-index; hosting controls
// there avoids outside-focus dismissal and keeps them above the backdrop.
const previousZIndex = panelHost.style.zIndex
const targetElement =
renderState?.targetElement instanceof HTMLElement ? renderState.targetElement : null
const previousOutline = targetElement?.style.outline
const previousOutlineOffset = targetElement?.style.outlineOffset
const previousBoxShadow = targetElement?.style.boxShadow
const previousBorderRadius = targetElement?.style.borderRadius
panelHost.style.zIndex = '80'
if (targetElement) {
targetElement.style.outline = '2px solid var(--ring)'
targetElement.style.outlineOffset = '4px'
targetElement.style.boxShadow = '0 0 0 2px var(--ring)'
targetElement.style.borderRadius ||= 'var(--radius-md)'
}
return () => {
panelHost.style.zIndex = previousZIndex
if (targetElement) {
targetElement.style.outline = previousOutline ?? ''
targetElement.style.outlineOffset = previousOutlineOffset ?? ''
targetElement.style.boxShadow = previousBoxShadow ?? ''
targetElement.style.borderRadius = previousBorderRadius ?? ''
}
}
}, [activeTourId, renderState])
if (!activeTourId || !renderState) {
return null
}
const viewport = {
width: typeof window === 'undefined' ? 1024 : window.innerWidth,
height: typeof window === 'undefined' ? 768 : window.innerHeight
}
const panelRect = panelRef.current?.getBoundingClientRect()
const panel = panelRect
? { width: panelRect.width, height: panelRect.height }
: PANEL_FALLBACK_SIZE
const panelPosition = clampContextualTourPanelPosition({
targetRect: renderState.rect,
viewport,
panel
})
const highlightStyle = {
left: Math.max(8, renderState.rect.left - 4),
top: Math.max(8, renderState.rect.top - 4),
width: Math.max(0, Math.min(viewport.width - 16, renderState.rect.width + 8)),
height: Math.max(0, Math.min(viewport.height - 16, renderState.rect.height + 8))
}
return (
<ContextualTourOverlaySurface
activeTourId={activeTourId}
renderState={renderState}
panelRef={panelRef}
highlightStyle={highlightStyle}
panelPosition={panelPosition}
panelHost={renderState.panelHost}
onSkip={dismissContextualTour}
onNext={() => {
if (renderState.isLastStep) {
completeContextualTour(activeTourId)
} else {
advanceContextualTour()
}
}}
onOverlayKeyDownCapture={handleContextualTourOverlayKeyDown}
/>
)
}
@@ -1,199 +0,0 @@
import { createPortal } from 'react-dom'
import { type CSSProperties, type JSX, type KeyboardEvent, type RefObject } from 'react'
import { Button } from '@/components/ui/button'
import { useAppStore } from '@/store'
import type { ContextualTourId } from '../../../../shared/contextual-tours'
const FOCUSABLE_SELECTOR =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
export type ActiveTourRenderState = {
rect: DOMRect
targetElement: Element
progress: { current: number; total: number }
title: string
body: string
isLastStep: boolean
panelHost: HTMLElement | null
}
type ContextualTourOverlaySurfaceProps = {
activeTourId: ContextualTourId
renderState: ActiveTourRenderState
panelRef: RefObject<HTMLElement | null>
highlightStyle: CSSProperties
panelPosition: CSSProperties
panelHost: HTMLElement | null
onSkip: (id: ContextualTourId) => void
onNext: () => void
onOverlayKeyDownCapture: (event: KeyboardEvent<HTMLDivElement>) => void
}
if (typeof window !== 'undefined') {
const guardedWindow = window as Window & {
__orcaContextualTourGlobalKeyGuardInstalled?: boolean
}
if (!guardedWindow.__orcaContextualTourGlobalKeyGuardInstalled) {
guardedWindow.__orcaContextualTourGlobalKeyGuardInstalled = true
window.addEventListener('keydown', handleContextualTourGlobalKeyDown, true)
}
}
export function ContextualTourOverlaySurface({
activeTourId,
renderState,
panelRef,
highlightStyle,
panelPosition,
panelHost,
onSkip,
onNext,
onOverlayKeyDownCapture
}: ContextualTourOverlaySurfaceProps): JSX.Element {
const panelHostSlot = panelHost?.getAttribute('data-slot')
const hostedPanelClass =
panelHostSlot === 'sheet-content'
? 'absolute left-3 top-[3.75rem] z-[80] w-[min(20rem,calc(100%-1.5rem))] rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-[0_10px_24px_rgba(0,0,0,0.18)]'
: 'relative z-[80] ml-3 mb-3 mt-1 w-[min(20rem,calc(100%-1.5rem))] shrink-0 rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-[0_10px_24px_rgba(0,0,0,0.18)]'
const panel = (
<section
ref={panelRef}
aria-live="polite"
aria-modal="true"
aria-label={renderState.title}
data-contextual-tour-panel=""
role="dialog"
tabIndex={-1}
className={
panelHost
? hostedPanelClass
: 'fixed w-[min(20rem,calc(100vw-1.5rem))] rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-[0_10px_24px_rgba(0,0,0,0.18)]'
}
style={panelHost ? undefined : panelPosition}
>
<div className="text-[11px] font-medium text-muted-foreground">
{renderState.progress.current}/{renderState.progress.total}
</div>
<h2 className="mt-1 text-sm font-semibold">{renderState.title}</h2>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{renderState.body}</p>
<div className="mt-3 flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8"
onClick={() => onSkip(activeTourId)}
>
Skip
</Button>
<Button type="button" size="sm" className="h-8" onClick={onNext}>
{renderState.isLastStep ? 'Done' : 'Next'}
</Button>
</div>
</section>
)
return (
<div
className={`fixed inset-0 z-[70] ${panelHost ? 'pointer-events-none' : 'pointer-events-auto'}`}
data-contextual-tour-overlay=""
role="presentation"
onKeyDownCapture={onOverlayKeyDownCapture}
>
<div className="absolute inset-0 bg-background/55" />
{panelHost ? null : (
<div
aria-hidden="true"
className="fixed rounded-md border border-ring ring-2 ring-ring shadow-[0_10px_24px_rgba(0,0,0,0.18)]"
style={highlightStyle}
/>
)}
{panelHost ? createPortal(panel, panelHost) : panel}
</div>
)
}
export function handleContextualTourOverlayKeyDown(event: KeyboardEvent<HTMLDivElement>): void {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
const skipButton = event.currentTarget.querySelector<HTMLButtonElement>('button')
skipButton?.click()
return
}
if (event.key !== 'Tab') {
return
}
const focusRoot =
document.querySelector<HTMLElement>('[data-contextual-tour-panel]') ?? event.currentTarget
const focusableElements = getContextualTourFocusableElements(focusRoot)
if (focusableElements.length === 0) {
event.preventDefault()
return
}
const activeElement = document.activeElement
const activeIndex =
activeElement instanceof HTMLElement ? focusableElements.indexOf(activeElement) : -1
const nextIndex = event.shiftKey
? activeIndex <= 0
? focusableElements.length - 1
: activeIndex - 1
: activeIndex === -1 || activeIndex === focusableElements.length - 1
? 0
: activeIndex + 1
event.preventDefault()
event.stopPropagation()
focusableElements[nextIndex]?.focus({ preventScroll: true })
}
export function handleContextualTourGlobalKeyDown(event: globalThis.KeyboardEvent): void {
const activeTourId = useAppStore.getState().activeContextualTourId
if (!activeTourId || (event.key !== 'Escape' && event.key !== 'Tab')) {
return
}
const overlay = document.querySelector<HTMLElement>('[data-contextual-tour-overlay]')
const focusRoot = document.querySelector<HTMLElement>('[data-contextual-tour-panel]') ?? overlay
if (!overlay || !focusRoot) {
return
}
if (event.key === 'Escape') {
event.preventDefault()
event.stopImmediatePropagation()
useAppStore.getState().dismissContextualTour(activeTourId)
return
}
const focusableElements = getContextualTourFocusableElements(focusRoot)
if (focusableElements.length === 0) {
event.preventDefault()
event.stopImmediatePropagation()
return
}
const activeElement = document.activeElement
const activeIndex =
activeElement instanceof HTMLElement ? focusableElements.indexOf(activeElement) : -1
const nextIndex = event.shiftKey
? activeIndex <= 0
? focusableElements.length - 1
: activeIndex - 1
: activeIndex === -1 || activeIndex === focusableElements.length - 1
? 0
: activeIndex + 1
event.preventDefault()
event.stopImmediatePropagation()
focusableElements[nextIndex]?.focus({ preventScroll: true })
}
export function getContextualTourFocusableElements(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(element) => element.getClientRects().length > 0 || element === document.activeElement
)
}
@@ -1,228 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getContextualTour } from '../../../../shared/contextual-tours'
import {
clampContextualTourPanelPosition,
getContextualTourRequestDecision,
getContextualTourStepProgress,
getMeasurableContextualTourTarget,
getNextVisibleContextualTourStepIndex,
getVisibleContextualTourStepIndexes,
isContextualTourAllowedForModal
} from './contextual-tour-gate'
describe('contextual tour gate', () => {
it('allows workspace creation only over its own creation modals', () => {
const workspaceCreation = getContextualTour('workspace-creation')
const tasks = getContextualTour('tasks')
expect(isContextualTourAllowedForModal(workspaceCreation, 'new-workspace-composer')).toBe(true)
expect(isContextualTourAllowedForModal(workspaceCreation, 'add-repo')).toBe(true)
expect(isContextualTourAllowedForModal(workspaceCreation, 'quick-open')).toBe(false)
expect(isContextualTourAllowedForModal(tasks, 'none')).toBe(true)
expect(isContextualTourAllowedForModal(tasks, 'new-workspace-composer')).toBe(false)
})
it('does not start when the required first target is missing', () => {
const decision = getContextualTourRequestDecision({
tour: getContextualTour('tasks'),
persistedUIReady: true,
onboardingVisible: false,
seenIds: [],
sessionConsumed: false,
activeTourId: null,
activeModal: 'none',
blockingSurfaceVisible: false,
targetExists: () => false
})
expect(decision).toEqual({ kind: 'blocked', reason: 'missing-start-target' })
})
it('returns null when selector lookup or measurement throws', () => {
expect(
getMeasurableContextualTourTarget('[', {
querySelector: () => {
throw new Error('bad selector')
}
} as unknown as ParentNode)
).toBeNull()
expect(
getMeasurableContextualTourTarget('[data-contextual-tour-target="tasks-source-filters"]', {
querySelector: () => ({
getBoundingClientRect: () => {
throw new Error('detached element')
}
})
} as unknown as ParentNode)
).toBeNull()
})
it('returns null when the target is inside a hidden subtree', () => {
expect(
getMeasurableContextualTourTarget('[data-contextual-tour-target="browser-address"]', {
querySelector: () => ({
closest: (selector: string) => (selector.includes('aria-hidden') ? {} : null),
getBoundingClientRect: () => ({
left: 0,
top: 0,
right: 120,
bottom: 32,
width: 120,
height: 32
})
})
} as unknown as ParentNode)
).toBeNull()
})
it('uses the first visible measurable target when hidden mounted copies exist first', () => {
const hiddenElement = {
closest: (selector: string) => (selector.includes('aria-hidden') ? {} : null),
getBoundingClientRect: () => ({
left: 0,
top: 0,
right: 120,
bottom: 32,
width: 120,
height: 32
})
}
const visibleElement = {
closest: () => null,
getBoundingClientRect: () => ({
left: 10,
top: 20,
right: 210,
bottom: 64,
width: 200,
height: 44
})
}
const target = getMeasurableContextualTourTarget(
'[data-contextual-tour-target="browser-address"]',
{
querySelectorAll: () => [hiddenElement, visibleElement]
} as unknown as ParentNode
)
expect(target?.element).toBe(visibleElement)
expect(target?.rect.width).toBe(200)
})
it('starts an unseen tour only when global gates pass', () => {
const tour = getContextualTour('tasks')
const hasFirstTarget = (selector: string): boolean => selector === tour.steps[0]?.targetSelector
expect(
getContextualTourRequestDecision({
tour,
persistedUIReady: true,
onboardingVisible: false,
seenIds: [],
sessionConsumed: false,
activeTourId: null,
activeModal: 'none',
blockingSurfaceVisible: false,
targetExists: hasFirstTarget
})
).toEqual({ kind: 'start', stepIndex: 0 })
expect(
getContextualTourRequestDecision({
tour,
persistedUIReady: false,
onboardingVisible: false,
seenIds: [],
sessionConsumed: false,
activeTourId: null,
activeModal: 'none',
blockingSurfaceVisible: false,
targetExists: hasFirstTarget
})
).toEqual({ kind: 'blocked', reason: 'persisted-ui-not-ready' })
expect(
getContextualTourRequestDecision({
tour,
persistedUIReady: true,
onboardingVisible: true,
seenIds: [],
sessionConsumed: false,
activeTourId: null,
activeModal: 'none',
blockingSurfaceVisible: false,
targetExists: hasFirstTarget
})
).toEqual({ kind: 'blocked', reason: 'onboarding' })
expect(
getContextualTourRequestDecision({
tour,
persistedUIReady: true,
onboardingVisible: false,
seenIds: ['tasks'],
sessionConsumed: false,
activeTourId: null,
activeModal: 'none',
blockingSurfaceVisible: false,
targetExists: hasFirstTarget
})
).toEqual({ kind: 'blocked', reason: 'seen' })
expect(
getContextualTourRequestDecision({
tour,
persistedUIReady: true,
onboardingVisible: false,
seenIds: [],
sessionConsumed: false,
activeTourId: null,
activeModal: 'none',
blockingSurfaceVisible: true,
targetExists: hasFirstTarget
})
).toEqual({ kind: 'blocked', reason: 'blocking-surface' })
})
it('skips missing later steps and keeps progress relative to visible steps', () => {
const tour = getContextualTour('browser')
const visibleSelectors = new Set([tour.steps[0]!.targetSelector, tour.steps[2]!.targetSelector])
const targetExists = (selector: string): boolean => visibleSelectors.has(selector)
const visibleStepIndexes = getVisibleContextualTourStepIndexes(tour, targetExists)
expect(visibleStepIndexes).toEqual([0, 2])
expect(
getNextVisibleContextualTourStepIndex({
tour,
currentStepIndex: 0,
targetExists
})
).toBe(2)
expect(getContextualTourStepProgress({ visibleStepIndexes, stepIndex: 2 })).toEqual({
current: 2,
total: 2
})
})
it('clamps the panel inside narrow viewports', () => {
const position = clampContextualTourPanelPosition({
targetRect: {
left: 20,
right: 140,
top: 40,
bottom: 100,
width: 120,
height: 60
},
viewport: { width: 320, height: 220 },
panel: { width: 304, height: 160 }
})
expect(position.left).toBeGreaterThanOrEqual(12)
expect(position.top).toBeGreaterThanOrEqual(12)
expect(position.left).toBeLessThanOrEqual(12)
expect(position.top).toBeLessThanOrEqual(48)
})
})
@@ -1,271 +0,0 @@
import type { ContextualTour, ContextualTourStep } from '../../../../shared/contextual-tours'
export type ContextualTourTarget = {
element: Element
rect: DOMRect
}
export type ContextualTourRequestDecision =
| { kind: 'start'; stepIndex: number }
| {
kind: 'blocked'
reason:
| 'persisted-ui-not-ready'
| 'onboarding'
| 'seen'
| 'session-consumed'
| 'active-tour'
| 'modal'
| 'blocking-surface'
| 'missing-start-target'
}
export type ContextualTourPanelPosition = {
left: number
top: number
}
type ViewportSize = {
width: number
height: number
}
type PanelSize = {
width: number
height: number
}
const PANEL_HOST_SELECTOR = '[data-slot="dialog-content"], [data-slot="sheet-content"]'
export function getContextualTourPanelHost(targetElement: Element): HTMLElement | null {
return targetElement.closest<HTMLElement>(PANEL_HOST_SELECTOR)
}
export function isContextualTourAllowedForModal(
tour: ContextualTour,
activeModal: string
): boolean {
if (activeModal === 'none') {
return true
}
return tour.allowedActiveModals?.includes(activeModal) === true
}
export function getMeasurableContextualTourTarget(
selector: string,
root?: ParentNode
): ContextualTourTarget | null {
const queryRoot = root ?? (typeof document !== 'undefined' ? document : null)
const elements = getContextualTourTargetCandidates(selector, queryRoot)
if (!elements) {
return null
}
for (const element of elements) {
if (!element || typeof element.getBoundingClientRect !== 'function') {
continue
}
if (!isContextualTourTargetVisible(element)) {
continue
}
let rect: DOMRect
try {
rect = element.getBoundingClientRect()
} catch {
continue
}
if (isMeasurableRect(rect)) {
return { element, rect }
}
}
return null
}
export function hasContextualTourTarget(selector: string, root?: ParentNode): boolean {
return getMeasurableContextualTourTarget(selector, root) !== null
}
export function getContextualTourStartStepIndex(
tour: ContextualTour,
targetExists: (selector: string) => boolean
): number | null {
const requiredStepIndex = tour.steps.findIndex((step) => step.requiredForStart === true)
const startIndex = Math.max(requiredStepIndex, 0)
const step = tour.steps[startIndex]
if (!step || !targetExists(step.targetSelector)) {
return null
}
return startIndex
}
export function getVisibleContextualTourStepIndexes(
tour: ContextualTour,
targetExists: (selector: string) => boolean
): number[] {
const indexes: number[] = []
tour.steps.forEach((step, index) => {
if (targetExists(step.targetSelector)) {
indexes.push(index)
}
})
return indexes
}
export function getNextVisibleContextualTourStepIndex(args: {
tour: ContextualTour
currentStepIndex: number
targetExists: (selector: string) => boolean
}): number | null {
return (
getVisibleContextualTourStepIndexes(args.tour, args.targetExists).find(
(index) => index > args.currentStepIndex
) ?? null
)
}
export function getContextualTourRequestDecision(args: {
tour: ContextualTour
persistedUIReady: boolean
onboardingVisible: boolean
seenIds: readonly string[]
sessionConsumed: boolean
activeTourId: string | null
activeModal: string
blockingSurfaceVisible: boolean
targetExists: (selector: string) => boolean
}): ContextualTourRequestDecision {
if (!args.persistedUIReady) {
return { kind: 'blocked', reason: 'persisted-ui-not-ready' }
}
if (args.onboardingVisible) {
return { kind: 'blocked', reason: 'onboarding' }
}
if (args.seenIds.includes(args.tour.id)) {
return { kind: 'blocked', reason: 'seen' }
}
if (args.sessionConsumed) {
return { kind: 'blocked', reason: 'session-consumed' }
}
if (args.activeTourId !== null) {
return { kind: 'blocked', reason: 'active-tour' }
}
if (!isContextualTourAllowedForModal(args.tour, args.activeModal)) {
return { kind: 'blocked', reason: 'modal' }
}
if (args.blockingSurfaceVisible) {
return { kind: 'blocked', reason: 'blocking-surface' }
}
const stepIndex = getContextualTourStartStepIndex(args.tour, args.targetExists)
if (stepIndex === null) {
return { kind: 'blocked', reason: 'missing-start-target' }
}
return { kind: 'start', stepIndex }
}
export function getContextualTourStepProgress(args: {
visibleStepIndexes: readonly number[]
stepIndex: number
}): { current: number; total: number } | null {
const visibleIndex = args.visibleStepIndexes.indexOf(args.stepIndex)
if (visibleIndex < 0) {
return null
}
return { current: visibleIndex + 1, total: args.visibleStepIndexes.length }
}
export function clampContextualTourPanelPosition(args: {
targetRect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom' | 'width' | 'height'>
viewport: ViewportSize
panel: PanelSize
gap?: number
margin?: number
}): ContextualTourPanelPosition {
const gap = args.gap ?? 12
const margin = args.margin ?? 12
const { targetRect, viewport, panel } = args
const roomRight = viewport.width - targetRect.right
const roomLeft = targetRect.left
const roomBelow = viewport.height - targetRect.bottom
const roomAbove = targetRect.top
let left: number
let top: number
if (roomRight >= panel.width + gap || roomRight >= roomLeft) {
left = targetRect.right + gap
top = targetRect.top
} else {
left = targetRect.left - panel.width - gap
top = targetRect.top
}
if (roomRight < panel.width + gap && roomLeft < panel.width + gap) {
left = targetRect.left + targetRect.width / 2 - panel.width / 2
top =
roomBelow >= panel.height + gap || roomBelow >= roomAbove
? targetRect.bottom + gap
: targetRect.top - panel.height - gap
}
return {
left: clampNumber(left, margin, Math.max(margin, viewport.width - panel.width - margin)),
top: clampNumber(top, margin, Math.max(margin, viewport.height - panel.height - margin))
}
}
export function getContextualTourStepCopy(step: ContextualTourStep): string {
return step.body || step.fallbackCopy || ''
}
function isMeasurableRect(rect: DOMRect): boolean {
return (
Number.isFinite(rect.width) && Number.isFinite(rect.height) && rect.width > 0 && rect.height > 0
)
}
function getContextualTourTargetCandidates(
selector: string,
queryRoot: ParentNode | null
): Element[] | null {
if (!queryRoot) {
return []
}
try {
if (typeof queryRoot.querySelectorAll === 'function') {
return Array.from(queryRoot.querySelectorAll(selector))
}
return queryRoot.querySelector(selector) ? [queryRoot.querySelector(selector)!] : []
} catch {
return null
}
}
function isContextualTourTargetVisible(element: Element): boolean {
if (
typeof element.closest === 'function' &&
element.closest('[hidden],[inert],[aria-hidden="true"]')
) {
return false
}
if (typeof window === 'undefined' || typeof HTMLElement === 'undefined') {
return true
}
let current: Element | null = element
while (current instanceof HTMLElement) {
const style = window.getComputedStyle(current)
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
style.visibility === 'collapse'
) {
return false
}
current = current.parentElement
}
return true
}
function clampNumber(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max)
}
@@ -1,118 +0,0 @@
import { useEffect } from 'react'
import type { ContextualTourId } from '../../../../shared/contextual-tours'
import { useAppStore } from '@/store'
const TOUR_SOURCES = {
'right-sidebar': 'right_sidebar_visible',
'workspace-board': 'workspace_board_visible',
browser: 'browser_visible',
tasks: 'tasks_open',
automations: 'automations_open',
'workspace-creation': 'workspace_creation_visible'
} satisfies Record<ContextualTourId, string>
export function useContextualTour(
id: ContextualTourId,
enabled: boolean,
source: string = TOUR_SOURCES[id]
): void {
const requestContextualTour = useAppStore((s) => s.requestContextualTour)
const cancelContextualTour = useAppStore((s) => s.cancelContextualTour)
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
const activeModal = useAppStore((s) => s.activeModal)
const activeContextualTourId = useAppStore((s) => s.activeContextualTourId)
const activeContextualTourSource = useAppStore((s) => s.activeContextualTourSource)
const contextualToursSeenIds = useAppStore((s) => s.contextualToursSeenIds)
const contextualTourShownThisSession = useAppStore((s) => s.contextualTourShownThisSession)
const contextualToursOnboardingVisible = useAppStore((s) => s.contextualToursOnboardingVisible)
const contextualToursBlockingSurfaceVisible = useAppStore(
(s) => s.contextualToursBlockingSurfaceVisible
)
useEffect(() => {
// Why: a tour can be registered by multiple surfaces; an inactive sibling
// must not cancel the instance started by the visible surface.
if (!enabled && activeContextualTourId === id && activeContextualTourSource === source) {
cancelContextualTour(id)
}
}, [
activeContextualTourId,
activeContextualTourSource,
cancelContextualTour,
enabled,
id,
source
])
useEffect(() => {
if (
!enabled ||
typeof window === 'undefined' ||
typeof document === 'undefined' ||
!persistedUIReady ||
contextualToursOnboardingVisible ||
contextualToursBlockingSurfaceVisible ||
activeContextualTourId !== null ||
contextualTourShownThisSession ||
contextualToursSeenIds.includes(id)
) {
return
}
let frame: number | null = null
let attempts = 0
const request = (): void => {
if (frame !== null) {
return
}
attempts += 1
frame = window.requestAnimationFrame(() => {
frame = null
requestContextualTour(id, source)
})
}
request()
const timeout = window.setTimeout(request, 250)
const observer =
typeof MutationObserver === 'undefined' || !document.body
? null
: new MutationObserver(request)
observer?.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['aria-hidden', 'class', 'data-contextual-tour-target', 'hidden', 'style']
})
// Why: native prompts and async surface hydration can pause or miss the
// first target measurement; retry briefly without long-lived polling.
const interval = window.setInterval(() => {
if (attempts >= 20) {
window.clearInterval(interval)
return
}
request()
}, 500)
return () => {
if (frame !== null) {
window.cancelAnimationFrame(frame)
}
window.clearTimeout(timeout)
window.clearInterval(interval)
observer?.disconnect()
}
}, [
activeContextualTourId,
contextualToursBlockingSurfaceVisible,
activeModal,
contextualTourShownThisSession,
contextualToursOnboardingVisible,
contextualToursSeenIds,
enabled,
id,
persistedUIReady,
requestContextualTour,
source
])
}
@@ -934,7 +934,7 @@ export function FloatingTerminalPanel({
className={isActive ? 'absolute inset-0 flex' : 'absolute inset-0 hidden'}
aria-hidden={!isActive}
>
<BrowserPane browserTab={tab} isActive={open && isActive} />
<BrowserPane browserTab={tab} isActive={isActive} />
</div>
)
})}
@@ -29,7 +29,6 @@ import {
import { getActiveChecksStatus } from './active-checks-status'
import { getVisibleRightSidebarActivityItems } from './right-sidebar-activity-visibility'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
const MIN_WIDTH = 220
// Why: long file names (e.g. construction drawing sheets, multi-part document
@@ -110,7 +109,6 @@ function RightSidebarInner(): React.JSX.Element {
() => getVisibleRightSidebarActivityItems(activityItems, { isFolder, isSshRepo }),
[activityItems, isFolder, isSshRepo]
)
useContextualTour('right-sidebar', rightSidebarOpen, 'right_sidebar_visible')
// If the active tab is hidden (e.g. switched from a git repo to a folder),
// fall back to the first visible tab.
@@ -205,12 +203,10 @@ function RightSidebarInner(): React.JSX.Element {
// stays mounted for performance — see App.tsx).
rightSidebarOpen ? 'overflow-visible' : 'overflow-hidden'
)}
data-contextual-tour-target="right-sidebar-shell"
>
{/* Panel content area */}
<div
className="flex flex-col flex-1 min-w-0 bg-sidebar overflow-hidden"
data-contextual-tour-target="right-sidebar-panel"
style={{
borderLeft: rightSidebarOpen ? '1px solid var(--sidebar-border)' : 'none'
}}
@@ -225,7 +221,6 @@ function RightSidebarInner(): React.JSX.Element {
<div
ref={topActivityStripRef}
className="right-sidebar-activity-strip flex min-w-0 flex-1 items-center overflow-hidden pl-2 right-sidebar-header-no-drag"
data-contextual-tour-target="right-sidebar-activity"
>
{/* Why: the top strip shares a narrow titlebar with the close
button and Windows controls. Overflow goes behind More
@@ -271,7 +266,6 @@ function RightSidebarInner(): React.JSX.Element {
<div
ref={topActivityStripRef}
className="right-sidebar-activity-strip flex h-10 min-h-10 items-center border-b border-border px-2 right-sidebar-header-no-drag"
data-contextual-tour-target="right-sidebar-activity"
>
{/* Why: Windows has fixed native-style controls in the titlebar
area; keep sidebar navigation in the sidebar body so the
@@ -335,10 +329,7 @@ function RightSidebarInner(): React.JSX.Element {
{activityBarPosition === 'side' && (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className="flex flex-col items-center w-10 min-w-[40px] bg-sidebar border-l border-border side-activity-bar-windows-inset"
data-contextual-tour-target="right-sidebar-activity"
>
<div className="flex flex-col items-center w-10 min-w-[40px] bg-sidebar border-l border-border side-activity-bar-windows-inset">
<TooltipProvider delayDuration={400}>{sideActivityBarIcons}</TooltipProvider>
</div>
</ContextMenuTrigger>
@@ -320,10 +320,7 @@ export function CreateStep({
(= content size), so a long path inside the Location row would blow out
the dialog width even with flex + truncate on the row itself. min-w-0
here caps the grid track at the dialog's max-width. */}
<div
className="space-y-3.5 pt-1 min-w-0"
data-contextual-tour-target="workspace-creation-source"
>
<div className="space-y-3.5 pt-1 min-w-0">
{/* Kind toggle. Real radiogroup so screen readers announce it as a choice. */}
<div
ref={radioGroupRef}
@@ -429,13 +426,7 @@ export function CreateStep({
</p>
)}
<Button
onClick={onCreate}
disabled={!canSubmit}
size="lg"
className="w-full"
data-contextual-tour-target="workspace-creation-action"
>
<Button onClick={onCreate} disabled={!canSubmit} size="lg" className="w-full">
{isCreating ? 'Creating…' : 'Create project'}
</Button>
</div>
@@ -33,7 +33,6 @@ import {
effectiveExternalWorktreeVisibility,
isLegacyRepoForExternalWorktreeVisibility
} from '../../../../shared/worktree-ownership'
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
const AddRepoDialog = React.memo(function AddRepoDialog() {
const activeModal = useAppStore((s) => s.activeModal)
@@ -131,11 +130,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
const isOpen = activeModal === 'add-repo'
const repoId = addedRepo?.id ?? ''
const isRuntimeEnvironmentActive = Boolean(settings?.activeRuntimeEnvironmentId?.trim())
useContextualTour(
'workspace-creation',
isOpen && step === 'setup',
'workspace_creation_add_project'
)
const worktrees = useMemo(() => {
return worktreesByRepo[repoId] ?? []
@@ -483,7 +477,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
</DialogDescription>
</DialogHeader>
<div className="space-y-3 pt-2" data-contextual-tour-target="workspace-creation-source">
<div className="space-y-3 pt-2">
<div className="space-y-1">
<label
htmlFor="server-project-path"
@@ -507,7 +501,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
onClick={() => void handleAddServerPath('git')}
disabled={!serverPath.trim() || isAddingServerPath}
className="h-10"
data-contextual-tour-target="workspace-creation-action"
>
Add Git Project
</Button>
@@ -555,16 +548,12 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
</DialogDescription>
</DialogHeader>
<div
className="grid grid-cols-3 gap-3 pt-2"
data-contextual-tour-target="workspace-creation-source"
>
<div className="grid grid-cols-3 gap-3 pt-2">
<Button
onClick={handleBrowse}
disabled={isAdding}
variant="outline"
className="h-auto py-5 px-2 flex flex-col items-center gap-2 text-center border-border/80"
data-contextual-tour-target="workspace-creation-action"
>
<FolderOpen className="size-6 text-muted-foreground" />
<div>
@@ -138,7 +138,7 @@ export function ProjectAddedContent({
return (
<>
<DialogHeader data-contextual-tour-target="workspace-creation-source">
<DialogHeader>
<DialogTitle>Repo added</DialogTitle>
<DialogDescription>
{repoName
@@ -147,7 +147,7 @@ export function ProjectAddedContent({
</DialogDescription>
</DialogHeader>
<div className="space-y-3 pt-1" data-contextual-tour-target="workspace-creation-setup">
<div className="space-y-3 pt-1">
{hasHiddenWorktrees ? (
<div
ref={radioGroupRef}
@@ -204,12 +204,7 @@ export function ProjectAddedContent({
<Settings className="size-3" />
Configure repo
</button>
<Button
type="button"
size="sm"
onClick={handlePrimaryAction}
data-contextual-tour-target="workspace-creation-action"
>
<Button type="button" size="sm" onClick={handlePrimaryAction}>
{selectedChoice === 'existing' ? (
<>
<GitBranch className="size-4" />
@@ -234,7 +234,7 @@ export function RemoteStep({
</DialogDescription>
</DialogHeader>
<div className="space-y-3 pt-1" data-contextual-tour-target="workspace-creation-source">
<div className="space-y-3 pt-1">
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">SSH target</label>
{sshTargets.length === 0 ? (
@@ -301,7 +301,6 @@ export function RemoteStep({
onClick={onAdd}
disabled={!selectedTargetId || !remotePath.trim() || isAddingRemote}
className="w-full"
data-contextual-tour-target="workspace-creation-action"
>
{isAddingRemote ? 'Adding...' : 'Add remote project'}
</Button>
@@ -353,7 +352,7 @@ export function CloneStep({
<DialogDescription>Enter the Git URL and choose where to clone it.</DialogDescription>
</DialogHeader>
<div className="space-y-3 pt-1" data-contextual-tour-target="workspace-creation-source">
<div className="space-y-3 pt-1">
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">Git URL</label>
<Input
@@ -397,7 +396,6 @@ export function CloneStep({
onClick={onClone}
disabled={!cloneUrl.trim() || !cloneDestination.trim() || isCloning}
className="w-full"
data-contextual-tour-target="workspace-creation-action"
>
{isCloning ? 'Cloning...' : 'Clone'}
</Button>
@@ -32,7 +32,6 @@ import {
} from './worktree-manual-order'
import type { WorkspaceStatus, WorktreeMeta } from '../../../../shared/types'
import { makeWorkspaceStatusId } from '../../../../shared/workspace-statuses'
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
type WorkspaceKanbanDrawerProps = {
open: boolean
@@ -420,7 +419,6 @@ export default function WorkspaceKanbanDrawer({
useWorkspaceKanbanShiftWheelScroll(boardRef, laneScrollerRef, open, isPointerDragActiveRef)
useWorkspaceKanbanOutsideDismiss({ open, boardRef, preserveOpenForMenu, onOpenChange })
useContextualTour('workspace-board', open, 'workspace_board_visible')
useEffect(() => {
if (!open || selectedWorktreeIds.size === 0) {
@@ -468,7 +466,6 @@ export default function WorkspaceKanbanDrawer({
} as React.CSSProperties
}
data-workspace-board-compact={workspaceBoardCompact ? 'true' : 'false'}
data-contextual-tour-target="workspace-board-surface"
onOpenAutoFocus={(event) => {
// Why: Radix focuses the first toolbar button on open, which opens
// its tooltip without hover and makes the drawer feel noisy.
@@ -58,7 +58,6 @@ export default function WorkspaceKanbanLaneGrid({
return (
<div
className="grid h-full min-h-0 min-w-full grid-rows-[minmax(0,1fr)] gap-3"
data-contextual-tour-target="workspace-board-lanes"
style={{
gridTemplateColumns: `repeat(${statuses.length}, minmax(${columnWidth}px, ${columnWidth}px))`
}}
@@ -83,7 +83,6 @@ export default function WorkspaceKanbanStatusLane({
<section
data-workspace-status-drop-target=""
data-workspace-status={status.id}
data-contextual-tour-target="workspace-board-cards"
className={cn(
'group/lane',
'relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden rounded-md border border-t-2 border-sidebar-border transition-colors',
-228
View File
@@ -11,7 +11,6 @@ import type {
import { createUISlice } from './ui'
import { createWorktreeNavHistorySlice } from './worktree-nav-history'
import type { AppState } from '../types'
import type { ContextualTourId } from '../../../../shared/contextual-tours'
afterEach(() => {
vi.restoreAllMocks()
@@ -762,233 +761,6 @@ describe('createUISlice feature tips', () => {
})
})
function stubContextualTourTargets(selectors: readonly string[]): void {
const selectorSet = new Set(selectors)
vi.stubGlobal('document', {
querySelector: vi.fn((selector: string) =>
selectorSet.has(selector)
? {
getBoundingClientRect: () => ({
left: 10,
top: 10,
right: 110,
bottom: 50,
width: 100,
height: 40
})
}
: null
)
})
}
describe('createUISlice contextual tours', () => {
it('normalizes persisted contextual tour ids during hydration', () => {
const store = createUIStore()
store.getState().hydratePersistedUI(
makePersistedUI({
contextualToursSeenIds: ['tasks', 'unknown', 'tasks', 'browser'] as never
})
)
expect(store.getState().contextualToursSeenIds).toEqual(['tasks', 'browser'])
})
it('marks contextual tours seen and persists them once', () => {
const setMock = vi.fn(() => Promise.resolve())
vi.stubGlobal('window', {
api: {
ui: {
set: setMock
}
}
})
const store = createUIStore()
store.getState().markContextualToursSeen(['tasks'])
store.getState().markContextualToursSeen(['tasks'])
expect(store.getState().contextualToursSeenIds).toEqual(['tasks'])
expect(setMock).toHaveBeenCalledTimes(1)
expect(setMock).toHaveBeenCalledWith({ contextualToursSeenIds: ['tasks'] })
})
it('starts a tour only after persisted UI and required first target are ready', () => {
const store = createUIStore()
const tasksFirstSelector = '[data-contextual-tour-target="tasks-source-filters"]'
stubContextualTourTargets([tasksFirstSelector])
store.getState().requestContextualTour('tasks', 'tasks_open')
expect(store.getState().activeContextualTourId).toBeNull()
store.getState().hydratePersistedUI(makePersistedUI())
store.getState().requestContextualTour('tasks', 'tasks_open')
expect(store.getState().activeContextualTourId).toBe('tasks')
expect(store.getState().activeContextualTourStepIndex).toBe(0)
expect(store.getState().activeContextualTourSource).toBe('tasks_open')
expect(store.getState().contextualTourShownThisSession).toBe(true)
expect(store.getState().contextualToursSeenIds).toEqual([])
})
it('does not mark seen when the required first target is absent', () => {
const store = createUIStore()
stubContextualTourTargets([])
store.getState().hydratePersistedUI(makePersistedUI())
store.getState().requestContextualTour('tasks', 'tasks_open')
expect(store.getState().activeContextualTourId).toBeNull()
expect(store.getState().contextualToursSeenIds).toEqual([])
expect(store.getState().contextualTourShownThisSession).toBe(false)
})
it('does not start while a root confirmation surface is visible', () => {
const store = createUIStore()
stubContextualTourTargets(['[data-contextual-tour-target="tasks-source-filters"]'])
store.getState().hydratePersistedUI(makePersistedUI())
store.getState().setContextualToursBlockingSurfaceVisible(true)
store.getState().requestContextualTour('tasks', 'tasks_open')
expect(store.getState().activeContextualTourId).toBeNull()
expect(store.getState().contextualTourShownThisSession).toBe(false)
})
it('allows only workspace creation over its creation modals', () => {
const store = createUIStore()
stubContextualTourTargets([
'[data-contextual-tour-target="workspace-creation-source"]',
'[data-contextual-tour-target="tasks-source-filters"]'
])
store.getState().hydratePersistedUI(makePersistedUI())
store.getState().openModal('new-workspace-composer')
store.getState().requestContextualTour('tasks', 'tasks_open')
expect(store.getState().activeContextualTourId).toBeNull()
store.getState().requestContextualTour('workspace-creation', 'workspace_creation_modal')
expect(store.getState().activeContextualTourId).toBe('workspace-creation')
})
it('advances across visible steps and closes when no later target remains', () => {
const store = createUIStore()
const visibleSelectors = [
'[data-contextual-tour-target="browser-address"], [data-orca-browser-address-bar="true"]',
'[data-contextual-tour-target="browser-annotation-control"]'
]
stubContextualTourTargets(visibleSelectors)
store.getState().hydratePersistedUI(makePersistedUI())
store.getState().requestContextualTour('browser', 'browser_visible')
store.getState().advanceContextualTour()
expect(store.getState().activeContextualTourStepIndex).toBe(2)
store.getState().advanceContextualTour()
expect(store.getState().activeContextualTourId).toBeNull()
})
it('cancels a not-yet-rendered tour without persistence churn', () => {
const setMock = vi.fn(() => Promise.resolve())
vi.stubGlobal('window', {
api: {
ui: {
set: setMock
}
}
})
const store = createUIStore()
store.setState({
activeContextualTourId: 'tasks',
activeContextualTourStepIndex: 0,
activeContextualTourSource: 'tasks_open',
contextualTourShownThisSession: true
})
store.getState().cancelContextualTour('tasks')
expect(store.getState().activeContextualTourId).toBeNull()
expect(store.getState().contextualTourShownThisSession).toBe(false)
expect(store.getState().contextualToursSeenIds).toEqual([])
expect(setMock).not.toHaveBeenCalled()
})
it('preserves the session guard when canceling an already-rendered tour', () => {
const setMock = vi.fn(() => Promise.resolve())
vi.stubGlobal('window', {
api: {
ui: {
set: setMock
}
}
})
const store = createUIStore()
store.setState({
activeContextualTourId: 'tasks',
activeContextualTourStepIndex: 0,
activeContextualTourSource: 'tasks_open',
contextualTourShownThisSession: true,
contextualToursSeenIds: ['tasks']
})
store.getState().cancelContextualTour('tasks')
expect(store.getState().activeContextualTourId).toBeNull()
expect(store.getState().contextualTourShownThisSession).toBe(true)
expect(store.getState().contextualToursSeenIds).toEqual<ContextualTourId[]>(['tasks'])
expect(setMock).not.toHaveBeenCalled()
})
it('dismisses active tours as seen', () => {
const setMock = vi.fn(() => Promise.resolve())
vi.stubGlobal('window', {
api: {
ui: {
set: setMock
}
}
})
const store = createUIStore()
store.setState({
activeContextualTourId: 'automations',
activeContextualTourStepIndex: 0,
activeContextualTourSource: 'automations_open',
contextualTourShownThisSession: true
})
store.getState().dismissContextualTour('automations')
expect(store.getState().activeContextualTourId).toBeNull()
expect(store.getState().contextualToursSeenIds).toEqual<ContextualTourId[]>(['automations'])
expect(setMock).toHaveBeenCalledWith({ contextualToursSeenIds: ['automations'] })
})
it('ignores stale dismissals for a different active tour', () => {
const setMock = vi.fn(() => Promise.resolve())
vi.stubGlobal('window', {
api: {
ui: {
set: setMock
}
}
})
const store = createUIStore()
store.setState({
activeContextualTourId: 'tasks',
activeContextualTourStepIndex: 0,
activeContextualTourSource: 'tasks_open',
contextualTourShownThisSession: true
})
store.getState().dismissContextualTour('browser')
expect(store.getState().activeContextualTourId).toBe('tasks')
expect(store.getState().contextualToursSeenIds).toEqual([])
expect(setMock).not.toHaveBeenCalled()
})
})
describe('createUISlice space navigation', () => {
it('returns to the tasks page after opening Space from an in-progress draft', () => {
const store = createUIStore()
-165
View File
@@ -27,11 +27,6 @@ import {
type WorkspaceCleanupDismissal
} from '../../../../shared/workspace-cleanup'
import { normalizeFeatureTipIds, type FeatureTipId } from '../../../../shared/feature-tips'
import {
getContextualTour,
normalizeContextualTourIds,
type ContextualTourId
} from '../../../../shared/contextual-tours'
import { PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items'
import {
normalizeVisibleTaskProviders,
@@ -59,11 +54,6 @@ import { DEFAULT_PET_ID, isBundledPetId } from '../../components/pet/pet-models'
import { revokeCustomPetBlobUrl } from '../../components/pet/pet-blob-cache'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports'
import {
getContextualTourRequestDecision,
hasContextualTourTarget,
getNextVisibleContextualTourStepIndex
} from '../../components/contextual-tours/contextual-tour-gate'
export type PendingSidebarWorktreeReveal = {
worktreeId: string
@@ -455,21 +445,6 @@ export type UISlice = {
closeModal: () => void
featureTipsSeenIds: FeatureTipId[]
markFeatureTipsSeen: (ids: FeatureTipId[]) => void
contextualToursSeenIds: ContextualTourId[]
activeContextualTourId: ContextualTourId | null
activeContextualTourStepIndex: number
activeContextualTourSource: string | null
contextualTourShownThisSession: boolean
contextualToursOnboardingVisible: boolean
contextualToursBlockingSurfaceVisible: boolean
setContextualToursOnboardingVisible: (visible: boolean) => void
setContextualToursBlockingSurfaceVisible: (visible: boolean) => void
requestContextualTour: (id: ContextualTourId, source: string) => void
advanceContextualTour: () => void
dismissContextualTour: (id?: ContextualTourId) => void
completeContextualTour: (id?: ContextualTourId) => void
cancelContextualTour: (id?: ContextualTourId) => void
markContextualToursSeen: (ids: ContextualTourId[]) => void
trustedOrcaHooks: PersistedTrustedOrcaHooks
markOrcaHookScriptConfirmed: (
repoId: string,
@@ -906,145 +881,6 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
window.api.ui.set({ featureTipsSeenIds: next }).catch(console.error)
return { featureTipsSeenIds: next }
}),
contextualToursSeenIds: [],
activeContextualTourId: null,
activeContextualTourStepIndex: 0,
activeContextualTourSource: null,
contextualTourShownThisSession: false,
contextualToursOnboardingVisible: false,
contextualToursBlockingSurfaceVisible: false,
setContextualToursOnboardingVisible: (visible) =>
set((s) =>
s.contextualToursOnboardingVisible === visible
? s
: { contextualToursOnboardingVisible: visible }
),
setContextualToursBlockingSurfaceVisible: (visible) =>
set((s) =>
s.contextualToursBlockingSurfaceVisible === visible
? s
: { contextualToursBlockingSurfaceVisible: visible }
),
requestContextualTour: (id, source) =>
set((s) => {
const tour = getContextualTour(id)
const decision = getContextualTourRequestDecision({
tour,
persistedUIReady: s.persistedUIReady,
onboardingVisible: s.contextualToursOnboardingVisible,
seenIds: s.contextualToursSeenIds,
sessionConsumed: s.contextualTourShownThisSession,
activeTourId: s.activeContextualTourId,
activeModal: s.activeModal,
blockingSurfaceVisible: s.contextualToursBlockingSurfaceVisible,
targetExists: hasContextualTourTarget
})
if (decision.kind !== 'start') {
return s
}
return {
activeContextualTourId: id,
activeContextualTourStepIndex: decision.stepIndex,
activeContextualTourSource: source,
contextualTourShownThisSession: true
}
}),
advanceContextualTour: () =>
set((s) => {
if (!s.activeContextualTourId) {
return s
}
const nextStepIndex = getNextVisibleContextualTourStepIndex({
tour: getContextualTour(s.activeContextualTourId),
currentStepIndex: s.activeContextualTourStepIndex,
targetExists: hasContextualTourTarget
})
if (nextStepIndex === null) {
return {
activeContextualTourId: null,
activeContextualTourStepIndex: 0,
activeContextualTourSource: null
}
}
return { activeContextualTourStepIndex: nextStepIndex }
}),
dismissContextualTour: (id) => {
const activeTourId = get().activeContextualTourId
if (id && activeTourId !== id) {
return
}
const tourId = id ?? activeTourId
if (tourId) {
get().markContextualToursSeen([tourId])
}
set((s) => {
if (id && s.activeContextualTourId !== id) {
return s
}
return {
activeContextualTourId: null,
activeContextualTourStepIndex: 0,
activeContextualTourSource: null
}
})
},
completeContextualTour: (id) => {
const activeTourId = get().activeContextualTourId
if (id && activeTourId !== id) {
return
}
const tourId = id ?? activeTourId
if (tourId) {
get().markContextualToursSeen([tourId])
}
set((s) => {
if (id && s.activeContextualTourId !== id) {
return s
}
return {
activeContextualTourId: null,
activeContextualTourStepIndex: 0,
activeContextualTourSource: null
}
})
},
cancelContextualTour: (id) =>
set((s) => {
const activeTourId = s.activeContextualTourId
const tourId = id ?? activeTourId
if (!tourId || (id && activeTourId !== id)) {
return s
}
const alreadyShown = s.contextualToursSeenIds.includes(tourId)
return {
activeContextualTourId: null,
activeContextualTourStepIndex: 0,
activeContextualTourSource: null,
contextualTourShownThisSession: alreadyShown ? s.contextualTourShownThisSession : false
}
}),
markContextualToursSeen: (ids) =>
set((s) => {
if (ids.length === 0) {
return s
}
const current = new Set(s.contextualToursSeenIds)
let changed = false
for (const id of ids) {
if (!current.has(id)) {
current.add(id)
changed = true
}
}
if (!changed) {
return s
}
const next = [...current]
if (typeof window !== 'undefined') {
window.api.ui.set({ contextualToursSeenIds: next }).catch(console.error)
}
return { contextualToursSeenIds: next }
}),
trustedOrcaHooks: {},
markOrcaHookScriptConfirmed: (repoId, kind, contentHash) =>
set((s) => {
@@ -1372,7 +1208,6 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
browserKagiSessionLink: normalizeKagiSessionLink(ui.browserKagiSessionLink ?? ''),
taskResumeState: sanitizeTaskResumeState(ui.taskResumeState),
featureTipsSeenIds: normalizeFeatureTipIds(ui.featureTipsSeenIds),
contextualToursSeenIds: normalizeContextualTourIds(ui.contextualToursSeenIds),
trustedOrcaHooks: filterTrustedOrcaHooksToValidRepos(
ui.trustedOrcaHooks ?? {},
validRepoIds
+1 -2
View File
@@ -378,8 +378,7 @@ export function getDefaultUIState(): PersistedUIState {
setupScriptPromptDismissedRepoIds: [],
acknowledgedAgentsByPaneKey: {},
workspaceCleanup: { dismissals: {} },
featureTipsSeenIds: [],
contextualToursSeenIds: []
featureTipsSeenIds: []
}
}
-54
View File
@@ -1,54 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
CONTEXTUAL_TOURS,
normalizeContextualTourIds,
type ContextualTour,
type ContextualTourId
} from './contextual-tours'
describe('contextual tour definitions', () => {
it('defines the required tours with three concise steps each', () => {
const expectedIds: ContextualTourId[] = [
'right-sidebar',
'workspace-board',
'browser',
'tasks',
'automations',
'workspace-creation'
]
expect(CONTEXTUAL_TOURS.map((tour) => tour.id)).toEqual(expectedIds)
for (const tour of CONTEXTUAL_TOURS) {
expect(tour.steps).toHaveLength(3)
expect(tour.steps[0]?.requiredForStart).toBe(true)
for (const step of tour.steps) {
expect(step.title.length).toBeGreaterThan(0)
expect(step.body.length).toBeGreaterThan(0)
expect(step.body.length).toBeLessThanOrEqual(140)
expect(step.targetSelector).toContain('data-contextual-tour-target')
}
}
})
it('keeps workspace creation as the only modal-allowed tour', () => {
const modalTours = (CONTEXTUAL_TOURS as readonly ContextualTour[]).filter(
(tour) => tour.allowedActiveModals?.length
)
expect(modalTours.map((tour) => tour.id)).toEqual(['workspace-creation'])
expect(modalTours[0]?.allowedActiveModals).toEqual(['new-workspace-composer', 'add-repo'])
})
it('normalizes persisted ids by removing unknowns and duplicates', () => {
expect(
normalizeContextualTourIds([
'tasks',
'unknown',
'browser',
'tasks',
null,
'workspace-creation'
])
).toEqual(['tasks', 'browser', 'workspace-creation'])
})
})
-177
View File
@@ -1,177 +0,0 @@
export type ContextualTourId =
| 'right-sidebar'
| 'workspace-board'
| 'browser'
| 'tasks'
| 'automations'
| 'workspace-creation'
export type ContextualTourStep = {
title: string
body: string
targetSelector: string
requiredForStart?: boolean
fallbackCopy?: string
}
export type ContextualTour = {
id: ContextualTourId
allowedActiveModals?: readonly string[]
steps: readonly ContextualTourStep[]
}
export const CONTEXTUAL_TOURS = [
{
id: 'right-sidebar',
steps: [
{
title: 'Use the sidebar for workspace context',
body: 'Open files, search, source control, checks, and SSH ports without leaving the workspace.',
targetSelector: '[data-contextual-tour-target="right-sidebar-shell"]',
requiredForStart: true
},
{
title: 'Switch tools from the activity buttons',
body: 'Pick the tool you need; Orca only shows buttons that apply to the current project.',
targetSelector: '[data-contextual-tour-target="right-sidebar-activity"]'
},
{
title: 'Keep the panel sized for the job',
body: 'Use the toggle or resize edge to make room while keeping the current tool ready.',
targetSelector: '[data-contextual-tour-target="right-sidebar-panel"]'
}
]
},
{
id: 'workspace-board',
steps: [
{
title: 'Plan work on the board',
body: 'Use the board when you want to see workspaces by status instead of by project.',
targetSelector: '[data-contextual-tour-target="workspace-board-surface"]',
requiredForStart: true
},
{
title: 'Move work through lanes',
body: 'Statuses make active, reviewing, and finished work easy to scan.',
targetSelector: '[data-contextual-tour-target="workspace-board-lanes"]'
},
{
title: 'Drag cards and tune density',
body: 'Drop cards into lanes, resize columns, or switch compact mode from the board controls.',
targetSelector: '[data-contextual-tour-target="workspace-board-cards"]'
}
]
},
{
id: 'browser',
steps: [
{
title: 'Preview the app here',
body: 'Use the address bar for localhost, URLs, or search while you keep coding nearby.',
targetSelector:
'[data-contextual-tour-target="browser-address"], [data-orca-browser-address-bar="true"]',
requiredForStart: true
},
{
title: 'Grab page context for agents',
body: 'On supported local pages, grab controls can copy elements or hand page context to an agent.',
targetSelector: '[data-contextual-tour-target="browser-grab-control"]'
},
{
title: 'Mark design feedback in place',
body: 'On supported local pages, annotate elements and send those notes to an agent.',
targetSelector: '[data-contextual-tour-target="browser-annotation-control"]'
}
]
},
{
id: 'tasks',
steps: [
{
title: 'Choose the work source',
body: 'Switch between connected providers and project filters without changing pages.',
targetSelector: '[data-contextual-tour-target="tasks-source-filters"]',
requiredForStart: true
},
{
title: 'Filter to the work you need',
body: 'Use presets and search to narrow issues, reviews, merge requests, or tasks.',
targetSelector: '[data-contextual-tour-target="tasks-search-presets"]'
},
{
title: 'Start from tracked work',
body: 'Open an item or create one, then use it to start a workspace with the right context.',
targetSelector:
'[data-contextual-tour-target="tasks-actions"], [data-contextual-tour-target="tasks-search-presets"]'
}
]
},
{
id: 'automations',
steps: [
{
title: 'Review recurring work',
body: 'The list shows scheduled agent work, next runs, and external automation sources.',
targetSelector: '[data-contextual-tour-target="automations-list"]',
requiredForStart: true
},
{
title: 'Create a schedule',
body: 'Add an automation for recurring checks, maintenance, or follow-up agent work.',
targetSelector: '[data-contextual-tour-target="automations-create"]'
},
{
title: 'Run and inspect results',
body: 'Use overview and runs to trigger work manually and review what happened.',
targetSelector: '[data-contextual-tour-target="automations-runs"]'
}
]
},
{
id: 'workspace-creation',
allowedActiveModals: ['new-workspace-composer', 'add-repo'],
steps: [
{
title: 'Confirm the project source',
body: 'Choose or confirm the folder, clone, remote project, or existing project before creating work.',
targetSelector: '[data-contextual-tour-target="workspace-creation-source"]',
requiredForStart: true
},
{
title: 'Review advanced options',
body: 'Use Advanced or setup-step options for scripts, existing worktrees, notes, or workspace details.',
targetSelector: '[data-contextual-tour-target="workspace-creation-setup"]'
},
{
title: 'Create when the inputs are ready',
body: 'Start the workspace after the project, name, agent, and setup choices look right.',
targetSelector: '[data-contextual-tour-target="workspace-creation-action"]'
}
]
}
] as const satisfies readonly ContextualTour[]
export const CONTEXTUAL_TOUR_IDS = CONTEXTUAL_TOURS.map((tour) => tour.id)
export function isContextualTourId(value: unknown): value is ContextualTourId {
return typeof value === 'string' && CONTEXTUAL_TOUR_IDS.includes(value as ContextualTourId)
}
export function getContextualTour(id: ContextualTourId): ContextualTour {
return CONTEXTUAL_TOURS.find((tour) => tour.id === id)!
}
export function normalizeContextualTourIds(value: unknown): ContextualTourId[] {
if (!Array.isArray(value)) {
return []
}
const seen = new Set<ContextualTourId>()
for (const item of value) {
if (isContextualTourId(item)) {
seen.add(item)
}
}
return [...seen]
}
-4
View File
@@ -13,7 +13,6 @@ import type { WorkspaceCleanupUIState } from './workspace-cleanup'
import type { GitLabProjectSettings } from './gitlab-types'
import type { TaskProvider } from './task-providers'
import type { FeatureTipId } from './feature-tips'
import type { ContextualTourId } from './contextual-tours'
import type { GitBranchChangeStatus } from './git-status-types'
import type { KeybindingOverrides, TerminalShortcutPolicy } from './keybindings'
import type { RepoIcon } from './repo-icon'
@@ -2213,9 +2212,6 @@ export type PersistedUIState = {
/** Feature tips already surfaced to the user. Startup only opens the tips
* modal when this list is missing one of the current tip ids. */
featureTipsSeenIds?: FeatureTipId[]
/** Contextual tours already surfaced to the user. Unknown ids are ignored
* during hydration so downgrade/upgrade cycles remain forward-compatible. */
contextualToursSeenIds?: ContextualTourId[]
}
export const PET_SIZE_MIN = 60
@@ -1,14 +1,6 @@
import { ONBOARDING_FINAL_STEP } from '../../../src/shared/constants'
const SEEN_FIRST_RUN_FEATURE_TIP_IDS = ['voice-dictation'] as const
const SEEN_FIRST_RUN_CONTEXTUAL_TOUR_IDS = [
'right-sidebar',
'workspace-board',
'browser',
'tasks',
'automations',
'workspace-creation'
] as const
export function getE2ECompletedOnboardingProfile() {
return {
@@ -27,8 +19,7 @@ export function getE2ECompletedOnboardingProfile() {
ui: {
// Why: completed-onboarding E2E profiles should not be interrupted by
// first-run education modals that cover the UI under test.
featureTipsSeenIds: [...SEEN_FIRST_RUN_FEATURE_TIP_IDS],
contextualToursSeenIds: [...SEEN_FIRST_RUN_CONTEXTUAL_TOUR_IDS]
featureTipsSeenIds: [...SEEN_FIRST_RUN_FEATURE_TIP_IDS]
}
}
}