diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index d5bf1b61543..528ed9a1bcb 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -2161,6 +2161,19 @@ describe('Store', () => { }) }) + it('updateUI merges contextual tour seen ids instead of replacing stale snapshots', async () => { + const store = await createStore() + + store.updateUI({ + contextualToursSeenIds: ['browser'] + }) + store.updateUI({ + contextualToursSeenIds: ['workspace-agent-sessions', 'unknown', 'browser'] as never + }) + + expect(store.getUI().contextualToursSeenIds).toEqual(['browser', 'workspace-agent-sessions']) + }) + it('normalizes malformed persisted feature discovery state on read', async () => { writeDataFile({ schemaVersion: 1, @@ -2169,6 +2182,7 @@ describe('Store', () => { settings: {}, ui: { featureTipsSeenIds: ['voice-dictation', 'unknown-tip', 'voice-dictation'], + contextualToursSeenIds: ['tasks', 'unknown', 'tasks'] as never, featureInteractions: { tasks: { firstInteractedAt: 100 }, automations: { firstInteractedAt: 150, interactionCount: 4 }, @@ -2183,6 +2197,7 @@ describe('Store', () => { const store = await createStore() expect(store.getUI().featureTipsSeenIds).toEqual(['voice-dictation']) + expect(store.getUI().contextualToursSeenIds).toEqual(['tasks']) expect(store.getUI().featureInteractions).toEqual({ tasks: { firstInteractedAt: 100, interactionCount: 1 }, automations: { firstInteractedAt: 150, interactionCount: 4 } diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 947cbeab4b7..c54a8de0945 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -89,6 +89,7 @@ import { normalizeFeatureInteractions, type FeatureInteractionId } from '../shared/feature-interactions' +import { normalizeContextualTourIds } from '../shared/contextual-tours' import { normalizeFeatureTipIds } from '../shared/feature-tips' import { DEFAULT_WORKSPACE_STATUS_ID, @@ -264,6 +265,17 @@ function mergeFeatureInteractions( return merged } +function mergeContextualTourSeenIds( + current: PersistedState['ui']['contextualToursSeenIds'], + incoming: PersistedState['ui']['contextualToursSeenIds'] +): PersistedState['ui']['contextualToursSeenIds'] { + const merged = new Set(normalizeContextualTourIds(current)) + for (const id of normalizeContextualTourIds(incoming)) { + merged.add(id) + } + return [...merged] +} + function normalizeSortBy(sortBy: unknown): PersistedState['ui']['sortBy'] { if ( sortBy === 'smart' || @@ -2753,6 +2765,7 @@ export class Store { this.state.ui?.workspaceBoardColumnWidth ), featureTipsSeenIds: normalizeFeatureTipIds(this.state.ui?.featureTipsSeenIds), + contextualToursSeenIds: normalizeContextualTourIds(this.state.ui?.contextualToursSeenIds), featureInteractions: normalizeFeatureInteractions(this.state.ui?.featureInteractions) } } @@ -2792,6 +2805,15 @@ export class Store { updates.featureTipsSeenIds !== undefined ? normalizeFeatureTipIds(updates.featureTipsSeenIds) : normalizeFeatureTipIds(this.state.ui?.featureTipsSeenIds), + // Why: renderer and paired clients can mark different tours seen from + // stale UI snapshots; union them so completed tours stay suppressed. + contextualToursSeenIds: + updates.contextualToursSeenIds !== undefined + ? mergeContextualTourSeenIds( + this.state.ui?.contextualToursSeenIds, + updates.contextualToursSeenIds + ) + : normalizeContextualTourIds(this.state.ui?.contextualToursSeenIds), // Why: runtime RPCs and the renderer can both record education state. // Merge instead of replacing so a stale renderer snapshot cannot erase // runtime-only feature interactions. diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index caae7ab1bab..e39d8130313 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -39,6 +39,7 @@ import type { SshConnectionStatus } from '../../../shared/ssh-types' type RepoOption = React.ComponentProps['repos'][number] type NewWorkspaceComposerCardProps = { + contextualTourSource?: string containerClassName?: string composerRef?: React.RefObject nameInputRef?: React.RefObject @@ -203,6 +204,7 @@ function useComposerFileDragOver(): { } export default function NewWorkspaceComposerCard({ + contextualTourSource, containerClassName, composerRef, nameInputRef, @@ -293,9 +295,10 @@ export default function NewWorkspaceComposerCard({ useContextualTour( 'workspace-creation', eligibleRepos.length > 0 && Boolean(repoId), - activeModal === 'new-workspace-composer' - ? 'workspace_creation_modal' - : 'workspace_creation_visible' + contextualTourSource ?? + (activeModal === 'new-workspace-composer' + ? 'workspace_creation_modal' + : 'workspace_creation_visible') ) return ( diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 02bf3bd0b47..ac760b891a3 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -25,6 +25,8 @@ type ComposerModalData = { * `workspace_created.source` carries the right value. Falls back to * `unknown` when omitted. */ telemetrySource?: WorkspaceCreateTelemetrySource + contextualTourSource?: string + setupGuideTourRequestId?: string } export default function NewWorkspaceComposerModal(): React.JSX.Element | null { @@ -207,6 +209,7 @@ function QuickTabBody({ {primaryActionLabel} tab.id).join(',') : '' + const activeContextualTourId = useAppStore((s) => s.activeContextualTourId) + const hasSplitTerminalPane = useAppStore((s) => + Boolean(s.featureInteractions['terminal-pane-split']?.interactionCount) + ) useContextualTour( 'workspace-agent-sessions', - Boolean(activeWorktreeId && activeView === 'terminal' && workspaceSessionReady), + Boolean( + activeWorktreeId && + activeView === 'terminal' && + workspaceSessionReady && + activeTabType === 'terminal' && + Boolean(activeTabId) && + (!hasSplitTerminalPane || activeContextualTourId === 'workspace-agent-sessions') + ), 'workspace_agent_sessions_visible' ) diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx index 9a7a146731c..64a369a3c6a 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ContextualTourId } from '../../../../shared/contextual-tours' import { getContextualTourCleanupOutcome } from './ContextualTourOverlay' +import { performContextualTourStepAction } from './contextual-tour-step-actions' import { ContextualTourOverlaySurface, handleContextualTourGlobalKeyDown, @@ -49,6 +50,7 @@ function renderSurface( onSkip?: (id: ContextualTourId) => void onNext?: () => void onBack?: () => void + onStepAction?: Parameters[0]['onStepAction'] } = {} ): ReactElement { const renderState = { ...baseRenderState, ...overrides } @@ -62,6 +64,7 @@ function renderSurface( onSkip: callbacks.onSkip ?? vi.fn(), onBack: callbacks.onBack ?? vi.fn(), onNext: callbacks.onNext ?? vi.fn(), + onStepAction: callbacks.onStepAction ?? vi.fn(), onOverlayKeyDownCapture: handleContextualTourOverlayKeyDown }) } @@ -224,6 +227,25 @@ describe('ContextualTourOverlaySurface', () => { expect(onNext).toHaveBeenCalledTimes(1) }) + it('renders configured step action labels and wires them through the action callback', () => { + const onStepAction = vi.fn() + const primaryAction = { kind: 'split-terminal-pane' as const, label: 'Split terminal' } + const secondaryAction = { kind: 'next' as const, label: 'Skip' } + const element = renderSurface( + { + primaryAction, + secondaryAction + }, + { onStepAction } + ) + + findElementByText(element, 'Split terminal')?.props.onClick?.() + findElementByText(element, 'Skip')?.props.onClick?.() + + expect(onStepAction).toHaveBeenCalledWith(primaryAction) + expect(onStepAction).toHaveBeenCalledWith(secondaryAction) + }) + it('handles Escape by clicking Skip before page-level handlers see it', () => { const click = vi.fn() const preventDefault = vi.fn() @@ -317,6 +339,57 @@ describe('ContextualTourOverlaySurface', () => { }) }) +describe('performContextualTourStepAction', () => { + it('opens Tasks after detaching the terminal-owned tour source', () => { + const finishTour = vi.fn() + const advanceContextualTour = vi.fn() + const detachContextualTourSource = vi.fn() + const openTaskPage = vi.fn() + + performContextualTourStepAction({ + action: { kind: 'open-tasks', label: 'Show tasks' }, + activeTabId: 'tab-1', + isLastStep: false, + finishTour, + advanceContextualTour, + detachContextualTourSource, + setSidebarOpen: vi.fn(), + openTaskPage, + openModal: vi.fn(), + dispatchTerminalPaneSplit: vi.fn(), + schedule: vi.fn() + }) + + expect(detachContextualTourSource).toHaveBeenCalledTimes(1) + expect(openTaskPage).toHaveBeenCalledTimes(1) + expect(advanceContextualTour).toHaveBeenCalledTimes(1) + expect(finishTour).not.toHaveBeenCalled() + }) + + it('dispatches the terminal-pane split action against the active tab', () => { + const dispatchTerminalPaneSplit = vi.fn() + + performContextualTourStepAction({ + action: { kind: 'split-terminal-pane', label: 'Split terminal' }, + activeTabId: 'tab-1', + isLastStep: false, + finishTour: vi.fn(), + advanceContextualTour: vi.fn(), + detachContextualTourSource: vi.fn(), + setSidebarOpen: vi.fn(), + openTaskPage: vi.fn(), + openModal: vi.fn(), + dispatchTerminalPaneSplit, + schedule: vi.fn() + }) + + expect(dispatchTerminalPaneSplit).toHaveBeenCalledWith({ + tabId: 'tab-1', + direction: 'vertical' + }) + }) +}) + describe('getContextualTourPanelHost', () => { it('hosts controls inside Radix dialog and sheet content', () => { const dialogHost = {} as HTMLElement diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx index a316558b6d7..8af2f06b3b8 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx @@ -1,6 +1,10 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type JSX } from 'react' import { useAppStore } from '@/store' -import { getContextualTour, type ContextualTourId } from '../../../../shared/contextual-tours' +import { + getContextualTour, + type ContextualTourId, + type ContextualTourStepAction +} from '../../../../shared/contextual-tours' import type { ContextualTourOutcome } from '../../../../shared/feature-education-telemetry' import { trackContextualTourOutcome, @@ -23,6 +27,11 @@ import { handleContextualTourOverlayKeyDown, type ActiveTourRenderState } from './ContextualTourOverlaySurface' +import { + REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT, + type RequestActiveTerminalPaneSplitDetail +} from '@/constants/terminal' +import { performContextualTourStepAction } from './contextual-tour-step-actions' export function ContextualTourOverlay(): JSX.Element | null { const activeTourId = useAppStore((s) => s.activeContextualTourId) @@ -36,12 +45,17 @@ export function ContextualTourOverlay(): JSX.Element | null { const blockingSurfaceVisible = useAppStore((s) => s.contextualToursBlockingSurfaceVisible) const activeTourSuppressed = useAppStore((s) => s.activeContextualTourSuppressed) const keybindings = useAppStore((s) => s.keybindings) + const activeTabId = useAppStore((s) => s.activeTabId) const markContextualToursSeen = useAppStore((s) => s.markContextualToursSeen) const advanceContextualTour = useAppStore((s) => s.advanceContextualTour) const regressContextualTour = useAppStore((s) => s.regressContextualTour) const dismissContextualTour = useAppStore((s) => s.dismissContextualTour) const completeContextualTour = useAppStore((s) => s.completeContextualTour) const cancelContextualTour = useAppStore((s) => s.cancelContextualTour) + const detachContextualTourSource = useAppStore((s) => s.detachContextualTourSource) + const setSidebarOpen = useAppStore((s) => s.setSidebarOpen) + const openTaskPage = useAppStore((s) => s.openTaskPage) + const openModal = useAppStore((s) => s.openModal) const [renderState, setRenderState] = useState(null) const [measureVersion, setMeasureVersion] = useState(0) const panelRef = useRef(null) @@ -177,6 +191,8 @@ export function ContextualTourOverlay(): JSX.Element | null { title: activeStep.title, body: formatContextualTourStepCopy(getContextualTourStepCopy(activeStep), keybindings), control: activeStep.control, + primaryAction: activeStep.primaryAction, + secondaryAction: activeStep.secondaryAction, preferredPlacement: activeStep.preferredPlacement, isLastStep: progress.current === progress.total, isFirstStep: progress.current === 1, @@ -284,6 +300,40 @@ export function ContextualTourOverlay(): JSX.Element | null { return null } + const finishTour = (): void => { + emitContextualTourOutcome('completed') + completeContextualTour(activeTourId) + } + + const handleStepAction = (action: ContextualTourStepAction): void => { + performContextualTourStepAction({ + action, + activeTabId, + isLastStep: renderState.isLastStep, + finishTour, + advanceContextualTour, + detachContextualTourSource: () => { + if (activeTourSource) { + detachContextualTourSource(activeTourId, activeTourSource) + } + }, + setSidebarOpen, + openTaskPage, + openModal, + dispatchTerminalPaneSplit: (detail) => { + window.dispatchEvent( + new CustomEvent( + REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT, + { detail } + ) + ) + }, + schedule: (callback) => { + window.setTimeout(callback, 0) + } + }) + } + const viewport = { width: typeof window === 'undefined' ? 1024 : window.innerWidth, height: typeof window === 'undefined' ? 768 : window.innerHeight @@ -311,12 +361,12 @@ export function ContextualTourOverlay(): JSX.Element | null { onBack={regressContextualTour} onNext={() => { if (renderState.isLastStep) { - emitContextualTourOutcome('completed') - completeContextualTour(activeTourId) + finishTour() } else { advanceContextualTour() } }} + onStepAction={handleStepAction} onOverlayKeyDownCapture={handleContextualTourOverlayKeyDown} /> ) diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx index e38b48781ae..8b2215994bc 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx @@ -7,7 +7,8 @@ import { useAppStore } from '@/store' import type { ContextualTourId, ContextualTourStepPlacement, - ContextualTourStepControl + ContextualTourStepControl, + ContextualTourStepAction } from '../../../../shared/contextual-tours' import { ContextualTourArrow } from './ContextualTourArrow' import { ContextualTourControl } from './ContextualTourControl' @@ -25,6 +26,8 @@ export type ActiveTourRenderState = { title: string body: string control?: ContextualTourStepControl + primaryAction?: ContextualTourStepAction + secondaryAction?: ContextualTourStepAction preferredPlacement?: ContextualTourStepPlacement isLastStep: boolean isFirstStep: boolean @@ -45,6 +48,7 @@ type ContextualTourOverlaySurfaceProps = { onSkip: (id: ContextualTourId) => void onBack: () => void onNext: () => void + onStepAction: (action: ContextualTourStepAction) => void onOverlayKeyDownCapture: (event: KeyboardEvent) => void } @@ -73,6 +77,7 @@ export function ContextualTourOverlaySurface({ onSkip, onBack, onNext, + onStepAction, onOverlayKeyDownCapture }: ContextualTourOverlaySurfaceProps): JSX.Element { const panelHostSlot = panelHost?.getAttribute('data-slot') @@ -90,6 +95,12 @@ export function ContextualTourOverlaySurface({ ) const stepKey = `${activeTourId}-${renderState.progress.current}` + const primaryAction = + renderState.primaryAction ?? + ({ + kind: renderState.isLastStep ? 'complete' : 'next', + label: renderState.isLastStep ? 'Done' : 'Next' + } satisfies ContextualTourStepAction) const panel = (
) : null} - + ) : null} + 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 d2a160007e1..39d7533c55b 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 @@ -243,4 +243,35 @@ describe('contextual tour gate', () => { expect(getContextualTourOutcomeStepTotal(visibleStepIndexes)).toBe(2) expect(getContextualTourOutcomeStepTotal([])).toBe(1) }) + + it('keeps the full workspace-agent-sessions tour defined while skipping unavailable later targets', () => { + const tour = getContextualTour('workspace-agent-sessions') + const visibleSelectors = new Set([ + tour.steps[0]!.targetSelector, + tour.steps[1]!.targetSelector, + tour.steps[4]!.targetSelector + ]) + const targetExists = (selector: string): boolean => visibleSelectors.has(selector) + const visibleStepIndexes = getVisibleContextualTourStepIndexes(tour, targetExists) + + expect(tour.steps.map((step) => step.title)).toEqual([ + 'Work side by side', + 'Split the terminal', + 'Keep separate tasks isolated', + 'Start from real work', + 'Orchestrate capable agents' + ]) + expect(visibleStepIndexes).toEqual([0, 1, 4]) + expect( + getNextVisibleContextualTourStepIndex({ + tour, + currentStepIndex: 1, + targetExists + }) + ).toBe(4) + expect(getContextualTourStepProgress({ visibleStepIndexes, stepIndex: 4 })).toEqual({ + current: 3, + total: 3 + }) + }) }) 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 new file mode 100644 index 00000000000..b275bd191cd --- /dev/null +++ b/src/renderer/src/components/contextual-tours/contextual-tour-step-actions.ts @@ -0,0 +1,54 @@ +import type { ContextualTourStepAction } from '../../../../shared/contextual-tours' +import type { RequestActiveTerminalPaneSplitDetail } from '@/constants/terminal' + +export function performContextualTourStepAction(args: { + action: ContextualTourStepAction + activeTabId: string | null + isLastStep: boolean + finishTour: () => void + advanceContextualTour: () => void + detachContextualTourSource: () => void + setSidebarOpen: (open: boolean) => void + openTaskPage: () => void + openModal: (modal: 'setup-guide') => void + dispatchTerminalPaneSplit: (detail: RequestActiveTerminalPaneSplitDetail) => void + schedule: (callback: () => void) => void +}): void { + const advanceOrFinish = (): void => { + if (args.isLastStep) { + args.finishTour() + } else { + args.advanceContextualTour() + } + } + + switch (args.action.kind) { + case 'next': + advanceOrFinish() + return + case 'complete': + args.finishTour() + return + case 'split-terminal-pane': + if (args.activeTabId) { + args.dispatchTerminalPaneSplit({ tabId: args.activeTabId, direction: 'vertical' }) + } + return + case 'show-worktrees': + args.setSidebarOpen(true) + advanceOrFinish() + return + case 'open-tasks': + // Why: the auto tour starts from the terminal, but this CTA intentionally + // navigates to Tasks before the final setup-guide step. + args.detachContextualTourSource() + args.openTaskPage() + advanceOrFinish() + return + case 'open-getting-started': + args.finishTour() + args.schedule(() => { + args.openModal('setup-guide') + }) + } +} 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 new file mode 100644 index 00000000000..9ecbedb8e6b --- /dev/null +++ b/src/renderer/src/components/contextual-tours/request-contextual-tour-when-ready.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { requestContextualTourWhenReady } from './request-contextual-tour-when-ready' + +describe('requestContextualTourWhenReady', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it('retries a forced tour request until the target starts', () => { + vi.useFakeTimers() + let activeContextualTourId: string | null = null + const requestContextualTour = vi.fn(() => { + if (requestContextualTour.mock.calls.length === 3) { + activeContextualTourId = 'workspace-agent-sessions' + } + }) + vi.spyOn(useAppStore, 'getState').mockImplementation( + () => + ({ + activeContextualTourId, + requestContextualTour + }) as unknown as ReturnType + ) + + requestContextualTourWhenReady({ + id: 'workspace-agent-sessions', + source: 'setup_guide_parallel_work', + retryDelayMs: 10, + maxAttempts: 5 + }) + + vi.advanceTimersByTime(0) + vi.advanceTimersByTime(10) + vi.advanceTimersByTime(10) + vi.advanceTimersByTime(20) + + expect(requestContextualTour).toHaveBeenCalledTimes(3) + expect(requestContextualTour).toHaveBeenLastCalledWith( + 'workspace-agent-sessions', + 'setup_guide_parallel_work', + undefined, + { force: true } + ) + }) + + it('stops when another tour becomes active before the target starts', () => { + vi.useFakeTimers() + let activeContextualTourId: string | null = null + const requestContextualTour = vi.fn(() => { + activeContextualTourId = 'tasks' + }) + vi.spyOn(useAppStore, 'getState').mockImplementation( + () => + ({ + activeContextualTourId, + requestContextualTour + }) as unknown as ReturnType + ) + + requestContextualTourWhenReady({ + id: 'workspace-agent-sessions', + source: 'setup_guide_parallel_work', + retryDelayMs: 10, + maxAttempts: 5 + }) + + vi.advanceTimersByTime(0) + vi.advanceTimersByTime(50) + + expect(requestContextualTour).toHaveBeenCalledTimes(1) + }) + + it('cancels pending retries when the caller unmounts or starts another action', () => { + vi.useFakeTimers() + const requestContextualTour = vi.fn() + vi.spyOn(useAppStore, 'getState').mockImplementation( + () => + ({ + activeContextualTourId: null, + requestContextualTour + }) as unknown as ReturnType + ) + + const cancel = requestContextualTourWhenReady({ + id: 'workspace-agent-sessions', + source: 'setup_guide_parallel_work', + retryDelayMs: 10, + maxAttempts: 5 + }) + + vi.advanceTimersByTime(0) + cancel() + vi.advanceTimersByTime(50) + + expect(requestContextualTour).toHaveBeenCalledTimes(1) + }) + + it('stops before requesting when the destination surface is no longer current', () => { + vi.useFakeTimers() + const requestContextualTour = vi.fn() + const shouldContinue = vi.fn(() => false) + vi.spyOn(useAppStore, 'getState').mockImplementation( + () => + ({ + activeContextualTourId: null, + requestContextualTour + }) as unknown as ReturnType + ) + + requestContextualTourWhenReady({ + id: 'workspace-creation', + source: 'setup_guide_parallel_work', + shouldContinue, + retryDelayMs: 10, + maxAttempts: 5 + }) + + vi.advanceTimersByTime(50) + + expect(shouldContinue).toHaveBeenCalledTimes(1) + expect(requestContextualTour).not.toHaveBeenCalled() + }) +}) 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 new file mode 100644 index 00000000000..fd2c4e929c6 --- /dev/null +++ b/src/renderer/src/components/contextual-tours/request-contextual-tour-when-ready.ts @@ -0,0 +1,60 @@ +import type { ContextualTourId } from '../../../../shared/contextual-tours' +import { useAppStore } from '@/store' + +type RequestContextualTourWhenReadyArgs = { + id: ContextualTourId + source: string + wasFeaturePreviouslyInteracted?: boolean + maxAttempts?: number + retryDelayMs?: number + shouldContinue?: () => boolean +} + +export type { RequestContextualTourWhenReadyArgs } + +export function requestContextualTourWhenReady( + args: RequestContextualTourWhenReadyArgs +): () => void { + const maxAttempts = args.maxAttempts ?? 20 + const retryDelayMs = args.retryDelayMs ?? 100 + let attempts = 0 + let timeoutId: ReturnType | null = null + let cancelled = false + + const attempt = (): void => { + if (cancelled) { + return + } + if (args.shouldContinue && !args.shouldContinue()) { + cancelled = true + return + } + attempts += 1 + + const before = useAppStore.getState() + if (before.activeContextualTourId && before.activeContextualTourId !== args.id) { + return + } + + before.requestContextualTour(args.id, args.source, args.wasFeaturePreviouslyInteracted, { + force: true + }) + + const after = useAppStore.getState() + if (after.activeContextualTourId === args.id || attempts >= maxAttempts) { + return + } + timeoutId = setTimeout(attempt, retryDelayMs) + } + + // Why: setup-guide actions often reveal lazy surfaces first; retrying keeps + // explicit user-triggered education from depending on renderer mount timing. + timeoutId = setTimeout(attempt, 0) + + return () => { + cancelled = true + if (timeoutId !== null) { + clearTimeout(timeoutId) + } + } +} 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 22b779cc90d..6047ac16576 100644 --- a/src/renderer/src/components/contextual-tours/use-contextual-tour.ts +++ b/src/renderer/src/components/contextual-tours/use-contextual-tour.ts @@ -24,6 +24,9 @@ export function useContextualTour( const activeModal = useAppStore((s) => s.activeModal) const activeContextualTourId = useAppStore((s) => s.activeContextualTourId) const activeContextualTourSource = useAppStore((s) => s.activeContextualTourSource) + const activeContextualTourSourceDetached = useAppStore( + (s) => s.activeContextualTourSourceDetached + ) const featureInteractions = useAppStore((s) => s.featureInteractions) const contextualToursSeenIds = useAppStore((s) => s.contextualToursSeenIds) const contextualToursAutoEligible = useAppStore((s) => s.contextualToursAutoEligible) @@ -62,12 +65,18 @@ export function useContextualTour( useEffect(() => { // Why: source disable should end through the overlay so a shown tour gets // a cancellation outcome; the store flag also lets pre-render attempts retry. - if (!enabled && activeContextualTourId === id && activeContextualTourSource === source) { + if ( + !enabled && + activeContextualTourId === id && + activeContextualTourSource === source && + !activeContextualTourSourceDetached + ) { suppressContextualTour(id, source) } }, [ activeContextualTourId, activeContextualTourSource, + activeContextualTourSourceDetached, enabled, id, source, @@ -79,7 +88,11 @@ export function useContextualTour( const state = useAppStore.getState() // Why: surfaces like sheets can unmount without rendering an `enabled=false` // pass, so suppress their active tour during cleanup too. - if (state.activeContextualTourId === id && state.activeContextualTourSource === source) { + if ( + state.activeContextualTourId === id && + state.activeContextualTourSource === source && + !state.activeContextualTourSourceDetached + ) { state.suppressContextualTour(id, source) } } diff --git a/src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx b/src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx index 33561ed4072..d902f662094 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx @@ -4,7 +4,7 @@ import type { FeatureWallSetupStep, FeatureWallSetupStepId } from '../../../../shared/feature-wall-setup-steps' -import { getFeatureWallSetupSteps } from '../../../../shared/feature-wall-setup-steps' +import { getFeatureWallSetupStepsForSection } from '../../../../shared/feature-wall-setup-steps' import { cn } from '@/lib/utils' import type { FeatureWallSetupProgress } from './feature-wall-setup-progress' import { usePrefersReducedMotion } from './feature-wall-modal-helpers' @@ -69,22 +69,24 @@ function SetupStepRow(props: { function SetupSection(props: { title: string - count: string + steps: readonly FeatureWallSetupStep[] activeStepId: FeatureWallSetupStepId | null progress: FeatureWallSetupProgress onSelectStep: (id: FeatureWallSetupStepId) => void }): React.JSX.Element { - const steps = getFeatureWallSetupSteps() + const doneCount = props.steps.filter((step) => props.progress.stepDone[step.id]).length return (

{props.title}

- {props.count} + + {doneCount}/{props.steps.length} +
- {steps.map((step) => ( + {props.steps.map((step) => (
+ ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/lib/focus-terminal-tab-surface', () => ({ + focusTerminalTabSurface: vi.fn() +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + isWebRuntimeSessionActive: vi.fn(() => false) +})) + +vi.mock('./FeatureWallSetupStepVisuals', () => ({ + SetupTwoAgentsVisual: () =>
, + SetupWorkspacesVisual: () =>
+})) + +vi.mock('./AddReposAnimatedVisual', () => ({ + AddReposAnimatedVisual: () =>
+})) + +vi.mock('./SetupScriptAnimatedVisual', () => ({ + SetupScriptAnimatedVisual: () =>
+})) + +describe('TwoAgentsAction', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + useAppStore.setState({ + activeWorktreeId: null, + worktreesByRepo: {}, + tabsByWorktree: {}, + activeTabId: null, + activeGroupIdByWorktree: {}, + terminalLayoutsByTabId: {} + }) + }) + + it('creates a terminal tab before requesting the split tour when a worktree has only editor/browser tabs', () => { + const createTab = vi.fn(() => ({ id: 'terminal-tab-1' })) + const setActiveTab = vi.fn() + const setActiveTabType = vi.fn() + vi.mocked(worktreeActivation.activateAndRevealWorktree).mockReturnValue({ primaryTabId: null }) + useAppStore.setState({ + activeWorktreeId: 'worktree-1', + worktreesByRepo: { + 'repo-1': [{ id: 'worktree-1', repoId: 'repo-1' }] + } as never, + tabsByWorktree: { 'worktree-1': [] }, + activeTabId: 'editor-tab-1', + activeGroupIdByWorktree: { 'worktree-1': 'group-1' }, + terminalLayoutsByTabId: {}, + closeModal: vi.fn(), + createTab: createTab as never, + setActiveTabType: setActiveTabType as never, + setActiveTab: setActiveTab as never + }) + + const tabId = activateWorktreeTerminalForSetupTour('worktree-1') + + expect(tabId).toBe('terminal-tab-1') + expect(createTab).toHaveBeenCalledWith('worktree-1', 'group-1') + expect(setActiveTabType).toHaveBeenCalledWith('terminal') + expect(setActiveTab).toHaveBeenCalledWith('terminal-tab-1') + }) + + it('does not create a local fallback terminal for paired web runtime sessions', () => { + const createTab = vi.fn(() => ({ id: 'terminal-tab-1' })) + const setActiveTab = vi.fn() + vi.mocked(webRuntimeSession.isWebRuntimeSessionActive).mockReturnValue(true) + vi.mocked(worktreeActivation.activateAndRevealWorktree).mockReturnValue({ primaryTabId: null }) + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never, + activeWorktreeId: 'worktree-1', + worktreesByRepo: { + 'repo-1': [{ id: 'worktree-1', repoId: 'repo-1' }] + } as never, + tabsByWorktree: { 'worktree-1': [] }, + activeTabId: 'editor-tab-1', + activeGroupIdByWorktree: { 'worktree-1': 'group-1' }, + terminalLayoutsByTabId: {}, + createTab: createTab as never, + setActiveTab: setActiveTab as never + }) + + const tabId = activateWorktreeTerminalForSetupTour('worktree-1') + + expect(tabId).toBeNull() + expect(createTab).not.toHaveBeenCalled() + expect(setActiveTab).not.toHaveBeenCalled() + }) + + it('keeps setup-guide tour work alive across the modal transition that unmounts the action', () => { + const callbacks: FrameRequestCallback[] = [] + const callback = vi.fn() + vi.stubGlobal('window', { + requestAnimationFrame: vi.fn((frameCallback: FrameRequestCallback) => { + callbacks.push(frameCallback) + return callbacks.length + }), + cancelAnimationFrame: vi.fn() + }) + + requestSetupGuideTourAfterFrame(callback) + callbacks[0]?.(1) + + expect(callback).toHaveBeenCalledTimes(1) + + cancelPendingSetupGuideTourRequest() + }) + + it('gates setup-guide tour retries on the expected destination surface', () => { + vi.useFakeTimers() + const requestContextualTour = vi.fn() + useAppStore.setState({ + activeModal: 'none', + activeContextualTourId: null, + requestContextualTour: requestContextualTour as never + }) + + requestSetupGuideTourWhenReady({ + id: 'workspace-creation', + source: 'setup_guide_parallel_work', + shouldContinue: () => useAppStore.getState().activeModal === 'new-workspace-composer', + retryDelayMs: 10, + maxAttempts: 5 + }) + + vi.advanceTimersByTime(50) + + expect(requestContextualTour).not.toHaveBeenCalled() + + cancelPendingSetupGuideTourRequest() + vi.useRealTimers() + }) + + it('rejects a stale setup-guide composer request after close and reopen', () => { + useAppStore.setState({ + activeModal: 'new-workspace-composer', + modalData: { setupGuideTourRequestId: 'new-request' } + }) + + expect(isSetupGuideWorkspaceComposerRequestCurrent('old-request')).toBe(false) + expect(isSetupGuideWorkspaceComposerRequestCurrent('new-request')).toBe(true) + }) +}) diff --git a/src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.tsx b/src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.tsx index 93fdfaa9705..949318287fe 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.tsx @@ -4,15 +4,22 @@ import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' import { getDefaultRepoHookSettings } from '../../../../shared/constants' import { isGitRepoKind } from '../../../../shared/repo-kind' -import type { RepoHookSettings, Worktree } from '../../../../shared/types' +import type { RepoHookSettings, TerminalPaneLayoutNode, Worktree } from '../../../../shared/types' import { getRepositoryLocalCommandsSectionId } from '../settings/repository-settings-targets' import { AddReposAnimatedVisual } from './AddReposAnimatedVisual' import { SetupTwoAgentsVisual, SetupWorkspacesVisual } from './FeatureWallSetupStepVisuals' import { SetupScriptAnimatedVisual } from './SetupScriptAnimatedVisual' +import { + requestContextualTourWhenReady, + type RequestContextualTourWhenReadyArgs +} from '../contextual-tours/request-contextual-tour-when-ready' +import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session' export function AddReposAction(props: { reducedMotion: boolean }): React.JSX.Element { const openModal = useAppStore((s) => s.openModal) @@ -31,35 +38,85 @@ export function TwoAgentsAction(props: { reducedMotion: boolean }): React.JSX.El const targetWorktree = useSetupTargetWorktree() const openModal = useAppStore((s) => s.openModal) const closeModal = useAppStore((s) => s.closeModal) - const requestContextualTour = useAppStore((s) => s.requestContextualTour) + const paneTarget = useSecondPaneTarget(targetWorktree?.id ?? null) const handlePrimaryAction = useCallback(() => { + cancelPendingSetupGuideTourRequest() if (!targetWorktree) { - openModal('new-workspace-composer', { telemetrySource: 'unknown' }) - window.setTimeout(() => { - requestContextualTour('workspace-creation', 'setup_guide_try_it_out', false, { - force: true + const tourRequestId = createSetupGuideTourRequestId() + openModal('new-workspace-composer', { + telemetrySource: 'unknown', + contextualTourSource: 'setup_guide_parallel_work', + setupGuideTourRequestId: tourRequestId + }) + requestSetupGuideTourWhenReady({ + id: 'workspace-creation', + source: 'setup_guide_parallel_work', + wasFeaturePreviouslyInteracted: false, + shouldContinue: () => isSetupGuideWorkspaceComposerRequestCurrent(tourRequestId) + }) + return + } + if (paneTarget) { + closeModal() + requestSetupGuideTourAfterFrame(() => { + activateAndRevealWorktree(targetWorktree.id) + useAppStore.getState().setActiveTabType('terminal') + activateTabAndFocusPane(paneTarget.tabId, paneTarget.leafId, { + flashFocusedPane: true }) - }, 80) + }) + return + } + closeModal() + requestSetupGuideTourAfterFrame(() => { + activateWorktreeTerminalForSetupTour(targetWorktree.id) + requestSetupGuideTourWhenReady({ + id: 'workspace-agent-sessions', + source: 'setup_guide_parallel_work', + wasFeaturePreviouslyInteracted: false, + shouldContinue: () => isWorktreeTerminalStillCurrent(targetWorktree.id) + }) + }) + }, [closeModal, openModal, paneTarget, targetWorktree]) + + const handleSecondaryAction = useCallback(() => { + cancelPendingSetupGuideTourRequest() + if (!targetWorktree) { return } closeModal() window.requestAnimationFrame(() => { activateAndRevealWorktree(targetWorktree.id) - window.setTimeout(() => { - requestContextualTour('workspace-agent-sessions', 'setup_guide_try_it_out', false, { - force: true - }) - }, 120) }) - }, [closeModal, openModal, requestContextualTour, targetWorktree]) + }, [closeModal, targetWorktree]) return (
- +
+

+ {paneTarget + ? 'You already have room for parallel work. Focus the second pane, then start another agent manually.' + : 'Split the terminal first, then start another agent manually in the new pane.'} +

+
+ + {targetWorktree && !paneTarget ? ( + + ) : null} +
+
) } @@ -67,7 +124,6 @@ export function TwoAgentsAction(props: { reducedMotion: boolean }): React.JSX.El export function WorkspacesAction(props: { reducedMotion: boolean }): React.JSX.Element { const openModal = useAppStore((s) => s.openModal) const activeRepoId = useAppStore((s) => s.activeRepoId) - const requestContextualTour = useAppStore((s) => s.requestContextualTour) return (
@@ -76,15 +132,20 @@ export function WorkspacesAction(props: { reducedMotion: boolean }): React.JSX.E size="sm" className="w-fit gap-2" onClick={() => { + cancelPendingSetupGuideTourRequest() + const tourRequestId = createSetupGuideTourRequestId() openModal('new-workspace-composer', { ...(activeRepoId ? { initialRepoId: activeRepoId } : {}), - telemetrySource: 'unknown' + telemetrySource: 'unknown', + contextualTourSource: 'setup_guide_parallel_work', + setupGuideTourRequestId: tourRequestId + }) + requestSetupGuideTourWhenReady({ + id: 'workspace-creation', + source: 'setup_guide_parallel_work', + wasFeaturePreviouslyInteracted: false, + shouldContinue: () => isSetupGuideWorkspaceComposerRequestCurrent(tourRequestId) }) - window.setTimeout(() => { - requestContextualTour('workspace-creation', 'setup_guide_try_it_out', false, { - force: true - }) - }, 80) }} > @@ -211,3 +272,116 @@ function useSetupTargetWorktree(): Worktree | null { [activeWorktreeId, allWorktrees] ) } + +export function activateWorktreeTerminalForSetupTour(worktreeId: string): string | null { + const activation = activateAndRevealWorktree(worktreeId) + if (!activation) { + return null + } + const state = useAppStore.getState() + const activeRuntimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId ?? null + const webRuntimeActive = isWebRuntimeSessionActive(activeRuntimeEnvironmentId) + const activeGroupId = state.activeGroupIdByWorktree[worktreeId] + const tabs = state.tabsByWorktree[worktreeId] ?? [] + const activeTerminalTabId = + state.activeTabId && tabs.some((tab) => tab.id === state.activeTabId) ? state.activeTabId : null + const tabId = + activeTerminalTabId ?? + activation.primaryTabId ?? + tabs[0]?.id ?? + (webRuntimeActive ? null : state.createTab(worktreeId, activeGroupId).id) + if (!tabId) { + return null + } + // Why: the forced tour's split action targets the visible terminal tab. + // Worktree activation can restore an editor/browser as the active surface. + state.setActiveTabType('terminal') + state.setActiveTab(tabId) + focusTerminalTabSurface(tabId) + return tabId +} + +function useSecondPaneTarget(worktreeId: string | null): { tabId: string; leafId: string } | null { + const activeTabId = useAppStore((s) => s.activeTabId) + const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) + const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) + return useMemo(() => { + if (!worktreeId) { + return null + } + const tabIds = (tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) + const orderedTabIds = + activeTabId && tabIds.includes(activeTabId) + ? [activeTabId, ...tabIds.filter((tabId) => tabId !== activeTabId)] + : tabIds + for (const tabId of orderedTabIds) { + const root = terminalLayoutsByTabId[tabId]?.root + const secondLeafId = getSecondSplitLeafId(root) + if (secondLeafId) { + return { tabId, leafId: secondLeafId } + } + } + return null + }, [activeTabId, tabsByWorktree, terminalLayoutsByTabId, worktreeId]) +} + +function getSecondSplitLeafId(node: TerminalPaneLayoutNode | null | undefined): string | null { + if (!node || node.type === 'leaf') { + return null + } + return getLeftmostLeafId(node.second) +} + +function getLeftmostLeafId(node: TerminalPaneLayoutNode): string { + return node.type === 'leaf' ? node.leafId : getLeftmostLeafId(node.first) +} + +let pendingSetupGuideTourCancel: (() => void) | null = null +let pendingSetupGuideFrame: number | null = null +let setupGuideTourRequestSequence = 0 + +function createSetupGuideTourRequestId(): string { + setupGuideTourRequestSequence += 1 + return `setup-guide-tour-${setupGuideTourRequestSequence}` +} + +export function cancelPendingSetupGuideTourRequest(): void { + pendingSetupGuideTourCancel?.() + pendingSetupGuideTourCancel = null + if (pendingSetupGuideFrame !== null) { + window.cancelAnimationFrame(pendingSetupGuideFrame) + pendingSetupGuideFrame = null + } +} + +export function requestSetupGuideTourWhenReady(args: RequestContextualTourWhenReadyArgs): void { + cancelPendingSetupGuideTourRequest() + pendingSetupGuideTourCancel = requestContextualTourWhenReady(args) +} + +export function requestSetupGuideTourAfterFrame(callback: () => void): void { + cancelPendingSetupGuideTourRequest() + pendingSetupGuideFrame = window.requestAnimationFrame(() => { + pendingSetupGuideFrame = null + callback() + }) +} + +export function isSetupGuideWorkspaceComposerRequestCurrent(requestId: string): boolean { + const state = useAppStore.getState() + const modalData = state.modalData as { setupGuideTourRequestId?: unknown } + return ( + state.activeModal === 'new-workspace-composer' && + modalData.setupGuideTourRequestId === requestId + ) +} + +function isWorktreeTerminalStillCurrent(worktreeId: string): boolean { + const state = useAppStore.getState() + return ( + state.activeModal === 'none' && + state.activeWorktreeId === worktreeId && + state.activeView === 'terminal' && + state.activeTabType === 'terminal' + ) +} diff --git a/src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts b/src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts index 6ffdef92125..e130101425c 100644 --- a/src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts +++ b/src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest' import type { FeatureWallSetupProgressInput } from './feature-wall-setup-progress' import { getFeatureWallSetupProgress } from './feature-wall-setup-progress' -import { getFeatureWallSetupSteps } from '../../../../shared/feature-wall-setup-steps' +import { + getFeatureWallSetupSteps, + getFeatureWallSetupStepsForSection, + getFirstIncompleteFeatureWallSetupStepId +} from '../../../../shared/feature-wall-setup-steps' import type { Worktree } from '../../../../shared/types' function makeInput( @@ -19,6 +23,7 @@ function makeInput( worktreesByRepo: {}, tabsByWorktree: {}, agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, hasSetupScript: false, ...overrides } @@ -40,19 +45,54 @@ describe('getFeatureWallSetupProgress', () => { expect(progress.coreTotal).toBe(8) }) - it('keeps Add 2 projects as the final core setup task', () => { + it('orders visible parallel work before setup tasks', () => { expect(getFeatureWallSetupSteps().map((step) => step.id)).toEqual([ - 'default-agent', - 'notifications', 'two-agents', 'three-workspaces', + 'notifications', + 'default-agent', 'task-sources', - 'agent-capabilities', 'setup-script', - 'add-two-repos' + 'add-two-repos', + 'agent-capabilities' ]) }) + it('groups setup guide steps into Parallel work and Setup sections', () => { + expect(getFeatureWallSetupStepsForSection('parallel-work').map((step) => step.id)).toEqual([ + 'two-agents', + 'three-workspaces' + ]) + expect(getFeatureWallSetupStepsForSection('setup').map((step) => step.id)).toEqual([ + 'notifications', + 'default-agent', + 'task-sources', + 'setup-script', + 'add-two-repos', + 'agent-capabilities' + ]) + }) + + it('auto-selects incomplete parallel work before setup steps', () => { + const progress = getFeatureWallSetupProgress( + makeInput({ + settings: { + defaultTuiAgent: 'claude', + notifications: { enabled: true, agentTaskComplete: true } + } as never, + hasConnectedTaskSource: true, + hasSetupScript: true, + gitRepoCount: 2, + browserUseSkillInstalled: true, + computerUseSkillInstalled: true, + computerUsePermissionsReady: true, + orchestrationSkillInstalled: true + }) + ) + + expect(getFirstIncompleteFeatureWallSetupStepId(progress.stepDone)).toBe('two-agents') + }) + it('does not mark two agents complete from split-pane interaction alone', () => { const progress = getFeatureWallSetupProgress( makeInput({ @@ -117,6 +157,51 @@ describe('getFeatureWallSetupProgress', () => { expect(progress.stepDone['two-agents']).toBe(true) }) + it('marks two agents complete across live and retained hook-reported sessions in one worktree', () => { + const retainedEntry = { + entry: { + paneKey: 'tab-2:00000000-0000-4000-8000-000000000002', + state: 'done', + prompt: 'second task', + updatedAt: 2, + stateStartedAt: 2, + agentType: 'codex', + stateHistory: [] + }, + worktreeId: 'worktree-1', + tab: { id: 'tab-2', title: 'Terminal' }, + agentType: 'codex', + startedAt: 2 + } as never + const progress = getFeatureWallSetupProgress( + makeInput({ + worktreesByRepo: { 'repo-1': [makeWorktree('worktree-1')] }, + tabsByWorktree: { + 'worktree-1': [ + { id: 'tab-1', title: 'Terminal' }, + { id: 'tab-2', title: 'Terminal' } + ] as never + }, + agentStatusByPaneKey: { + 'tab-1:00000000-0000-4000-8000-000000000001': { + paneKey: 'tab-1:00000000-0000-4000-8000-000000000001', + state: 'working', + prompt: 'first task', + updatedAt: 1, + stateStartedAt: 1, + agentType: 'claude', + stateHistory: [] + } + }, + retainedAgentsByPaneKey: { + 'tab-2:00000000-0000-4000-8000-000000000002': retainedEntry + } + }) + ) + + expect(progress.stepDone['two-agents']).toBe(true) + }) + it('does not mark two agents complete when hook-reported agents are in separate worktrees', () => { const progress = getFeatureWallSetupProgress( makeInput({ diff --git a/src/renderer/src/components/feature-wall/feature-wall-setup-progress.ts b/src/renderer/src/components/feature-wall/feature-wall-setup-progress.ts index c8fcb045bfd..e6ff5aff869 100644 --- a/src/renderer/src/components/feature-wall/feature-wall-setup-progress.ts +++ b/src/renderer/src/components/feature-wall/feature-wall-setup-progress.ts @@ -6,6 +6,7 @@ import { import type { GlobalSettings, TerminalTab, Worktree } from '../../../../shared/types' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { RetainedAgentEntry } from '@/store/slices/agent-status' export type FeatureWallSetupProgressInput = { settings: GlobalSettings | null @@ -19,6 +20,7 @@ export type FeatureWallSetupProgressInput = { worktreesByRepo: Record tabsByWorktree: Record agentStatusByPaneKey: Record + retainedAgentsByPaneKey: Record hasSetupScript: boolean } @@ -44,7 +46,13 @@ function hasTwoHookReportedAgentsInOneWorktree(input: FeatureWallSetupProgressIn } } - const agentCountsByWorktree = new Map() + const paneKeysByWorktree = new Map>() + const addPaneKeyForWorktree = (worktreeId: string, paneKey: string): boolean => { + const paneKeys = paneKeysByWorktree.get(worktreeId) ?? new Set() + paneKeys.add(paneKey) + paneKeysByWorktree.set(worktreeId, paneKeys) + return paneKeys.size >= 2 + } for (const paneKey of Object.keys(input.agentStatusByPaneKey)) { const parsed = parsePaneKey(paneKey) if (!parsed) { @@ -54,11 +62,17 @@ function hasTwoHookReportedAgentsInOneWorktree(input: FeatureWallSetupProgressIn if (!worktreeId) { continue } - const count = (agentCountsByWorktree.get(worktreeId) ?? 0) + 1 - if (count >= 2) { + if (addPaneKeyForWorktree(worktreeId, paneKey)) { + return true + } + } + for (const [paneKey, retained] of Object.entries(input.retainedAgentsByPaneKey)) { + if (!validWorktreeIds.has(retained.worktreeId)) { + continue + } + if (addPaneKeyForWorktree(retained.worktreeId, paneKey)) { return true } - agentCountsByWorktree.set(worktreeId, count) } return false } diff --git a/src/renderer/src/components/setup-guide/SetupGuideModal.tsx b/src/renderer/src/components/setup-guide/SetupGuideModal.tsx index 0c0ddf61b12..511581f4105 100644 --- a/src/renderer/src/components/setup-guide/SetupGuideModal.tsx +++ b/src/renderer/src/components/setup-guide/SetupGuideModal.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState, type JSX } from 'react' import { FEATURE_WALL_SETUP_STEP_IDS, + getFirstIncompleteFeatureWallSetupStepId, getFeatureWallSetupSteps } from '../../../../shared/feature-wall-setup-steps' import type { FeatureWallSetupStepId } from '../../../../shared/feature-wall-setup-steps' @@ -38,13 +39,24 @@ export default function SetupGuideModal(): JSX.Element | null { const activeStep = setupSteps.find((step) => step.id === activeStepId) ?? setupSteps[0] ?? null useEffect(() => { - if (!isOpen || requestedStepId === null) { + if (!isOpen) { + setUserSelectedStep(false) + return + } + if (requestedStepId === null) { return } setUserSelectedStep(false) setActiveStepId(requestedStepId) }, [isOpen, requestedStepId]) + useEffect(() => { + if (!isOpen || userSelectedStep || requestedStepId !== null) { + return + } + setActiveStepId(getFirstIncompleteFeatureWallSetupStepId(progress.stepDone)) + }, [isOpen, progress.stepDone, requestedStepId, userSelectedStep]) + useEffect(() => { if ( !isOpen || @@ -55,11 +67,9 @@ export default function SetupGuideModal(): JSX.Element | null { ) { return } - const nextUnfinishedCoreStep = getFeatureWallSetupSteps().find( - (step) => !progress.stepDone[step.id] - ) - if (nextUnfinishedCoreStep && nextUnfinishedCoreStep.id !== activeStep.id) { - setActiveStepId(nextUnfinishedCoreStep.id) + const nextUnfinishedCoreStepId = getFirstIncompleteFeatureWallSetupStepId(progress.stepDone) + if (nextUnfinishedCoreStepId !== activeStep.id) { + setActiveStepId(nextUnfinishedCoreStepId) } }, [activeStep, isOpen, progress.stepDone, requestedStepId, userSelectedStep]) diff --git a/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts b/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts index 58630be6f4f..71f65e67ef2 100644 --- a/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts +++ b/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts @@ -27,6 +27,7 @@ export function useSetupGuideProgress( const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) + const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey) const preflightStatus = useAppStore((s) => s.preflightStatus) const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked) const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) @@ -169,6 +170,7 @@ export function useSetupGuideProgress( worktreesByRepo, tabsByWorktree, agentStatusByPaneKey, + retainedAgentsByPaneKey, hasSetupScript }), [ @@ -179,6 +181,7 @@ export function useSetupGuideProgress( detectedOrchestrationSkillInstalled, featureInteractions, agentStatusByPaneKey, + retainedAgentsByPaneKey, gitRepoCount, hasConnectedTaskSource, hasSetupScript, diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index 8fd7c31e335..610d6938301 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -109,6 +109,7 @@ const SidebarHeader = React.memo(function SidebarHeader() { }} aria-label="New workspace" disabled={!canCreateWorkspace} + data-contextual-tour-target="workspace-create-control" > diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index 1c085479e5a..8440c3f00db 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -18,7 +18,8 @@ import type { GlobalSettings } from '../../../../shared/types' import { getTaskPresetQuery, PER_REPO_FETCH_LIMIT } from '@/lib/new-workspace' import { LinearIcon } from '@/components/icons/LinearIcon' import { - getFeatureWallSetupSteps, + FEATURE_WALL_SETUP_PARALLEL_WORK_STEP_IDS, + getFirstIncompleteFeatureWallSetupStepId, type FeatureWallSetupStepId } from '../../../../shared/feature-wall-setup-steps' import { @@ -156,23 +157,28 @@ const SidebarNav = React.memo(function SidebarNav() { // count instead of changing while the lazy modal is mounting. const setupProgress = useSetupGuideProgress(true, false, false) const setupComplete = setupProgress.coreDoneCount >= setupProgress.coreTotal - const firstUnfinishedSetupStepId = React.useMemo(() => { - const firstUnfinished = getFeatureWallSetupSteps().find( - (step) => !setupProgress.stepDone[step.id] - ) - return firstUnfinished?.id ?? 'default-agent' - }, [setupProgress.stepDone]) + const firstUnfinishedSetupStepId = React.useMemo( + () => getFirstIncompleteFeatureWallSetupStepId(setupProgress.stepDone), + [setupProgress.stepDone] + ) + const hasIncompleteParallelWork = FEATURE_WALL_SETUP_PARALLEL_WORK_STEP_IDS.some( + (id) => !setupProgress.stepDone[id] + ) const hideMobileButton = React.useCallback(() => { void updateSettings({ showMobileButton: false }) }, [updateSettings]) return ( -
+
{!setupComplete ? (