Add workspace creation contextual tour

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-28 16:15:55 -07:00
co-authored by Orca
parent d06a623a31
commit 7acde5fab2
12 changed files with 151 additions and 16 deletions
@@ -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.
@@ -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 (
<div
@@ -307,7 +316,7 @@ export default function NewWorkspaceComposerCard({
)}
>
<div className="min-w-0 space-y-4 pt-3">
<div className="space-y-1">
<div className="space-y-1" data-contextual-tour-target="workspace-creation-project">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-muted-foreground">Project</label>
<Tooltip>
@@ -374,7 +383,7 @@ export default function NewWorkspaceComposerCard({
) : null}
</div>
<div className="min-w-0 space-y-1">
<div className="min-w-0 space-y-1" data-contextual-tour-target="workspace-creation-name">
<label className="text-xs font-medium text-muted-foreground">
{selectedRepoIsGit ? "Name or 'Create From'" : 'Workspace name'}{' '}
<span className="text-muted-foreground/70">[Optional]</span>
@@ -408,7 +417,7 @@ export default function NewWorkspaceComposerCard({
/>
</div>
<div className="space-y-1">
<div className="space-y-1" data-contextual-tour-target="workspace-creation-agent">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-muted-foreground">Agent</label>
<Tooltip>
@@ -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<')
@@ -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)
@@ -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}
</h2>
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">{renderState.body}</p>
{renderState.control ? <ContextualTourControl control={renderState.control} /> : null}
<div className="mt-3.5 flex items-center justify-between gap-3">
<ContextualTourProgressDots
current={renderState.progress.current}
@@ -150,6 +155,57 @@ export function ContextualTourOverlaySurface({
)
}
function ContextualTourControl({
control
}: {
control: ContextualTourStepControl
}): JSX.Element | null {
switch (control.kind) {
case 'auto-rename-branch-from-work':
return <AutoRenameBranchFromWorkControl />
default:
return null
}
}
function AutoRenameBranchFromWorkControl(): JSX.Element {
const enabled = useAppStore((s) => s.settings?.autoRenameBranchFromWork === true)
const updateSettings = useAppStore((s) => s.updateSettings)
return (
<div className="mt-3 rounded-md border border-border/70 bg-muted/35 px-3 py-2.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium text-foreground">Auto-name from first message</div>
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground">
Works when you leave the generated name unchanged.
</div>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label="Auto-name workspace from first agent message"
onClick={() => {
void updateSettings({ autoRenameBranchFromWork: !enabled })
}}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors',
enabled ? 'bg-foreground' : 'bg-muted-foreground/30'
)}
>
<span
className={cn(
'pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform',
enabled ? 'translate-x-4' : 'translate-x-0.5'
)}
/>
</button>
</div>
</div>
)
}
function ContextualTourProgressDots({
current,
total
@@ -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)
@@ -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<ContextualTourId, string>
export function useContextualTour(
+8 -2
View File
@@ -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', () => {
+11 -4
View File
@@ -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'])
})
})
+34 -1
View File
@@ -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[]
+4 -1
View File
@@ -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
@@ -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')