mirror of
https://github.com/stablyai/orca.git
synced 2026-10-14 08:02:56 +00:00
Add workspace creation contextual tour
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user