diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 6c1f8c1d9bf..6f6ffc6301c 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -516,19 +516,36 @@ export default function NewWorkspaceComposerCard({ () => projectHostSetupOptions.filter((option) => option.kind === 'ready'), [projectHostSetupOptions] ) + const selectedProjectOption = React.useMemo( + () => projectOptions.find((option) => option.id === selectedProjectId) ?? null, + [projectOptions, selectedProjectId] + ) + const isFolderWorkspaceTarget = selectedProjectOption?.kind === 'project-group' const handleProjectHostSetupChange = React.useCallback( (setupId: string): void => { onProjectHostSetupChange?.(setupId) }, [onProjectHostSetupChange] ) + const isOnboardingFolderWorkspaceTourHandoff = + isFolderWorkspaceTarget && contextualTourSource === 'folder_workspace_creation_modal' useContextualTour( - 'workspace-creation', + isFolderWorkspaceTarget ? 'folder-workspace-creation' : 'workspace-creation', projectOptions.length > 0 && Boolean(selectedProjectId), contextualTourSource ?? (activeModal === 'new-workspace-composer' - ? 'workspace_creation_modal' - : 'workspace_creation_visible') + ? isFolderWorkspaceTarget + ? 'folder_workspace_creation_modal' + : 'workspace_creation_modal' + : isFolderWorkspaceTarget + ? 'folder_workspace_creation_visible' + : 'workspace_creation_visible'), + { + recordFeatureInteraction: !isOnboardingFolderWorkspaceTourHandoff, + featureInteractionPersisted: isOnboardingFolderWorkspaceTourHandoff + ? Promise.resolve() + : undefined + } ) return ( diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index d9439d07c48..7ee7f3a235a 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -11,7 +11,7 @@ import { type BackgroundMountTerminalWorktreeDetail } from '@/constants/terminal' import { useAppStore } from '../store' -import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { folderWorkspaceKey, parseWorkspaceKey } from '../../../shared/workspace-scope' import { useAllWorktrees } from '../store/selectors' import { getConnectionId } from '../lib/connection-context' import { basename } from '../lib/path' @@ -37,6 +37,7 @@ import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload' import EditorAutosaveController from './editor/EditorAutosaveController' import type { Tab, TabContentType, TabGroupLayoutNode, TuiAgent } from '../../../shared/types' import { hasFeatureInteraction } from '../../../shared/feature-interactions' +import { requestContextualTourWhenReady } from './contextual-tours/request-contextual-tour-when-ready' import BrowserPane from './browser-pane/BrowserPane' import BrowserPaneOverlayLayer from './browser-pane/BrowserPaneOverlayLayer' import EmulatorPaneOverlayLayer from './emulator-pane/EmulatorPaneOverlayLayer' @@ -333,9 +334,23 @@ function Terminal(): React.JSX.Element | null { ? (browserTabsByWorktree[renderedActiveWorktreeId] ?? []).map((tab) => tab.id).join(',') : '' const activeContextualTourId = useAppStore((s) => s.activeContextualTourId) + const activeWorkspaceKey = useAppStore((s) => s.activeWorkspaceKey) + const featureInteractions = useAppStore((s) => s.featureInteractions) + const contextualToursAutoEligible = useAppStore((s) => s.contextualToursAutoEligible) + const contextualToursSeenIds = useAppStore((s) => s.contextualToursSeenIds) + const contextualTourShownThisSession = useAppStore((s) => s.contextualTourShownThisSession) + const contextualToursOnboardingVisible = useAppStore((s) => s.contextualToursOnboardingVisible) + const contextualToursBlockingSurfaceVisible = useAppStore( + (s) => s.contextualToursBlockingSurfaceVisible + ) + const activeModal = useAppStore((s) => s.activeModal) const hasSplitTerminalPane = useAppStore((s) => hasFeatureInteraction(s.featureInteractions, 'terminal-pane-split') ) + const hasFolderSidebarEducation = hasFeatureInteraction( + featureInteractions, + 'folder-workspace-right-sidebar' + ) useContextualTour( 'workspace-agent-sessions', @@ -350,6 +365,55 @@ function Terminal(): React.JSX.Element | null { 'workspace_agent_sessions_visible' ) + useEffect(() => { + // Why: this auto tour should not appear after the user leaves the folder + // workspace or already discovers the right sidebar through normal use. + const scope = parseWorkspaceKey(activeWorkspaceKey ?? activeWorktreeId ?? '') + if ( + scope?.type !== 'folder' || + !workspaceSessionReady || + hasFolderSidebarEducation || + contextualToursAutoEligible !== true || + contextualToursSeenIds.includes('folder-workspace-overview') || + contextualTourShownThisSession || + contextualToursOnboardingVisible || + contextualToursBlockingSurfaceVisible || + activeContextualTourId !== null || + activeModal !== 'none' + ) { + return + } + + return requestContextualTourWhenReady({ + id: 'folder-workspace-overview', + source: 'folder_workspace_active', + wasFeaturePreviouslyInteracted: false, + force: false, + shouldContinue: () => { + const state = useAppStore.getState() + const latestScope = parseWorkspaceKey( + state.activeWorkspaceKey ?? state.activeWorktreeId ?? '' + ) + return ( + latestScope?.type === 'folder' && + !hasFeatureInteraction(state.featureInteractions, 'folder-workspace-right-sidebar') + ) + } + }) + }, [ + activeWorkspaceKey, + activeWorktreeId, + activeContextualTourId, + activeModal, + contextualTourShownThisSession, + contextualToursAutoEligible, + contextualToursBlockingSurfaceVisible, + contextualToursOnboardingVisible, + contextualToursSeenIds, + hasFolderSidebarEducation, + workspaceSessionReady + ]) + // Save confirmation dialog state const [saveDialogFileId, setSaveDialogFileId] = useState(null) const saveDialogFile = saveDialogFileId ? openFiles.find((f) => f.id === saveDialogFileId) : null diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx index 8129a6f20d8..de8dd86abc9 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx @@ -5,6 +5,8 @@ import { type ContextualTourId, type ContextualTourStepAction } from '../../../../shared/contextual-tours' +import { getFeatureInteractionIdForContextualTour } from '../../../../shared/contextual-tour-feature-interactions' +import { hasFeatureInteraction } from '../../../../shared/feature-interactions' import type { ContextualTourOutcome } from '../../../../shared/feature-education-telemetry' import { trackContextualTourOutcome, @@ -23,7 +25,10 @@ import { } from './ContextualTourOverlaySurface' import { requestActiveTerminalPaneSplit } from '@/components/tab-bar/request-active-terminal-pane-split' import { performContextualTourStepAction } from './contextual-tour-step-actions' -import { openWorkspaceCreationComposerWithTourHandoff } from './workspace-creation-tour-handoff' +import { + openFolderWorkspaceCreationComposerWithTourHandoff, + openWorkspaceCreationComposerWithTourHandoff +} from './workspace-creation-tour-handoff' export function ContextualTourOverlay(): JSX.Element | null { const activeTourId = useAppStore((s) => s.activeContextualTourId) @@ -41,6 +46,7 @@ export function ContextualTourOverlay(): JSX.Element | null { const sidebarOpen = useAppStore((s) => s.sidebarOpen) const canCreateWorkspace = useAppStore((s) => s.repos.length > 0) const markContextualToursSeen = useAppStore((s) => s.markContextualToursSeen) + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const advanceContextualTour = useAppStore((s) => s.advanceContextualTour) const regressContextualTour = useAppStore((s) => s.regressContextualTour) const dismissContextualTour = useAppStore((s) => s.dismissContextualTour) @@ -205,7 +211,14 @@ export function ContextualTourOverlay(): JSX.Element | null { // paints, so missing or removed surfaces can retry on a later visit. markedTourIdRef.current = activeTourId markContextualToursSeen([activeTourId]) - }, [activeTourId, markContextualToursSeen, renderState]) + const featureInteractionId = getFeatureInteractionIdForContextualTour(activeTourId) + if ( + featureInteractionId && + !hasFeatureInteraction(useAppStore.getState().featureInteractions, featureInteractionId) + ) { + void recordFeatureInteraction(featureInteractionId) + } + }, [activeTourId, markContextualToursSeen, recordFeatureInteraction, renderState]) useEffect(() => { if (!activeTourId || !renderState || telemetryTourIdRef.current === activeTourId) { @@ -318,6 +331,13 @@ export function ContextualTourOverlay(): JSX.Element | null { openModal, canCreateWorkspace, openWorkspaceComposer: openWorkspaceCreationComposerWithTourHandoff, + openFolderWorkspaceComposer: (projectGroupId) => { + openFolderWorkspaceCreationComposerWithTourHandoff(projectGroupId) + }, + folderWorkspaceGroupId: + renderState.targetElement instanceof HTMLElement + ? renderState.targetElement.getAttribute('data-folder-workspace-group-id') + : null, dispatchTerminalPaneSplit: requestActiveTerminalPaneSplit, schedule: (callback) => { window.setTimeout(callback, 0) 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 index f9f07a1817e..b624cd00016 100644 --- a/src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts +++ b/src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts @@ -395,4 +395,29 @@ describe('contextual tour gate', () => { }) ).toBeNull() }) + + it('runs folder sidebar pre-step routing before deciding whether the next step is visible', () => { + const tour = getContextualTour('folder-workspace-overview') + const routedTabs: string[] = [] + const targetExists = (selector: string): boolean => { + if (selector.includes('folder-workspace-review-checks')) { + return routedTabs.at(-1) === 'pr-checks' + } + return false + } + + expect( + getNextVisibleContextualTourStepIndex({ + tour, + currentStepIndex: 0, + targetExists, + prepareStep: (step) => { + if (step.preStepAction?.kind === 'show-folder-sidebar-tab') { + routedTabs.push(step.preStepAction.tab) + } + } + }) + ).toBe(1) + expect(routedTabs).toEqual(['pr-checks']) + }) }) diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-gate.ts b/src/renderer/src/components/contextual-tours/contextual-tour-gate.ts index 07ffc248c28..5a5fd1b0803 100644 --- a/src/renderer/src/components/contextual-tours/contextual-tour-gate.ts +++ b/src/renderer/src/components/contextual-tours/contextual-tour-gate.ts @@ -72,11 +72,15 @@ export function hasContextualTourTarget(selector: string, root?: ParentNode): bo export function getContextualTourStartStepIndex( tour: ContextualTour, - targetExists: (selector: string) => boolean + targetExists: (selector: string) => boolean, + prepareStep?: (step: ContextualTourStep, index: number) => void ): number | null { const requiredStepIndex = tour.steps.findIndex((step) => step.requiredForStart === true) const startIndex = Math.max(requiredStepIndex, 0) const step = tour.steps[startIndex] + if (step) { + prepareStep?.(step, startIndex) + } if (!step || !targetExists(step.targetSelector)) { return null } @@ -85,10 +89,12 @@ export function getContextualTourStartStepIndex( export function getVisibleContextualTourStepIndexes( tour: ContextualTour, - targetExists: (selector: string) => boolean + targetExists: (selector: string) => boolean, + prepareStep?: (step: ContextualTourStep, index: number) => void ): number[] { const indexes: number[] = [] tour.steps.forEach((step, index) => { + prepareStep?.(step, index) if (targetExists(step.targetSelector)) { indexes.push(index) } @@ -100,28 +106,38 @@ export function getNextVisibleContextualTourStepIndex(args: { tour: ContextualTour currentStepIndex: number targetExists: (selector: string) => boolean + prepareStep?: (step: ContextualTourStep, index: number) => void }): number | null { - return ( - getVisibleContextualTourStepIndexes(args.tour, args.targetExists).find( - (index) => index > args.currentStepIndex - ) ?? null - ) + for (let index = args.currentStepIndex + 1; index < args.tour.steps.length; index += 1) { + const step = args.tour.steps[index] + if (!step) { + continue + } + args.prepareStep?.(step, index) + if (step.preStepAction || args.targetExists(step.targetSelector)) { + return index + } + } + return null } export function getPreviousVisibleContextualTourStepIndex(args: { tour: ContextualTour currentStepIndex: number targetExists: (selector: string) => boolean + prepareStep?: (step: ContextualTourStep, index: number) => void }): number | null { - const visible = getVisibleContextualTourStepIndexes(args.tour, args.targetExists) - let prev: number | null = null - for (const index of visible) { - if (index >= args.currentStepIndex) { - break + for (let index = args.currentStepIndex - 1; index >= 0; index -= 1) { + const step = args.tour.steps[index] + if (!step) { + continue + } + args.prepareStep?.(step, index) + if (step.preStepAction || args.targetExists(step.targetSelector)) { + return index } - prev = index } - return prev + return null } export function getContextualTourRequestDecision(args: { @@ -135,6 +151,7 @@ export function getContextualTourRequestDecision(args: { activeModal: string blockingSurfaceVisible: boolean targetExists: (selector: string) => boolean + prepareStep?: (step: ContextualTourStep, index: number) => void }): ContextualTourRequestDecision { if (!args.persistedUIReady) { return { kind: 'blocked', reason: 'persisted-ui-not-ready' } @@ -161,7 +178,7 @@ export function getContextualTourRequestDecision(args: { return { kind: 'blocked', reason: 'blocking-surface' } } - const stepIndex = getContextualTourStartStepIndex(args.tour, args.targetExists) + const stepIndex = getContextualTourStartStepIndex(args.tour, args.targetExists, args.prepareStep) if (stepIndex === null) { return { kind: 'blocked', reason: 'missing-start-target' } } @@ -214,6 +231,9 @@ function getContextualTourTargetCandidates( } function isContextualTourTargetVisible(element: Element): boolean { + if (isDisabledContextualTourTarget(element)) { + return false + } if ( typeof element.closest === 'function' && element.closest('[hidden],[inert],[aria-hidden="true"]') @@ -239,3 +259,15 @@ function isContextualTourTargetVisible(element: Element): boolean { } return true } + +function isDisabledContextualTourTarget(element: Element): boolean { + if ( + typeof element.getAttribute === 'function' && + element.getAttribute('aria-disabled') === 'true' + ) { + return true + } + return typeof HTMLButtonElement !== 'undefined' && element instanceof HTMLButtonElement + ? element.disabled + : false +} diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts index a807ade9fe4..639d8364f95 100644 --- a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts +++ b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts @@ -10,6 +10,7 @@ import { getMeasurableContextualTourTarget, getVisibleContextualTourStepIndexes } from './contextual-tour-gate' +import { performContextualTourPreStepAction } from './contextual-tour-pre-step-actions' import type { ActiveTourRenderState } from './ContextualTourOverlaySurface' import { translate } from '@/i18n/i18n' @@ -82,12 +83,15 @@ export function measureContextualTourOverlayRenderState(args: { }): ContextualTourOverlayMeasurementResult { const targetExists = (selector: string): boolean => getMeasurableContextualTourTarget(selector) !== null - const visibleStepIndexes = getVisibleContextualTourStepIndexes(args.tour, targetExists) + const visibleStepIndexes = getVisibleContextualTourStepIndexes(args.tour, targetExists, (step) => + performContextualTourPreStepAction(step) + ) const telemetryTotalSteps = Math.max( args.previousTelemetryTotalSteps, getContextualTourOutcomeStepTotal(visibleStepIndexes) ) const activeStep = args.tour.steps[args.activeStepIndex] + const activePreStepReady = performContextualTourPreStepAction(activeStep) const target = activeStep ? getMeasurableContextualTourTarget(activeStep.targetSelector) : null const progress = getContextualTourDisplayProgress({ tour: args.tour, @@ -96,6 +100,10 @@ export function measureContextualTourOverlayRenderState(args: { activeStep }) + if (activeStep?.preStepAction && !target) { + return activePreStepReady ? { kind: 'wait' } : { kind: 'cancel' } + } + if (visibleStepIndexes.length === 0 || !activeStep || !progress) { return { kind: 'cancel' } } diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-pre-step-actions.ts b/src/renderer/src/components/contextual-tours/contextual-tour-pre-step-actions.ts new file mode 100644 index 00000000000..e92dbcacbf3 --- /dev/null +++ b/src/renderer/src/components/contextual-tours/contextual-tour-pre-step-actions.ts @@ -0,0 +1,25 @@ +import { useAppStore } from '@/store' +import type { ContextualTourStep } from '../../../../shared/contextual-tours' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' + +export function performContextualTourPreStepAction(step: ContextualTourStep | undefined): boolean { + const action = step?.preStepAction + if (!action) { + return true + } + switch (action.kind) { + case 'show-folder-sidebar-tab': + return routeFolderSidebarTab(action.tab) + } +} + +function routeFolderSidebarTab(tab: 'workspaces' | 'pr-checks'): boolean { + const state = useAppStore.getState() + const scope = parseWorkspaceKey(state.activeWorkspaceKey ?? state.activeWorktreeId ?? '') + if (scope?.type !== 'folder') { + return false + } + state.setRightSidebarOpen(true) + state.setRightSidebarTab(tab) + return true +} diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-step-actions.test.ts b/src/renderer/src/components/contextual-tours/contextual-tour-step-actions.test.ts index 4c18f50d3bb..3e953e227bc 100644 --- a/src/renderer/src/components/contextual-tours/contextual-tour-step-actions.test.ts +++ b/src/renderer/src/components/contextual-tours/contextual-tour-step-actions.test.ts @@ -20,6 +20,8 @@ describe('performContextualTourStepAction', () => { openModal: vi.fn(), canCreateWorkspace: true, openWorkspaceComposer: vi.fn(), + openFolderWorkspaceComposer: vi.fn(), + folderWorkspaceGroupId: null, dispatchTerminalPaneSplit: vi.fn(), schedule: vi.fn() }) @@ -45,6 +47,8 @@ describe('performContextualTourStepAction', () => { openModal: vi.fn(), canCreateWorkspace: true, openWorkspaceComposer: vi.fn(), + openFolderWorkspaceComposer: vi.fn(), + folderWorkspaceGroupId: null, dispatchTerminalPaneSplit, schedule: vi.fn() }) @@ -73,6 +77,8 @@ describe('performContextualTourStepAction', () => { openModal: vi.fn(), canCreateWorkspace: true, openWorkspaceComposer, + openFolderWorkspaceComposer: vi.fn(), + folderWorkspaceGroupId: null, dispatchTerminalPaneSplit: vi.fn(), schedule: vi.fn() }) @@ -101,6 +107,8 @@ describe('performContextualTourStepAction', () => { openModal: vi.fn(), canCreateWorkspace: false, openWorkspaceComposer, + openFolderWorkspaceComposer: vi.fn(), + folderWorkspaceGroupId: null, dispatchTerminalPaneSplit: vi.fn(), schedule: vi.fn() }) @@ -108,4 +116,32 @@ describe('performContextualTourStepAction', () => { expect(detachContextualTourSource).not.toHaveBeenCalled() expect(openWorkspaceComposer).not.toHaveBeenCalled() }) + + it('finishes the folder workspace callout before scheduling the composer handoff', () => { + const finishTour = vi.fn() + const openFolderWorkspaceComposer = vi.fn() + const schedule = vi.fn((callback: () => void) => callback()) + + performContextualTourStepAction({ + action: { kind: 'create-folder-workspace', label: 'Create workspace' }, + activeTabId: 'tab-1', + isLastStep: true, + finishTour, + advanceContextualTour: vi.fn(), + detachContextualTourSource: vi.fn(), + setSidebarOpen: vi.fn(), + openTaskPage: vi.fn(), + openModal: vi.fn(), + canCreateWorkspace: true, + openWorkspaceComposer: vi.fn(), + openFolderWorkspaceComposer, + folderWorkspaceGroupId: 'group-1', + dispatchTerminalPaneSplit: vi.fn(), + schedule + }) + + expect(finishTour).toHaveBeenCalledTimes(1) + expect(schedule).toHaveBeenCalledTimes(1) + expect(openFolderWorkspaceComposer).toHaveBeenCalledWith('group-1') + }) }) diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-step-actions.ts b/src/renderer/src/components/contextual-tours/contextual-tour-step-actions.ts index c68a797b8ed..65cfc02c8ca 100644 --- a/src/renderer/src/components/contextual-tours/contextual-tour-step-actions.ts +++ b/src/renderer/src/components/contextual-tours/contextual-tour-step-actions.ts @@ -13,6 +13,8 @@ export function performContextualTourStepAction(args: { openModal: (modal: 'setup-guide', data?: Record) => void canCreateWorkspace: boolean openWorkspaceComposer: () => void + openFolderWorkspaceComposer: (projectGroupId: string) => void + folderWorkspaceGroupId: string | null dispatchTerminalPaneSplit: (detail: RequestActiveTerminalPaneSplitDetail) => void schedule: (callback: () => void) => void }): void { @@ -46,6 +48,19 @@ export function performContextualTourStepAction(args: { args.openWorkspaceComposer() } return + case 'create-folder-workspace': + if (args.folderWorkspaceGroupId) { + const projectGroupId = args.folderWorkspaceGroupId + // Why: opening the composer replaces this tour surface; finish first so + // the scheduled handoff does not compete with active-tour cleanup. + args.finishTour() + args.schedule(() => { + args.openFolderWorkspaceComposer(projectGroupId) + }) + } else { + advanceOrFinish() + } + return case 'show-worktrees': args.setSidebarOpen(true) advanceOrFinish() diff --git a/src/renderer/src/components/contextual-tours/request-contextual-tour-when-ready.test.ts b/src/renderer/src/components/contextual-tours/request-contextual-tour-when-ready.test.ts index 6ffad6f2e93..c360d5fd906 100644 --- a/src/renderer/src/components/contextual-tours/request-contextual-tour-when-ready.test.ts +++ b/src/renderer/src/components/contextual-tours/request-contextual-tour-when-ready.test.ts @@ -138,6 +138,7 @@ describe('requestContextualTourWhenReady', () => { vi.useFakeTimers() const requestContextualTour = vi.fn() const shouldContinue = vi.fn(() => false) + const onAbandon = vi.fn() vi.spyOn(useAppStore, 'getState').mockImplementation( () => ({ @@ -150,6 +151,7 @@ describe('requestContextualTourWhenReady', () => { id: 'workspace-creation', source: 'setup_guide_parallel_work', shouldContinue, + onAbandon, retryDelayMs: 10, maxAttempts: 5 }) @@ -157,6 +159,62 @@ describe('requestContextualTourWhenReady', () => { vi.advanceTimersByTime(50) expect(shouldContinue).toHaveBeenCalledTimes(1) + expect(onAbandon).toHaveBeenCalledWith('invalid') expect(requestContextualTour).not.toHaveBeenCalled() }) + + it('abandons after the retry window when the target never starts', () => { + vi.useFakeTimers() + const requestContextualTour = vi.fn() + const onAbandon = vi.fn() + vi.spyOn(useAppStore, 'getState').mockImplementation( + () => + ({ + activeContextualTourId: null, + requestContextualTour + }) as unknown as ReturnType + ) + + requestContextualTourWhenReady({ + id: 'workspace-creation', + source: 'workspace_creation_modal', + retryDelayMs: 10, + maxAttempts: 3, + onAbandon + }) + + vi.advanceTimersByTime(40) + + expect(requestContextualTour).toHaveBeenCalledTimes(3) + expect(onAbandon).toHaveBeenCalledTimes(1) + expect(onAbandon).toHaveBeenCalledWith('exhausted') + }) + + it('abandons when another active tour never clears', () => { + vi.useFakeTimers() + const requestContextualTour = vi.fn() + const onAbandon = vi.fn() + vi.spyOn(useAppStore, 'getState').mockImplementation( + () => + ({ + activeContextualTourId: 'tasks', + requestContextualTour + }) as unknown as ReturnType + ) + + requestContextualTourWhenReady({ + id: 'workspace-creation', + source: 'workspace_creation_modal', + retryDelayMs: 10, + maxAttempts: 3, + waitForActiveTourToClear: true, + onAbandon + }) + + vi.advanceTimersByTime(40) + + expect(requestContextualTour).not.toHaveBeenCalled() + expect(onAbandon).toHaveBeenCalledTimes(1) + expect(onAbandon).toHaveBeenCalledWith('blocked') + }) }) diff --git a/src/renderer/src/components/contextual-tours/request-contextual-tour-when-ready.ts b/src/renderer/src/components/contextual-tours/request-contextual-tour-when-ready.ts index f1136a76115..09cd4bb9b1f 100644 --- a/src/renderer/src/components/contextual-tours/request-contextual-tour-when-ready.ts +++ b/src/renderer/src/components/contextual-tours/request-contextual-tour-when-ready.ts @@ -9,6 +9,8 @@ type RequestContextualTourWhenReadyArgs = { retryDelayMs?: number waitForActiveTourToClear?: boolean shouldContinue?: () => boolean + onAbandon?: (reason: 'blocked' | 'exhausted' | 'invalid') => void + force?: boolean } export type { RequestContextualTourWhenReadyArgs } @@ -21,13 +23,23 @@ export function requestContextualTourWhenReady( let attempts = 0 let timeoutId: ReturnType | null = null let cancelled = false + let abandoned = false + + const abandon = (reason: 'blocked' | 'exhausted' | 'invalid'): void => { + if (abandoned) { + return + } + abandoned = true + cancelled = true + args.onAbandon?.(reason) + } const attempt = (): void => { if (cancelled) { return } if (args.shouldContinue && !args.shouldContinue()) { - cancelled = true + abandon('invalid') return } attempts += 1 @@ -36,16 +48,22 @@ export function requestContextualTourWhenReady( if (before.activeContextualTourId && before.activeContextualTourId !== args.id) { if (args.waitForActiveTourToClear && attempts < maxAttempts) { timeoutId = setTimeout(attempt, retryDelayMs) + } else { + abandon('blocked') } return } before.requestContextualTour(args.id, args.source, args.wasFeaturePreviouslyInteracted, { - force: true + force: args.force ?? true }) const after = useAppStore.getState() - if (after.activeContextualTourId === args.id || attempts >= maxAttempts) { + if (after.activeContextualTourId === args.id) { + return + } + if (attempts >= maxAttempts) { + abandon('exhausted') return } timeoutId = setTimeout(attempt, retryDelayMs) diff --git a/src/renderer/src/components/contextual-tours/use-contextual-tour.test.ts b/src/renderer/src/components/contextual-tours/use-contextual-tour.test.ts index f6c7cffb929..94bec9fb7d5 100644 --- a/src/renderer/src/components/contextual-tours/use-contextual-tour.test.ts +++ b/src/renderer/src/components/contextual-tours/use-contextual-tour.test.ts @@ -61,6 +61,39 @@ describe('createContextualTourInteractionSnapshot', () => { await expect(snapshot.persisted).resolves.toBeUndefined() }) + it('does not record feature interactions for tours mapped to none', () => { + const recordFeatureInteraction = vi.fn(() => Promise.resolve()) + + const snapshot = createContextualTourInteractionSnapshot({ + id: 'folder-workspace-create-callout', + featureInteractions: {}, + recordFeatureInteraction, + recordFeatureInteractionForTour: true + }) + + expect(recordFeatureInteraction).not.toHaveBeenCalled() + expect(snapshot.wasPreviouslyInteracted).toBe(false) + }) + + it('uses explicit tour-to-feature mappings when recording', () => { + const recordFeatureInteraction = vi.fn(() => Promise.resolve()) + + const snapshot = createContextualTourInteractionSnapshot({ + id: 'folder-workspace-overview', + featureInteractions: { + 'folder-workspace-right-sidebar': { + firstInteractedAt: 1, + interactionCount: 1 + } + }, + recordFeatureInteraction, + recordFeatureInteractionForTour: true + }) + + expect(recordFeatureInteraction).toHaveBeenCalledWith('folder-workspace-right-sidebar') + expect(snapshot.wasPreviouslyInteracted).toBe(true) + }) + it('reuses the floating workspace pre-open snapshot without double-recording', async () => { const persisted = Promise.resolve() const recordFeatureInteraction = vi.fn(() => Promise.resolve()) diff --git a/src/renderer/src/components/contextual-tours/use-contextual-tour.ts b/src/renderer/src/components/contextual-tours/use-contextual-tour.ts index 158e43865f8..774b6b16cdd 100644 --- a/src/renderer/src/components/contextual-tours/use-contextual-tour.ts +++ b/src/renderer/src/components/contextual-tours/use-contextual-tour.ts @@ -1,7 +1,9 @@ import { useEffect, useRef } from 'react' import type { ContextualTourId } from '../../../../shared/contextual-tours' +import { getFeatureInteractionIdForContextualTour } from '../../../../shared/contextual-tour-feature-interactions' import { hasFeatureInteraction, + type FeatureInteractionId, type FeatureInteractionState } from '../../../../shared/feature-interactions' import { useAppStore } from '@/store' @@ -13,7 +15,10 @@ const TOUR_SOURCES = { tasks: 'tasks_open', automations: 'automations_open', 'floating-workspace': 'floating_workspace_visible', - 'workspace-creation': 'workspace_creation_visible' + 'workspace-creation': 'workspace_creation_visible', + 'folder-workspace-create-callout': 'folder_workspace_create_onboarding', + 'folder-workspace-creation': 'folder_workspace_creation_visible', + 'folder-workspace-overview': 'folder_workspace_overview_visible' } satisfies Record export type UseContextualTourOptions = { @@ -25,18 +30,23 @@ export type UseContextualTourOptions = { export function createContextualTourInteractionSnapshot(args: { id: ContextualTourId featureInteractions: FeatureInteractionState - recordFeatureInteraction: (id: ContextualTourId) => Promise + recordFeatureInteraction: (id: FeatureInteractionId) => Promise recordFeatureInteractionForTour: boolean featureInteractionPersisted?: Promise | undefined wasFeaturePreviouslyInteracted?: boolean | undefined }): { persisted: Promise; wasPreviouslyInteracted: boolean } { + const featureInteractionId = getFeatureInteractionIdForContextualTour(args.id) const wasPreviouslyInteracted = - args.wasFeaturePreviouslyInteracted ?? hasFeatureInteraction(args.featureInteractions, args.id) + args.wasFeaturePreviouslyInteracted ?? + (featureInteractionId + ? hasFeatureInteraction(args.featureInteractions, featureInteractionId) + : false) return { wasPreviouslyInteracted, - persisted: args.recordFeatureInteractionForTour - ? args.recordFeatureInteraction(args.id) - : (args.featureInteractionPersisted ?? Promise.resolve()) + persisted: + args.recordFeatureInteractionForTour && featureInteractionId + ? args.recordFeatureInteraction(featureInteractionId) + : (args.featureInteractionPersisted ?? Promise.resolve()) } } @@ -209,7 +219,7 @@ export function useContextualTour( source, latestSnapshot?.id === id && latestSnapshot.source === source ? latestSnapshot.wasPreviouslyInteracted - : hasFeatureInteraction(useAppStore.getState().featureInteractions, id) + : getWasTourFeaturePreviouslyInteracted(id) ) }) }) @@ -261,3 +271,10 @@ export function useContextualTour( source ]) } + +function getWasTourFeaturePreviouslyInteracted(id: ContextualTourId): boolean { + const featureInteractionId = getFeatureInteractionIdForContextualTour(id) + return featureInteractionId + ? hasFeatureInteraction(useAppStore.getState().featureInteractions, featureInteractionId) + : false +} diff --git a/src/renderer/src/components/contextual-tours/workspace-creation-tour-handoff.test.ts b/src/renderer/src/components/contextual-tours/workspace-creation-tour-handoff.test.ts index cdc151e9ef5..148c4e39cc0 100644 --- a/src/renderer/src/components/contextual-tours/workspace-creation-tour-handoff.test.ts +++ b/src/renderer/src/components/contextual-tours/workspace-creation-tour-handoff.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' -import { openWorkspaceCreationComposerWithTourHandoff } from './workspace-creation-tour-handoff' +import { + openFolderWorkspaceCreationComposerWithTourHandoff, + openWorkspaceCreationComposerWithTourHandoff +} from './workspace-creation-tour-handoff' import { requestContextualTourWhenReady } from './request-contextual-tour-when-ready' vi.mock('./request-contextual-tour-when-ready', () => ({ @@ -144,3 +147,51 @@ describe('openWorkspaceCreationComposerWithTourHandoff', () => { expect(requestContextualTourWhenReady).not.toHaveBeenCalled() }) }) + +describe('openFolderWorkspaceCreationComposerWithTourHandoff', () => { + afterEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() + }) + + it('completes the onboarding callout and requests the folder composer tour', () => { + const openModal = vi.fn() + const completeContextualTour = vi.fn() + const setPendingFolderWorkspaceCreateTourGroupId = vi.fn() + let activeModal: ReturnType['activeModal'] = 'none' + vi.spyOn(useAppStore, 'getState').mockImplementation( + () => + ({ + activeContextualTourId: 'folder-workspace-create-callout', + activeModal, + contextualToursSeenIds: [], + completeContextualTour, + openModal, + setPendingFolderWorkspaceCreateTourGroupId + }) as unknown as ReturnType + ) + + openFolderWorkspaceCreationComposerWithTourHandoff('group-1') + + expect(completeContextualTour).toHaveBeenCalledWith('folder-workspace-create-callout') + expect(setPendingFolderWorkspaceCreateTourGroupId).toHaveBeenCalledWith(null) + expect(openModal).toHaveBeenCalledWith('new-workspace-composer', { + initialProjectGroupId: 'group-1', + telemetrySource: 'onboarding', + contextualTourSource: 'folder_workspace_creation_modal' + }) + expect(requestContextualTourWhenReady).toHaveBeenCalledWith({ + id: 'folder-workspace-creation', + source: 'folder_workspace_creation_modal', + wasFeaturePreviouslyInteracted: false, + waitForActiveTourToClear: true, + shouldContinue: expect.any(Function) + }) + + const [{ shouldContinue }] = vi.mocked(requestContextualTourWhenReady).mock.calls[0] + activeModal = 'new-workspace-composer' + expect(shouldContinue?.()).toBe(true) + activeModal = 'none' + expect(shouldContinue?.()).toBe(false) + }) +}) diff --git a/src/renderer/src/components/contextual-tours/workspace-creation-tour-handoff.ts b/src/renderer/src/components/contextual-tours/workspace-creation-tour-handoff.ts index e9f4983d66f..a1e9775a348 100644 --- a/src/renderer/src/components/contextual-tours/workspace-creation-tour-handoff.ts +++ b/src/renderer/src/components/contextual-tours/workspace-creation-tour-handoff.ts @@ -41,3 +41,28 @@ export function openWorkspaceCreationComposerWithTourHandoff(): void { shouldContinue: () => useAppStore.getState().activeModal === 'new-workspace-composer' }) } + +export function openFolderWorkspaceCreationComposerWithTourHandoff(projectGroupId: string): void { + const state = useAppStore.getState() + if (state.activeContextualTourId === 'folder-workspace-create-callout') { + state.completeContextualTour('folder-workspace-create-callout') + } + state.setPendingFolderWorkspaceCreateTourGroupId(null) + state.openModal('new-workspace-composer', { + initialProjectGroupId: projectGroupId, + telemetrySource: 'onboarding', + contextualTourSource: 'folder_workspace_creation_modal' + }) + + if (state.contextualToursSeenIds.includes('folder-workspace-creation')) { + return + } + + requestContextualTourWhenReady({ + id: 'folder-workspace-creation', + source: 'folder_workspace_creation_modal', + wasFeaturePreviouslyInteracted: false, + waitForActiveTourToClear: true, + shouldContinue: () => useAppStore.getState().activeModal === 'new-workspace-composer' + }) +} diff --git a/src/renderer/src/components/onboarding/RepoStep.test.tsx b/src/renderer/src/components/onboarding/RepoStep.test.tsx index de01325e1d5..bedf671bb98 100644 --- a/src/renderer/src/components/onboarding/RepoStep.test.tsx +++ b/src/renderer/src/components/onboarding/RepoStep.test.tsx @@ -54,7 +54,9 @@ describe('RepoStep', () => { expect(html).toContain('Browse for a folder') expect(html).toContain('group-hover:translate-x-0.5') expect(html).toContain('w-fit max-w-[calc(100%-3.75rem)]') - expect(html).toContain('Want to import many repos at once? Select the parent folder.') + expect(html).toContain( + 'Working in a monorepo? Select the folder above your apps/services to import them as a group.' + ) }) it('disables the nested import action when no repositories are selected', () => { @@ -75,9 +77,10 @@ describe('RepoStep', () => { expect(html).toContain('Import repositories') expect(html).toContain('Scanned folder: platform - /workspace/platform') - expect(html).not.toContain('Group name') - expect(html).not.toContain('Import separately') - expect(html).not.toContain('Import as project group') + expect(html).toContain('Is this a monorepo?') + expect(html).toContain('Group name') + expect(html).toContain('No, import separately') + expect(html).toContain('Import as group') expect(html.match(/disabled=""/g)?.length).toBeGreaterThanOrEqual(1) }) @@ -130,6 +133,6 @@ describe('RepoStep', () => { expect(html).toContain('web') expect(html).toContain('payments/api') expect(html).toContain('billing/api') - expect(html).not.toContain('Project group') + expect(html).toContain('Import as group') }) }) diff --git a/src/renderer/src/components/onboarding/RepoStep.tsx b/src/renderer/src/components/onboarding/RepoStep.tsx index 0202ef6a6a6..c29672ed0af 100644 --- a/src/renderer/src/components/onboarding/RepoStep.tsx +++ b/src/renderer/src/components/onboarding/RepoStep.tsx @@ -21,7 +21,7 @@ type RepoStepProps = { nestedScanInProgress: boolean nestedSelectedPaths: Set onNestedSelectedPathsChange: Dispatch>> - onImportNested: () => void + onImportNested: (mode: 'group' | 'separate', groupName: string) => void onCancelNested: () => void onStopNestedScan: () => void onOpenFolder: () => void @@ -172,8 +172,8 @@ export function RepoStep({ {translate( - 'auto.components.onboarding.RepoStep.6558d50c69', - 'Want to import many repos at once? Select the parent folder.' + 'auto.components.onboarding.RepoStep.monorepoParentFolderHint', + 'Working in a monorepo? Select the folder above your apps/services to import them as a group.' )} diff --git a/src/renderer/src/components/onboarding/RepoStepNestedImportPanel.tsx b/src/renderer/src/components/onboarding/RepoStepNestedImportPanel.tsx index e7b24e2b71e..c184fd5b4de 100644 --- a/src/renderer/src/components/onboarding/RepoStepNestedImportPanel.tsx +++ b/src/renderer/src/components/onboarding/RepoStepNestedImportPanel.tsx @@ -1,8 +1,10 @@ import { ArrowLeft, CircleStop, FolderOpen, Loader2 } from 'lucide-react' -import type { Dispatch, SetStateAction } from 'react' +import { useEffect, useId, useState, type Dispatch, type SetStateAction } from 'react' import { Button } from '@/components/ui/button' import { NestedRepoChecklist } from '@/components/repo/NestedRepoChecklist' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' import { translate } from '@/i18n/i18n' import type { NestedRepoScanResult } from '../../../../shared/types' import { getRuntimePathBasename } from '../../../../shared/cross-platform-path' @@ -13,7 +15,7 @@ type RepoStepNestedImportPanelProps = { nestedScanInProgress: boolean nestedSelectedPaths: Set onNestedSelectedPathsChange: Dispatch>> - onImportNested: () => void + onImportNested: (mode: 'group' | 'separate', groupName: string) => void onCancelNested: () => void onStopNestedScan: () => void busyLabel: string | null @@ -34,7 +36,40 @@ export function RepoStepNestedImportPanel({ disabled }: RepoStepNestedImportPanelProps) { const folderName = getRuntimePathBasename(nestedScan.selectedPath) || nestedScan.selectedPath + const groupNameInputId = useId() + const [groupName, setGroupName] = useState(folderName) + const [pendingImportMode, setPendingImportMode] = useState<'group' | 'separate' | null>(null) const nestedImportDisabled = disabled || nestedScanInProgress + const importing = Boolean(busyLabel) && !nestedScanInProgress + const showSeparateSpinner = importing && pendingImportMode === 'separate' + const showGroupSpinner = importing && pendingImportMode === 'group' + const nestedScanSummary = translate( + 'auto.components.onboarding.RepoStep.2e6438dd34', + 'Found {{value0}} {{value1}} in this folder.', + { + value0: nestedScan.repos.length, + value1: nestedScan.repos.length === 1 ? 'repository' : 'repositories' + } + ) + const nestedScanStatus = nestedScanInProgress + ? `${translate('auto.components.onboarding.RepoStep.220dd32d83', 'Scanning...')} ${nestedScanSummary}` + : nestedScanSummary + + useEffect(() => { + setGroupName(folderName) + }, [folderName, nestedScan.selectedPath]) + + useEffect(() => { + if (!importing) { + setPendingImportMode(null) + } + }, [importing]) + + const handleImport = (mode: 'group' | 'separate'): void => { + setPendingImportMode(mode) + onImportNested(mode, groupName.trim() || folderName) + } + return (
@@ -77,17 +112,7 @@ export function RepoStepNestedImportPanel({ ) : null} - - {translate( - 'auto.components.onboarding.RepoStep.2e6438dd34', - '{{value0}}Found {{value1}} {{value2}} in this folder.', - { - value0: nestedScanInProgress ? 'Scanning... ' : '', - value1: nestedScan.repos.length, - value2: nestedScan.repos.length === 1 ? 'repository' : 'repositories' - } - )} - + {nestedScanStatus}
{translate('auto.components.onboarding.RepoStep.cecd6593fa', 'Scanned folder:')}{' '} @@ -110,6 +135,33 @@ export function RepoStepNestedImportPanel({
) : null} +
+
+

+ {translate('auto.components.onboarding.RepoStep.fb33359f69', 'Is this a monorepo?')} +

+

+ {translate( + 'auto.components.onboarding.RepoStep.d75170194e', + 'Import them as a group if they belong together. Orca will group them and let you work from the parent folder.' + )} +

+
+
+ + setGroupName(event.target.value)} + disabled={nestedImportDisabled} + className="h-9 min-w-0" + placeholder={folderName} + /> +
+
+
diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts index 29017f90150..6616061b1b9 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts @@ -36,6 +36,7 @@ import { translate } from '@/i18n/i18n' import { resolveAgentPermissionModeSummary } from '../../../../shared/tui-agent-permissions' import { isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers' import { buildWindowsTerminalSnapshotPayload } from './windows-terminal-onboarding-telemetry' +import { requestContextualTourWhenReady } from '@/components/contextual-tours/request-contextual-tour-when-ready' export { STEPS } from './use-onboarding-flow-types' export type { StepId, StepNumber } from './use-onboarding-flow-types' @@ -228,6 +229,10 @@ export function useOnboardingFlow( const pathFailureReason = useAppStore((s) => s.pathFailureReason) const fetchRepos = useAppStore((s) => s.fetchRepos) const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) + const setSidebarOpen = useAppStore((s) => s.setSidebarOpen) + const setPendingFolderWorkspaceCreateTourGroupId = useAppStore( + (s) => s.setPendingFolderWorkspaceCreateTourGroupId + ) const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) const addRepoPath = useAppStore((s) => s.addRepoPath) const scanNestedRepos = useAppStore((s) => s.scanNestedRepos) @@ -614,7 +619,7 @@ export function useOnboardingFlow( path ) if (!closed) { - return + return false } // Why: the repo step has no keyboard-vs-button advance — Cmd+Enter // routes to `openFolder()` which collapses both into the path-clicked @@ -625,6 +630,7 @@ export function useOnboardingFlow( value_kind: 'repo', duration_ms: consumeStepDurationMs() }) + return true }, [ closeWith, @@ -894,83 +900,51 @@ export function useOnboardingFlow( ] ) - const importNested = useCallback(async () => { - const mode = 'separate' - const attemptId = nestedAttemptId - if ( - !nestedScan || - !attemptId || - !shouldEmitNestedRepoImportSubmitTelemetry({ - attemptId, - selectedCount: nestedSelectedPaths.size, - isBusy: busyLabel !== null - }) - ) { - return - } - const foundCount = nestedScan.repos.length - const selectedCount = nestedSelectedPaths.size - const runtimeKind = nestedRuntimeKind ?? onboardingNestedRepoRuntimeKind - setError(null) - setBusyLabel('Importing repositories…') - track( - 'add_repo_nested_import_action', - buildNestedRepoImportActionTelemetry({ - attemptId, - surface: 'onboarding', - runtimeKind, - action: 'import_separate', - foundCount, - selectedCount - }) - ) - let resultTracked = false - try { - const selectedProjectPaths = getSelectedNestedRepoPathsInScanOrder( - nestedScan, - nestedSelectedPaths - ) - const result = await importNestedRepos({ - parentPath: nestedScan.selectedPath, - groupName: '', - // Why: Set insertion order can drift after deselect/reselect; import - // ordering should match the visible scan order users reviewed. - projectPaths: selectedProjectPaths, - ...(nestedImportScanId ? { scanId: nestedImportScanId } : {}), - mode - }) + const importNested = useCallback( + async (mode: 'group' | 'separate', groupName: string) => { + const attemptId = nestedAttemptId + if ( + !nestedScan || + !attemptId || + !shouldEmitNestedRepoImportSubmitTelemetry({ + attemptId, + selectedCount: nestedSelectedPaths.size, + isBusy: busyLabel !== null + }) + ) { + return + } + const foundCount = nestedScan.repos.length + const selectedCount = nestedSelectedPaths.size + const runtimeKind = nestedRuntimeKind ?? onboardingNestedRepoRuntimeKind + setError(null) + setBusyLabel('Importing repositories…') track( - 'add_repo_nested_import_result', - buildNestedRepoImportResultTelemetry({ + 'add_repo_nested_import_action', + buildNestedRepoImportActionTelemetry({ attemptId, surface: 'onboarding', runtimeKind, - mode, + action: mode === 'group' ? 'import_group' : 'import_separate', foundCount, - selectedCount, - result + selectedCount }) ) - resultTracked = true - const importedRepoIds = - result?.projects - .map((entry) => entry.projectId) - .filter((projectId): projectId is string => typeof projectId === 'string') ?? [] - const projectId = importedRepoIds[0] - if (!projectId) { - const firstFailure = result?.projects.find((entry) => entry.status === 'failed')?.error - throw new Error( - firstFailure ? `No repositories imported: ${firstFailure}` : 'No repositories imported' + let resultTracked = false + try { + const selectedProjectPaths = getSelectedNestedRepoPathsInScanOrder( + nestedScan, + nestedSelectedPaths ) - } - for (const importedRepoId of importedRepoIds) { - // Why: imported repos are already persisted; non-authoritative SSH - // refreshes should not block onboarding from revealing the first project. - await fetchWorktrees(importedRepoId, { requireAuthoritative: true }) - } - await completeRepo(projectId, true, 'open_folder') - } catch (err) { - if (!resultTracked) { + const result = await importNestedRepos({ + parentPath: nestedScan.selectedPath, + groupName, + // Why: Set insertion order can drift after deselect/reselect; import + // ordering should match the visible scan order users reviewed. + projectPaths: selectedProjectPaths, + ...(nestedImportScanId ? { scanId: nestedImportScanId } : {}), + mode + }) track( 'add_repo_nested_import_result', buildNestedRepoImportResultTelemetry({ @@ -980,27 +954,102 @@ export function useOnboardingFlow( mode, foundCount, selectedCount, - result: null + result }) ) + resultTracked = true + const importedRepoIds = + result?.projects + .map((entry) => entry.projectId) + .filter((projectId): projectId is string => typeof projectId === 'string') ?? [] + const projectId = importedRepoIds[0] + if (!projectId) { + const firstFailure = result?.projects.find((entry) => entry.status === 'failed')?.error + throw new Error( + firstFailure ? `No repositories imported: ${firstFailure}` : 'No repositories imported' + ) + } + for (const importedRepoId of importedRepoIds) { + // Why: imported repos are already persisted; non-authoritative SSH + // refreshes should not block onboarding from revealing the first project. + await fetchWorktrees(importedRepoId, { requireAuthoritative: true }) + } + const completed = await completeRepo(projectId, true, 'open_folder') + if (completed && mode === 'group' && result?.group) { + const shouldRequestFolderWorkspaceCallout = (() => { + const state = useAppStore.getState() + return ( + state.contextualToursAutoEligible === true && + !state.contextualToursSeenIds.includes('folder-workspace-create-callout') + ) + })() + if (!shouldRequestFolderWorkspaceCallout) { + return + } + setPendingFolderWorkspaceCreateTourGroupId(result.group.id) + setSidebarOpen(true) + requestContextualTourWhenReady({ + id: 'folder-workspace-create-callout', + source: 'onboarding_group_import', + wasFeaturePreviouslyInteracted: false, + waitForActiveTourToClear: true, + force: false, + maxAttempts: 120, + retryDelayMs: 250, + onAbandon: () => { + const state = useAppStore.getState() + if ( + state.pendingFolderWorkspaceCreateTourGroupId === result.group?.id && + state.activeContextualTourId !== 'folder-workspace-create-callout' + ) { + state.setPendingFolderWorkspaceCreateTourGroupId(null) + } + }, + shouldContinue: () => { + const state = useAppStore.getState() + return ( + state.pendingFolderWorkspaceCreateTourGroupId === result.group?.id && + state.projectGroups.some((group) => group.id === result.group?.id) + ) + } + }) + } + } catch (err) { + if (!resultTracked) { + track( + 'add_repo_nested_import_result', + buildNestedRepoImportResultTelemetry({ + attemptId, + surface: 'onboarding', + runtimeKind, + mode, + foundCount, + selectedCount, + result: null + }) + ) + } + setError(err instanceof Error ? err.message : String(err)) + track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) + } finally { + setBusyLabel(null) } - setError(err instanceof Error ? err.message : String(err)) - track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) - } finally { - setBusyLabel(null) - } - }, [ - busyLabel, - completeRepo, - fetchWorktrees, - importNestedRepos, - nestedAttemptId, - nestedScan, - nestedSelectedPaths, - nestedImportScanId, - nestedRuntimeKind, - onboardingNestedRepoRuntimeKind - ]) + }, + [ + busyLabel, + completeRepo, + fetchWorktrees, + importNestedRepos, + nestedAttemptId, + nestedScan, + nestedSelectedPaths, + nestedImportScanId, + nestedRuntimeKind, + onboardingNestedRepoRuntimeKind, + setPendingFolderWorkspaceCreateTourGroupId, + setSidebarOpen + ] + ) const trackNestedBackAndClear = useCallback(() => { if (nestedScan && nestedAttemptId) { diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx index 3cd868a5baf..496e36269f9 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx @@ -273,6 +273,25 @@ describe('FolderWorkspacePrChecksPanel', () => { expect(container.textContent).not.toContain('unknown') }) + it('renders a targetable illustrative empty preview when no child worktrees are attached', () => { + mockState.store.workspaceLineageByChildKey = {} + mockState.store.worktreesByRepo = {} + + renderPanel() + + expect( + container.querySelector( + '[data-contextual-tour-target="folder-workspace-review-checks-empty"]' + ) + ).not.toBeNull() + expect(container.textContent).toContain( + 'Review checks will appear here after worktrees are attached to this folder workspace.' + ) + expect(container.textContent).toContain('Needs attention') + expect(container.textContent).toContain('Pending') + expect(container.textContent).toContain('Passing') + }) + it('summarizes failing and pending rows before the worktree count', () => { const repo = mockState.store.repos[0] const passingWorktree = mockState.store.worktreesByRepo[repo.id][0] @@ -407,13 +426,13 @@ describe('FolderWorkspacePrChecksPanel', () => { }) await vi.waitFor(() => { expect( - container.querySelector('[aria-label="Refresh PR checks"]')?.disabled + container.querySelector('[aria-label="Refresh review checks"]')?.disabled ).toBe(false) }) mockState.store.fetchHostedReviewForBranch.mockClear() act(() => { - container.querySelector('[aria-label="Refresh PR checks"]')?.click() + container.querySelector('[aria-label="Refresh review checks"]')?.click() }) await vi.waitFor(() => { expect(mockState.store.fetchHostedReviewForBranch).toHaveBeenCalled() @@ -424,7 +443,7 @@ describe('FolderWorkspacePrChecksPanel', () => { await vi.waitFor(() => { expect( - container.querySelector('[aria-label="Refresh PR checks"]')?.disabled + container.querySelector('[aria-label="Refresh review checks"]')?.disabled ).toBe(false) }) mockState.store.fetchHostedReviewForBranch.mockClear() @@ -469,12 +488,12 @@ describe('FolderWorkspacePrChecksPanel', () => { await vi.waitFor(() => { expect( - container.querySelector('[aria-label="Refresh PR checks"]')?.disabled + container.querySelector('[aria-label="Refresh review checks"]')?.disabled ).toBe(false) }) mockState.store.fetchHostedReviewForBranch.mockClear() act(() => { - container.querySelector('[aria-label="Refresh PR checks"]')?.click() + container.querySelector('[aria-label="Refresh review checks"]')?.click() }) await vi.waitFor(() => { diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx index 67b1e1b06ac..5461d4df88c 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx @@ -13,6 +13,7 @@ import { type ParentPrChecksRefreshOutcome, type ParentPrChecksRow } from './parent-pr-checks-rows' +import { PARENT_PR_CHECKS_GROUP_LABELS } from './parent-pr-checks-row-types' import { getParentPrChecksRefreshCandidates, runLimitedParentPrChecksRefreshes @@ -202,7 +203,7 @@ export default function FolderWorkspacePrChecksPanel({
{translate( 'auto.components.rightSidebar.FolderWorkspacePrChecksPanel.unavailable', - 'PR checks are only shown for folder workspaces.' + 'Review checks are only shown for folder workspaces.' )}
) @@ -233,7 +234,7 @@ export default function FolderWorkspacePrChecksPanel({ disabled={childWorktrees.length === 0 || isRefreshing} aria-label={translate( 'auto.components.rightSidebar.FolderWorkspacePrChecksPanel.refresh', - 'Refresh PR checks' + 'Refresh review checks' )} > @@ -242,7 +243,7 @@ export default function FolderWorkspacePrChecksPanel({ {translate( 'auto.components.rightSidebar.FolderWorkspacePrChecksPanel.refresh', - 'Refresh PR checks' + 'Refresh review checks' )} @@ -250,7 +251,26 @@ export default function FolderWorkspacePrChecksPanel({ {childWorktrees.length === 0 ? ( -
+
+