From b024b8835686c291bc08bbf977cb4385f190c613 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Sun, 24 May 2026 14:00:24 -0700 Subject: [PATCH] Revert "Add contextual feature tours" This reverts commit 77ae11fcc33570c5174495e912a476288cb0b8ed. --- src/renderer/src/App.tsx | 17 -- .../components/NewWorkspaceComposerCard.tsx | 4 +- .../components/NewWorkspaceComposerModal.tsx | 2 - src/renderer/src/components/TaskPage.tsx | 47 +-- .../automations/AutomationsPage.tsx | 17 +- .../components/browser-pane/BrowserPane.tsx | 25 +- .../src/components/confirmation-dialog.tsx | 11 - .../ContextualTourOverlay.test.tsx | 157 ---------- .../ContextualTourOverlay.tsx | 254 ---------------- .../ContextualTourOverlaySurface.tsx | 199 ------------- .../contextual-tour-gate.test.ts | 228 --------------- .../contextual-tours/contextual-tour-gate.ts | 271 ------------------ .../contextual-tours/use-contextual-tour.ts | 118 -------- .../FloatingTerminalPanel.tsx | 2 +- .../src/components/right-sidebar/index.tsx | 11 +- .../components/sidebar/AddRepoCreateStep.tsx | 13 +- .../src/components/sidebar/AddRepoDialog.tsx | 15 +- .../components/sidebar/AddRepoSetupStep.tsx | 11 +- .../src/components/sidebar/AddRepoSteps.tsx | 6 +- .../sidebar/WorkspaceKanbanDrawer.tsx | 3 - .../sidebar/WorkspaceKanbanLaneGrid.tsx | 1 - .../sidebar/WorkspaceKanbanStatusLane.tsx | 1 - src/renderer/src/store/slices/ui.test.ts | 228 --------------- src/renderer/src/store/slices/ui.ts | 165 ----------- src/shared/constants.ts | 3 +- src/shared/contextual-tours.test.ts | 54 ---- src/shared/contextual-tours.ts | 177 ------------ src/shared/types.ts | 4 - .../e2e-completed-onboarding-profile.ts | 11 +- 29 files changed, 25 insertions(+), 2030 deletions(-) delete mode 100644 src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx delete mode 100644 src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx delete mode 100644 src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx delete mode 100644 src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts delete mode 100644 src/renderer/src/components/contextual-tours/contextual-tour-gate.ts delete mode 100644 src/renderer/src/components/contextual-tours/use-contextual-tour.ts delete mode 100644 src/shared/contextual-tours.test.ts delete mode 100644 src/shared/contextual-tours.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 47148c05059..19bdbd54e89 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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()) const [onboarding, setOnboarding] = useState(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') ? : null} {mountedLazyModalIds.has('feature-tips') ? : null} - {/* 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 diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index b4c4035d3fe..d0403ba91df 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -307,7 +307,7 @@ export default function NewWorkspaceComposerCard({ )} >
-
+
@@ -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 {creating ? : null} {primaryActionLabel} diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index d924ace58c8..02bf3bd0b47 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -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 ?? '', diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index dce26a5bcd2..3408f42670a 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -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(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 {
-
+
{/* 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' ? ( -
+
{getGitHubTaskKindPresets(activeGithubTaskKind).map((option) => { const active = activeTaskPreset === option.id @@ -4411,10 +4393,7 @@ export default function TaskPage(): React.JSX.Element { ) : null}
-
+
-
+
@@ -4743,10 +4713,7 @@ export default function TaskPage(): React.JSX.Element { : null}
-
+
@@ -1444,10 +1437,7 @@ export default function AutomationsPage(): React.JSX.Element {
-
+
{automations.length + externalAutomationEntries.length > 0 ? (
@@ -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" > -
+
Overview diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index c96a12282dc..577d4e2180f 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -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({ 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 => { if (!activeBrowserPageId) { return @@ -2408,10 +2395,7 @@ function RemoteBrowserPagePane({ document.body ) : null} -
+
@@ -4417,7 +4397,6 @@ function BrowserPagePane({ onClick={() => startGrabIntent('annotate')} disabled={isBlankTab} aria-label="Annotate page element" - data-contextual-tour-target="browser-annotation-control" > {browserAnnotations.length > 0 ? ( diff --git a/src/renderer/src/components/confirmation-dialog.tsx b/src/renderer/src/components/confirmation-dialog.tsx index 92d9f493a65..833ad7e76ea 100644 --- a/src/renderer/src/components/confirmation-dialog.tsx +++ b/src/renderer/src/components/confirmation-dialog.tsx @@ -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(null) const activeRequestRef = useRef(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((options) => { return new Promise((resolve) => { const request: ConfirmationDialogRequest = { diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx deleted file mode 100644 index 617896b3a0c..00000000000 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx +++ /dev/null @@ -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 = {}, - callbacks: { onSkip?: (id: ContextualTourId) => void; onNext?: () => void } = {} -): ReactElement { - return ContextualTourOverlaySurface({ - activeTourId: 'tasks', - renderState: { ...baseRenderState, ...overrides }, - panelRef: { current: null } as RefObject, - 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 | 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 - } - - 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[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"]' - ) - }) -}) diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx deleted file mode 100644 index 97ab7620a1c..00000000000 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx +++ /dev/null @@ -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(null) - const [measureVersion, setMeasureVersion] = useState(0) - const panelRef = useRef(null) - const markedTourIdRef = useRef(null) - const previousFocusRef = useRef(null) - const focusedStepRef = useRef(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 ( - { - if (renderState.isLastStep) { - completeContextualTour(activeTourId) - } else { - advanceContextualTour() - } - }} - onOverlayKeyDownCapture={handleContextualTourOverlayKeyDown} - /> - ) -} diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx deleted file mode 100644 index 0491aaa2d51..00000000000 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx +++ /dev/null @@ -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 - highlightStyle: CSSProperties - panelPosition: CSSProperties - panelHost: HTMLElement | null - onSkip: (id: ContextualTourId) => void - onNext: () => void - onOverlayKeyDownCapture: (event: KeyboardEvent) => 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 = ( -
-
- {renderState.progress.current}/{renderState.progress.total} -
-

{renderState.title}

-

{renderState.body}

-
- - -
-
- ) - - return ( -
-
- {panelHost ? null : ( - - ) -} - -export function handleContextualTourOverlayKeyDown(event: KeyboardEvent): void { - if (event.key === 'Escape') { - event.preventDefault() - event.stopPropagation() - const skipButton = event.currentTarget.querySelector('button') - skipButton?.click() - return - } - - if (event.key !== 'Tab') { - return - } - - const focusRoot = - document.querySelector('[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('[data-contextual-tour-overlay]') - const focusRoot = document.querySelector('[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(FOCUSABLE_SELECTOR)).filter( - (element) => element.getClientRects().length > 0 || element === document.activeElement - ) -} diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts b/src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts deleted file mode 100644 index 31d16763d94..00000000000 --- a/src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-gate.ts b/src/renderer/src/components/contextual-tours/contextual-tour-gate.ts deleted file mode 100644 index d927a9950ee..00000000000 --- a/src/renderer/src/components/contextual-tours/contextual-tour-gate.ts +++ /dev/null @@ -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(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 - 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) -} diff --git a/src/renderer/src/components/contextual-tours/use-contextual-tour.ts b/src/renderer/src/components/contextual-tours/use-contextual-tour.ts deleted file mode 100644 index 21e9abe08d0..00000000000 --- a/src/renderer/src/components/contextual-tours/use-contextual-tour.ts +++ /dev/null @@ -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 - -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 - ]) -} diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 71b443317be..760e933ee50 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -934,7 +934,7 @@ export function FloatingTerminalPanel({ className={isActive ? 'absolute inset-0 flex' : 'absolute inset-0 hidden'} aria-hidden={!isActive} > - +
) })} diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx index d31e74b40b5..55f4e24b44b 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -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 */}
{/* 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 {
{/* 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' && ( -
+
{sideActivityBarIcons}
diff --git a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx index 9bd2ef9080f..1ef4f80bce0 100644 --- a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx @@ -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. */} -
+
{/* Kind toggle. Real radiogroup so screen readers announce it as a choice. */}
)} -
diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index ceb5a50bc72..0b182c16f70 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -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() { -
+