diff --git a/docs/reference/feature-education-retention-analytics.md b/docs/reference/feature-education-retention-analytics.md index 1478ab46fb6..29204bb5016 100644 --- a/docs/reference/feature-education-retention-analytics.md +++ b/docs/reference/feature-education-retention-analytics.md @@ -32,7 +32,7 @@ Emitted once when a contextual tour first renders a measured target. Payload: -- `tour_id`: `workspace-board`, `browser`, `tasks`, or `automations` +- `tour_id`: `workspace-board`, `browser`, `tasks`, `automations`, or `workspace-creation` - `source`: bounded source enum from `src/shared/feature-education-telemetry.ts` - `was_feature_previously_interacted`: boolean from local education state at the moment the tour is shown @@ -123,6 +123,7 @@ Question: Do users later use the feature taught by the tour? Build funnels from tour events to real product events: - `tasks`: `contextual_tour_outcome` -> workspace creation from task-linked flows where existing telemetry exposes the source; if the current event is insufficient, add a targeted task-started-workspace event. +- `workspace-creation`: `contextual_tour_outcome` -> `workspace_created` - `browser`: `contextual_tour_outcome` -> browser grab/annotation events. If these do not exist, add targeted events for the concrete actions, not a broad feature-interaction event. - `automations`: `contextual_tour_outcome` -> automation creation/run events. If these do not exist, add targeted events for create/run. - `workspace-board`: `contextual_tour_outcome` -> board-specific actions such as card moved/status changed. If these do not exist, add targeted events for those actions. diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index d0403ba91df..caae7ab1bab 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -20,6 +20,7 @@ import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' import { getScreenSubmitModifierLabel } from '@/lib/screen-submit-shortcut' +import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' import type { GitHubWorkItem, GitLabWorkItem, @@ -248,6 +249,7 @@ export default function NewWorkspaceComposerCard({ }: NewWorkspaceComposerCardProps): React.JSX.Element { const { isFileDragOver, dragHandlers } = useComposerFileDragOver() const openModal = useAppStore((s) => s.openModal) + const activeModal = useAppStore((s) => s.activeModal) const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) const updateSettings = useAppStore((s) => s.updateSettings) const submitShortcutModifierLabel = getScreenSubmitModifierLabel() @@ -288,6 +290,13 @@ export default function NewWorkspaceComposerCard({ const handleAddRepo = React.useCallback((): void => { openModal('add-repo') }, [openModal]) + useContextualTour( + 'workspace-creation', + eligibleRepos.length > 0 && Boolean(repoId), + activeModal === 'new-workspace-composer' + ? 'workspace_creation_modal' + : 'workspace_creation_visible' + ) return (
-
+
@@ -374,7 +383,7 @@ export default function NewWorkspaceComposerCard({ ) : null}
-
+
-
+
diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx index 526dbbbed89..5539d1cca23 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx @@ -126,6 +126,20 @@ describe('ContextualTourOverlaySurface', () => { expect(markup).toContain('Done') }) + it('renders configured step controls inside the tour panel', () => { + const markup = renderToStaticMarkup( + renderSurface({ + control: { kind: 'auto-rename-branch-from-work' }, + title: 'Name it now or let Orca do it', + body: 'Type a name if you know it.' + }) + ) + + expect(markup).toContain('Auto-name from first message') + expect(markup).toContain('role="switch"') + expect(markup).toContain('Auto-name workspace from first agent message') + }) + it('hides the Back button on the first visible step', () => { const markup = renderToStaticMarkup(renderSurface({ isFirstStep: true })) expect(markup).not.toContain('>Back<') diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx index b174fc2942b..f4c8fec030c 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx @@ -182,6 +182,7 @@ export function ContextualTourOverlay(): JSX.Element | null { progress, title: activeStep.title, body: getContextualTourStepCopy(activeStep), + control: activeStep.control, isLastStep: progress.current === progress.total, isFirstStep: progress.current === 1, panelHost: getContextualTourPanelHost(target.element) diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx index eeb6eace749..f81b0bcd57a 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx @@ -4,7 +4,10 @@ import { ArrowLeft, ArrowRight } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import { useAppStore } from '@/store' -import type { ContextualTourId } from '../../../../shared/contextual-tours' +import type { + ContextualTourId, + ContextualTourStepControl +} from '../../../../shared/contextual-tours' import type { ContextualTourPanelPlacement } from './contextual-tour-gate' const FOCUSABLE_SELECTOR = @@ -17,6 +20,7 @@ export type ActiveTourRenderState = { progress: { current: number; total: number } title: string body: string + control?: ContextualTourStepControl isLastStep: boolean isFirstStep: boolean panelHost: HTMLElement | null @@ -101,6 +105,7 @@ export function ContextualTourOverlaySurface({ {renderState.title}

{renderState.body}

+ {renderState.control ? : null}
+ default: + return null + } +} + +function AutoRenameBranchFromWorkControl(): JSX.Element { + const enabled = useAppStore((s) => s.settings?.autoRenameBranchFromWork === true) + const updateSettings = useAppStore((s) => s.updateSettings) + + return ( +
+
+
+
Auto-name from first message
+
+ Works when you leave the generated name unchanged. +
+
+ +
+
+ ) +} + function ContextualTourProgressDots({ current, total diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts b/src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts index 46a325a4955..222784fc23b 100644 --- a/src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts +++ b/src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts @@ -14,9 +14,12 @@ import { import { getContextualTourPanelCssPosition } from './contextual-tour-panel-position' describe('contextual tour gate', () => { - it('blocks tours while another modal is active', () => { + it('allows only workspace creation over its workspace composer modal', () => { + const workspaceCreation = getContextualTour('workspace-creation') const tasks = getContextualTour('tasks') + expect(isContextualTourAllowedForModal(workspaceCreation, 'new-workspace-composer')).toBe(true) + expect(isContextualTourAllowedForModal(workspaceCreation, 'add-repo')).toBe(false) expect(isContextualTourAllowedForModal(tasks, 'none')).toBe(true) expect(isContextualTourAllowedForModal(tasks, 'new-workspace-composer')).toBe(false) expect(isContextualTourAllowedForModal(tasks, 'add-repo')).toBe(false) diff --git a/src/renderer/src/components/contextual-tours/use-contextual-tour.ts b/src/renderer/src/components/contextual-tours/use-contextual-tour.ts index d18282042ff..7603505bf54 100644 --- a/src/renderer/src/components/contextual-tours/use-contextual-tour.ts +++ b/src/renderer/src/components/contextual-tours/use-contextual-tour.ts @@ -7,7 +7,8 @@ const TOUR_SOURCES = { 'workspace-board': 'workspace_board_visible', browser: 'browser_visible', tasks: 'tasks_open', - automations: 'automations_open' + automations: 'automations_open', + 'workspace-creation': 'workspace_creation_visible' } satisfies Record export function useContextualTour( diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index dded20b6237..f37443e4b5c 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -1130,14 +1130,20 @@ describe('createUISlice contextual tours', () => { expect(store.getState().contextualTourShownThisSession).toBe(false) }) - it('blocks contextual tours while creation modals are open', () => { + it('allows only workspace creation over its workspace composer modal', () => { const store = createUIStore() - stubContextualTourTargets(['[data-contextual-tour-target="tasks-source-filters"]']) + stubContextualTourTargets([ + '[data-contextual-tour-target="tasks-source-filters"]', + '[data-contextual-tour-target="workspace-creation-project"]' + ]) store.getState().hydratePersistedUI(makeAutoTourEligibleUI()) store.getState().openModal('new-workspace-composer') store.getState().requestContextualTour('tasks', 'tasks_open') expect(store.getState().activeContextualTourId).toBeNull() + + store.getState().requestContextualTour('workspace-creation', 'workspace_creation_modal') + expect(store.getState().activeContextualTourId).toBe('workspace-creation') }) it('advances across visible steps and leaves completion to the overlay', () => { diff --git a/src/shared/contextual-tours.test.ts b/src/shared/contextual-tours.test.ts index 53f49d3457f..4d54d32c13b 100644 --- a/src/shared/contextual-tours.test.ts +++ b/src/shared/contextual-tours.test.ts @@ -8,7 +8,13 @@ import { describe('contextual tour definitions', () => { it('defines the required tours with three concise steps each', () => { - const expectedIds: ContextualTourId[] = ['workspace-board', 'browser', 'tasks', 'automations'] + const expectedIds: ContextualTourId[] = [ + 'workspace-board', + 'browser', + 'tasks', + 'automations', + 'workspace-creation' + ] expect(CONTEXTUAL_TOURS.map((tour) => tour.id)).toEqual(expectedIds) for (const tour of CONTEXTUAL_TOURS) { @@ -23,12 +29,13 @@ describe('contextual tour definitions', () => { } }) - it('keeps contextual tours out of modal-only flows', () => { + it('allows only workspace creation over its workspace composer modal', () => { const modalTours = (CONTEXTUAL_TOURS as readonly ContextualTour[]).filter( (tour) => tour.allowedActiveModals?.length ) - expect(modalTours).toEqual([]) + expect(modalTours.map((tour) => tour.id)).toEqual(['workspace-creation']) + expect(modalTours[0]?.allowedActiveModals).toEqual(['new-workspace-composer']) }) it('normalizes persisted ids by removing unknowns and duplicates', () => { @@ -41,6 +48,6 @@ describe('contextual tour definitions', () => { null, 'workspace-creation' ]) - ).toEqual(['tasks', 'browser']) + ).toEqual(['tasks', 'browser', 'workspace-creation']) }) }) diff --git a/src/shared/contextual-tours.ts b/src/shared/contextual-tours.ts index 1907f8e3e04..568509cbc38 100644 --- a/src/shared/contextual-tours.ts +++ b/src/shared/contextual-tours.ts @@ -1,4 +1,13 @@ -export type ContextualTourId = 'workspace-board' | 'browser' | 'tasks' | 'automations' +export type ContextualTourId = + | 'workspace-board' + | 'browser' + | 'tasks' + | 'automations' + | 'workspace-creation' + +export type ContextualTourStepControl = { + kind: 'auto-rename-branch-from-work' +} export type ContextualTourStep = { title: string @@ -6,6 +15,7 @@ export type ContextualTourStep = { targetSelector: string requiredForStart?: boolean fallbackCopy?: string + control?: ContextualTourStepControl } export type ContextualTour = { @@ -100,6 +110,29 @@ export const CONTEXTUAL_TOURS = [ targetSelector: '[data-contextual-tour-target="automations-runs"]' } ] + }, + { + id: 'workspace-creation', + allowedActiveModals: ['new-workspace-composer'], + steps: [ + { + title: 'Pick the code to work in', + body: 'Choose the project for this task so the workspace opens in the right codebase.', + targetSelector: '[data-contextual-tour-target="workspace-creation-project"]', + requiredForStart: true + }, + { + title: 'Name it now or let Orca do it', + body: 'Type a name if you know it. Or leave the field blank and let Orca rename the workspace after the first agent message.', + targetSelector: '[data-contextual-tour-target="workspace-creation-name"]', + control: { kind: 'auto-rename-branch-from-work' } + }, + { + title: 'Choose who starts the work', + body: 'Pick the agent you want, then create the workspace. Orca opens a separate place for this task.', + targetSelector: '[data-contextual-tour-target="workspace-creation-agent"]' + } + ] } ] as const satisfies readonly ContextualTour[] diff --git a/src/shared/feature-education-telemetry.ts b/src/shared/feature-education-telemetry.ts index d12713e70c6..b4fd7b8e7dd 100644 --- a/src/shared/feature-education-telemetry.ts +++ b/src/shared/feature-education-telemetry.ts @@ -4,7 +4,8 @@ export const FEATURE_EDUCATION_CONTEXTUAL_TOUR_IDS = [ 'workspace-board', 'browser', 'tasks', - 'automations' + 'automations', + 'workspace-creation' ] as const satisfies readonly ContextualTourId[] export const FEATURE_EDUCATION_SOURCES = [ @@ -12,6 +13,8 @@ export const FEATURE_EDUCATION_SOURCES = [ 'browser_visible', 'tasks_open', 'automations_open', + 'workspace_creation_visible', + 'workspace_creation_modal', 'unknown' ] as const diff --git a/tests/e2e/helpers/e2e-completed-onboarding-profile.ts b/tests/e2e/helpers/e2e-completed-onboarding-profile.ts index 7224a88155d..40260f6c67d 100644 --- a/tests/e2e/helpers/e2e-completed-onboarding-profile.ts +++ b/tests/e2e/helpers/e2e-completed-onboarding-profile.ts @@ -6,7 +6,8 @@ const SEEN_FIRST_RUN_CONTEXTUAL_TOUR_IDS = [ 'workspace-board', 'browser', 'tasks', - 'automations' + 'automations', + 'workspace-creation' ] as const const SEEN_FIRST_RUN_FEATURE_INTERACTION_TIMESTAMP = Date.parse('2026-01-01T00:00:00.000Z')