mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
@@ -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 ?? '',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -378,8 +378,7 @@ export function getDefaultUIState(): PersistedUIState {
|
||||
setupScriptPromptDismissedRepoIds: [],
|
||||
acknowledgedAgentsByPaneKey: {},
|
||||
workspaceCleanup: { dismissals: {} },
|
||||
featureTipsSeenIds: [],
|
||||
contextualToursSeenIds: []
|
||||
featureTipsSeenIds: []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user