Guide monorepo setup with folder workspaces

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-23 18:24:05 -07:00
co-authored by Orca
parent e03a1cf769
commit 47ff726d2c
44 changed files with 1209 additions and 238 deletions
@@ -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 (
+65 -1
View File
@@ -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<string | null>(null)
const saveDialogFile = saveDialogFileId ? openFiles.find((f) => f.id === saveDialogFileId) : null
@@ -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)
@@ -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'])
})
})
@@ -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
}
@@ -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' }
}
@@ -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
}
@@ -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')
})
})
@@ -13,6 +13,8 @@ export function performContextualTourStepAction(args: {
openModal: (modal: 'setup-guide', data?: Record<string, unknown>) => 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()
@@ -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<typeof useAppStore.getState>
)
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<typeof useAppStore.getState>
)
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')
})
})
@@ -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<typeof setTimeout> | 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)
@@ -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())
@@ -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<ContextualTourId, string>
export type UseContextualTourOptions = {
@@ -25,18 +30,23 @@ export type UseContextualTourOptions = {
export function createContextualTourInteractionSnapshot(args: {
id: ContextualTourId
featureInteractions: FeatureInteractionState
recordFeatureInteraction: (id: ContextualTourId) => Promise<void>
recordFeatureInteraction: (id: FeatureInteractionId) => Promise<void>
recordFeatureInteractionForTour: boolean
featureInteractionPersisted?: Promise<void> | undefined
wasFeaturePreviouslyInteracted?: boolean | undefined
}): { persisted: Promise<void>; 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
}
@@ -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<typeof useAppStore.getState>['activeModal'] = 'none'
vi.spyOn(useAppStore, 'getState').mockImplementation(
() =>
({
activeContextualTourId: 'folder-workspace-create-callout',
activeModal,
contextualToursSeenIds: [],
completeContextualTour,
openModal,
setPendingFolderWorkspaceCreateTourGroupId
}) as unknown as ReturnType<typeof useAppStore.getState>
)
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)
})
})
@@ -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'
})
}
@@ -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')
})
})
@@ -21,7 +21,7 @@ type RepoStepProps = {
nestedScanInProgress: boolean
nestedSelectedPaths: Set<string>
onNestedSelectedPathsChange: Dispatch<SetStateAction<Set<string>>>
onImportNested: () => void
onImportNested: (mode: 'group' | 'separate', groupName: string) => void
onCancelNested: () => void
onStopNestedScan: () => void
onOpenFolder: () => void
@@ -172,8 +172,8 @@ export function RepoStep({
</span>
<span>
{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.'
)}
</span>
</div>
@@ -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<string>
onNestedSelectedPathsChange: Dispatch<SetStateAction<Set<string>>>
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 (
<div className="flex h-full min-h-0 min-w-0 flex-col gap-3">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg border border-border bg-muted/30 p-5">
@@ -77,17 +112,7 @@ export function RepoStepNestedImportPanel({
</TooltipContent>
</Tooltip>
) : null}
<span className="min-w-0 truncate">
{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'
}
)}
</span>
<span className="min-w-0 truncate">{nestedScanStatus}</span>
</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{translate('auto.components.onboarding.RepoStep.cecd6593fa', 'Scanned folder:')}{' '}
@@ -110,6 +135,33 @@ export function RepoStepNestedImportPanel({
<NestedRepoScanLimitNotice scan={nestedScan} />
</div>
) : null}
<div className="mt-4 shrink-0 space-y-3 rounded-md border border-border bg-background/60 p-3">
<div className="min-w-0 space-y-1">
<p className="text-sm font-medium text-foreground">
{translate('auto.components.onboarding.RepoStep.fb33359f69', 'Is this a monorepo?')}
</p>
<p className="text-xs leading-5 text-muted-foreground">
{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.'
)}
</p>
</div>
<div className="min-w-0 space-y-1">
<Label htmlFor={groupNameInputId} className="text-[11px] text-muted-foreground">
{translate('auto.components.onboarding.RepoStep.39d51212cc', 'Group name')}
</Label>
<Input
id={groupNameInputId}
aria-label={translate('auto.components.onboarding.RepoStep.39d51212cc', 'Group name')}
value={groupName}
onChange={(event) => setGroupName(event.target.value)}
disabled={nestedImportDisabled}
className="h-9 min-w-0"
placeholder={folderName}
/>
</div>
</div>
<div className="mt-4 flex shrink-0 flex-wrap items-center gap-2">
<button
type="button"
@@ -122,11 +174,21 @@ export function RepoStepNestedImportPanel({
</button>
<button
type="button"
className="ml-auto rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-40"
className="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-border bg-background px-4 py-3 text-sm font-medium text-foreground hover:bg-muted/60 disabled:opacity-40"
disabled={nestedImportDisabled || nestedSelectedPaths.size === 0}
onClick={onImportNested}
onClick={() => handleImport('separate')}
>
{translate('auto.components.onboarding.RepoStep.2d20200346', 'Import repositories')}
{showSeparateSpinner ? <Loader2 className="size-3.5 animate-spin" /> : null}
{translate('auto.components.onboarding.RepoStep.aa0247680d', 'No, import separately')}
</button>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-40"
disabled={nestedImportDisabled || nestedSelectedPaths.size === 0}
onClick={() => handleImport('group')}
>
{showGroupSpinner ? <Loader2 className="size-3.5 animate-spin" /> : null}
{translate('auto.components.onboarding.RepoStep.a0bc4d1f8e', 'Import as group')}
</button>
</div>
</div>
@@ -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) {
@@ -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<HTMLButtonElement>('[aria-label="Refresh PR checks"]')?.disabled
container.querySelector<HTMLButtonElement>('[aria-label="Refresh review checks"]')?.disabled
).toBe(false)
})
mockState.store.fetchHostedReviewForBranch.mockClear()
act(() => {
container.querySelector<HTMLButtonElement>('[aria-label="Refresh PR checks"]')?.click()
container.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[aria-label="Refresh PR checks"]')?.disabled
container.querySelector<HTMLButtonElement>('[aria-label="Refresh review checks"]')?.disabled
).toBe(false)
})
mockState.store.fetchHostedReviewForBranch.mockClear()
@@ -469,12 +488,12 @@ describe('FolderWorkspacePrChecksPanel', () => {
await vi.waitFor(() => {
expect(
container.querySelector<HTMLButtonElement>('[aria-label="Refresh PR checks"]')?.disabled
container.querySelector<HTMLButtonElement>('[aria-label="Refresh review checks"]')?.disabled
).toBe(false)
})
mockState.store.fetchHostedReviewForBranch.mockClear()
act(() => {
container.querySelector<HTMLButtonElement>('[aria-label="Refresh PR checks"]')?.click()
container.querySelector<HTMLButtonElement>('[aria-label="Refresh review checks"]')?.click()
})
await vi.waitFor(() => {
@@ -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({
<div className="flex min-h-0 flex-1 items-center justify-center p-6 text-center text-sm text-muted-foreground">
{translate(
'auto.components.rightSidebar.FolderWorkspacePrChecksPanel.unavailable',
'PR checks are only shown for folder workspaces.'
'Review checks are only shown for folder workspaces.'
)}
</div>
)
@@ -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'
)}
>
<RefreshCw className={cn('size-3.5', isRefreshing && 'animate-spin')} />
@@ -242,7 +243,7 @@ export default function FolderWorkspacePrChecksPanel({
<TooltipContent side="bottom">
{translate(
'auto.components.rightSidebar.FolderWorkspacePrChecksPanel.refresh',
'Refresh PR checks'
'Refresh review checks'
)}
</TooltipContent>
</Tooltip>
@@ -250,7 +251,26 @@ export default function FolderWorkspacePrChecksPanel({
</div>
{childWorktrees.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
<div
className="flex flex-1 flex-col items-center justify-center px-6 text-center"
data-contextual-tour-target="folder-workspace-review-checks-empty"
>
<div className="mb-4 grid w-full max-w-[15rem] gap-2" aria-hidden="true">
{[
[PARENT_PR_CHECKS_GROUP_LABELS.needsAttention, 'bg-rose-500/55'],
[PARENT_PR_CHECKS_GROUP_LABELS.pending, 'bg-amber-500/55'],
[PARENT_PR_CHECKS_GROUP_LABELS.passing, 'bg-emerald-500/55']
].map(([label, dotClass]) => (
<div
key={label}
className="flex items-center gap-2 rounded-md border border-dashed border-border bg-muted/30 px-3 py-2 text-left opacity-55"
>
<div className={cn('size-2 rounded-full', dotClass)} />
<div className="min-w-0 flex-1 truncate text-xs text-muted-foreground">{label}</div>
<div className="h-1.5 w-7 rounded-full bg-muted-foreground/20" />
</div>
))}
</div>
<div className="text-sm font-medium text-foreground">
{translate(
'auto.components.rightSidebar.FolderWorkspacePrChecksPanel.emptyTitle',
@@ -260,7 +280,7 @@ export default function FolderWorkspacePrChecksPanel({
<div className="mt-2 max-w-[16rem] text-xs leading-5 text-muted-foreground">
{translate(
'auto.components.rightSidebar.FolderWorkspacePrChecksPanel.emptyCopy',
'PR checks will appear here after worktrees are attached to this folder workspace.'
'Review checks will appear here after worktrees are attached to this folder workspace.'
)}
</div>
</div>
@@ -239,6 +239,19 @@ describe('FolderWorkspaceWorktreesPanel', () => {
expect(container.textContent).toContain('Workspace-key child')
})
it('renders a targetable illustrative empty preview when no child worktrees are attached', () => {
renderPanel()
expect(
container.querySelector('[data-contextual-tour-target="folder-workspace-attached-empty"]')
).not.toBeNull()
expect(container.textContent).toContain(
'Worktrees created from this workspace will show up here.'
)
expect(container.textContent).toContain('api auth fix')
expect(container.textContent).toContain('web signup flow')
})
it('renders attached child worktrees as affiliate WorktreeCards in recent order', () => {
const oldChild = makeWorktree({
id: 'repo-1::/old',
@@ -145,7 +145,31 @@ export default function FolderWorkspaceWorktreesPanel(): React.JSX.Element {
</div>
{childWorktrees.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
<div
className="flex flex-1 flex-col items-center justify-center px-6 text-center"
data-contextual-tour-target="folder-workspace-attached-empty"
>
<div className="mb-4 w-full max-w-[15rem] space-y-2" aria-hidden="true">
{[
translate(
'auto.components.rightSidebar.FolderWorkspaceWorktreesPanel.previewApiAuthFix',
'api auth fix'
),
translate(
'auto.components.rightSidebar.FolderWorkspaceWorktreesPanel.previewWebSignupFlow',
'web signup flow'
)
].map((label) => (
<div
key={label}
className="flex items-center gap-2 rounded-md border border-dashed border-border bg-muted/30 px-3 py-2 text-left opacity-55"
>
<div className="size-2 rounded-full bg-muted-foreground/40" />
<div className="min-w-0 flex-1 truncate text-xs text-muted-foreground">{label}</div>
<div className="h-1.5 w-8 rounded-full bg-muted-foreground/20" />
</div>
))}
</div>
<div className="text-sm font-medium text-foreground">
{translate(
'auto.components.rightSidebar.FolderWorkspaceWorktreesPanel.emptyTitle',
@@ -25,6 +25,7 @@ export type ActivityBarItem = {
folderOnly?: boolean
/** When true, shown only for worktrees that belong to an SSH repo. */
sshOnly?: boolean
contextualTourTarget?: string
}
const STATUS_DOT_COLOR: Record<CheckStatus, string> = {
@@ -126,6 +127,7 @@ export function ActivityBarButton({
)}
onClick={onClick}
aria-label={item.shortcut ? `${item.title} (${item.shortcut})` : item.title}
data-contextual-tour-target={item.contextualTourTarget}
>
<Icon size={isTop ? 16 : 18} />
@@ -67,6 +67,7 @@ function RightSidebarInner(): React.JSX.Element {
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
const rightSidebarRouteRequestId = useAppStore((s) => s.rightSidebarRouteRequestId)
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
const showRightSidebarFiles = useAppStore((s) => s.showRightSidebarFiles)
const toggleRightSidebar = useAppStore((s) => s.toggleRightSidebar)
const checksStatus = useAppStore((s) => (s.rightSidebarOpen ? getActiveChecksStatus(s) : null))
@@ -107,14 +108,16 @@ function RightSidebarInner(): React.JSX.Element {
'Attached worktrees'
),
shortcut: '',
folderOnly: true
folderOnly: true,
contextualTourTarget: 'folder-workspace-worktrees-tab'
},
{
id: 'pr-checks',
icon: ListChecks,
title: translate('auto.components.right.sidebar.index.parentPrChecks', 'PR Checks'),
title: translate('auto.components.right.sidebar.index.folderReviewChecks', 'Review checks'),
shortcut: '',
folderOnly: true
folderOnly: true,
contextualTourTarget: 'folder-workspace-review-checks-tab'
},
{
id: 'source-control',
@@ -188,6 +191,9 @@ function RightSidebarInner(): React.JSX.Element {
const selectActivityTab = (tab: ActiveRightSidebarTab): void => {
if (activeFolderWorkspaceKey) {
rememberedFolderTabByWorkspaceKeyRef.current[activeFolderWorkspaceKey] = tab
if (tab === 'workspaces' || tab === 'pr-checks') {
void recordFeatureInteraction?.('folder-workspace-right-sidebar')
}
}
if (tab === 'explorer') {
showRightSidebarFiles()
@@ -321,7 +321,9 @@ describe('rendered right sidebar titlebar drag regions', () => {
expect(markup).toContain('aria-label="Agents')
expect(markup).not.toContain('aria-label="Search')
expect(markup).toContain('aria-label="Attached worktrees')
expect(markup).toContain('aria-label="PR Checks')
expect(markup).toContain('aria-label="Review checks')
expect(markup).toContain('data-contextual-tour-target="folder-workspace-worktrees-tab"')
expect(markup).toContain('data-contextual-tour-target="folder-workspace-review-checks-tab"')
expect(markup).not.toContain('aria-label="Source Control')
expect(markup).not.toContain('aria-label="Checks')
})
@@ -362,7 +364,7 @@ describe('rendered right sidebar titlebar drag regions', () => {
await act(async () => {
container
.querySelector<HTMLButtonElement>('[aria-label^="PR Checks"]')
.querySelector<HTMLButtonElement>('[aria-label^="Review checks"]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
@@ -104,8 +104,8 @@ describe('AddRepoDialogStepContent nested imports', () => {
const html = renderNestedStep(0)
expect(html).toContain('Is this a monorepo?')
expect(html).toContain('aria-label="Monorepo name"')
expect(html).toContain('Yes, import as monorepo')
expect(html).toContain('aria-label="Group name"')
expect(html).toContain('Import as group')
expect(html).toContain('No, import separately')
expect(html).not.toContain('>Import</button>')
})
@@ -114,8 +114,8 @@ describe('AddRepoDialogStepContent nested imports', () => {
const html = renderNestedStep(1)
expect(html).toContain('Is this a monorepo?')
expect(html).toContain('aria-label="Monorepo name"')
expect(html).toContain('Yes, import as monorepo')
expect(html).toContain('aria-label="Group name"')
expect(html).toContain('Import as group')
expect(html).toContain('No, import separately')
expect(html).not.toContain('>Import</button>')
})
@@ -78,13 +78,15 @@ describe('AddRepoNestedImportStep', () => {
expect(html).toContain('Import repositories from folder')
expect(html).toContain('Found 3 repositories in')
expect(html).toContain('/workspace/platform')
expect(html).toContain('aria-label="Monorepo name"')
expect(html).toContain('aria-label="Group name"')
expect(html).not.toContain('What is a')
expect(html).toContain('Is this a monorepo?')
expect(html).toContain('Choose this if these projects belong together')
expect(html).toContain(
'Import them as a group if they&#x27;re a monorepo or otherwise belong together'
)
expect(html).toContain('Orca will group them and let you work from the parent folder')
expect(html).toContain('No, import separately')
expect(html).toContain('Yes, import as monorepo')
expect(html).toContain('Import as group')
expect(html).toContain('payments/api')
expect(html).toContain('billing/api')
expect(html).not.toContain('disabled=""')
@@ -97,9 +99,9 @@ describe('AddRepoNestedImportStep', () => {
expect(html).toContain('Is this a monorepo?')
expect(html).toContain('No, import separately')
expect(html).toContain('Yes, import as monorepo')
expect(html).toContain('Import as group')
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>No, import separately<\/button>/)
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Yes, import as monorepo<\/button>/)
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Import as group<\/button>/)
})
it('maps the monorepo choice to grouped import and the non-monorepo choice to separate import', () => {
@@ -130,7 +132,7 @@ describe('AddRepoNestedImportStep', () => {
})
act(() => {
findButton(host, 'Yes, import as monorepo').click()
findButton(host, 'Import as group').click()
findButton(host, 'No, import separately').click()
})
@@ -174,13 +176,11 @@ describe('AddRepoNestedImportStep', () => {
})
act(() => {
findButton(host, 'Yes, import as monorepo').click()
findButton(host, 'Import as group').click()
})
expect(onImport).toHaveBeenCalledWith('group')
expect(
findButton(host, 'Yes, import as monorepo').querySelector('.animate-spin')
).not.toBeNull()
expect(findButton(host, 'Import as group').querySelector('.animate-spin')).not.toBeNull()
expect(findButton(host, 'No, import separately').querySelector('.animate-spin')).toBeNull()
})
})
@@ -110,7 +110,7 @@ export function AddRepoNestedImportStep({
</p>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.d75170194e',
'auto.components.sidebar.AddRepoNestedImportStep.groupImportCopy',
"Import them as a group if they're a monorepo or otherwise belong together. Orca will group them and let you work from the parent folder."
)}
</p>
@@ -119,7 +119,7 @@ export function AddRepoNestedImportStep({
<div className="flex shrink-0 items-center gap-1">
<Label htmlFor={groupNameInputId} className="text-[11px] text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.39d51212cc',
'auto.components.sidebar.AddRepoNestedImportStep.groupNameLabel',
'Group name'
)}
</Label>
@@ -127,7 +127,7 @@ export function AddRepoNestedImportStep({
<Input
id={groupNameInputId}
aria-label={translate(
'auto.components.sidebar.AddRepoNestedImportStep.39d51212cc',
'auto.components.sidebar.AddRepoNestedImportStep.groupNameLabel',
'Group name'
)}
value={groupName}
@@ -155,7 +155,7 @@ export function AddRepoNestedImportStep({
>
{showGroupSpinner ? <Loader2 className="size-3.5 animate-spin" /> : null}
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.a0bc4d1f8e',
'auto.components.sidebar.AddRepoNestedImportStep.importAsGroup',
'Import as group'
)}
</Button>
@@ -240,7 +240,7 @@ import { buildSidebarHostOptions } from './sidebar-host-options'
import { HostSectionHeaderMenu } from './HostSectionHeaderMenu'
import { ProjectHeaderActions } from './ProjectHeaderActions'
import { translate } from '@/i18n/i18n'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides'
import {
isConfirmedStaleFolderPathStatus,
@@ -251,6 +251,7 @@ import {
getKnownSidebarWorktreeById,
sidebarWorkspaceStillExists
} from './worktree-list-folder-reveal'
import { openFolderWorkspaceCreationComposerWithTourHandoff } from '@/components/contextual-tours/workspace-creation-tour-handoff'
import {
getFolderPathStatusRouteOptionsForRows,
getFolderWorkspaceExecutionHostIdForRows,
@@ -594,6 +595,8 @@ type VirtualizedWorktreeViewportProps = {
handleRenameProjectGroup: (groupId: string, currentName: string) => void
handleDeleteProjectGroup: (groupId: string, groupName: string) => void
handleCreateFolderWorkspace: (projectGroup: ProjectGroup) => void
pendingFolderWorkspaceCreateTourGroupId: string | null
activeFolderWorkspaceProjectGroupId: string | null
activeModal: string
pendingRevealWorktree: PendingSidebarWorktreeReveal | null
pendingRevealSidebarRow: PendingSidebarRowReveal | null
@@ -1222,6 +1225,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
handleRenameProjectGroup,
handleDeleteProjectGroup,
handleCreateFolderWorkspace,
pendingFolderWorkspaceCreateTourGroupId,
activeFolderWorkspaceProjectGroupId,
activeModal,
pendingRevealWorktree,
pendingRevealSidebarRow,
@@ -3955,6 +3960,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
projectGroupPathStatus?.exists === false &&
(isConfirmedStaleFolderPathStatus(projectGroupPathStatus) ||
projectGroupPathStatus.reason === 'ambiguous-connection')
const projectGroupId = row.projectGroup?.id ?? null
const isOnboardingFolderWorkspaceCreateTarget =
Boolean(projectGroupId) &&
projectGroupId === pendingFolderWorkspaceCreateTourGroupId &&
!folderWorkspaceCreateDisabled
const isFolderWorkspaceChildCreateTarget =
Boolean(projectGroupId) &&
projectGroupId === activeFolderWorkspaceProjectGroupId &&
!isOnboardingFolderWorkspaceCreateTarget &&
!folderWorkspaceCreateDisabled
const projectGroupDepth = row.projectGroupDepth ?? 0
const isHeaderCollapsed = collapsedGroups.has(row.key)
// Why: repo/project headers already reveal actions on hover; tuck
@@ -4095,7 +4110,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
</div>
</div>
<ProjectHeaderActions>
<ProjectHeaderActions
className={
isOnboardingFolderWorkspaceCreateTarget ||
isFolderWorkspaceChildCreateTarget
? 'pointer-events-auto opacity-100 can-hover:pointer-events-auto can-hover:opacity-100'
: undefined
}
>
{showHeaderCollapseAffordance ? (
<div
className="flex size-5 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/70 hover:text-foreground"
@@ -4193,8 +4215,19 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
variant="ghost"
size="icon-xs"
data-repo-header-action=""
data-contextual-tour-target={
isOnboardingFolderWorkspaceCreateTarget
? 'folder-workspace-create-onboarding'
: isFolderWorkspaceChildCreateTarget
? 'folder-workspace-child-create'
: undefined
}
data-folder-workspace-group-id={row.projectGroup.id}
className={cn(
REPO_HEADER_ACTION_BUTTON_CLASS,
(isOnboardingFolderWorkspaceCreateTarget ||
isFolderWorkspaceChildCreateTarget) &&
'ml-0 max-w-5 opacity-100',
folderWorkspaceCreateDisabled &&
'cursor-not-allowed text-muted-foreground/60 hover:bg-transparent hover:text-muted-foreground/60'
)}
@@ -4913,6 +4946,10 @@ const WorktreeList = React.memo(function WorktreeList({
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeWorkspaceKey = useAppStore((s) => s.activeWorkspaceKey)
const pendingFolderWorkspaceCreateTourGroupId = useAppStore(
(s) => s.pendingFolderWorkspaceCreateTourGroupId
)
const currentSidebarWorktreeId = activeWorktreeId
const groupBy = useAppStore((s) => s.groupBy)
const setGroupBy = useAppStore((s) => s.setGroupBy)
@@ -5333,6 +5370,16 @@ const WorktreeList = React.memo(function WorktreeList({
)
const projectGroups = useAppStore((s) => s.projectGroups ?? EMPTY_PROJECT_GROUPS)
const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
const activeFolderWorkspaceProjectGroupId = useMemo(() => {
const scope = parseWorkspaceKey(activeWorkspaceKey ?? activeWorktreeId ?? '')
if (scope?.type !== 'folder') {
return null
}
return (
folderWorkspaces.find((workspace) => workspace.id === scope.folderWorkspaceId)
?.projectGroupId ?? null
)
}, [activeWorktreeId, activeWorkspaceKey, folderWorkspaces])
const effectiveCollapsedGroups = useMemo(() => {
if (!agentSendTargetWorktreeId) {
return collapsedGroups
@@ -5962,6 +6009,10 @@ const WorktreeList = React.memo(function WorktreeList({
if (!projectGroup.parentPath) {
return
}
if (projectGroup.id === useAppStore.getState().pendingFolderWorkspaceCreateTourGroupId) {
openFolderWorkspaceCreationComposerWithTourHandoff(projectGroup.id)
return
}
openModal('new-workspace-composer', {
initialProjectGroupId: projectGroup.id,
telemetrySource: 'sidebar'
@@ -6401,6 +6452,8 @@ const WorktreeList = React.memo(function WorktreeList({
handleRenameProjectGroup={handleRenameProjectGroup}
handleDeleteProjectGroup={handleDeleteProjectGroup}
handleCreateFolderWorkspace={handleCreateFolderWorkspace}
pendingFolderWorkspaceCreateTourGroupId={pendingFolderWorkspaceCreateTourGroupId}
activeFolderWorkspaceProjectGroupId={activeFolderWorkspaceProjectGroupId}
activeModal={activeModal}
pendingRevealWorktree={pendingRevealWorktree}
pendingRevealSidebarRow={pendingRevealSidebarRow}
+20 -7
View File
@@ -3403,7 +3403,10 @@
"8401a7a0d0": "1 repository",
"d4f1df62ef": "{{value0}} repositories",
"b4263a2ac4": "Found {{value0}} in {{value1}}.",
"24eda6c8b2": "Scanning... {{value0}}"
"24eda6c8b2": "Scanning... {{value0}}",
"groupImportCopy": "Import them as a group if they're a monorepo or otherwise belong together. Orca will group them and let you work from the parent folder.",
"groupNameLabel": "Group name",
"importAsGroup": "Import as group"
},
"AddRepoRemoteStep": {
"5b205b5281": "Stop scan",
@@ -8969,7 +8972,8 @@
"fc3095d2ed": "explorer",
"aiVaultSessionHistory": "Agents",
"folderWorkspaces": "Attached worktrees",
"parentPrChecks": "PR Checks"
"parentPrChecks": "PR Checks",
"folderReviewChecks": "Review checks"
},
"right": {
"panel": {
@@ -9481,7 +9485,14 @@
"c3d9d44ca2": "Stop scan",
"cf23006ba7": "Selected host",
"7ec3f48820": "/home/user",
"2e6438dd34": "{{value0}}Found {{value1}} {{value2}} in this folder."
"220dd32d83": "Scanning...",
"2e6438dd34": "Found {{value0}} {{value1}} in this folder.",
"monorepoParentFolderHint": "Working in a monorepo? Select the folder above your apps/services to import them as a group.",
"fb33359f69": "Is this a monorepo?",
"d75170194e": "Import them as a group if they belong together. Orca will group them and let you work from the parent folder.",
"39d51212cc": "Group name",
"aa0247680d": "No, import separately",
"a0bc4d1f8e": "Import as group"
},
"ThemeStep": {
"a4b254779d": "macOS Option key",
@@ -11644,13 +11655,15 @@
"countOne": "1 attached worktree",
"countMany": "{{value0}} attached worktrees",
"emptyTitle": "No attached worktrees yet",
"emptyCopy": "Worktrees created from this workspace will show up here."
"emptyCopy": "Worktrees created from this workspace will show up here.",
"previewApiAuthFix": "api auth fix",
"previewWebSignupFlow": "web signup flow"
},
"FolderWorkspacePrChecksPanel": {
"unavailable": "PR checks are only shown for folder workspaces.",
"refresh": "Refresh PR checks",
"unavailable": "Review checks are only shown for folder workspaces.",
"refresh": "Refresh review checks",
"emptyTitle": "No attached worktrees yet",
"emptyCopy": "PR checks will appear here after worktrees are attached to this folder workspace.",
"emptyCopy": "Review checks will appear here after worktrees are attached to this folder workspace.",
"openChecksTab": "Open {{value0}} Checks tab",
"openReviewExternally": "Open {{value0}} externally",
"summary": "{{value0}} attached · {{value1}} with PR/MR · {{value2}} attention · {{value3}} pending · {{value4}} passing · {{value5}} no PR · {{value6}} unknown",
+21 -8
View File
@@ -3397,7 +3397,10 @@
"b4263a2ac4": "Se encontraron {{value0}} en {{value1}}.",
"24eda6c8b2": "Explorando... {{value0}}",
"b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.",
"e907ec8935": "What is a monorepo name?"
"e907ec8935": "What is a monorepo name?",
"groupImportCopy": "Impórtalos como grupo si son un monorepo o si pertenecen juntos. Orca los agrupará y te permitirá trabajar desde la carpeta principal.",
"groupNameLabel": "Nombre del grupo",
"importAsGroup": "Importar como grupo"
},
"AddRepoRemoteStep": {
"5b205b5281": "Detener escaneo",
@@ -8969,7 +8972,8 @@
"fc3095d2ed": "explorador",
"aiVaultSessionHistory": "Agents",
"folderWorkspaces": "Árboles de trabajo adjuntos",
"parentPrChecks": "Comprobaciones de PR"
"parentPrChecks": "Comprobaciones de PR",
"folderReviewChecks": "Comprobaciones de revisión"
},
"right": {
"panel": {
@@ -9481,7 +9485,14 @@
"c3d9d44ca2": "Detener escaneo",
"cf23006ba7": "Servidor de ejecución",
"7ec3f48820": "/casa/usuario",
"2e6438dd34": "{{value0}}Encontrado {{value1}} {{value2}} en esta carpeta."
"220dd32d83": "Escaneando...",
"2e6438dd34": "Encontrado {{value0}} {{value1}} en esta carpeta.",
"monorepoParentFolderHint": "¿Trabajas en un monorepo? Selecciona la carpeta que está encima de tus apps/servicios para importarlas como grupo.",
"fb33359f69": "¿Es esto un monorepo?",
"d75170194e": "Impórtalos como grupo si pertenecen juntos. Orca los agrupará y te permitirá trabajar desde la carpeta principal.",
"39d51212cc": "Nombre del grupo",
"aa0247680d": "No, importar por separado",
"a0bc4d1f8e": "Importar como grupo"
},
"ThemeStep": {
"a4b254779d": "Tecla de opción de macOS",
@@ -10431,7 +10442,7 @@
"21783df79f": "Contraer árbol de archivos",
"481e63ca52": "Archivos",
"d5ac717d65": "no comprometido",
"39b6b9e4e4": "Committed on Branch"
"39b6b9e4e4": "Confirmado en la rama"
},
"CombinedDiffViewer": {
"35cc27aeb2": "en control de fuente",
@@ -11644,13 +11655,15 @@
"countOne": "1 attached worktree",
"countMany": "{{value0}} attached worktrees",
"emptyTitle": "No attached worktrees yet",
"emptyCopy": "Worktrees created from this workspace will show up here."
"emptyCopy": "Worktrees created from this workspace will show up here.",
"previewApiAuthFix": "corrección de autenticación de API",
"previewWebSignupFlow": "flujo de registro web"
},
"FolderWorkspacePrChecksPanel": {
"unavailable": "Las comprobaciones de PR solo se muestran en espacios de trabajo de carpeta.",
"refresh": "Actualizar comprobaciones de PR",
"unavailable": "Las comprobaciones de revisión solo se muestran en espacios de trabajo de carpeta.",
"refresh": "Actualizar comprobaciones de revisión",
"emptyTitle": "Aún no hay árboles de trabajo adjuntos",
"emptyCopy": "Las comprobaciones de PR aparecerán aquí cuando se adjunten árboles de trabajo a este espacio de trabajo de carpeta.",
"emptyCopy": "Las comprobaciones de revisión aparecerán aquí cuando se adjunten árboles de trabajo a este espacio de trabajo de carpeta.",
"openChecksTab": "Abrir la pestaña Comprobaciones de {{value0}}",
"openReviewExternally": "Abrir {{value0}} externamente",
"summary": "{{value0}} adjuntos · {{value1}} con PR/MR · {{value2}} requieren atención · {{value3}} pendientes · {{value4}} correctos · {{value5}} sin PR · {{value6}} desconocidos",
+21 -8
View File
@@ -3378,7 +3378,10 @@
"b4263a2ac4": "{{value1}} で {{value0}} が見つかりました。",
"24eda6c8b2": "スキャン中... {{value0}}",
"b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.",
"e907ec8935": "What is a monorepo name?"
"e907ec8935": "What is a monorepo name?",
"groupImportCopy": "モノレポ、または一緒に扱うべきプロジェクトの場合は、グループとしてインポートします。Orca がグループ化し、親フォルダーから作業できるようにします。",
"groupNameLabel": "グループ名",
"importAsGroup": "グループとしてインポート"
},
"AddRepoRemoteStep": {
"5b205b5281": "スキャンの停止",
@@ -8969,7 +8972,8 @@
"fc3095d2ed": "エクスプローラ",
"aiVaultSessionHistory": "Agents",
"folderWorkspaces": "添付ワークツリー",
"parentPrChecks": "PR チェック"
"parentPrChecks": "PR チェック",
"folderReviewChecks": "レビューチェック"
},
"right": {
"panel": {
@@ -9481,7 +9485,14 @@
"c3d9d44ca2": "スキャンの停止",
"cf23006ba7": "ランタイムサーバー",
"7ec3f48820": "/home/user",
"2e6438dd34": "{{value0}}このフォルダー内で {{value1}} {{value2}} が見つかりました。"
"220dd32d83": "スキャン中...",
"2e6438dd34": "このフォルダー内で {{value0}} {{value1}} が見つかりました。",
"monorepoParentFolderHint": "モノレポで作業していますか?アプリ/サービスの上位フォルダーを選択して、グループとしてインポートします。",
"fb33359f69": "これはモノレポですか?",
"d75170194e": "関連するプロジェクトの場合はグループとしてインポートします。Orca がグループ化し、親フォルダーから作業できるようにします。",
"39d51212cc": "グループ名",
"aa0247680d": "いいえ、個別にインポート",
"a0bc4d1f8e": "グループとしてインポート"
},
"ThemeStep": {
"a4b254779d": "macOSのオプションキー",
@@ -10431,7 +10442,7 @@
"21783df79f": "ファイルツリーを折りたたむ",
"481e63ca52": "ファイル",
"d5ac717d65": "commit されていない",
"39b6b9e4e4": "Committed on Branch"
"39b6b9e4e4": "ブランチにコミット済み"
},
"CombinedDiffViewer": {
"35cc27aeb2": "ソース管理内",
@@ -11644,13 +11655,15 @@
"countOne": "1 attached worktree",
"countMany": "{{value0}} attached worktrees",
"emptyTitle": "No attached worktrees yet",
"emptyCopy": "Worktrees created from this workspace will show up here."
"emptyCopy": "Worktrees created from this workspace will show up here.",
"previewApiAuthFix": "API 認証修正",
"previewWebSignupFlow": "Web サインアップフロー"
},
"FolderWorkspacePrChecksPanel": {
"unavailable": "PR チェックはフォルダーワークスペースでのみ表示されます。",
"refresh": "PR チェックを更新",
"unavailable": "レビューチェックはフォルダーワークスペースでのみ表示されます。",
"refresh": "レビューチェックを更新",
"emptyTitle": "添付ワークツリーはまだありません",
"emptyCopy": "このフォルダーワークスペースにワークツリーを添付すると、ここに PR チェックが表示されます。",
"emptyCopy": "このフォルダーワークスペースにワークツリーを添付すると、ここにレビューチェックが表示されます。",
"openChecksTab": "{{value0}} のチェックタブを開く",
"openReviewExternally": "{{value0}} を外部で開く",
"summary": "{{value0}} 件添付 · PR/MR あり {{value1}} 件 · 要対応 {{value2}} 件 · 保留中 {{value3}} 件 · 成功 {{value4}} 件 · PR なし {{value5}} 件 · 不明 {{value6}} 件",
+21 -8
View File
@@ -3378,7 +3378,10 @@
"b4263a2ac4": "{{value1}}에서 {{value0}}을(를) 찾았습니다.",
"24eda6c8b2": "스캔 중... {{value0}}",
"b20bb7c24f": "이 리포지토리들을 하나의 그룹으로 묶어 둡니다. 마이크로서비스처럼 서로 관련된 리포지토리에 적합합니다.",
"e907ec8935": "모노 repo 이름이란?"
"e907ec8935": "모노 repo 이름이란?",
"groupImportCopy": "모노레포이거나 함께 속한 프로젝트라면 그룹으로 가져오세요. Orca가 그룹으로 묶고 상위 폴더에서 작업할 수 있게 합니다.",
"groupNameLabel": "그룹 이름",
"importAsGroup": "그룹으로 가져오기"
},
"AddRepoRemoteStep": {
"5b205b5281": "스캔 중지",
@@ -8969,7 +8972,8 @@
"fc3095d2ed": "탐색기",
"aiVaultSessionHistory": "Agents",
"folderWorkspaces": "연결된 워크트리",
"parentPrChecks": "PR 검사"
"parentPrChecks": "PR 검사",
"folderReviewChecks": "리뷰 검사"
},
"right": {
"panel": {
@@ -9481,7 +9485,14 @@
"c3d9d44ca2": "스캔 중지",
"cf23006ba7": "선택한 호스트",
"7ec3f48820": "/home/user",
"2e6438dd34": "{{value0}}이 폴더에서 {{value1}} {{value2}}을(를) 찾았습니다."
"220dd32d83": "스캔 중...",
"2e6438dd34": "이 폴더에서 {{value0}} {{value1}}을(를) 찾았습니다.",
"monorepoParentFolderHint": "모노레포에서 작업 중인가요? 앱/서비스 위의 폴더를 선택해 그룹으로 가져오세요.",
"fb33359f69": "이 폴더가 모노레포인가요?",
"d75170194e": "함께 속한 프로젝트라면 그룹으로 가져오세요. Orca가 그룹으로 묶고 상위 폴더에서 작업할 수 있게 합니다.",
"39d51212cc": "그룹 이름",
"aa0247680d": "아니요, 별도로 가져오기",
"a0bc4d1f8e": "그룹으로 가져오기"
},
"ThemeStep": {
"a4b254779d": "macOS 옵션 키",
@@ -10431,7 +10442,7 @@
"21783df79f": "파일 트리 축소",
"481e63ca52": "파일",
"d5ac717d65": "commit 안 됨",
"39b6b9e4e4": "Committed on Branch"
"39b6b9e4e4": "브랜치에 커밋됨"
},
"CombinedDiffViewer": {
"35cc27aeb2": "소스 제어에서",
@@ -11644,13 +11655,15 @@
"countOne": "연결된 워크트리 1개",
"countMany": "연결된 워크트리 {{value0}}개",
"emptyTitle": "아직 연결된 워크트리가 없습니다",
"emptyCopy": "이 워크스페이스에서 만든 워크트리가 여기에 표시됩니다."
"emptyCopy": "이 워크스페이스에서 만든 워크트리가 여기에 표시됩니다.",
"previewApiAuthFix": "API 인증 수정",
"previewWebSignupFlow": "웹 가입 흐름"
},
"FolderWorkspacePrChecksPanel": {
"unavailable": "PR 검사는 폴더 워크스페이스에서만 표시됩니다.",
"refresh": "PR 체크 새로고침",
"unavailable": "리뷰 검사는 폴더 워크스페이스에서만 표시됩니다.",
"refresh": "리뷰 검사 새로 고침",
"emptyTitle": "아직 연결된 워크트리가 없습니다",
"emptyCopy": "이 폴더 워크스페이스에 워크트리가 연결되면 PR 검사가 여기에 표시됩니다.",
"emptyCopy": "이 폴더 워크스페이스에 워크트리가 연결되면 리뷰 검사가 여기에 표시됩니다.",
"openChecksTab": "{{value0}} 검사 탭 열기",
"openReviewExternally": "{{value0}} 외부에서 열기",
"summary": "{{value0}}개 연결됨 · PR/MR 있음 {{value1}}개 · 주의 필요 {{value2}}개 · 대기 중 {{value3}}개 · 통과 {{value4}}개 · PR 없음 {{value5}}개 · 알 수 없음 {{value6}}개",
+23 -9
View File
@@ -1102,7 +1102,8 @@
"importHostSetup": "导入",
"notePasteTooLarge": "笔记字段的粘贴内容过大。",
"reuseExistingBranch": "复用分支",
"reuseExistingBranchHint": "检出已有分支,而不是基于它创建新分支。"
"reuseExistingBranchHint": "检出已有分支,而不是基于它创建新分支。",
"createMultiple": "创建更多"
},
"NewWorkspaceComposerModal": {
"fa90f739a5": "创建工作区之前选择项目、工作区名称和 Agent。"
@@ -3377,7 +3378,10 @@
"b4263a2ac4": "在 {{value1}} 中找到 {{value0}}。",
"24eda6c8b2": "正在扫描... {{value0}}",
"b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.",
"e907ec8935": "What is a monorepo name?"
"e907ec8935": "What is a monorepo name?",
"groupImportCopy": "如果它们是 Monorepo,或本来就应该放在一起,请将它们作为组导入。Orca 会将它们分组,并让你从父文件夹开始工作。",
"groupNameLabel": "组名称",
"importAsGroup": "作为组导入"
},
"AddRepoRemoteStep": {
"5b205b5281": "停止扫描",
@@ -8968,7 +8972,8 @@
"fc3095d2ed": "探险家",
"aiVaultSessionHistory": "Agents",
"folderWorkspaces": "已附加的工作树",
"parentPrChecks": "PR 检查"
"parentPrChecks": "PR 检查",
"folderReviewChecks": "评审检查"
},
"right": {
"panel": {
@@ -9480,7 +9485,14 @@
"c3d9d44ca2": "停止扫描",
"cf23006ba7": "运行时服务器",
"7ec3f48820": "/home/user",
"2e6438dd34": "{{value0}}在此文件夹中找到{{value1}} {{value2}}。"
"220dd32d83": "正在扫描...",
"2e6438dd34": "在此文件夹中找到 {{value0}} {{value1}}。",
"monorepoParentFolderHint": "正在使用 Monorepo?请选择 apps/services 上一级的文件夹,将它们作为组导入。",
"fb33359f69": "这是 Monorepo 吗?",
"d75170194e": "如果这些项目属于同一组,请将它们作为组导入。Orca 会将它们分组,并让你从父文件夹开始工作。",
"39d51212cc": "组名称",
"aa0247680d": "否,单独导入",
"a0bc4d1f8e": "作为组导入"
},
"ThemeStep": {
"a4b254779d": "macOS Option 键",
@@ -10430,7 +10442,7 @@
"21783df79f": "折叠文件树",
"481e63ca52": "文件",
"d5ac717d65": "未提交",
"39b6b9e4e4": "Committed on Branch"
"39b6b9e4e4": "已在分支上提交"
},
"CombinedDiffViewer": {
"35cc27aeb2": "在源代码控制中",
@@ -11643,13 +11655,15 @@
"countOne": "1 个附加的工作树",
"countMany": "{{value0}} 个附加的工作树",
"emptyTitle": "还没有附加的工作树",
"emptyCopy": "从此工作区创建的工作树将显示在这里。"
"emptyCopy": "从此工作区创建的工作树将显示在这里。",
"previewApiAuthFix": "API 认证修复",
"previewWebSignupFlow": "网页注册流程"
},
"FolderWorkspacePrChecksPanel": {
"unavailable": "PR 检查仅在文件夹工作区中显示。",
"refresh": "刷新 PR 检查",
"unavailable": "评审检查仅在文件夹工作区中显示。",
"refresh": "刷新评审检查",
"emptyTitle": "还没有附加的工作树",
"emptyCopy": "将工作树附加到此文件夹工作区后,PR 检查会显示在这里。",
"emptyCopy": "将工作树附加到此文件夹工作区后,评审检查会显示在这里。",
"openChecksTab": "打开 {{value0}} 的检查标签页",
"openReviewExternally": "在外部打开 {{value0}}",
"summary": "已附加 {{value0}} 个 · 有 PR/MR {{value1}} 个 · 需注意 {{value2}} 个 · 等待中 {{value3}} 个 · 通过 {{value4}} 个 · 无 PR {{value5}} 个 · 未知 {{value6}} 个",
+13
View File
@@ -2599,6 +2599,19 @@ describe('createUISlice contextual tours', () => {
expect(store.getState().activeContextualTourWasFeaturePreviouslyInteracted).toBe(true)
})
it('uses mapped feature interactions for contextual tour telemetry snapshots', () => {
const store = createUIStore()
const overviewFirstSelector =
'[data-contextual-tour-target="folder-workspace-worktrees-tab"], [data-contextual-tour-target="folder-workspace-attached-empty"]'
stubContextualTourTargets([overviewFirstSelector])
store.getState().hydratePersistedUI(makeAutoTourEligibleUI())
store.getState().recordFeatureInteraction('folder-workspace-right-sidebar')
store.getState().requestContextualTour('folder-workspace-overview', 'folder_workspace_active')
expect(store.getState().activeContextualTourWasFeaturePreviouslyInteracted).toBe(true)
})
it('lets the caller preserve the pre-enable interaction snapshot for telemetry', () => {
const store = createUIStore()
const tasksFirstSelector = '[data-contextual-tour-target="tasks-source-filters"]'
+52 -7
View File
@@ -47,8 +47,10 @@ import {
import {
getContextualTour,
normalizeContextualTourIds,
type ContextualTourStep,
type ContextualTourId
} from '../../../../shared/contextual-tours'
import { getFeatureInteractionIdForContextualTour } from '../../../../shared/contextual-tour-feature-interactions'
import { PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items'
import {
normalizeVisibleTaskProviders,
@@ -109,6 +111,7 @@ import {
import { buildAgentNotificationId } from '../../../../shared/agent-notification-id'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { translate } from '@/i18n/i18n'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
export type PendingSidebarWorktreeReveal = {
worktreeId: string
@@ -175,6 +178,27 @@ function mergeFeatureInteractionState(
return merged
}
function hasFeatureInteractionForContextualTour(
state: FeatureInteractionState,
id: ContextualTourId
): boolean {
const featureInteractionId = getFeatureInteractionIdForContextualTour(id)
return featureInteractionId ? hasFeatureInteraction(state, featureInteractionId) : false
}
function prepareContextualTourStepWithState(step: ContextualTourStep, state: AppState): void {
const action = step.preStepAction
if (action?.kind !== 'show-folder-sidebar-tab') {
return
}
const scope = parseWorkspaceKey(state.activeWorkspaceKey ?? state.activeWorktreeId ?? '')
if (scope?.type !== 'folder') {
return
}
state.setRightSidebarOpen(true)
state.setRightSidebarTab(action.tab)
}
function mergeContextualTourSeenIds(
current: readonly ContextualTourId[],
incoming: PersistedUIState['contextualToursSeenIds']
@@ -740,6 +764,8 @@ export type UISlice = {
contextualToursOnboardingVisible: boolean
contextualToursBlockingSurfaceVisible: boolean
lastCompletedContextualTourId: ContextualTourId | null
pendingFolderWorkspaceCreateTourGroupId: string | null
setPendingFolderWorkspaceCreateTourGroupId: (groupId: string | null) => void
setContextualToursAutoEligible: (eligible: boolean) => void
setContextualToursOnboardingVisible: (visible: boolean) => void
setContextualToursBlockingSurfaceVisible: (visible: boolean) => void
@@ -1541,6 +1567,13 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
contextualToursOnboardingVisible: false,
contextualToursBlockingSurfaceVisible: false,
lastCompletedContextualTourId: null,
pendingFolderWorkspaceCreateTourGroupId: null,
setPendingFolderWorkspaceCreateTourGroupId: (groupId) =>
set((s) =>
s.pendingFolderWorkspaceCreateTourGroupId === groupId
? s
: { pendingFolderWorkspaceCreateTourGroupId: groupId }
),
setContextualToursAutoEligible: (eligible) =>
set((s) => {
if (s.contextualToursAutoEligible === eligible) {
@@ -1576,7 +1609,8 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
activeTourId: s.activeContextualTourId,
activeModal: s.activeModal,
blockingSurfaceVisible: s.contextualToursBlockingSurfaceVisible,
targetExists: hasContextualTourTarget
targetExists: hasContextualTourTarget,
prepareStep: (step) => prepareContextualTourStepWithState(step, get())
})
if (decision.kind !== 'start') {
if (s.contextualTourNavigationInteractionSnapshot[id] === undefined) {
@@ -1599,7 +1633,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
activeContextualTourWasFeaturePreviouslyInteracted:
wasFeaturePreviouslyInteracted ??
navigationSnapshot ??
hasFeatureInteraction(s.featureInteractions, id),
hasFeatureInteractionForContextualTour(s.featureInteractions, id),
contextualTourNavigationInteractionSnapshot: remainingNavigationSnapshot,
activeContextualTourSuppressed: false,
contextualTourShownThisSession: true,
@@ -1633,7 +1667,8 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
const nextStepIndex = getNextVisibleContextualTourStepIndex({
tour,
currentStepIndex: s.activeContextualTourStepIndex,
targetExists: hasContextualTourTarget
targetExists: hasContextualTourTarget,
prepareStep: (step) => prepareContextualTourStepWithState(step, get())
})
if (nextStepIndex !== null) {
return { activeContextualTourStepIndex: nextStepIndex }
@@ -1655,7 +1690,8 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
const previousStepIndex = getPreviousVisibleContextualTourStepIndex({
tour: getContextualTour(s.activeContextualTourId),
currentStepIndex: s.activeContextualTourStepIndex,
targetExists: hasContextualTourTarget
targetExists: hasContextualTourTarget,
prepareStep: (step) => prepareContextualTourStepWithState(step, get())
})
if (previousStepIndex === null) {
return s
@@ -1682,7 +1718,10 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
activeContextualTourSourceDetached: false,
activeContextualTourWasFeaturePreviouslyInteracted: false,
activeContextualTourSuppressed: false,
lastCompletedContextualTourId: null
lastCompletedContextualTourId: null,
...(tourId === 'folder-workspace-create-callout'
? { pendingFolderWorkspaceCreateTourGroupId: null }
: {})
}
})
},
@@ -1706,7 +1745,10 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
activeContextualTourSourceDetached: false,
activeContextualTourWasFeaturePreviouslyInteracted: false,
activeContextualTourSuppressed: false,
lastCompletedContextualTourId: tourId ?? null
lastCompletedContextualTourId: tourId ?? null,
...(tourId === 'folder-workspace-create-callout'
? { pendingFolderWorkspaceCreateTourGroupId: null }
: {})
}
})
},
@@ -1726,7 +1768,10 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
activeContextualTourWasFeaturePreviouslyInteracted: false,
activeContextualTourSuppressed: false,
lastCompletedContextualTourId: null,
contextualTourShownThisSession: alreadyShown ? s.contextualTourShownThisSession : false
contextualTourShownThisSession: alreadyShown ? s.contextualTourShownThisSession : false,
...(tourId === 'folder-workspace-create-callout'
? { pendingFolderWorkspaceCreateTourGroupId: null }
: {})
}
}),
markContextualToursSeen: (ids) =>
@@ -0,0 +1,21 @@
import type { ContextualTourId } from './contextual-tours'
import type { FeatureInteractionId } from './feature-interactions'
export const CONTEXTUAL_TOUR_FEATURE_INTERACTION_BY_ID = {
'workspace-board': 'workspace-board',
'workspace-agent-sessions': 'workspace-agent-sessions',
browser: 'browser',
tasks: 'tasks',
automations: 'automations',
'floating-workspace': 'floating-workspace',
'workspace-creation': 'workspace-creation',
'folder-workspace-create-callout': null,
'folder-workspace-creation': 'folder-workspace-creation',
'folder-workspace-overview': 'folder-workspace-right-sidebar'
} as const satisfies Record<ContextualTourId, FeatureInteractionId | null>
export function getFeatureInteractionIdForContextualTour(
id: ContextualTourId
): FeatureInteractionId | null {
return CONTEXTUAL_TOUR_FEATURE_INTERACTION_BY_ID[id]
}
+21 -7
View File
@@ -15,7 +15,10 @@ describe('contextual tour definitions', () => {
'tasks',
'automations',
'floating-workspace',
'workspace-creation'
'workspace-creation',
'folder-workspace-create-callout',
'folder-workspace-creation',
'folder-workspace-overview'
]
expect(CONTEXTUAL_TOURS.map((tour) => tour.id)).toEqual(expectedIds)
@@ -23,9 +26,8 @@ describe('contextual tour definitions', () => {
expect(tour.steps[0]?.requiredForStart).toBe(true)
const stepCount = (tour.steps as readonly unknown[]).length
if (stepCount === 1) {
expect(
(tour.steps[0] as ContextualTour['steps'][number]).advanceOnFeatureInteraction
).toBeTruthy()
const step = tour.steps[0] as ContextualTour['steps'][number]
expect(step.advanceOnFeatureInteraction ?? step.primaryAction).toBeTruthy()
} else {
expect(stepCount).toBeGreaterThanOrEqual(2)
}
@@ -185,8 +187,13 @@ describe('contextual tour definitions', () => {
(tour) => tour.allowedActiveModals?.length
)
expect(modalTours.map((tour) => tour.id)).toEqual(['workspace-creation'])
expect(modalTours[0]?.allowedActiveModals).toEqual(['new-workspace-composer'])
expect(modalTours.map((tour) => tour.id)).toEqual([
'workspace-creation',
'folder-workspace-creation'
])
for (const tour of modalTours) {
expect(tour.allowedActiveModals).toEqual(['new-workspace-composer'])
}
})
it('normalizes persisted ids by removing unknowns and duplicates', () => {
@@ -197,9 +204,16 @@ describe('contextual tour definitions', () => {
'workspace-agent-sessions',
'browser',
'tasks',
'folder-workspace-create-callout',
null,
'workspace-creation'
])
).toEqual(['tasks', 'workspace-agent-sessions', 'browser', 'workspace-creation'])
).toEqual([
'tasks',
'workspace-agent-sessions',
'browser',
'folder-workspace-create-callout',
'workspace-creation'
])
})
})
+80
View File
@@ -8,6 +8,9 @@ export type ContextualTourId =
| 'automations'
| 'floating-workspace'
| 'workspace-creation'
| 'folder-workspace-create-callout'
| 'folder-workspace-creation'
| 'folder-workspace-overview'
export type ContextualTourStepControl = {
kind: 'auto-rename-branch-from-work'
@@ -21,6 +24,12 @@ export type ContextualTourStepActionKind =
| 'show-worktrees'
| 'open-tasks'
| 'open-getting-started'
| 'create-folder-workspace'
export type ContextualTourStepPreAction = {
kind: 'show-folder-sidebar-tab'
tab: 'workspaces' | 'pr-checks'
}
export type ContextualTourStepAction = {
kind: ContextualTourStepActionKind
@@ -42,6 +51,7 @@ export type ContextualTourStep = {
primaryAction?: ContextualTourStepAction
secondaryAction?: ContextualTourStepAction
advanceOnFeatureInteraction?: FeatureInteractionId
preStepAction?: ContextualTourStepPreAction
}
export type ContextualTour = {
@@ -202,6 +212,76 @@ export const CONTEXTUAL_TOURS = [
targetSelector: '[data-contextual-tour-target="workspace-creation-agent"]'
}
]
},
{
id: 'folder-workspace-create-callout',
steps: [
{
title: 'Create a folder workspace',
body: 'Run an agent from the parent folder when a task spans multiple repos.',
targetSelector: '[data-contextual-tour-target="folder-workspace-create-onboarding"]',
requiredForStart: true,
preferredPlacement: 'right',
primaryAction: { kind: 'create-folder-workspace', label: 'Create workspace' }
}
]
},
{
id: 'folder-workspace-creation',
allowedActiveModals: ['new-workspace-composer'],
steps: [
{
title: 'Pick the folder group',
body: 'Choose the parent folder that this workspace should run from.',
targetSelector: '[data-contextual-tour-target="workspace-creation-project"]',
requiredForStart: true
},
{
title: 'Name the parent workspace',
body: 'Give this parent workspace a short name for the task spanning these repos.',
targetSelector: '[data-contextual-tour-target="workspace-creation-name"]'
},
{
title: 'Choose what agent starts in the parent folder',
body: 'Pick the agent that should open in the parent folder workspace.',
targetSelector: '[data-contextual-tour-target="workspace-creation-agent"]'
}
]
},
{
id: 'folder-workspace-overview',
steps: [
{
title: 'Attached worktrees',
body: 'Worktrees created from this parent workspace appear here.',
targetSelector:
'[data-contextual-tour-target="folder-workspace-worktrees-tab"], [data-contextual-tour-target="folder-workspace-attached-empty"]',
requiredForStart: true,
preferredPlacement: 'left',
// Why: these targets live behind right-sidebar tabs; pre-route before
// measuring so the overlay never attaches to a hidden tab panel.
preStepAction: { kind: 'show-folder-sidebar-tab', tab: 'workspaces' },
primaryAction: { kind: 'next', label: 'Next' }
},
{
title: 'Review checks',
body: 'Once attached worktrees have PRs or MRs, this rolls up failing, pending, and passing checks across repos.',
targetSelector:
'[data-contextual-tour-target="folder-workspace-review-checks-tab"], [data-contextual-tour-target="folder-workspace-review-checks-empty"]',
preferredPlacement: 'left',
// Why: these targets live behind right-sidebar tabs; pre-route before
// measuring so the overlay never attaches to a hidden tab panel.
preStepAction: { kind: 'show-folder-sidebar-tab', tab: 'pr-checks' },
primaryAction: { kind: 'next', label: 'Next' }
},
{
title: 'Start a child workspace',
body: 'Creating from the parent workspace keeps related child work attached.',
targetSelector: '[data-contextual-tour-target="folder-workspace-child-create"]',
preferredPlacement: 'right',
primaryAction: { kind: 'complete', label: 'Done' }
}
]
}
] as const satisfies readonly ContextualTour[]
+10 -1
View File
@@ -7,7 +7,10 @@ export const FEATURE_EDUCATION_CONTEXTUAL_TOUR_IDS = [
'tasks',
'automations',
'floating-workspace',
'workspace-creation'
'workspace-creation',
'folder-workspace-create-callout',
'folder-workspace-creation',
'folder-workspace-overview'
] as const satisfies readonly ContextualTourId[]
export const FEATURE_EDUCATION_SOURCES = [
@@ -19,6 +22,12 @@ export const FEATURE_EDUCATION_SOURCES = [
'floating_workspace_visible',
'workspace_creation_visible',
'workspace_creation_modal',
'folder_workspace_create_onboarding',
'onboarding_group_import',
'folder_workspace_creation_visible',
'folder_workspace_creation_modal',
'folder_workspace_overview_visible',
'folder_workspace_active',
'setup_guide_parallel_work',
'unknown'
] as const
@@ -23,6 +23,8 @@ export type FeatureInteractionId =
| 'browser-grab'
| 'markdown-file-created'
| 'workspace-creation'
| 'folder-workspace-creation'
| 'folder-workspace-right-sidebar'
| 'agent-browser-setup'
| 'agent-browser-use'
| 'agent-orchestration-setup'
@@ -94,6 +96,11 @@ export const FEATURE_INTERACTIONS = [
{ id: 'browser-grab', interaction: 'browser element grab or screenshot used' },
{ id: 'markdown-file-created', interaction: 'untitled markdown file explicitly created' },
{ id: 'workspace-creation', interaction: 'workspace creation flow opened' },
{ id: 'folder-workspace-creation', interaction: 'folder workspace creation flow opened' },
{
id: 'folder-workspace-right-sidebar',
interaction: 'folder workspace right-sidebar education opened'
},
{ id: 'agent-browser-setup', interaction: 'Agent Browser Use setup enabled or opened' },
{ id: 'agent-browser-use', interaction: 'agent browser runtime method used' },
{
@@ -44,6 +44,8 @@ export const FEATURE_INTERACTION_CATEGORY_BY_ID = {
'browser-grab': 'browser',
'markdown-file-created': 'notes',
'workspace-creation': 'workspace',
'folder-workspace-creation': 'workspace',
'folder-workspace-right-sidebar': 'workspace',
'agent-browser-setup': 'setup',
'agent-browser-use': 'agent',
'agent-orchestration-setup': 'setup',
+19 -2
View File
@@ -1,6 +1,7 @@
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, relative } from 'node:path'
import { describe, expect, it } from 'vitest'
import { CONTEXTUAL_TOUR_FEATURE_INTERACTION_BY_ID } from './contextual-tour-feature-interactions'
import {
FEATURE_INTERACTIONS,
FEATURE_INTERACTION_CATEGORIES,
@@ -55,6 +56,8 @@ describe('feature interactions', () => {
'browser-grab',
'markdown-file-created',
'workspace-creation',
'folder-workspace-creation',
'folder-workspace-right-sidebar',
'agent-browser-setup',
'agent-browser-use',
'agent-orchestration-setup',
@@ -199,6 +202,19 @@ describe('feature interactions', () => {
it('keeps every catalog id wired to a production writer', () => {
const productionText = collectProductionSourceText()
const mappedFeatureInteractionIds = new Set<FeatureInteractionId>()
for (const [tourId, featureInteractionId] of Object.entries(
CONTEXTUAL_TOUR_FEATURE_INTERACTION_BY_ID
)) {
// Why: keep tour-id detection near runtime request/use sites so the
// catalog mapping cannot satisfy the wiring test by mentioning itself.
const requestedByRuntimePath = new RegExp(
`\\b(?:requestContextualTourWhenReady|requestContextualTour|useContextualTour)\\b[\\s\\S]{0,500}['"]${escapeRegExp(tourId)}['"]`
)
if (featureInteractionId && requestedByRuntimePath.test(productionText)) {
mappedFeatureInteractionIds.add(featureInteractionId)
}
}
const missingWriters = FEATURE_INTERACTIONS.map((feature) => feature.id).filter((id) => {
const escaped = escapeRegExp(id)
const directRecord = new RegExp(
@@ -209,12 +225,13 @@ describe('feature interactions', () => {
return (
!directRecord.test(productionText) &&
!contextualTourRecord.test(productionText) &&
!runtimeMappingReturn.test(productionText)
!runtimeMappingReturn.test(productionText) &&
!mappedFeatureInteractionIds.has(id)
)
})
expect(missingWriters).toEqual([])
}, 15_000)
}, 45_000)
})
function collectProductionSourceText(): string {