From 39e6f85fdcd11b297b648359639ca8d191a85a35 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 29 May 2026 16:20:13 -0700 Subject: [PATCH] Add Orca CLI feature tip (#3279) Co-authored-by: Orca --- src/cli/runtime/transport.test.ts | 6 +- src/renderer/src/App.tsx | 37 ++- src/renderer/src/assets/main.css | 122 ++++++-- .../src/components/WorktreeJumpPalette.tsx | 4 + .../components/cmd-j/palette-results.test.ts | 33 +- .../src/components/cmd-j/palette-results.ts | 48 ++- .../feature-tips/FeatureTipsModal.tsx | 295 +++++++++++++++++- .../feature-tip-cli-install-action.test.ts | 56 ++++ .../feature-tip-cli-install-action.ts | 15 + .../feature-tip-modal-state.test.ts | 32 +- .../feature-tips/feature-tip-modal-state.ts | 2 + .../feature-tip-startup-gate.test.ts | 118 ++++++- .../feature-tips/feature-tip-startup-gate.ts | 10 + .../feature-tip-telemetry.test.ts | 49 +++ .../feature-tips/feature-tip-telemetry.ts | 24 ++ .../AgentsOrchestrationVisual.tsx | 16 +- .../OrchestrationPage.tsx | 244 ++++++++------- .../orchestration-cards.tsx | 55 +++- .../orchestration-types.ts | 2 + .../OnboardingInlineCommandTerminal.tsx | 4 +- .../src/components/settings/CliSection.tsx | 2 +- .../src/components/settings/general-search.ts | 6 +- .../components/settings/settings-search.ts | 2 + .../src/lib/agent-feature-install-commands.ts | 1 + src/shared/agent-feature-install-commands.ts | 5 + src/shared/feature-tips.test.ts | 50 ++- src/shared/feature-tips.ts | 22 +- src/shared/telemetry-events.test.ts | 40 +++ src/shared/telemetry-events.ts | 28 ++ .../e2e-completed-onboarding-profile.ts | 2 +- 30 files changed, 1127 insertions(+), 203 deletions(-) create mode 100644 src/renderer/src/components/feature-tips/feature-tip-cli-install-action.test.ts create mode 100644 src/renderer/src/components/feature-tips/feature-tip-cli-install-action.ts create mode 100644 src/renderer/src/components/feature-tips/feature-tip-telemetry.test.ts create mode 100644 src/renderer/src/components/feature-tips/feature-tip-telemetry.ts diff --git a/src/cli/runtime/transport.test.ts b/src/cli/runtime/transport.test.ts index c6d88d010..ddbe43124 100644 --- a/src/cli/runtime/transport.test.ts +++ b/src/cli/runtime/transport.test.ts @@ -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) }) }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d2a412df8..fb7d2c9ae 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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(null) const featureTipsPromptedThisSessionRef = useRef(false) const featureTipsSuppressedByOnboardingThisSessionRef = useRef(false) + const [featureTipCliInstalled, setFeatureTipCliInstalled] = useState(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, diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index fc93e49c0..8032f8706 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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 { diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 456c3fc5c..0a1de13d7 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -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('') diff --git a/src/renderer/src/components/cmd-j/palette-results.test.ts b/src/renderer/src/components/cmd-j/palette-results.test.ts index 3ad5928f4..4c3bfb0b5 100644 --- a/src/renderer/src/components/cmd-j/palette-results.test.ts +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -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() diff --git a/src/renderer/src/components/cmd-j/palette-results.ts b/src/renderer/src/components/cmd-j/palette-results.ts index 90056df6a..f62958565 100644 --- a/src/renderer/src/components/cmd-j/palette-results.ts +++ b/src/renderer/src/components/cmd-j/palette-results.ts @@ -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[] { diff --git a/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx b/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx index 5f672dc31..2371be283 100644 --- a/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx +++ b/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx @@ -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 ( + diff --git a/src/renderer/src/components/feature-wall/agents-orchestration/orchestration-cards.tsx b/src/renderer/src/components/feature-wall/agents-orchestration/orchestration-cards.tsx index 1196eb033..69fb4cd81 100644 --- a/src/renderer/src/components/feature-wall/agents-orchestration/orchestration-cards.tsx +++ b/src/renderer/src/components/feature-wall/agents-orchestration/orchestration-cards.tsx @@ -35,10 +35,13 @@ export function WorkspaceCard(props: { /> {name} @@ -62,20 +65,53 @@ export function AgentRow(props: { return (
- + {state === 'working' ? ( ) : ( - - + + )} - {icon} + + {icon} + 0 && 'feature-wall-msg-received' )} + style={{ fontSize: 'var(--feature-wall-agent-message-size, 13px)' }} > {message} diff --git a/src/renderer/src/components/feature-wall/agents-orchestration/orchestration-types.ts b/src/renderer/src/components/feature-wall/agents-orchestration/orchestration-types.ts index c26ba26f6..6f10f8569 100644 --- a/src/renderer/src/components/feature-wall/agents-orchestration/orchestration-types.ts +++ b/src/renderer/src/components/feature-wall/agents-orchestration/orchestration-types.ts @@ -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' diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx index dab0902b1..9b07547cc 100644 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx @@ -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 ? ( -
+

{description}

) : null} diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 30b9128e9..bc332e736 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -120,7 +120,7 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem } return ( -
+

Orca CLI

diff --git a/src/renderer/src/components/settings/general-search.ts b/src/renderer/src/components/settings/general-search.ts index 6a5ffbcb3..332dbd68c 100644 --- a/src/renderer/src/components/settings/general-search.ts +++ b/src/renderer/src/components/settings/general-search.ts @@ -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', diff --git a/src/renderer/src/components/settings/settings-search.ts b/src/renderer/src/components/settings/settings-search.ts index c72750bf5..59abab8a2 100644 --- a/src/renderer/src/components/settings/settings-search.ts +++ b/src/renderer/src/components/settings/settings-search.ts @@ -2,6 +2,8 @@ export type SettingsSearchEntry = { title: string description?: string keywords?: string[] + cmdJKeywords?: string[] + targetSectionId?: string } export function normalizeSettingsSearchQuery(query: string): string { diff --git a/src/renderer/src/lib/agent-feature-install-commands.ts b/src/renderer/src/lib/agent-feature-install-commands.ts index 6607c22cd..030996c80 100644 --- a/src/renderer/src/lib/agent-feature-install-commands.ts +++ b/src/renderer/src/lib/agent-feature-install-commands.ts @@ -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' diff --git a/src/shared/agent-feature-install-commands.ts b/src/shared/agent-feature-install-commands.ts index 0b9a4ed58..fe9d227b3 100644 --- a/src/shared/agent-feature-install-commands.ts +++ b/src/shared/agent-feature-install-commands.ts @@ -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 +]) diff --git a/src/shared/feature-tips.test.ts b/src/shared/feature-tips.test.ts index 56365ab72..f47377206 100644 --- a/src/shared/feature-tips.test.ts +++ b/src/shared/feature-tips.test.ts @@ -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() }) - 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(['voice-dictation']) + seenTipIds: new Set(['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(), - 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(['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(), 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') }) }) diff --git a/src/shared/feature-tips.ts b/src/shared/feature-tips.ts index a4b8bc371..7db691fe9 100644 --- a/src/shared/feature-tips.ts +++ b/src/shared/feature-tips.ts @@ -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 { const completedIds = new Set() + if (state.cliInstalled) { + completedIds.add('orca-cli') + } if (state.voiceDictationEnabled) { completedIds.add('voice-dictation') } diff --git a/src/shared/telemetry-events.test.ts b/src/shared/telemetry-events.test.ts index 29e917216..90924dd50 100644 --- a/src/shared/telemetry-events.test.ts +++ b/src/shared/telemetry-events.test.ts @@ -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({ diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index fb62716ce..fe00d07f9 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -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. diff --git a/tests/e2e/helpers/e2e-completed-onboarding-profile.ts b/tests/e2e/helpers/e2e-completed-onboarding-profile.ts index 8127616b7..74bb5494a 100644 --- a/tests/e2e/helpers/e2e-completed-onboarding-profile.ts +++ b/tests/e2e/helpers/e2e-completed-onboarding-profile.ts @@ -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 {