diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 19bdbd54e89..47148c05059 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -113,6 +113,7 @@ 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, @@ -286,6 +287,8 @@ 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, @@ -413,6 +416,7 @@ 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) @@ -447,6 +451,17 @@ 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, @@ -557,6 +572,7 @@ 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 @@ -1686,6 +1702,7 @@ 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 d0403ba91df..b4c4035d3fe 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -307,7 +307,7 @@ export default function NewWorkspaceComposerCard({ )} >
-
+
@@ -452,6 +452,7 @@ 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 02bf3bd0b47..d924ace58c8 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -8,6 +8,7 @@ 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, @@ -98,6 +99,7 @@ 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 3408f42670a..dce26a5bcd2 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -132,6 +132,7 @@ 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 { @@ -3071,6 +3072,17 @@ 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) @@ -4147,7 +4159,10 @@ 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 @@ -4323,7 +4338,10 @@ export default function TaskPage(): React.JSX.Element { ) : null} {taskSource === 'github' && githubMode === 'items' ? ( -
+
{getGitHubTaskKindPresets(activeGithubTaskKind).map((option) => { const active = activeTaskPreset === option.id @@ -4393,7 +4411,10 @@ export default function TaskPage(): React.JSX.Element { ) : null}
-
+
-
+
@@ -4713,7 +4743,10 @@ export default function TaskPage(): React.JSX.Element { : null}
-
+
@@ -1437,7 +1444,10 @@ export default function AutomationsPage(): React.JSX.Element {
-
+
{automations.length + externalAutomationEntries.length > 0 ? (
@@ -1809,7 +1819,10 @@ 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 577d4e2180f..c96a12282dc 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -151,6 +151,7 @@ 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< @@ -784,6 +785,9 @@ 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' }) @@ -810,6 +814,15 @@ export default function BrowserPane({ }) }, [activeBrowserPageId, runtimeEnvironmentActive]) + useContextualTour( + 'browser', + isActive && + activeBrowserPage !== null && + !runtimeEnvironmentActive && + !activeBrowserPageIsBlank, + 'browser_visible' + ) + const reclaimActiveBrowserForDesktop = useCallback(async (): Promise => { if (!activeBrowserPageId) { return @@ -2395,7 +2408,10 @@ function RemoteBrowserPagePane({ document.body ) : null} -
+
@@ -4397,6 +4417,7 @@ 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 833ad7e76ea..92d9f493a65 100644 --- a/src/renderer/src/components/confirmation-dialog.tsx +++ b/src/renderer/src/components/confirmation-dialog.tsx @@ -9,6 +9,7 @@ import { DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useAppStore } from '@/store' type ConfirmationDialogOptions = { title: string @@ -39,6 +40,9 @@ 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 @@ -47,6 +51,13 @@ 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 new file mode 100644 index 00000000000..617896b3a0c --- /dev/null +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx @@ -0,0 +1,157 @@ +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 new file mode 100644 index 00000000000..97ab7620a1c --- /dev/null +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx @@ -0,0 +1,254 @@ +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 new file mode 100644 index 00000000000..0491aaa2d51 --- /dev/null +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx @@ -0,0 +1,199 @@ +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 new file mode 100644 index 00000000000..31d16763d94 --- /dev/null +++ b/src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts @@ -0,0 +1,228 @@ +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 new file mode 100644 index 00000000000..d927a9950ee --- /dev/null +++ b/src/renderer/src/components/contextual-tours/contextual-tour-gate.ts @@ -0,0 +1,271 @@ +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 new file mode 100644 index 00000000000..21e9abe08d0 --- /dev/null +++ b/src/renderer/src/components/contextual-tours/use-contextual-tour.ts @@ -0,0 +1,118 @@ +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 760e933ee50..71b443317be 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 55f4e24b44b..d31e74b40b5 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -29,6 +29,7 @@ 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 @@ -109,6 +110,7 @@ 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. @@ -203,10 +205,12 @@ 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 @@ -266,6 +271,7 @@ 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 @@ -329,7 +335,10 @@ 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 1ef4f80bce0..9bd2ef9080f 100644 --- a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx @@ -320,7 +320,10 @@ 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 0b182c16f70..ceb5a50bc72 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -33,6 +33,7 @@ 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) @@ -130,6 +131,11 @@ 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] ?? [] @@ -477,7 +483,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { -
+