Add Orca CLI feature tip (#3279)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-29 16:20:13 -07:00 committed by GitHub
parent aeadf5a035
commit 39e6f85fdc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1127 additions and 203 deletions

View File

@ -109,9 +109,9 @@ describe.skipIf(process.platform === 'win32')('runtime transport', () => {
// A generous timeout: a passing fix rejects on close well before this; the
// pre-fix behavior would hang the full duration and trip vitest's own limit.
const start = Date.now()
await expect(
sendRequest(metadata, 'status.get', undefined, 60000)
).rejects.toMatchObject({ code: 'runtime_unavailable' })
await expect(sendRequest(metadata, 'status.get', undefined, 60000)).rejects.toMatchObject({
code: 'runtime_unavailable'
})
expect(Date.now() - start).toBeLessThan(5000)
})
})

View File

@ -115,7 +115,11 @@ import type { VirtualizedScrollAnchor } from './hooks/useVirtualizedScrollAnchor
import type { RemoteWorkspacePatchResult } from '../../shared/remote-workspace-types'
import type { OnboardingState } from '../../shared/types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import { getFeatureTipsAppOpenDecision } from './components/feature-tips/feature-tip-startup-gate'
import {
getFeatureTipsAppOpenDecision,
isCliFeatureTipCompleted
} from './components/feature-tips/feature-tip-startup-gate'
import { trackOrcaCliFeatureTipShown } from './components/feature-tips/feature-tip-telemetry'
import {
keybindingMatchesAction,
type KeybindingActionId,
@ -446,6 +450,7 @@ function App(): React.JSX.Element {
const [onboarding, setOnboarding] = useState<OnboardingState | null>(null)
const featureTipsPromptedThisSessionRef = useRef(false)
const featureTipsSuppressedByOnboardingThisSessionRef = useRef(false)
const [featureTipCliInstalled, setFeatureTipCliInstalled] = useState<boolean | null>(null)
const [onboardingSettingsDetour, setOnboardingSettingsDetour] = useState(false)
const shouldRenderOnboarding = onboarding !== null && shouldShowOnboarding(onboarding)
const onboardingSettingsDetourActive =
@ -486,9 +491,35 @@ function App(): React.JSX.Element {
return onOnboardingReopened(setOnboarding)
}, [])
useEffect(() => {
if (!persistedUIReady) {
return
}
let cancelled = false
void window.api.cli
.getInstallStatus()
.then((status) => {
if (cancelled) {
return
}
setFeatureTipCliInstalled(isCliFeatureTipCompleted(status))
})
.catch(() => {
if (!cancelled) {
setFeatureTipCliInstalled(true)
}
})
return () => {
cancelled = true
}
}, [persistedUIReady])
useEffect(() => {
const featureTipsDecision = getFeatureTipsAppOpenDecision({
activeModal,
cliInstalled: featureTipCliInstalled,
featureTipsSeenIds,
featureInteractions,
onboarding,
@ -510,6 +541,9 @@ function App(): React.JSX.Element {
}
featureTipsPromptedThisSessionRef.current = true
if (featureTipsDecision.tipId === 'orca-cli') {
trackOrcaCliFeatureTipShown('app_open')
}
// Why: once a tip is visible, app quit/crash should not make it reappear
// on the next launch just because the user never clicked a dismiss button.
actions.markFeatureTipsSeen([featureTipsDecision.tipId])
@ -517,6 +551,7 @@ function App(): React.JSX.Element {
}, [
activeModal,
actions,
featureTipCliInstalled,
featureInteractions,
featureTipsSeenIds,
onboarding,

View File

@ -1407,6 +1407,81 @@
transform-origin: center;
}
/* CLI feature tip — terminal cursor inside the agent-control command. */
@keyframes cli-tip-caret {
0%,
45% {
opacity: 1;
}
46%,
100% {
opacity: 0;
}
}
.animate-cli-tip-caret {
animation: cli-tip-caret 0.9s steps(1, end) infinite;
}
@keyframes cli-tip-command-line {
0% {
opacity: 0;
transform: translateY(2px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.animate-cli-tip-command-line {
animation: cli-tip-command-line 160ms ease-out both;
}
@media (prefers-reduced-motion: reduce) {
.animate-cli-tip-caret {
animation: none;
opacity: 1;
transform: none;
}
.animate-cli-tip-command-line {
animation: none;
}
}
.cli-tip-orchestration-frame .feature-wall-orch-stage {
--feature-wall-workspace-title-size: 11.5px;
--feature-wall-agent-message-size: 10.5px;
--feature-wall-agent-row-gap: 5px;
--feature-wall-agent-status-col: 10px;
--feature-wall-agent-icon-col: 12px;
--feature-wall-agent-status-box: 10px;
--feature-wall-agent-status-icon: 9px;
--feature-wall-agent-icon-box: 12px;
--feature-wall-child-indent: 22px;
align-content: start !important;
padding-top: 10px !important;
padding-left: 12px !important;
padding-right: 52px !important;
}
.cli-tip-orchestration-frame .feature-wall-agent-icon svg {
height: 11px;
width: 11px;
}
.cli-tip-orchestration-frame .feature-wall-agent-status [aria-label] {
height: 10px;
width: 10px;
}
.cli-tip-orchestration-frame .feature-wall-agent-status [aria-label] > span {
height: 6px;
width: 6px;
border-width: 1.5px;
}
/* ── Feature wall: agents-orchestration animations ───────────────── */
/* Why: drives the supported-agents marquee on page 1 of the agents tile.
@ -1509,7 +1584,7 @@
offset-distance: 100%;
transform: translate(-12px, -12px) scale(1);
transition:
offset-distance 1500ms cubic-bezier(0.25, 0.6, 0.25, 1),
offset-distance 1600ms cubic-bezier(0.25, 0.6, 0.25, 1),
transform 280ms ease,
opacity 280ms ease;
}
@ -1537,37 +1612,6 @@
animation: feature-wall-msg-pop 1100ms ease-out;
}
@keyframes feature-wall-orch-spin {
to {
transform: rotate(360deg);
}
}
.feature-wall-spawn-spinner {
width: 11px;
height: 11px;
border: 1.5px solid rgb(245 158 11);
border-top-color: transparent;
border-radius: 999px;
animation: feature-wall-orch-spin 800ms linear infinite;
}
/* "Creating workspaces…" placeholder shown before the child workspaces
reveal. Once the orchestrator finishes spawning, the wrapper collapses
and the real child cards take its place. */
.feature-wall-creating-children {
overflow: hidden;
opacity: 1;
max-height: 60px;
transition:
opacity 220ms ease,
max-height 320ms cubic-bezier(0.4, 0, 0.2, 1);
}
.feature-wall-creating-children[data-hidden='true'] {
opacity: 0;
max-height: 0;
pointer-events: none;
}
/* Children container fades in once the orchestrator finishes creating. */
.feature-wall-children-wrapper {
opacity: 0;
@ -1581,6 +1625,20 @@
transform: none;
}
@keyframes feature-wall-child-card-in {
0% {
opacity: 0;
transform: translateY(4px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.feature-wall-child-card-shell {
animation: feature-wall-child-card-in 260ms ease-out both;
}
/* Each child agent row stays collapsed until *its* dispatch bubble lands on
it, so the child workspace visibly gains an agent as work arrives. */
.feature-wall-spawn-row {

View File

@ -191,6 +191,7 @@ function findBrowserSelection(
function getSettingsTargetFromSectionId(sectionId: string): {
pane: SettingsNavTarget
repoId: string | null
sectionId?: string
} {
if (sectionId.startsWith('repo-')) {
return { pane: 'repo', repoId: sectionId.slice('repo-'.length) }
@ -831,6 +832,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const handleSelectSettings = useCallback(
(result: CmdJSettingsResult) => {
const target = getSettingsTargetFromSectionId(result.sectionId)
if (result.targetSectionId) {
target.sectionId = result.targetSectionId
}
skipRestoreFocusRef.current = true
closeModal()
setSelectedItemId('')

View File

@ -65,6 +65,22 @@ const actions: CmdJQuickAction[] = [
]
const sections: SettingsNavSection[] = [
{
id: 'general',
title: 'General',
description: 'Workspace defaults.',
icon: Settings,
searchEntries: [
{
title: 'Orca CLI',
description: 'Register or remove the orca shell command.',
keywords: ['cli', 'path', 'terminal', 'command', 'shell command'],
cmdJKeywords: ['cli', 'path', 'command', 'shell command'],
targetSectionId: 'cli'
}
],
group: 'setup'
},
{
id: 'terminal',
title: 'Terminal',
@ -141,11 +157,26 @@ describe('Cmd+J palette middle-band ranking', () => {
['terminal', 'settings:terminal'],
['browser', 'settings:browser'],
['quick commands', 'settings:quick-commands'],
['add quick command', 'add-quick-command']
['add quick command', 'add-quick-command'],
['orca cli', 'settings:general:cli'],
['shell command', 'settings:general:cli']
])('ranks %s first', (query, expectedId) => {
expect(top(query)).toBe(expectedId)
})
it('builds targeted settings rows for Settings subsections', () => {
const cliResult = buildCmdJSettingsResults(sections).find(
(result) => result.id === 'settings:general:cli'
)
expect(cliResult).toMatchObject({
title: 'Orca CLI',
description: 'Register or remove the orca shell command.',
sectionId: 'general',
targetSectionId: 'cli'
})
})
it('does not match settings on one-character or description-only queries', () => {
expect(top('t')).toBeUndefined()
expect(top('cookie import')).toBeUndefined()

View File

@ -9,6 +9,7 @@ export type CmdJSettingsResult = {
description: string
icon: LucideIcon
sectionId: string
targetSectionId?: string
order: number
configKeywords: string[]
}
@ -50,6 +51,7 @@ function normalizeQuery(value: string): string {
function keywordParts(section: SettingsNavSection): string[] {
const baseId = section.id.startsWith('repo-') ? 'repo' : section.id
const idWords = baseId.replace(/-/g, ' ')
const paneLevelEntries = section.searchEntries.filter((entry) => !entry.targetSectionId)
return [
section.id,
baseId,
@ -58,10 +60,14 @@ function keywordParts(section: SettingsNavSection): string[] {
`${section.title} settings`,
`${idWords} settings`,
...(SETTINGS_ALIASES[baseId] ?? []),
...section.searchEntries.map((entry) => entry.title)
...paneLevelEntries.map((entry) => entry.title)
]
}
function targetEntryKeywordParts(entryTitle: string): string[] {
return [entryTitle, `${entryTitle} settings`]
}
function uniqueNormalized(values: readonly string[]): string[] {
return [...new Set(values.map(normalizeQuery).filter(Boolean))]
}
@ -69,16 +75,36 @@ function uniqueNormalized(values: readonly string[]): string[] {
export function buildCmdJSettingsResults(
sections: readonly SettingsNavSection[]
): CmdJSettingsResult[] {
return sections.map((section, order) => ({
id: `settings:${section.id}`,
kind: 'settings',
title: section.title,
description: section.description,
icon: section.icon,
sectionId: section.id,
order,
configKeywords: uniqueNormalized(keywordParts(section))
}))
return sections.flatMap((section, order) => {
const paneResult: CmdJSettingsResult = {
id: `settings:${section.id}`,
kind: 'settings',
title: section.title,
description: section.description,
icon: section.icon,
sectionId: section.id,
order,
configKeywords: uniqueNormalized(keywordParts(section))
}
const targetedResults = section.searchEntries
.filter((entry) => entry.targetSectionId)
.map((entry, entryIndex) => ({
id: `settings:${section.id}:${entry.targetSectionId}`,
kind: 'settings' as const,
title: entry.title,
description: entry.description ?? section.description,
icon: section.icon,
sectionId: section.id,
targetSectionId: entry.targetSectionId,
order: order + (entryIndex + 1) / 100,
configKeywords: uniqueNormalized([
...targetEntryKeywordParts(entry.title),
...(entry.cmdJKeywords ?? entry.keywords ?? [])
])
}))
return [paneResult, ...targetedResults]
})
}
export function buildCmdJActionResults(actions: readonly CmdJQuickAction[]): CmdJActionResult[] {

View File

@ -1,9 +1,22 @@
import type { JSX } from 'react'
import { Mic, Sparkles } from 'lucide-react'
import { useEffect, useState, type JSX } from 'react'
import { Loader2, Mic } from 'lucide-react'
import { toast } from 'sonner'
import { getDefaultVoiceSettings } from '../../../../shared/constants'
import type { FeatureTip } from '../../../../shared/feature-tips'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { AgentsOrchestrationVisual } from '@/components/feature-wall/AgentsOrchestrationVisual'
import {
ORCHESTRATION_CLI_COMMAND_LOOP_MS,
ORCHESTRATION_CLI_COMMAND_TIMINGS_MS
} from '@/components/feature-wall/agents-orchestration/orchestration-types'
import { usePrefersReducedMotion } from '@/components/feature-wall/feature-wall-modal-helpers'
import { OnboardingInlineCommandTerminal } from '@/components/onboarding/OnboardingInlineCommandTerminal'
import { ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands'
import {
ORCHESTRATION_ENABLED_STORAGE_KEY,
ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY,
notifyOrchestrationSetupStateChanged
} from '@/lib/orchestration-setup-state'
import {
Dialog,
DialogContent,
@ -13,11 +26,114 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { useAppStore } from '@/store'
import { installCliFromFeatureTip } from './feature-tip-cli-install-action'
import { getFeatureTipForModal } from './feature-tip-modal-state'
import {
getOrcaCliFeatureTipTelemetrySource,
trackOrcaCliFeatureTipSetupClicked,
trackOrcaCliFeatureTipSetupResult
} from './feature-tip-telemetry'
const WAVEFORM_BAR_HEIGHTS = [30, 60, 90, 70, 100, 50, 80, 35, 65]
const CLI_AGENT_COMMANDS = [
'orca worktree create --name auth-pr-1',
'orca worktree create --name auth-pr-2',
'orca orchestration dispatch --task pr1 --to w1',
'orca orchestration dispatch --task pr2 --to w2'
]
function CliFeatureTipVisual(): JSX.Element {
const reducedMotion = usePrefersReducedMotion()
const [visibleCommandCount, setVisibleCommandCount] = useState(
reducedMotion ? CLI_AGENT_COMMANDS.length : 0
)
useEffect(() => {
if (reducedMotion) {
setVisibleCommandCount(CLI_AGENT_COMMANDS.length)
return
}
let cancelled = false
const timeouts: number[] = []
const later = (fn: () => void, ms: number): void => {
timeouts.push(window.setTimeout(() => !cancelled && fn(), ms))
}
// Why: terminal lines mirror the orchestration tour beat timings so the
// shell shows each command as the parent agent runs it.
const runOnce = (): void => {
setVisibleCommandCount(0)
ORCHESTRATION_CLI_COMMAND_TIMINGS_MS.forEach((ms, index) => {
later(() => setVisibleCommandCount(index + 1), ms)
})
later(runOnce, ORCHESTRATION_CLI_COMMAND_LOOP_MS)
}
runOnce()
return () => {
cancelled = true
timeouts.forEach((id) => window.clearTimeout(id))
}
}, [reducedMotion])
return (
<div
className="relative flex min-h-[27rem] flex-col overflow-hidden bg-muted/60 px-6 py-7"
aria-hidden="true"
>
<div className="absolute inset-x-0 top-0 h-16 bg-gradient-to-b from-background/55 to-transparent" />
<div className="relative rounded-lg border border-border/70 bg-card/95 shadow-xs">
<div className="flex items-center gap-2 border-b border-border/70 px-3 py-2">
<span className="size-2 rounded-full bg-muted-foreground/35" />
<span className="size-2 rounded-full bg-muted-foreground/25" />
<span className="size-2 rounded-full bg-muted-foreground/20" />
</div>
<div className="space-y-1.5 px-3 py-3 font-mono text-[10.5px] leading-[1.35] text-foreground">
<div className="truncate text-muted-foreground">
<span className="mr-1.5 text-foreground"></span>Claude Code session started
</div>
{CLI_AGENT_COMMANDS.map((command, index) => {
const isVisible = index < visibleCommandCount
const isCurrentLine = isVisible && index === visibleCommandCount - 1
return (
<div
key={command}
className={`truncate ${isVisible ? 'animate-cli-tip-command-line' : 'invisible'}`}
>
<span className="text-amber-600">&gt; </span>
<span>{command}</span>
{isCurrentLine ? (
<span className="animate-cli-tip-caret ml-0.5 inline-block h-3 w-1 translate-y-0.5 rounded-sm bg-foreground/70" />
) : null}
</div>
)
})}
</div>
</div>
<div className="cli-tip-orchestration-frame relative mt-5 flex h-[17rem] items-center justify-center overflow-hidden rounded-lg border border-border/70 bg-background/80 px-5 shadow-xs">
<div className="origin-center">
<AgentsOrchestrationVisual
activeStepId="orchestration"
reducedMotion={reducedMotion}
widthPx={350}
heightPx={252}
orchestrationCreatedChildCount={Math.min(visibleCommandCount, 2)}
orchestrationLoopMs={ORCHESTRATION_CLI_COMMAND_LOOP_MS}
orchestrationShowResponseBeats={false}
/>
</div>
</div>
</div>
)
}
function FeatureTipVisual({ tip }: { tip: FeatureTip }): JSX.Element {
if (tip.action === 'setup-cli') {
return <CliFeatureTipVisual />
}
switch (tip.action) {
case 'enable-voice':
return (
@ -40,6 +156,46 @@ function FeatureTipVisual({ tip }: { tip: FeatureTip }): JSX.Element {
}
}
function FeatureTipActions({
currentTip,
primaryBusy,
onPrimaryAction,
onSkip,
showSkip = true,
fullWidth = false
}: {
currentTip: FeatureTip
primaryBusy: boolean
onPrimaryAction: () => void
onSkip: () => void
showSkip?: boolean
fullWidth?: boolean
}): JSX.Element {
return (
<>
{showSkip ? (
<Button variant="ghost" onClick={onSkip} disabled={primaryBusy}>
Maybe Later
</Button>
) : null}
<Button
className={fullWidth ? 'w-full' : undefined}
onClick={onPrimaryAction}
disabled={primaryBusy}
>
{primaryBusy ? (
<>
<Loader2 className="size-4 animate-spin" />
Installing...
</>
) : (
currentTip.ctaLabel
)}
</Button>
</>
)
}
export default function FeatureTipsModal(): JSX.Element | null {
const activeModal = useAppStore((s) => s.activeModal)
const closeModal = useAppStore((s) => s.closeModal)
@ -51,8 +207,11 @@ export default function FeatureTipsModal(): JSX.Element | null {
const featureInteractions = useAppStore((s) => s.featureInteractions)
const markFeatureTipsSeen = useAppStore((s) => s.markFeatureTipsSeen)
const modalData = useAppStore((s) => s.modalData)
const [primaryBusy, setPrimaryBusy] = useState(false)
const [skillTerminalOpen, setSkillTerminalOpen] = useState(false)
const isOpen = activeModal === 'feature-tips'
const currentTip = getFeatureTipForModal({
cliInstalled: true,
modalData,
seenTipIds,
featureInteractions,
@ -68,6 +227,7 @@ export default function FeatureTipsModal(): JSX.Element | null {
const handleOpenChange = (open: boolean): void => {
if (!open) {
markCurrentTipSeen()
setSkillTerminalOpen(false)
closeModal()
}
}
@ -77,7 +237,18 @@ export default function FeatureTipsModal(): JSX.Element | null {
closeModal()
}
const handlePrimaryAction = (): void => {
const openCliSettings = (): void => {
openSettingsTarget({ pane: 'general', repoId: null, sectionId: 'cli' })
openSettingsPage()
}
const enableOrchestrationSkillSetup = (): void => {
localStorage.setItem(ORCHESTRATION_ENABLED_STORAGE_KEY, '1')
localStorage.removeItem(ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY)
notifyOrchestrationSetupStateChanged()
}
const handlePrimaryAction = async (): Promise<void> => {
if (!currentTip) {
return
}
@ -95,6 +266,46 @@ export default function FeatureTipsModal(): JSX.Element | null {
closeModal()
openSettingsTarget({ pane: 'voice', repoId: null })
openSettingsPage()
break
}
case 'setup-cli': {
const telemetrySource = getOrcaCliFeatureTipTelemetrySource(modalData.source)
trackOrcaCliFeatureTipSetupClicked(telemetrySource)
setPrimaryBusy(true)
try {
const result = await installCliFromFeatureTip(() => window.api.cli.install())
if (result.kind === 'installed') {
trackOrcaCliFeatureTipSetupResult(telemetrySource, 'installed')
toast.success('Registered `orca` in PATH.')
enableOrchestrationSkillSetup()
setSkillTerminalOpen(true)
return
}
trackOrcaCliFeatureTipSetupResult(telemetrySource, 'needs_attention')
toast.warning('Orca CLI needs attention', {
description: result.status.detail ?? 'Open Settings to finish CLI setup.'
})
closeModal()
openCliSettings()
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to install Orca CLI.'
if (
import.meta.env.DEV &&
message.includes('Development mode uses a generated launcher for validation only')
) {
trackOrcaCliFeatureTipSetupResult(telemetrySource, 'dev_preview')
toast.info('Development preview: opening skills setup terminal.')
enableOrchestrationSkillSetup()
setSkillTerminalOpen(true)
return
}
trackOrcaCliFeatureTipSetupResult(telemetrySource, 'failed')
toast.error(message)
} finally {
setPrimaryBusy(false)
}
}
}
}
@ -103,17 +314,69 @@ export default function FeatureTipsModal(): JSX.Element | null {
return null
}
if (currentTip.action === 'setup-cli') {
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="grid gap-0 overflow-hidden p-0 sm:max-w-4xl md:grid-cols-[minmax(22rem,0.95fr)_minmax(26rem,1.05fr)]">
<div className="flex min-h-[27rem] flex-col justify-between px-8 py-9">
<DialogHeader className="gap-4 text-left">
<div className="space-y-3">
<DialogTitle className="max-w-[22rem] text-3xl font-semibold leading-tight tracking-tight">
{currentTip.title}
</DialogTitle>
<DialogDescription className="max-w-sm text-sm leading-relaxed">
{currentTip.description}
</DialogDescription>
{skillTerminalOpen ? null : (
<div className="max-w-sm space-y-2 rounded-md border border-border/70 bg-muted/35 p-3 text-sm leading-relaxed text-muted-foreground">
<p className="font-medium text-foreground">Try asking:</p>
<p>Split this PR into two workspaces and create PRs for each.</p>
<p>When the agent in workspace X finishes, send it the review task.</p>
</div>
)}
</div>
{skillTerminalOpen ? (
<OnboardingInlineCommandTerminal
command={ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND}
title="Skill setup"
ariaLabel="Orca CLI and orchestration skill install terminal"
description="Press Enter to install the Orca CLI and orchestration skills for your agents."
terminalHeightPx={150}
terminalTopMarginPx={4}
descriptionPaddingClassName="px-4 py-2"
autoScrollIntoView={false}
worktreeId="feature-tip-cli-skills-terminal"
/>
) : null}
</DialogHeader>
<DialogFooter className="mt-8 flex sm:justify-stretch">
{skillTerminalOpen ? (
<Button className="w-full" onClick={handleSkip}>
Done
</Button>
) : (
<FeatureTipActions
currentTip={currentTip}
primaryBusy={primaryBusy}
onPrimaryAction={() => void handlePrimaryAction()}
onSkip={handleSkip}
showSkip={false}
fullWidth
/>
)}
</DialogFooter>
</div>
<FeatureTipVisual tip={currentTip} />
</DialogContent>
</Dialog>
)
}
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md gap-4 p-7" showCloseButton>
<DialogHeader className="items-center gap-4 px-8 text-center sm:text-center">
<Badge
variant="outline"
className="gap-1.5 px-2.5 py-1 text-[11px] uppercase tracking-[0.08em]"
>
<Sparkles className="size-3" />
{currentTip.eyebrow}
</Badge>
<FeatureTipVisual tip={currentTip} />
<DialogTitle className="text-2xl font-semibold tracking-tight">
{currentTip.title}
@ -124,10 +387,12 @@ export default function FeatureTipsModal(): JSX.Element | null {
</DialogHeader>
<DialogFooter className="sm:justify-center">
<Button variant="ghost" onClick={handleSkip}>
Maybe Later
</Button>
<Button onClick={handlePrimaryAction}>{currentTip.ctaLabel}</Button>
<FeatureTipActions
currentTip={currentTip}
primaryBusy={primaryBusy}
onPrimaryAction={() => void handlePrimaryAction()}
onSkip={handleSkip}
/>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
import { installCliFromFeatureTip } from './feature-tip-cli-install-action'
function cliStatus(overrides: Partial<CliInstallStatus> = {}): CliInstallStatus {
return {
platform: 'darwin',
commandName: 'orca',
commandPath: '/usr/local/bin/orca',
pathDirectory: '/usr/local/bin',
pathConfigured: true,
launcherPath: '/Applications/Orca.app/Contents/MacOS/orca',
installMethod: 'symlink',
supported: true,
state: 'installed',
currentTarget: null,
unsupportedReason: null,
detail: null,
...overrides
}
}
describe('feature tip CLI install action', () => {
it('returns installed after a successful CLI registration', async () => {
const status = cliStatus()
await expect(installCliFromFeatureTip(async () => status)).resolves.toEqual({
kind: 'installed',
status
})
})
it('returns needs-attention when installation does not finish cleanly', async () => {
const status = cliStatus({
state: 'conflict',
detail: 'Another orca command is already on PATH.'
})
await expect(installCliFromFeatureTip(async () => status)).resolves.toEqual({
kind: 'needs-attention',
status
})
})
it('returns needs-attention when the launcher is installed but not visible on PATH', async () => {
const status = cliStatus({
pathConfigured: false,
detail: 'Restart your shell so PATH includes /usr/local/bin.'
})
await expect(installCliFromFeatureTip(async () => status)).resolves.toEqual({
kind: 'needs-attention',
status
})
})
})

View File

@ -0,0 +1,15 @@
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
export type FeatureTipCliInstallResult =
| { kind: 'installed'; status: CliInstallStatus }
| { kind: 'needs-attention'; status: CliInstallStatus }
export async function installCliFromFeatureTip(
installCli: () => Promise<CliInstallStatus>
): Promise<FeatureTipCliInstallResult> {
const status = await installCli()
if (status.state === 'installed' && status.pathConfigured) {
return { kind: 'installed', status }
}
return { kind: 'needs-attention', status }
}

View File

@ -15,6 +15,7 @@ function makeSettings(voiceEnabled = false): Pick<GlobalSettings, 'voice'> {
describe('feature tip modal state', () => {
it('keeps rendering the opened tip after app open has marked it seen', () => {
const tip = getFeatureTipForModal({
cliInstalled: false,
modalData: { tipId: 'voice-dictation' },
seenTipIds: ['voice-dictation'],
featureInteractions: {},
@ -24,19 +25,45 @@ describe('feature tip modal state', () => {
expect(tip?.id).toBe('voice-dictation')
})
it('falls back to the next unseen tip when no modal tip id is pinned', () => {
it('falls back to the CLI tip first when no modal tip id is pinned', () => {
const tip = getFeatureTipForModal({
cliInstalled: false,
modalData: {},
seenTipIds: [],
featureInteractions: {},
settings: makeSettings()
})
expect(tip?.id).toBe('voice-dictation')
expect(tip?.id).toBe('orca-cli')
})
it('falls back to the CLI tip when voice was already seen and the CLI is not installed', () => {
const tip = getFeatureTipForModal({
cliInstalled: false,
modalData: {},
seenTipIds: ['voice-dictation'],
featureInteractions: {},
settings: makeSettings()
})
expect(tip?.id).toBe('orca-cli')
})
it('returns no tip when every tip is already seen and no modal tip id is pinned', () => {
const tip = getFeatureTipForModal({
cliInstalled: false,
modalData: {},
seenTipIds: ['voice-dictation', 'orca-cli'],
featureInteractions: {},
settings: makeSettings()
})
expect(tip).toBeNull()
})
it('returns no CLI tip when the CLI is already installed', () => {
const tip = getFeatureTipForModal({
cliInstalled: true,
modalData: {},
seenTipIds: ['voice-dictation'],
featureInteractions: {},
@ -48,6 +75,7 @@ describe('feature tip modal state', () => {
it('returns no unpinned tip after the user already interacted with the feature', () => {
const tip = getFeatureTipForModal({
cliInstalled: true,
modalData: {},
seenTipIds: [],
featureInteractions: {

View File

@ -10,6 +10,7 @@ import {
} from '../../../../shared/feature-tips'
export function getFeatureTipForModal(args: {
cliInstalled: boolean
modalData: Record<string, unknown>
seenTipIds: readonly FeatureTipId[]
featureInteractions: FeatureInteractionState
@ -23,6 +24,7 @@ export function getFeatureTipForModal(args: {
const pendingTips = getOrderedUnseenFeatureTips({
seenTipIds: new Set(args.seenTipIds),
completedTipIds: getCompletedFeatureTipIds({
cliInstalled: args.cliInstalled,
voiceDictationEnabled: args.settings?.voice?.enabled === true,
featureInteractions: args.featureInteractions
})

View File

@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest'
import { getDefaultOnboardingState, getDefaultVoiceSettings } from '../../../../shared/constants'
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
import type { GlobalSettings, OnboardingState } from '../../../../shared/types'
import { getFeatureTipsAppOpenDecision } from './feature-tip-startup-gate'
import { getFeatureTipsAppOpenDecision, isCliFeatureTipCompleted } from './feature-tip-startup-gate'
const existingUserOnboarding: OnboardingState = {
...getDefaultOnboardingState(),
@ -21,11 +22,30 @@ function makeSettings(voiceEnabled = false): Pick<GlobalSettings, 'voice'> {
}
}
function makeCliStatus(overrides: Partial<CliInstallStatus> = {}): CliInstallStatus {
return {
platform: 'darwin',
commandName: 'orca',
supported: true,
state: 'installed',
commandPath: '/usr/local/bin/orca',
pathDirectory: '/usr/local/bin',
pathConfigured: true,
launcherPath: '/Applications/Orca.app/Contents/MacOS/orca',
installMethod: 'symlink',
currentTarget: null,
unsupportedReason: null,
detail: null,
...overrides
}
}
describe('feature tip startup gate', () => {
it('opens the feature tip for an existing user on app open', () => {
it('opens the CLI feature tip first for an existing user on app open', () => {
expect(
getFeatureTipsAppOpenDecision({
activeModal: 'none',
cliInstalled: false,
featureTipsSeenIds: [],
featureInteractions: {},
onboarding: existingUserOnboarding,
@ -34,13 +54,14 @@ describe('feature tip startup gate', () => {
settings: makeSettings(),
suppressedByOnboardingThisSession: false
})
).toEqual({ kind: 'open', tipId: 'voice-dictation' })
).toEqual({ kind: 'open', tipId: 'orca-cli' })
})
it('suppresses feature tips for first-time users while onboarding is showing', () => {
expect(
getFeatureTipsAppOpenDecision({
activeModal: 'none',
cliInstalled: false,
featureTipsSeenIds: [],
featureInteractions: {},
onboarding: firstTimeOnboarding,
@ -56,6 +77,7 @@ describe('feature tip startup gate', () => {
expect(
getFeatureTipsAppOpenDecision({
activeModal: 'none',
cliInstalled: false,
featureTipsSeenIds: [],
featureInteractions: {},
onboarding: existingUserOnboarding,
@ -67,10 +89,59 @@ describe('feature tip startup gate', () => {
).toEqual({ kind: 'skip' })
})
it('does not reopen after the tip was marked seen', () => {
it('opens the CLI tip after the voice tip was marked seen', () => {
expect(
getFeatureTipsAppOpenDecision({
activeModal: 'none',
cliInstalled: false,
featureTipsSeenIds: ['voice-dictation'],
featureInteractions: {},
onboarding: existingUserOnboarding,
persistedUIReady: true,
promptedThisSession: false,
settings: makeSettings(),
suppressedByOnboardingThisSession: false
})
).toEqual({ kind: 'open', tipId: 'orca-cli' })
})
it('opens the CLI tip after voice dictation is already enabled', () => {
expect(
getFeatureTipsAppOpenDecision({
activeModal: 'none',
cliInstalled: false,
featureTipsSeenIds: [],
featureInteractions: {},
onboarding: existingUserOnboarding,
persistedUIReady: true,
promptedThisSession: false,
settings: makeSettings(true),
suppressedByOnboardingThisSession: false
})
).toEqual({ kind: 'open', tipId: 'orca-cli' })
})
it('does not open after every tip was marked seen', () => {
expect(
getFeatureTipsAppOpenDecision({
activeModal: 'none',
cliInstalled: false,
featureTipsSeenIds: ['voice-dictation', 'orca-cli'],
featureInteractions: {},
onboarding: existingUserOnboarding,
persistedUIReady: true,
promptedThisSession: false,
settings: makeSettings(),
suppressedByOnboardingThisSession: false
})
).toEqual({ kind: 'skip' })
})
it('does not open the CLI tip after the CLI is installed', () => {
expect(
getFeatureTipsAppOpenDecision({
activeModal: 'none',
cliInstalled: true,
featureTipsSeenIds: ['voice-dictation'],
featureInteractions: {},
onboarding: existingUserOnboarding,
@ -82,16 +153,33 @@ describe('feature tip startup gate', () => {
).toEqual({ kind: 'skip' })
})
it('does not open after voice dictation is already enabled', () => {
it('waits for CLI install status before opening the CLI tip', () => {
expect(
getFeatureTipsAppOpenDecision({
activeModal: 'none',
cliInstalled: null,
featureTipsSeenIds: ['voice-dictation'],
featureInteractions: {},
onboarding: existingUserOnboarding,
persistedUIReady: true,
promptedThisSession: false,
settings: makeSettings(),
suppressedByOnboardingThisSession: false
})
).toEqual({ kind: 'skip' })
})
it('waits for CLI install status before opening later tips', () => {
expect(
getFeatureTipsAppOpenDecision({
activeModal: 'none',
cliInstalled: null,
featureTipsSeenIds: [],
featureInteractions: {},
onboarding: existingUserOnboarding,
persistedUIReady: true,
promptedThisSession: false,
settings: makeSettings(true),
settings: makeSettings(),
suppressedByOnboardingThisSession: false
})
).toEqual({ kind: 'skip' })
@ -101,6 +189,7 @@ describe('feature tip startup gate', () => {
expect(
getFeatureTipsAppOpenDecision({
activeModal: 'none',
cliInstalled: true,
featureTipsSeenIds: [],
featureInteractions: {
'voice-dictation': { firstInteractedAt: 100, interactionCount: 1 }
@ -113,4 +202,21 @@ describe('feature tip startup gate', () => {
})
).toEqual({ kind: 'skip' })
})
it('requires an installed CLI to also be configured on PATH', () => {
expect(isCliFeatureTipCompleted(makeCliStatus())).toBe(true)
expect(isCliFeatureTipCompleted(makeCliStatus({ pathConfigured: false }))).toBe(false)
})
it('treats unsupported CLI setup as completed for feature tips', () => {
expect(
isCliFeatureTipCompleted(
makeCliStatus({
supported: false,
state: 'unsupported',
pathConfigured: false
})
)
).toBe(true)
})
})

View File

@ -3,6 +3,7 @@ import {
getCompletedFeatureTipIds,
getOrderedUnseenFeatureTips
} from '../../../../shared/feature-tips'
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
import type { FeatureInteractionState } from '../../../../shared/feature-interactions'
import type { GlobalSettings, OnboardingState } from '../../../../shared/types'
import { shouldShowOnboarding } from '../onboarding/should-show-onboarding'
@ -12,8 +13,15 @@ export type FeatureTipsAppOpenDecision =
| { kind: 'skip' }
| { kind: 'suppress-for-onboarding' }
export function isCliFeatureTipCompleted(status: CliInstallStatus): boolean {
// Why: unsupported launch modes cannot complete setup, but an installed
// launcher still needs attention until it is reachable on PATH.
return !status.supported || (status.state === 'installed' && status.pathConfigured)
}
export function getFeatureTipsAppOpenDecision(args: {
activeModal: string
cliInstalled: boolean | null
featureTipsSeenIds: readonly FeatureTipId[]
featureInteractions: FeatureInteractionState
onboarding: OnboardingState | null
@ -33,6 +41,7 @@ export function getFeatureTipsAppOpenDecision(args: {
!args.settings ||
args.onboarding === null ||
args.activeModal !== 'none' ||
args.cliInstalled === null ||
shouldShowOnboarding(args.onboarding)
) {
return { kind: 'skip' }
@ -41,6 +50,7 @@ export function getFeatureTipsAppOpenDecision(args: {
const unseenTips = getOrderedUnseenFeatureTips({
seenTipIds: new Set<FeatureTipId>(args.featureTipsSeenIds),
completedTipIds: getCompletedFeatureTipIds({
cliInstalled: args.cliInstalled,
voiceDictationEnabled: args.settings.voice?.enabled === true,
featureInteractions: args.featureInteractions
})

View File

@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const trackMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/telemetry', () => ({
track: trackMock
}))
import {
getOrcaCliFeatureTipTelemetrySource,
trackOrcaCliFeatureTipSetupClicked,
trackOrcaCliFeatureTipSetupResult,
trackOrcaCliFeatureTipShown
} from './feature-tip-telemetry'
describe('feature tip telemetry', () => {
beforeEach(() => {
trackMock.mockClear()
})
it('keeps feature tip sources low-cardinality', () => {
expect(getOrcaCliFeatureTipTelemetrySource('app_open')).toBe('app_open')
expect(getOrcaCliFeatureTipTelemetrySource('settings')).toBe('manual')
expect(getOrcaCliFeatureTipTelemetrySource(undefined)).toBe('manual')
})
it('tracks CLI tip exposure once per explicit call', () => {
trackOrcaCliFeatureTipShown('app_open')
expect(trackMock).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledWith('orca_cli_feature_tip_shown', {
source: 'app_open'
})
})
it('tracks setup click and result without raw CLI details', () => {
trackOrcaCliFeatureTipSetupClicked('app_open')
trackOrcaCliFeatureTipSetupResult('app_open', 'installed')
expect(trackMock).toHaveBeenCalledTimes(2)
expect(trackMock).toHaveBeenNthCalledWith(1, 'orca_cli_feature_tip_setup_clicked', {
source: 'app_open'
})
expect(trackMock).toHaveBeenNthCalledWith(2, 'orca_cli_feature_tip_setup_result', {
source: 'app_open',
result: 'installed'
})
})
})

View File

@ -0,0 +1,24 @@
import { track } from '@/lib/telemetry'
import type { EventProps } from '../../../../shared/telemetry-events'
export type OrcaCliFeatureTipSource = EventProps<'orca_cli_feature_tip_shown'>['source']
export type OrcaCliFeatureTipSetupResult = EventProps<'orca_cli_feature_tip_setup_result'>['result']
export function getOrcaCliFeatureTipTelemetrySource(value: unknown): OrcaCliFeatureTipSource {
return value === 'app_open' ? 'app_open' : 'manual'
}
export function trackOrcaCliFeatureTipShown(source: OrcaCliFeatureTipSource): void {
track('orca_cli_feature_tip_shown', { source })
}
export function trackOrcaCliFeatureTipSetupClicked(source: OrcaCliFeatureTipSource): void {
track('orca_cli_feature_tip_setup_clicked', { source })
}
export function trackOrcaCliFeatureTipSetupResult(
source: OrcaCliFeatureTipSource,
result: OrcaCliFeatureTipSetupResult
): void {
track('orca_cli_feature_tip_setup_result', { source, result })
}

View File

@ -13,8 +13,19 @@ export function AgentsOrchestrationVisual(props: {
activeStepId: AgentsStepId
widthPx?: number
heightPx?: number
orchestrationCreatedChildCount?: number
orchestrationLoopMs?: number
orchestrationShowResponseBeats?: boolean
}): JSX.Element {
const { reducedMotion, activeStepId, widthPx, heightPx } = props
const {
reducedMotion,
activeStepId,
widthPx,
heightPx,
orchestrationCreatedChildCount,
orchestrationLoopMs,
orchestrationShowResponseBeats
} = props
return (
<div
className="relative flex flex-col text-foreground"
@ -30,6 +41,9 @@ export function AgentsOrchestrationVisual(props: {
<OrchestrationPage
active={activeStepId === 'orchestration'}
reducedMotion={reducedMotion}
controlledCreatedChildCount={orchestrationCreatedChildCount}
loopMs={orchestrationLoopMs}
showResponseBeats={orchestrationShowResponseBeats}
/>
</Page>
</div>

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { JSX } from 'react'
import { ChevronDown, Workflow } from 'lucide-react'
import { ClaudeIcon, OpenAIIcon } from '../../status-bar/icons'
@ -8,6 +8,7 @@ import {
BUBBLE_LAND_MS,
INITIAL_ROW_MESSAGES,
INITIAL_ROW_STATE,
ORCHESTRATION_CLI_COMMAND_TIMINGS_MS,
PHASE1_BEATS,
type AgentKey,
type Beat,
@ -27,57 +28,79 @@ const INITIAL_CHILD_PENDING: RowPending = {
'child-claude': true
}
// How long the "Creating workspaces…" spinner shows before the child cards
// fade in. Keep it slow enough to read at a glance.
const CREATING_CHILDREN_MS = 1400
const CHILD_ONE_CREATE_MS = ORCHESTRATION_CLI_COMMAND_TIMINGS_MS[0]
const CHILD_TWO_CREATE_MS = ORCHESTRATION_CLI_COMMAND_TIMINGS_MS[1]
const FIRST_DISPATCH_MS = ORCHESTRATION_CLI_COMMAND_TIMINGS_MS[2]
export function OrchestrationPage(props: { active: boolean; reducedMotion: boolean }): JSX.Element {
const { active, reducedMotion } = props
export function OrchestrationPage(props: {
active: boolean
reducedMotion: boolean
controlledCreatedChildCount?: number
loopMs?: number
showResponseBeats?: boolean
}): JSX.Element {
const {
active,
reducedMotion,
controlledCreatedChildCount,
loopMs,
showResponseBeats = true
} = props
const stageRef = useRef<HTMLDivElement | null>(null)
const arrowsRef = useRef<SVGSVGElement | null>(null)
const bubbleLayerRef = useRef<HTMLDivElement | null>(null)
const rowRefs = useRef<Partial<Record<AgentKey, HTMLDivElement | null>>>({})
const childCountControlledRef = useRef(controlledCreatedChildCount !== undefined)
const [rowState, setRowState] = useState<RowState>(INITIAL_ROW_STATE)
const [rowMessages, setRowMessages] = useState<RowMessages>(INITIAL_ROW_MESSAGES)
const [rowFlash, setRowFlash] = useState<RowFlash>({})
const [rowPending, setRowPending] = useState<RowPending>(INITIAL_CHILD_PENDING)
const [childrenVisible, setChildrenVisible] = useState(false)
const [createdChildCount, setCreatedChildCount] = useState(0)
const displayedChildCount = controlledCreatedChildCount ?? createdChildCount
// Why: bubbles measure the recipient row at fire-time, so the pending flag
// has to flip *before* the path is computed. React state updates are async,
// so keep a synchronous mirror to flip styles immediately.
const pendingMirror = useRef<RowPending>({ ...INITIAL_CHILD_PENDING })
useEffect(() => {
const drawArrow = (): void => {
const arrows = arrowsRef.current
const stage = stageRef.current
if (!arrows || !stage) {
return
}
arrows.removeAttribute('data-fading')
const stageRect = stage.getBoundingClientRect()
arrows.setAttribute('viewBox', `0 0 ${stageRect.width} ${stageRect.height}`)
arrows.setAttribute('width', String(stageRect.width))
arrows.setAttribute('height', String(stageRect.height))
const coordEl = stage.querySelector('[data-feature-wall-card="coord"]')
if (!(coordEl instanceof HTMLElement)) {
arrows.innerHTML = ''
return
}
const codexEl = stage.querySelector('[data-feature-wall-card="child"]')
const claudeEl = stage.querySelector('[data-feature-wall-card="child-claude"]')
const paths: string[] = []
if (codexEl instanceof HTMLElement) {
paths.push(arrowPathFromCoordTo(coordEl, codexEl, stageRect))
}
if (claudeEl instanceof HTMLElement) {
paths.push(arrowPathFromCoordTo(coordEl, claudeEl, stageRect))
}
arrows.innerHTML = paths.map((d) => `<path d="${d}"/>`).join('')
}
childCountControlledRef.current = controlledCreatedChildCount !== undefined
const drawArrow = useCallback((): void => {
const arrows = arrowsRef.current
const stage = stageRef.current
if (!arrows || !stage) {
return
}
arrows.removeAttribute('data-fading')
const stageRect = stage.getBoundingClientRect()
arrows.setAttribute('viewBox', `0 0 ${stageRect.width} ${stageRect.height}`)
arrows.setAttribute('width', String(stageRect.width))
arrows.setAttribute('height', String(stageRect.height))
const coordEl = stage.querySelector('[data-feature-wall-card="coord"]')
if (!(coordEl instanceof HTMLElement)) {
arrows.innerHTML = ''
return
}
const codexEl = stage.querySelector('[data-feature-wall-card="child"]')
const claudeEl = stage.querySelector('[data-feature-wall-card="child-claude"]')
const paths: string[] = []
if (codexEl instanceof HTMLElement) {
paths.push(arrowPathFromCoordTo(coordEl, codexEl, stageRect))
}
if (claudeEl instanceof HTMLElement) {
paths.push(arrowPathFromCoordTo(coordEl, claudeEl, stageRect))
}
arrows.innerHTML = paths.map((d) => `<path d="${d}"/>`).join('')
}, [])
useEffect(() => {
if (active && displayedChildCount >= 2) {
requestAnimationFrame(() => drawArrow())
}
}, [active, displayedChildCount, drawArrow])
useEffect(() => {
if (!active) {
// Reset everything to the initial state when the user pages away so
// re-entering the step plays from the top.
@ -85,7 +108,7 @@ export function OrchestrationPage(props: { active: boolean; reducedMotion: boole
setRowMessages(INITIAL_ROW_MESSAGES)
setRowFlash({})
setRowPending(INITIAL_CHILD_PENDING)
setChildrenVisible(false)
setCreatedChildCount(0)
pendingMirror.current = { ...INITIAL_CHILD_PENDING }
const arrows = arrowsRef.current
if (arrows) {
@ -104,7 +127,7 @@ export function OrchestrationPage(props: { active: boolean; reducedMotion: boole
setRowState(INITIAL_ROW_STATE)
setRowMessages(INITIAL_ROW_MESSAGES)
setRowPending({})
setChildrenVisible(true)
setCreatedChildCount(2)
pendingMirror.current = {}
requestAnimationFrame(() => drawArrow())
return
@ -182,33 +205,42 @@ export function OrchestrationPage(props: { active: boolean; reducedMotion: boole
setRowState(INITIAL_ROW_STATE)
setRowMessages(INITIAL_ROW_MESSAGES)
setRowPending(INITIAL_CHILD_PENDING)
setChildrenVisible(false)
setCreatedChildCount(0)
pendingMirror.current = { ...INITIAL_CHILD_PENDING }
// Reveal the children after a "creating workspaces…" beat. Arrows are
// drawn once their target cards are in the DOM and the fade-in is past
// its first paint.
later(() => {
setChildrenVisible(true)
later(() => drawArrow(), 360)
}, CREATING_CHILDREN_MS)
if (!childCountControlledRef.current) {
// Reveal each child workspace when the matching shell command appears,
// so the CLI tip reads as Claude driving the exact Orca workflow shown.
later(() => {
setCreatedChildCount(1)
}, CHILD_ONE_CREATE_MS)
later(() => {
setCreatedChildCount(2)
later(() => drawArrow(), 360)
}, CHILD_TWO_CREATE_MS)
}
const beats = showResponseBeats ? PHASE1_BEATS : PHASE1_BEATS.slice(0, 2)
let beatIdx = 0
const next = (): void => {
if (beatIdx >= PHASE1_BEATS.length) {
if (beatIdx >= beats.length) {
later(done, 800)
return
}
fireBubble(PHASE1_BEATS[beatIdx])
fireBubble(beats[beatIdx])
beatIdx += 1
later(next, BUBBLE_GAP_MS)
}
later(next, CREATING_CHILDREN_MS + 600)
later(next, FIRST_DISPATCH_MS)
}
const loop = (): void => {
runOnce(() => later(loop, 1400))
runOnce(() => {
const beatCount = showResponseBeats ? PHASE1_BEATS.length : 2
const elapsedMs = FIRST_DISPATCH_MS + beatCount * BUBBLE_GAP_MS + 800
later(loop, loopMs ? Math.max(0, loopMs - elapsedMs) : 1400)
})
}
later(loop, 80)
loop()
const onResize = (): void => drawArrow()
window.addEventListener('resize', onResize)
@ -222,7 +254,7 @@ export function OrchestrationPage(props: { active: boolean; reducedMotion: boole
cleanupLayer.innerHTML = ''
}
}
}, [active, reducedMotion])
}, [active, reducedMotion, drawArrow, loopMs, showResponseBeats])
return (
<div
@ -260,7 +292,11 @@ export function OrchestrationPage(props: { active: boolean; reducedMotion: boole
<div
className="flex justify-start"
style={{ marginLeft: 28, marginTop: 0, marginBottom: 0 }}
style={{
marginLeft: 'var(--feature-wall-child-indent, 28px)',
marginTop: 0,
marginBottom: 0
}}
>
<span
className="inline-flex items-center gap-1 rounded-md border border-border bg-card px-1.5 text-muted-foreground"
@ -273,71 +309,67 @@ export function OrchestrationPage(props: { active: boolean; reducedMotion: boole
</span>
</div>
<div
className="feature-wall-creating-children"
data-hidden={childrenVisible ? 'true' : undefined}
style={{ marginLeft: 28 }}
aria-hidden={childrenVisible}
>
<div className="flex items-center gap-2 px-1.5 pb-2 pt-1 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
<span className="feature-wall-spawn-spinner" aria-hidden />
<span>Creating workspaces</span>
</div>
</div>
<div
className="feature-wall-children-wrapper"
data-visible={childrenVisible ? 'true' : undefined}
data-visible={displayedChildCount > 0 ? 'true' : undefined}
style={{
width: 'calc(100% - 28px)',
width: 'calc(100% - var(--feature-wall-child-indent, 28px))',
marginLeft: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 8
}}
>
<WorkspaceCard
variant="default"
name="PR 1/2: migrate users.sql"
dataCard="child"
childPadding
rows={[
<AgentRow
key="child-codex"
agentKey="child-codex"
icon={<OpenAIIcon size={13} />}
state={rowState['child-codex']}
message={rowMessages['child-codex']}
flashKey={rowFlash['child-codex'] ?? 0}
pending={rowPending['child-codex']}
spawnRow
registerRef={(node) => {
rowRefs.current['child-codex'] = node
}}
{displayedChildCount >= 1 ? (
<div className="feature-wall-child-card-shell">
<WorkspaceCard
variant="default"
name="PR 1/2: migrate users.sql"
dataCard="child"
childPadding
rows={[
<AgentRow
key="child-codex"
agentKey="child-codex"
icon={<OpenAIIcon size={13} />}
state={rowState['child-codex']}
message={rowMessages['child-codex']}
flashKey={rowFlash['child-codex'] ?? 0}
pending={rowPending['child-codex']}
spawnRow
registerRef={(node) => {
rowRefs.current['child-codex'] = node
}}
/>
]}
/>
]}
/>
<WorkspaceCard
variant="default"
name="PR 2/2: withSession middleware"
dataCard="child-claude"
childPadding
rows={[
<AgentRow
key="child-claude"
agentKey="child-claude"
icon={<ClaudeIcon size={13} />}
state={rowState['child-claude']}
message={rowMessages['child-claude']}
flashKey={rowFlash['child-claude'] ?? 0}
pending={rowPending['child-claude']}
spawnRow
registerRef={(node) => {
rowRefs.current['child-claude'] = node
}}
</div>
) : null}
{displayedChildCount >= 2 ? (
<div className="feature-wall-child-card-shell">
<WorkspaceCard
variant="default"
name="PR 2/2: withSession middleware"
dataCard="child-claude"
childPadding
rows={[
<AgentRow
key="child-claude"
agentKey="child-claude"
icon={<ClaudeIcon size={13} />}
state={rowState['child-claude']}
message={rowMessages['child-claude']}
flashKey={rowFlash['child-claude'] ?? 0}
pending={rowPending['child-claude']}
spawnRow
registerRef={(node) => {
rowRefs.current['child-claude'] = node
}}
/>
]}
/>
]}
/>
</div>
) : null}
</div>
</div>

View File

@ -35,10 +35,13 @@ export function WorkspaceCard(props: {
/>
<span
className={cn(
'truncate text-[14.5px] font-semibold leading-[1.2] text-foreground',
'truncate font-semibold leading-[1.2] text-foreground',
dimName && 'opacity-55'
)}
style={dimName ? { opacity: 0.55 } : undefined}
style={{
fontSize: 'var(--feature-wall-workspace-title-size, 14.5px)',
...(dimName ? { opacity: 0.55 } : {})
}}
>
{name}
</span>
@ -62,20 +65,53 @@ export function AgentRow(props: {
return (
<div
ref={registerRef}
className={cn('grid items-center gap-[9px] pl-1', spawnRow && 'feature-wall-spawn-row')}
style={{ gridTemplateColumns: '16px 16px minmax(0, 1fr)' }}
className={cn(
'feature-wall-agent-row grid items-center pl-1',
spawnRow && 'feature-wall-spawn-row'
)}
style={{
columnGap: 'var(--feature-wall-agent-row-gap, 9px)',
gridTemplateColumns:
'var(--feature-wall-agent-status-col, 16px) var(--feature-wall-agent-icon-col, 16px) minmax(0, 1fr)'
}}
data-pending={pending ? 'true' : undefined}
>
<span className="inline-flex size-4 items-center justify-center">
<span
className="feature-wall-agent-status inline-flex items-center justify-center"
style={{
height: 'var(--feature-wall-agent-status-box, 16px)',
width: 'var(--feature-wall-agent-status-box, 16px)'
}}
>
{state === 'working' ? (
<AgentStateDot state="working" size="md" />
) : (
<span className="inline-flex size-3 items-center justify-center text-emerald-500">
<CircleCheck className="size-3" aria-hidden />
<span
className="inline-flex items-center justify-center text-emerald-500"
style={{
height: 'var(--feature-wall-agent-status-icon, 12px)',
width: 'var(--feature-wall-agent-status-icon, 12px)'
}}
>
<CircleCheck
aria-hidden
style={{
height: 'var(--feature-wall-agent-status-icon, 12px)',
width: 'var(--feature-wall-agent-status-icon, 12px)'
}}
/>
</span>
)}
</span>
<span className="inline-flex size-4 items-center justify-center">{icon}</span>
<span
className="feature-wall-agent-icon inline-flex items-center justify-center"
style={{
height: 'var(--feature-wall-agent-icon-box, 16px)',
width: 'var(--feature-wall-agent-icon-box, 16px)'
}}
>
{icon}
</span>
<span
// Why: re-keying on flashKey forces React to remount the span so the
// CSS `feature-wall-msg-received` animation actually replays each
@ -83,9 +119,10 @@ export function AgentRow(props: {
// keeps its already-finished animation and only the text changes.
key={flashKey}
className={cn(
'truncate text-[13px] leading-[1.3] text-foreground',
'truncate leading-[1.3] text-foreground',
flashKey > 0 && 'feature-wall-msg-received'
)}
style={{ fontSize: 'var(--feature-wall-agent-message-size, 13px)' }}
>
{message}
</span>

View File

@ -2,6 +2,8 @@
export const BUBBLE_FLIGHT_MS = 1600
export const BUBBLE_LAND_MS = BUBBLE_FLIGHT_MS + 360
export const BUBBLE_GAP_MS = 3400
export const ORCHESTRATION_CLI_COMMAND_TIMINGS_MS = [250, 2500, 5200, 8600] as const
export const ORCHESTRATION_CLI_COMMAND_LOOP_MS = 12800
export type AgentKey = 'coord-claude' | 'child-codex' | 'child-claude'

View File

@ -17,6 +17,7 @@ type OnboardingInlineCommandTerminalProps = {
ariaLabel: string
terminalHeightPx?: number
terminalTopMarginPx?: number
descriptionPaddingClassName?: string
autoScrollIntoView?: boolean
worktreeId?: string
onOpened?: () => void
@ -30,6 +31,7 @@ export function OnboardingInlineCommandTerminal({
ariaLabel,
terminalHeightPx = 280,
terminalTopMarginPx = 20,
descriptionPaddingClassName = 'px-4 py-3',
autoScrollIntoView = true,
worktreeId = ONBOARDING_INLINE_TERMINAL_WORKTREE_ID,
onOpened,
@ -225,7 +227,7 @@ export function OnboardingInlineCommandTerminal({
className="min-h-0 overflow-hidden rounded-xl border border-border bg-card"
>
{description ? (
<div className="border-b border-border px-4 py-3">
<div className={`border-b border-border ${descriptionPaddingClassName}`}>
<p className="text-xs leading-relaxed text-muted-foreground">{description}</p>
</div>
) : null}

View File

@ -120,7 +120,7 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
}
return (
<section className="space-y-4">
<section className="space-y-4" data-settings-section="cli">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Orca CLI</h2>
<p className="text-xs text-muted-foreground">

View File

@ -80,9 +80,11 @@ export const GENERAL_NAVIGATION_SEARCH_ENTRIES: SettingsSearchEntry[] = [
export const GENERAL_CLI_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Shell command',
title: 'Orca CLI',
description: 'Register or remove the orca shell command.',
keywords: ['cli', 'path', 'terminal', 'command']
keywords: ['cli', 'path', 'terminal', 'command', 'shell command'],
cmdJKeywords: ['cli', 'path', 'command', 'shell command'],
targetSectionId: 'cli'
},
{
title: 'Agent skill',

View File

@ -2,6 +2,8 @@ export type SettingsSearchEntry = {
title: string
description?: string
keywords?: string[]
cmdJKeywords?: string[]
targetSectionId?: string
}
export function normalizeSettingsSearchQuery(query: string): string {

View File

@ -3,6 +3,7 @@ export {
COMPUTER_USE_SKILL_INSTALL_COMMAND,
COMPUTER_USE_SKILL_NAME,
ORCA_CLI_SKILL_INSTALL_COMMAND,
ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND,
ORCA_CLI_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
} from '../../../shared/agent-feature-install-commands'

View File

@ -22,3 +22,8 @@ export const COMPUTER_USE_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallC
export const ORCHESTRATION_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
ORCHESTRATION_SKILL_NAME
])
export const ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
ORCA_CLI_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
])

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
FEATURE_TIPS,
getCompletedFeatureTipIds,
getOrderedUnseenFeatureTips,
normalizeFeatureTipIds,
@ -10,12 +11,12 @@ describe('feature tips', () => {
it('orders new unseen tips before older unseen tips', () => {
const tips = getOrderedUnseenFeatureTips({ seenTipIds: new Set<FeatureTipId>() })
expect(tips.map((tip) => tip.id)).toEqual(['voice-dictation'])
expect(tips.map((tip) => tip.id)).toEqual(['orca-cli', 'voice-dictation'])
})
it('skips tips the user has already seen', () => {
const tips = getOrderedUnseenFeatureTips({
seenTipIds: new Set<FeatureTipId>(['voice-dictation'])
seenTipIds: new Set<FeatureTipId>(['voice-dictation', 'orca-cli'])
})
expect(tips.map((tip) => tip.id)).toEqual([])
@ -24,7 +25,22 @@ describe('feature tips', () => {
it('skips tips for features the user has already completed', () => {
const tips = getOrderedUnseenFeatureTips({
seenTipIds: new Set<FeatureTipId>(),
completedTipIds: getCompletedFeatureTipIds({ voiceDictationEnabled: true })
completedTipIds: getCompletedFeatureTipIds({
cliInstalled: true,
voiceDictationEnabled: true
})
})
expect(tips.map((tip) => tip.id)).toEqual([])
})
it('skips the CLI tip when the CLI is already installed', () => {
const tips = getOrderedUnseenFeatureTips({
seenTipIds: new Set<FeatureTipId>(['voice-dictation']),
completedTipIds: getCompletedFeatureTipIds({
cliInstalled: true,
voiceDictationEnabled: false
})
})
expect(tips.map((tip) => tip.id)).toEqual([])
@ -34,6 +50,7 @@ describe('feature tips', () => {
const tips = getOrderedUnseenFeatureTips({
seenTipIds: new Set<FeatureTipId>(),
completedTipIds: getCompletedFeatureTipIds({
cliInstalled: false,
voiceDictationEnabled: false,
featureInteractions: {
'voice-dictation': { firstInteractedAt: 100, interactionCount: 1 }
@ -41,12 +58,31 @@ describe('feature tips', () => {
})
})
expect(tips.map((tip) => tip.id)).toEqual([])
expect(tips.map((tip) => tip.id)).toEqual(['orca-cli'])
})
it('normalizes persisted tip ids', () => {
expect(normalizeFeatureTipIds(['feature-tour', 'bogus', 'voice-dictation'])).toEqual([
'voice-dictation'
])
expect(
normalizeFeatureTipIds(['feature-tour', 'orca-cli', 'bogus', 'voice-dictation'])
).toEqual(['orca-cli', 'voice-dictation'])
})
it('describes the CLI tip as an install action with concrete workflows', () => {
const cliTip = FEATURE_TIPS.find((tip) => tip.id === 'orca-cli')
expect(cliTip).toMatchObject({
action: 'setup-cli',
title: 'Let agents drive Orca with the Orca CLI',
ctaLabel: 'Install CLI & Skills'
})
expect(cliTip?.description).toContain('coordinate child workspaces')
expect(cliTip?.description).toContain('communicate between workspaces')
})
it('does not label the voice dictation tip as new', () => {
const voiceTip = FEATURE_TIPS.find((tip) => tip.id === 'voice-dictation')
expect(voiceTip?.eyebrow).toBe('Tip')
expect(voiceTip?.priority).toBe('unseen')
})
})

View File

@ -4,11 +4,11 @@ import {
type FeatureInteractionState
} from './feature-interactions'
export type FeatureTipId = 'voice-dictation'
export type FeatureTipId = 'voice-dictation' | 'orca-cli'
export type FeatureTipPriority = 'new' | 'unseen'
export type FeatureTipAction = 'enable-voice'
export type FeatureTipAction = 'enable-voice' | 'setup-cli'
export type FeatureTip = {
id: FeatureTipId
@ -23,15 +23,26 @@ export type FeatureTip = {
}
export type CompletedFeatureTipState = {
cliInstalled: boolean
voiceDictationEnabled: boolean
featureInteractions?: FeatureInteractionState
}
export const FEATURE_TIPS = [
{
id: 'voice-dictation',
id: 'orca-cli',
priority: 'new',
eyebrow: 'New',
eyebrow: 'Tip',
title: 'Let agents drive Orca with the Orca CLI',
description: 'Enable agents to coordinate child workspaces and communicate between workspaces.',
action: 'setup-cli',
ctaLabel: 'Install CLI & Skills',
completedByFeatureInteractions: []
},
{
id: 'voice-dictation',
priority: 'unseen',
eyebrow: 'Tip',
title: 'Voice Dictation is here',
description:
'Speak into any focused pane and Orca will transcribe it. Press the dictation shortcut to start and stop.',
@ -63,6 +74,9 @@ export function normalizeFeatureTipIds(value: unknown): FeatureTipId[] {
export function getCompletedFeatureTipIds(state: CompletedFeatureTipState): Set<FeatureTipId> {
const completedIds = new Set<FeatureTipId>()
if (state.cliInstalled) {
completedIds.add('orca-cli')
}
if (state.voiceDictationEnabled) {
completedIds.add('voice-dictation')
}

View File

@ -265,6 +265,46 @@ describe('settings_changed schema', () => {
})
})
describe('orca cli feature tip schemas', () => {
it('accepts the shown event for app-open exposure', () => {
const parsed = eventSchemas.orca_cli_feature_tip_shown.safeParse({
source: 'app_open'
})
expect(parsed.success).toBe(true)
})
it('accepts setup click and setup result events', () => {
expect(
eventSchemas.orca_cli_feature_tip_setup_clicked.safeParse({
source: 'app_open'
}).success
).toBe(true)
expect(
eventSchemas.orca_cli_feature_tip_setup_result.safeParse({
source: 'app_open',
result: 'installed'
}).success
).toBe(true)
})
it('rejects raw CLI details and unknown result values', () => {
expect(
eventSchemas.orca_cli_feature_tip_setup_result.safeParse({
source: 'app_open',
result: 'installed',
command_path: '/Users/alice/bin/orca'
}).success
).toBe(false)
expect(
eventSchemas.orca_cli_feature_tip_setup_result.safeParse({
source: 'app_open',
result: 'installed_after_retry'
}).success
).toBe(false)
})
})
describe('commonPropsSchema', () => {
it('round-trips a realistic payload', () => {
const parsed = commonPropsSchema.safeParse({

View File

@ -323,6 +323,27 @@ const settingsChangedSchema = z
const telemetryOptedInSchema = z.object({ via: optInViaSchema }).strict()
const telemetryOptedOutSchema = z.object({ via: optInViaSchema }).strict()
const orcaCliFeatureTipSourceSchema = z.enum(['app_open', 'manual'])
const orcaCliFeatureTipShownSchema = z
.object({
source: orcaCliFeatureTipSourceSchema,
nth_repo_added: nthRepoAddedSchema
})
.strict()
const orcaCliFeatureTipSetupClickedSchema = z
.object({
source: orcaCliFeatureTipSourceSchema,
nth_repo_added: nthRepoAddedSchema
})
.strict()
const orcaCliFeatureTipSetupResultSchema = z
.object({
source: orcaCliFeatureTipSourceSchema,
result: z.enum(['installed', 'needs_attention', 'dev_preview', 'failed']),
nth_repo_added: nthRepoAddedSchema
})
.strict()
const featureWallOpenedSchema = z
.object({
source: featureWallOpenSourceSchema
@ -1046,6 +1067,10 @@ export const eventSchemas = {
telemetry_opted_in: telemetryOptedInSchema,
telemetry_opted_out: telemetryOptedOutSchema,
orca_cli_feature_tip_shown: orcaCliFeatureTipShownSchema,
orca_cli_feature_tip_setup_clicked: orcaCliFeatureTipSetupClickedSchema,
orca_cli_feature_tip_setup_result: orcaCliFeatureTipSetupResultSchema,
feature_wall_opened: featureWallOpenedSchema,
feature_wall_closed: featureWallClosedSchema,
feature_wall_tile_focused: featureWallTileFocusedSchema,
@ -1130,6 +1155,9 @@ type _CohortExtendedRoster =
| 'agent_started'
| 'agent_prompt_sent'
| 'agent_error'
| 'orca_cli_feature_tip_shown'
| 'orca_cli_feature_tip_setup_clicked'
| 'orca_cli_feature_tip_setup_result'
// Why: `z.object({}).strict()` infers a string index signature, which would
// make every key appear present. Ignore index-signature-only keys here so
// strict empty event payloads do not get pulled into keyed telemetry rosters.

View File

@ -1,6 +1,6 @@
import { ONBOARDING_FINAL_STEP } from '../../../src/shared/constants'
const SEEN_FIRST_RUN_FEATURE_TIP_IDS = ['voice-dictation'] as const
const SEEN_FIRST_RUN_FEATURE_TIP_IDS = ['voice-dictation', 'orca-cli'] as const
export function getE2ECompletedOnboardingProfile() {
return {