mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
Guide monorepo setup with folder workspaces
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
|
||||
+58
@@ -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
|
||||
}
|
||||
|
||||
+52
-1
@@ -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()
|
||||
|
||||
+4
-2
@@ -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'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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}} 件",
|
||||
|
||||
@@ -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}}개",
|
||||
|
||||
@@ -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}} 个",
|
||||
|
||||
@@ -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"]'
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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[]
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user