parent
aeadf5a035
commit
39e6f85fdc
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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">> </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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ export type SettingsSearchEntry = {
|
|||
title: string
|
||||
description?: string
|
||||
keywords?: string[]
|
||||
cmdJKeywords?: string[]
|
||||
targetSectionId?: string
|
||||
}
|
||||
|
||||
export function normalizeSettingsSearchQuery(query: string): string {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue