Add setup guide and intro callout for mobile emulator agent control (#5511)
Provides an optional, inline setup walkthrough within the emulator pane and an introductory tab callout to guide users through registering the Orca CLI and installing the necessary skill for agent control. - Adds inline setup guide components, visibility hooks, and unit tests - Introduces tab intro callout on macOS with Keep/Hide actions - Displays a persistent toast when the emulator pane is hidden - Ensures settings and tabs remain synced on user preference changes
This commit is contained in:
parent
ee8327dde9
commit
ffd340c264
|
|
@ -1011,6 +1011,36 @@
|
|||
background: color-mix(in srgb, var(--worktree-sidebar-foreground) 14%, var(--worktree-sidebar));
|
||||
}
|
||||
|
||||
/* Why: the first-run Mobile Emulator intro sits directly under that menu row;
|
||||
an upward caret aligned to the phone icon reads as a callout, not a footer. */
|
||||
.mobile-emulator-tab-intro-callout--menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-emulator-tab-intro-callout--menu::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: 0.75rem;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
transform: rotate(45deg);
|
||||
border-left: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--card) 80%, var(--background));
|
||||
}
|
||||
|
||||
/* Why: anchor the optional setup card over the emulator without blurring the
|
||||
device preview, which read as a rendering glitch. */
|
||||
.mobile-emulator-agent-setup-guide-scrim {
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
color-mix(in srgb, var(--background) 92%, transparent),
|
||||
transparent
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Why: the detected command is editable, but a pure app-background fill reads
|
||||
like a black box in dark mode and overpowers the prompt card. */
|
||||
.setup-script-prompt-command {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { isMacOs } from './emulator-pane-types'
|
|||
import { EmulatorUnavailablePane } from './emulator-unavailable-pane'
|
||||
import { EmulatorPaneToolbar } from './emulator-pane-toolbar'
|
||||
import { EmulatorDeviceFrame } from './emulator-device-frame'
|
||||
import { MobileEmulatorAgentSetupGuideLayer } from './MobileEmulatorAgentSetupGuideLayer'
|
||||
import { useEmulatorPaneSession } from './use-emulator-pane-session'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
|
|
@ -69,25 +70,27 @@ function EmulatorPaneContent({ tab, worktreeId, isActive = true }: EmulatorPaneP
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-muted px-3 py-6">
|
||||
{!isLive && !loading ? (
|
||||
<p className="mb-4 text-center text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.emulator.pane.EmulatorPane.59b08fa031',
|
||||
'No emulator connected'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<EmulatorDeviceFrame
|
||||
previewUrl={previewUrl}
|
||||
wsUrl={wsUrl}
|
||||
streamKey={streamKey}
|
||||
deviceName={displayName}
|
||||
loading={loading}
|
||||
isLive={isLive}
|
||||
onTap={(x, y) => void sendTap(x, y)}
|
||||
onGesture={(points) => void sendGesture(points)}
|
||||
/>
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-muted px-3 py-6">
|
||||
<MobileEmulatorAgentSetupGuideLayer isActive={isActive} worktreeId={worktreeId}>
|
||||
{!isLive && !loading ? (
|
||||
<p className="mb-4 text-center text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.emulator.pane.EmulatorPane.59b08fa031',
|
||||
'No emulator connected'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<EmulatorDeviceFrame
|
||||
previewUrl={previewUrl}
|
||||
wsUrl={wsUrl}
|
||||
streamKey={streamKey}
|
||||
deviceName={displayName}
|
||||
loading={loading}
|
||||
isLive={isLive}
|
||||
onTap={(x, y) => void sendTap(x, y)}
|
||||
onGesture={(points) => void sendGesture(points)}
|
||||
/>
|
||||
</MobileEmulatorAgentSetupGuideLayer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/store'
|
||||
import { Button } from '../ui/button'
|
||||
import { MobileEmulatorAgentSetupGuideSteps } from './MobileEmulatorAgentSetupGuideSteps'
|
||||
import type { useMobileEmulatorAgentSetupState } from './use-mobile-emulator-agent-setup-state'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type MobileEmulatorAgentSetupGuideProps = {
|
||||
setup: ReturnType<typeof useMobileEmulatorAgentSetupState>
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
export function MobileEmulatorAgentSetupGuide({
|
||||
setup,
|
||||
worktreeId
|
||||
}: MobileEmulatorAgentSetupGuideProps): React.JSX.Element {
|
||||
const dismissMobileEmulatorAgentSetup = useAppStore((s) => s.dismissMobileEmulatorAgentSetup)
|
||||
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const dismiss = (): void => {
|
||||
dismissMobileEmulatorAgentSetup()
|
||||
}
|
||||
|
||||
const openSettings = (): void => {
|
||||
recordFeatureInteraction('mobile-emulator-agent-setup')
|
||||
openSettingsTarget({ pane: 'mobile-emulator', repoId: null })
|
||||
openSettingsPage()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={translate(
|
||||
'auto.components.emulator.pane.MobileEmulatorAgentSetupGuide.2fda9ff015',
|
||||
'Set up agent control'
|
||||
)}
|
||||
className="overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-[0_10px_24px_rgba(0,0,0,0.18)]"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<p className="min-w-0 flex-1 text-[11px] leading-4 text-muted-foreground">
|
||||
{setup.setupComplete ? (
|
||||
<span className="font-medium text-foreground">
|
||||
{translate(
|
||||
'auto.components.emulator.pane.MobileEmulatorAgentSetupGuide.0ac0fef514',
|
||||
'Agent control is ready.'
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium text-foreground">
|
||||
{translate(
|
||||
'auto.components.emulator.pane.MobileEmulatorAgentSetupGuide.2bdfff8763',
|
||||
'Agent control (optional).'
|
||||
)}{' '}
|
||||
</span>
|
||||
{translate(
|
||||
'auto.components.emulator.pane.MobileEmulatorAgentSetupGuide.72736b051f',
|
||||
'Set up Orca CLI + skill when you want agents to drive this simulator.'
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1 self-center">
|
||||
{setup.setupComplete ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-6 px-2.5 text-[11px]"
|
||||
onClick={dismiss}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.emulator.pane.MobileEmulatorAgentSetupGuide.d10ae98046',
|
||||
'Done'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[11px] text-muted-foreground"
|
||||
onClick={dismiss}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.emulator.pane.MobileEmulatorAgentSetupGuide.3756cbeca7',
|
||||
'Not now'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={expanded ? 'secondary' : 'default'}
|
||||
className="h-6 gap-1 px-2 text-[11px]"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{expanded
|
||||
? translate(
|
||||
'auto.components.emulator.pane.MobileEmulatorAgentSetupGuide.6d950431d2',
|
||||
'Hide'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.emulator.pane.MobileEmulatorAgentSetupGuide.ebceac65a4',
|
||||
'Set up'
|
||||
)}
|
||||
{expanded ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && !setup.setupComplete ? (
|
||||
<div className="scrollbar-sleek max-h-[min(36vh,16rem)] overflow-y-auto border-t border-border/60 px-3 pb-2">
|
||||
<div className="flex items-center justify-end py-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-[10px] font-medium',
|
||||
setup.setupComplete
|
||||
? 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{setup.completedCount}/2
|
||||
</span>
|
||||
</div>
|
||||
<MobileEmulatorAgentSetupGuideSteps setup={setup} worktreeId={worktreeId} />
|
||||
<div className="pb-1 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openSettings}
|
||||
className="text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
{translate(
|
||||
'auto.components.emulator.pane.MobileEmulatorAgentSetupGuide.3f003507f4',
|
||||
'Open full setup in Settings'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { useState, type ReactNode } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { MobileEmulatorAgentSetupGuide } from './MobileEmulatorAgentSetupGuide'
|
||||
import { shouldShowMobileEmulatorAgentSetupGuide } from './mobile-emulator-agent-setup-visibility'
|
||||
import { useMobileEmulatorAgentSetupState } from './use-mobile-emulator-agent-setup-state'
|
||||
|
||||
type MobileEmulatorAgentSetupGuideLayerProps = {
|
||||
children: ReactNode
|
||||
isActive: boolean
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
export function MobileEmulatorAgentSetupGuideLayer({
|
||||
children,
|
||||
isActive,
|
||||
worktreeId
|
||||
}: MobileEmulatorAgentSetupGuideLayerProps): React.JSX.Element {
|
||||
const mobileEmulatorAgentSetupDismissed = useAppStore((s) => s.mobileEmulatorAgentSetupDismissed)
|
||||
const setup = useMobileEmulatorAgentSetupState(isActive)
|
||||
const [initialProbeComplete, setInitialProbeComplete] = useState(false)
|
||||
|
||||
if (!initialProbeComplete && setup.statusReady) {
|
||||
setInitialProbeComplete(true)
|
||||
}
|
||||
|
||||
const showGuide = shouldShowMobileEmulatorAgentSetupGuide({
|
||||
dismissed: mobileEmulatorAgentSetupDismissed,
|
||||
initialProbeComplete,
|
||||
isActive,
|
||||
statusReady: setup.statusReady
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{children}
|
||||
{showGuide ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 flex max-h-[min(72%,28rem)] flex-col justify-end px-3 pb-3">
|
||||
{/* Why: a bottom scrim keeps the card readable without blurring the
|
||||
simulator preview, which read as a rendering glitch. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mobile-emulator-agent-setup-guide-scrim absolute inset-x-0 bottom-0 h-40"
|
||||
/>
|
||||
<div className="pointer-events-auto relative">
|
||||
<MobileEmulatorAgentSetupGuide setup={setup} worktreeId={worktreeId} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/store'
|
||||
import { ORCA_CLI_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands'
|
||||
import {
|
||||
AGENT_SKILL_CLI_PREREQUISITE_NOTICE,
|
||||
ensureOrcaCliAvailableForAgentSkillTerminal
|
||||
} from '@/lib/agent-skill-cli-prerequisite'
|
||||
import { AgentSkillSetupPanel } from '../settings/AgentSkillSetupPanel'
|
||||
import { StepBadge } from '../settings/BrowserUseStepBadge'
|
||||
import { Button } from '../ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
||||
import {
|
||||
getMobileEmulatorCliStepBadgeState,
|
||||
shouldShowMobileEmulatorSkillPreInstallNotice
|
||||
} from './mobile-emulator-agent-setup-cli-state'
|
||||
import type { useMobileEmulatorAgentSetupState } from './use-mobile-emulator-agent-setup-state'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type MobileEmulatorAgentSetupGuideStepsProps = {
|
||||
setup: ReturnType<typeof useMobileEmulatorAgentSetupState>
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
export function MobileEmulatorAgentSetupGuideSteps({
|
||||
setup,
|
||||
worktreeId
|
||||
}: MobileEmulatorAgentSetupGuideStepsProps): React.JSX.Element {
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const terminalWorktreeId = `mobile-emulator-${worktreeId}-orca-cli-skill-terminal`
|
||||
const showSkillPreInstallNotice = shouldShowMobileEmulatorSkillPreInstallNotice({
|
||||
cliEnabled: setup.cliEnabled,
|
||||
cliSkillInstalled: setup.cliSkillInstalled
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border/40">
|
||||
<div className="flex items-center gap-3 py-2.5">
|
||||
<StepBadge
|
||||
index={1}
|
||||
state={getMobileEmulatorCliStepBadgeState({
|
||||
cliBusy: setup.cliBusy,
|
||||
cliEnabled: setup.cliEnabled,
|
||||
cliPathNeedsAttention: setup.cliPathNeedsAttention
|
||||
})}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-sm font-medium">
|
||||
{translate(
|
||||
'auto.components.emulator.pane.MobileEmulatorAgentSetupGuideSteps.9b49d892e3',
|
||||
'Enable Orca CLI'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.emulator.pane.MobileEmulatorAgentSetupGuideSteps.3d8dc52c93',
|
||||
'Registers the orca command for emulator control in agent shells.'
|
||||
)}
|
||||
</p>
|
||||
{setup.cliInstallStatus?.commandPath && setup.cliEnabled ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.MobileEmulatorAgentControlRow.aaf62a3dd2',
|
||||
'Installed at'
|
||||
)}{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5">
|
||||
{setup.cliInstallStatus.commandPath}
|
||||
</code>
|
||||
</p>
|
||||
) : null}
|
||||
{setup.cliPathNeedsAttention && setup.cliInstallStatus?.detail ? (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400">
|
||||
{setup.cliInstallStatus.detail}
|
||||
</p>
|
||||
) : null}
|
||||
{!setup.cliEnabled && !setup.cliPathNeedsAttention && setup.cliInstallStatus?.detail ? (
|
||||
<p className="text-[11px] text-muted-foreground">{setup.cliInstallStatus.detail}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={setup.cliEnabled ? 'outline' : 'default'}
|
||||
disabled={
|
||||
setup.cliLoading || setup.cliBusy || !setup.cliSupported || setup.cliEnabled
|
||||
}
|
||||
onClick={() => {
|
||||
recordFeatureInteraction('mobile-emulator-agent-setup')
|
||||
void setup.handleEnableCli()
|
||||
}}
|
||||
>
|
||||
{setup.cliLoading ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
{setup.cliActionLabel}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!setup.cliSupported && !setup.cliLoading && setup.cliInstallStatus?.detail ? (
|
||||
<TooltipContent side="left" sideOffset={6}>
|
||||
{setup.cliInstallStatus.detail}
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div className={cn('flex items-start gap-3 py-2.5', setup.step2Blocked && 'opacity-60')}>
|
||||
<div className="mt-0.5 shrink-0">
|
||||
<StepBadge index={2} state={setup.cliSkillInstalled ? 'done' : 'pending'} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{translate(
|
||||
'auto.components.emulator.pane.MobileEmulatorAgentSetupGuideSteps.21f5687c07',
|
||||
'Orca CLI skill'
|
||||
)}
|
||||
</p>
|
||||
<AgentSkillSetupPanel
|
||||
variant="inline"
|
||||
hideHeader
|
||||
className="min-w-0"
|
||||
title={translate(
|
||||
'auto.components.emulator.pane.MobileEmulatorAgentSetupGuideSteps.21f5687c07',
|
||||
'Orca CLI skill'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.emulator.pane.MobileEmulatorAgentSetupGuideSteps.64fb057667',
|
||||
'Teaches agents the orca emulator commands for this worktree.'
|
||||
)}
|
||||
command={ORCA_CLI_SKILL_INSTALL_COMMAND}
|
||||
terminalTitle="Mobile emulator Orca CLI skill setup"
|
||||
terminalAriaLabel="Mobile emulator Orca CLI skill install terminal"
|
||||
terminalWorktreeId={terminalWorktreeId}
|
||||
installed={setup.cliSkillInstalled}
|
||||
loading={setup.cliSkillLoading || setup.setupRechecking}
|
||||
error={setup.cliSkillError}
|
||||
installDisabled={setup.step2Blocked}
|
||||
showInstallWhenInstalled={!setup.cliSkillInstalled}
|
||||
terminalHeightPx={112}
|
||||
preInstallNotice={
|
||||
showSkillPreInstallNotice ? AGENT_SKILL_CLI_PREREQUISITE_NOTICE : undefined
|
||||
}
|
||||
onBeforeOpenTerminal={async () => {
|
||||
recordFeatureInteraction('mobile-emulator-agent-setup')
|
||||
await ensureOrcaCliAvailableForAgentSkillTerminal()
|
||||
}}
|
||||
onRecheck={() => {
|
||||
recordFeatureInteraction('mobile-emulator-agent-setup')
|
||||
void setup.recheckSetup()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useMobileEmulatorTabIntroActions } from './use-mobile-emulator-tab-intro-actions'
|
||||
|
||||
type MobileEmulatorTabIntroCalloutProps = {
|
||||
onAction?: () => void
|
||||
}
|
||||
|
||||
export function MobileEmulatorTabIntroCallout({
|
||||
onAction
|
||||
}: MobileEmulatorTabIntroCalloutProps): React.JSX.Element {
|
||||
const { keepIntro, hideIntro, dismissIntro } = useMobileEmulatorTabIntroActions()
|
||||
|
||||
const runAndNotify = (action: () => void): void => {
|
||||
action()
|
||||
onAction?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mobile-emulator-tab-intro-callout--menu mx-1 mt-1 flex items-center gap-2 rounded-lg border border-border/70 bg-card/80 px-2 py-1.5 text-foreground"
|
||||
// Why: Radix dropdown treats pointer-down inside custom panels as an
|
||||
// outside-select; keep the menu open while the user reads or clicks Keep/Hide.
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<p className="min-w-0 flex-1 text-[11px] leading-4 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.emulator.pane.MobileEmulatorTabIntroCallout.5789936d9a',
|
||||
'Preview iOS simulators while agents drive the screen.'
|
||||
)}
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 px-2 text-[11px]"
|
||||
onClick={() => runAndNotify(keepIntro)}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.emulator.pane.MobileEmulatorTabIntroCallout.8014b4b80b',
|
||||
'Keep'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[11px] text-muted-foreground"
|
||||
onClick={() => runAndNotify(hideIntro)}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.emulator.pane.MobileEmulatorTabIntroCallout.6e051a40b7',
|
||||
'Hide'
|
||||
)}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'auto.components.emulator.pane.MobileEmulatorTabIntroCallout.1924982130',
|
||||
'Dismiss'
|
||||
)}
|
||||
className="size-6 text-muted-foreground"
|
||||
onClick={() => runAndNotify(dismissIntro)}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.emulator.pane.MobileEmulatorTabIntroCallout.1924982130',
|
||||
'Dismiss'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
|
||||
import {
|
||||
getMobileEmulatorCliPathNeedsAttention,
|
||||
getMobileEmulatorCliStepBadgeState,
|
||||
shouldShowMobileEmulatorSkillPreInstallNotice
|
||||
} from './mobile-emulator-agent-setup-cli-state'
|
||||
|
||||
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('getMobileEmulatorCliPathNeedsAttention', () => {
|
||||
it('flags installed CLIs that are not visible on PATH yet', () => {
|
||||
expect(getMobileEmulatorCliPathNeedsAttention(cliStatus({ pathConfigured: false }))).toBe(true)
|
||||
expect(getMobileEmulatorCliPathNeedsAttention(cliStatus())).toBe(false)
|
||||
expect(getMobileEmulatorCliPathNeedsAttention(cliStatus({ state: 'not_installed' }))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMobileEmulatorCliStepBadgeState', () => {
|
||||
it('marks enabled CLIs as done', () => {
|
||||
expect(
|
||||
getMobileEmulatorCliStepBadgeState({
|
||||
cliBusy: false,
|
||||
cliEnabled: true,
|
||||
cliPathNeedsAttention: false
|
||||
})
|
||||
).toBe('done')
|
||||
})
|
||||
|
||||
it('marks PATH-fix and registration flows as in progress', () => {
|
||||
expect(
|
||||
getMobileEmulatorCliStepBadgeState({
|
||||
cliBusy: true,
|
||||
cliEnabled: false,
|
||||
cliPathNeedsAttention: false
|
||||
})
|
||||
).toBe('in-progress')
|
||||
expect(
|
||||
getMobileEmulatorCliStepBadgeState({
|
||||
cliBusy: false,
|
||||
cliEnabled: false,
|
||||
cliPathNeedsAttention: true
|
||||
})
|
||||
).toBe('in-progress')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldShowMobileEmulatorSkillPreInstallNotice', () => {
|
||||
it('hides the prereq notice once either step is already complete', () => {
|
||||
expect(
|
||||
shouldShowMobileEmulatorSkillPreInstallNotice({
|
||||
cliEnabled: true,
|
||||
cliSkillInstalled: false
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldShowMobileEmulatorSkillPreInstallNotice({
|
||||
cliEnabled: false,
|
||||
cliSkillInstalled: true
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldShowMobileEmulatorSkillPreInstallNotice({
|
||||
cliEnabled: false,
|
||||
cliSkillInstalled: false
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
|
||||
import type { StepState } from '../settings/BrowserUseStepBadge'
|
||||
|
||||
export function getMobileEmulatorCliPathNeedsAttention(status: CliInstallStatus | null): boolean {
|
||||
return status?.state === 'installed' && !status.pathConfigured
|
||||
}
|
||||
|
||||
export function getMobileEmulatorCliStepBadgeState(input: {
|
||||
cliBusy: boolean
|
||||
cliEnabled: boolean
|
||||
cliPathNeedsAttention: boolean
|
||||
}): StepState {
|
||||
if (input.cliEnabled) {
|
||||
return 'done'
|
||||
}
|
||||
if (input.cliBusy || input.cliPathNeedsAttention) {
|
||||
return 'in-progress'
|
||||
}
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
export function shouldShowMobileEmulatorSkillPreInstallNotice(input: {
|
||||
cliEnabled: boolean
|
||||
cliSkillInstalled: boolean
|
||||
}): boolean {
|
||||
// Why: an installed skill should not reopen with "Install" just because CLI
|
||||
// probes are stale; only gate first-time setup on CLI availability.
|
||||
return !input.cliSkillInstalled && !input.cliEnabled
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldShowMobileEmulatorAgentSetupGuide } from './mobile-emulator-agent-setup-visibility'
|
||||
|
||||
describe('shouldShowMobileEmulatorAgentSetupGuide', () => {
|
||||
it('shows while setup is incomplete on an active pane', () => {
|
||||
expect(
|
||||
shouldShowMobileEmulatorAgentSetupGuide({
|
||||
dismissed: false,
|
||||
initialProbeComplete: true,
|
||||
isActive: true,
|
||||
statusReady: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('stays visible when setup is complete until the user dismisses it', () => {
|
||||
expect(
|
||||
shouldShowMobileEmulatorAgentSetupGuide({
|
||||
dismissed: false,
|
||||
initialProbeComplete: true,
|
||||
isActive: true,
|
||||
statusReady: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides before the first probe completes or after dismissal', () => {
|
||||
expect(
|
||||
shouldShowMobileEmulatorAgentSetupGuide({
|
||||
dismissed: false,
|
||||
initialProbeComplete: false,
|
||||
isActive: true,
|
||||
statusReady: false
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldShowMobileEmulatorAgentSetupGuide({
|
||||
dismissed: true,
|
||||
initialProbeComplete: true,
|
||||
isActive: true,
|
||||
statusReady: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('stays visible while Re-check or focus refresh reloads probes', () => {
|
||||
expect(
|
||||
shouldShowMobileEmulatorAgentSetupGuide({
|
||||
dismissed: false,
|
||||
initialProbeComplete: true,
|
||||
isActive: true,
|
||||
statusReady: false
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides on inactive panes pre-mounted for split safety', () => {
|
||||
expect(
|
||||
shouldShowMobileEmulatorAgentSetupGuide({
|
||||
dismissed: false,
|
||||
initialProbeComplete: true,
|
||||
isActive: false,
|
||||
statusReady: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
export type MobileEmulatorAgentSetupVisibilityInput = {
|
||||
dismissed: boolean
|
||||
initialProbeComplete: boolean
|
||||
isActive: boolean
|
||||
statusReady: boolean
|
||||
}
|
||||
|
||||
export function shouldShowMobileEmulatorAgentSetupGuide({
|
||||
dismissed,
|
||||
initialProbeComplete,
|
||||
isActive,
|
||||
statusReady
|
||||
}: MobileEmulatorAgentSetupVisibilityInput): boolean {
|
||||
if (!isActive || dismissed) {
|
||||
return false
|
||||
}
|
||||
// Why: only gate the first paint on probe readiness; in-panel Re-check and focus
|
||||
// refresh briefly set loading again and must not collapse the guide.
|
||||
if (!initialProbeComplete && !statusReady) {
|
||||
return false
|
||||
}
|
||||
// Why: when setup is already complete, keep a compact "ready" banner with Done
|
||||
// until the user explicitly dismisses it.
|
||||
return true
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { toast } from 'sonner'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const MOBILE_EMULATOR_HIDDEN_TOAST_ID = 'mobile-emulator-hidden'
|
||||
|
||||
type MobileEmulatorHiddenToastDeps = {
|
||||
openSettingsPage: AppState['openSettingsPage']
|
||||
openSettingsTarget: AppState['openSettingsTarget']
|
||||
}
|
||||
|
||||
export function showMobileEmulatorHiddenToast(deps: MobileEmulatorHiddenToastDeps): void {
|
||||
// Why: matches other one-time opt-out nudges — stay on screen until the user
|
||||
// dismisses it so the Settings re-enable path is easy to find.
|
||||
toast.info(
|
||||
translate(
|
||||
'auto.components.emulator.pane.mobile.emulator.hidden.toast.e8f098a870',
|
||||
'Mobile Emulator hidden'
|
||||
),
|
||||
{
|
||||
id: MOBILE_EMULATOR_HIDDEN_TOAST_ID,
|
||||
description: (
|
||||
<p className="text-sm text-popover-foreground/80">
|
||||
{translate(
|
||||
'auto.components.emulator.pane.mobile.emulator.hidden.toast.c46c979c1d',
|
||||
'Re-enable Mobile Emulator anytime in'
|
||||
)}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
deps.openSettingsTarget({ pane: 'mobile-emulator', repoId: null })
|
||||
deps.openSettingsPage()
|
||||
toast.dismiss(MOBILE_EMULATOR_HIDDEN_TOAST_ID)
|
||||
}}
|
||||
className="cursor-pointer font-medium text-popover-foreground underline underline-offset-2 hover:text-primary"
|
||||
>
|
||||
{translate(
|
||||
'auto.components.emulator.pane.mobile.emulator.hidden.toast.600f9a745a',
|
||||
'Settings › Mobile Emulator'
|
||||
)}
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
),
|
||||
duration: Infinity,
|
||||
dismissible: true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldShowMobileEmulatorTabIntro } from './mobile-emulator-tab-intro-visibility'
|
||||
|
||||
describe('shouldShowMobileEmulatorTabIntro', () => {
|
||||
it('shows the intro on macOS until the user dismisses it', () => {
|
||||
expect(
|
||||
shouldShowMobileEmulatorTabIntro({
|
||||
persistedUIReady: true,
|
||||
mobileEmulatorTabIntroDismissed: false,
|
||||
mobileEmulatorEnabled: true,
|
||||
isMacOs: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the intro after dismissal', () => {
|
||||
expect(
|
||||
shouldShowMobileEmulatorTabIntro({
|
||||
persistedUIReady: true,
|
||||
mobileEmulatorTabIntroDismissed: true,
|
||||
mobileEmulatorEnabled: true,
|
||||
isMacOs: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the intro when the feature is disabled', () => {
|
||||
expect(
|
||||
shouldShowMobileEmulatorTabIntro({
|
||||
persistedUIReady: true,
|
||||
mobileEmulatorTabIntroDismissed: false,
|
||||
mobileEmulatorEnabled: false,
|
||||
isMacOs: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the intro before persisted UI is ready or off macOS', () => {
|
||||
expect(
|
||||
shouldShowMobileEmulatorTabIntro({
|
||||
persistedUIReady: false,
|
||||
mobileEmulatorTabIntroDismissed: false,
|
||||
mobileEmulatorEnabled: true,
|
||||
isMacOs: true
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldShowMobileEmulatorTabIntro({
|
||||
persistedUIReady: true,
|
||||
mobileEmulatorTabIntroDismissed: false,
|
||||
mobileEmulatorEnabled: true,
|
||||
isMacOs: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
export type MobileEmulatorTabIntroVisibilityInput = {
|
||||
persistedUIReady: boolean
|
||||
mobileEmulatorTabIntroDismissed: boolean
|
||||
mobileEmulatorEnabled: boolean
|
||||
isMacOs: boolean
|
||||
}
|
||||
|
||||
export function shouldShowMobileEmulatorTabIntro({
|
||||
persistedUIReady,
|
||||
mobileEmulatorTabIntroDismissed,
|
||||
mobileEmulatorEnabled,
|
||||
isMacOs
|
||||
}: MobileEmulatorTabIntroVisibilityInput): boolean {
|
||||
return persistedUIReady && isMacOs && mobileEmulatorEnabled && !mobileEmulatorTabIntroDismissed
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
|
||||
import { ORCA_CLI_SKILL_NAME } from '@/lib/agent-feature-install-commands'
|
||||
import {
|
||||
ensureOrcaCliAvailableForAgentSkillTerminal,
|
||||
isOrcaCliAvailableOnPath
|
||||
} from '@/lib/agent-skill-cli-prerequisite'
|
||||
import {
|
||||
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
|
||||
useInstalledAgentSkill
|
||||
} from '@/hooks/useInstalledAgentSkills'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { getMobileEmulatorCliPathNeedsAttention } from './mobile-emulator-agent-setup-cli-state'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
function getCliActionLabel(status: CliInstallStatus | null, busy: boolean): string {
|
||||
if (busy) {
|
||||
return translate(
|
||||
'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.fdcca1ec75',
|
||||
'Registering...'
|
||||
)
|
||||
}
|
||||
if (isOrcaCliAvailableOnPath(status)) {
|
||||
return translate(
|
||||
'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.69fb2c2289',
|
||||
'Enabled'
|
||||
)
|
||||
}
|
||||
if (status?.state === 'installed') {
|
||||
return translate(
|
||||
'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.c6705092ba',
|
||||
'Fix PATH'
|
||||
)
|
||||
}
|
||||
return translate(
|
||||
'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.7c1b6bdb1e',
|
||||
'Enable'
|
||||
)
|
||||
}
|
||||
|
||||
export function useMobileEmulatorAgentSetupState(enabled = true): {
|
||||
cliActionLabel: string
|
||||
cliBusy: boolean
|
||||
cliEnabled: boolean
|
||||
cliInstallStatus: CliInstallStatus | null
|
||||
cliPathNeedsAttention: boolean
|
||||
cliLoading: boolean
|
||||
cliSkillError: string | null
|
||||
cliSkillInstalled: boolean
|
||||
cliSkillLoading: boolean
|
||||
cliSupported: boolean
|
||||
completedCount: number
|
||||
handleEnableCli: () => Promise<void>
|
||||
recheckSetup: () => Promise<void>
|
||||
refreshCliSkill: () => Promise<boolean>
|
||||
setupComplete: boolean
|
||||
setupRechecking: boolean
|
||||
statusReady: boolean
|
||||
step2Blocked: boolean
|
||||
} {
|
||||
const [cliInstallStatus, setCliInstallStatus] = useState<CliInstallStatus | null>(null)
|
||||
const [cliLoading, setCliLoading] = useState(true)
|
||||
const [cliBusy, setCliBusy] = useState(false)
|
||||
const [setupRechecking, setSetupRechecking] = useState(false)
|
||||
const mountedRef = useMountedRef()
|
||||
const {
|
||||
installed: cliSkillInstalled,
|
||||
loading: cliSkillLoading,
|
||||
error: cliSkillError,
|
||||
refresh: refreshCliSkill
|
||||
} = useInstalledAgentSkill(ORCA_CLI_SKILL_NAME, {
|
||||
enabled,
|
||||
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
|
||||
})
|
||||
|
||||
const refreshCliStatus = useCallback(async (): Promise<void> => {
|
||||
setCliLoading(true)
|
||||
try {
|
||||
setCliInstallStatus(await window.api.cli.getInstallStatus())
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate(
|
||||
'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.51074ccb05',
|
||||
'Failed to load CLI status.'
|
||||
)
|
||||
)
|
||||
}
|
||||
setCliInstallStatus(null)
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setCliLoading(false)
|
||||
}
|
||||
}
|
||||
}, [mountedRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
void refreshCliStatus()
|
||||
}, [enabled, refreshCliStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
// Why: users often register the CLI from Settings first; refresh on focus so
|
||||
// the emulator guide reflects the latest install/PATH state.
|
||||
const handleFocus = (): void => {
|
||||
void refreshCliStatus()
|
||||
void refreshCliSkill()
|
||||
}
|
||||
window.addEventListener('focus', handleFocus)
|
||||
return () => window.removeEventListener('focus', handleFocus)
|
||||
}, [enabled, refreshCliSkill, refreshCliStatus])
|
||||
|
||||
const cliEnabled = isOrcaCliAvailableOnPath(cliInstallStatus)
|
||||
const cliPathNeedsAttention = getMobileEmulatorCliPathNeedsAttention(cliInstallStatus)
|
||||
const cliSupported = cliInstallStatus?.supported ?? false
|
||||
const completedCount = [cliEnabled, cliSkillInstalled].filter(Boolean).length
|
||||
const step2Blocked = !cliEnabled && !cliSkillInstalled
|
||||
const setupComplete = cliEnabled && cliSkillInstalled
|
||||
const statusReady = !cliLoading && !cliSkillLoading
|
||||
|
||||
const recheckSetup = useCallback(async (): Promise<void> => {
|
||||
if (setupRechecking) {
|
||||
return
|
||||
}
|
||||
setSetupRechecking(true)
|
||||
try {
|
||||
const [cliStatus, skillInstalled] = await Promise.all([
|
||||
window.api.cli.getInstallStatus(),
|
||||
refreshCliSkill()
|
||||
])
|
||||
if (mountedRef.current) {
|
||||
setCliInstallStatus(cliStatus)
|
||||
}
|
||||
const cliReady = isOrcaCliAvailableOnPath(cliStatus)
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
if (cliReady && skillInstalled) {
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.35dea1ae12',
|
||||
'Agent control is ready.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
if (skillInstalled) {
|
||||
toast.message(
|
||||
translate(
|
||||
'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.9dff3a6338',
|
||||
'Skill is installed. Enable the Orca CLI to finish setup.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
if (cliReady) {
|
||||
toast.message(
|
||||
translate(
|
||||
'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.15986a1080',
|
||||
'Orca CLI is ready. Install the skill to finish setup.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
toast.message(
|
||||
translate(
|
||||
'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.4c26913def',
|
||||
'Still not set up. Complete both steps to enable agent control.'
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate(
|
||||
'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.c94ff11e91',
|
||||
'Could not re-check setup status.'
|
||||
)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setSetupRechecking(false)
|
||||
}
|
||||
}
|
||||
}, [mountedRef, refreshCliSkill, setupRechecking])
|
||||
|
||||
const handleEnableCli = useCallback(async (): Promise<void> => {
|
||||
setCliBusy(true)
|
||||
try {
|
||||
const next = await ensureOrcaCliAvailableForAgentSkillTerminal({
|
||||
onStatusChange: setCliInstallStatus
|
||||
})
|
||||
if (mountedRef.current && isOrcaCliAvailableOnPath(next)) {
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.2b519eed94',
|
||||
'Registered the Orca CLI in PATH.'
|
||||
)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setCliBusy(false)
|
||||
}
|
||||
}
|
||||
}, [mountedRef])
|
||||
|
||||
return {
|
||||
cliActionLabel: getCliActionLabel(cliInstallStatus, cliBusy),
|
||||
cliBusy,
|
||||
cliEnabled,
|
||||
cliInstallStatus,
|
||||
cliPathNeedsAttention,
|
||||
cliLoading,
|
||||
cliSkillError,
|
||||
cliSkillInstalled,
|
||||
cliSkillLoading,
|
||||
cliSupported,
|
||||
completedCount,
|
||||
handleEnableCli,
|
||||
recheckSetup,
|
||||
refreshCliSkill,
|
||||
setupComplete,
|
||||
setupRechecking,
|
||||
statusReady,
|
||||
step2Blocked
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { useMobileEmulatorTabIntroActions } from './use-mobile-emulator-tab-intro-actions'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
dismiss: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
let latestActions: ReturnType<typeof useMobileEmulatorTabIntroActions> | null = null
|
||||
|
||||
function Probe(): null {
|
||||
latestActions = useMobileEmulatorTabIntroActions()
|
||||
return null
|
||||
}
|
||||
|
||||
async function renderProbe(): Promise<void> {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
await act(async () => {
|
||||
root?.render(<Probe />)
|
||||
})
|
||||
}
|
||||
|
||||
async function flushAsyncAction(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
function configureStoreForHideAction(overrides: {
|
||||
updateSettings: AppState['updateSettings']
|
||||
closeUnifiedTab?: AppState['closeUnifiedTab']
|
||||
dismissMobileEmulatorTabIntro?: AppState['dismissMobileEmulatorTabIntro']
|
||||
}): {
|
||||
closeUnifiedTab: NonNullable<typeof overrides.closeUnifiedTab>
|
||||
dismissMobileEmulatorTabIntro: NonNullable<typeof overrides.dismissMobileEmulatorTabIntro>
|
||||
openSettingsPage: AppState['openSettingsPage']
|
||||
openSettingsTarget: AppState['openSettingsTarget']
|
||||
} {
|
||||
const closeUnifiedTab =
|
||||
overrides.closeUnifiedTab ??
|
||||
vi.fn(() => ({
|
||||
closedTabId: 'simulator-tab',
|
||||
wasLastTab: false,
|
||||
worktreeId: 'worktree-1'
|
||||
}))
|
||||
const dismissMobileEmulatorTabIntro = overrides.dismissMobileEmulatorTabIntro ?? vi.fn()
|
||||
const openSettingsPage = vi.fn()
|
||||
const openSettingsTarget = vi.fn()
|
||||
|
||||
useAppStore.setState({
|
||||
closeUnifiedTab,
|
||||
dismissMobileEmulatorTabIntro,
|
||||
openSettingsPage,
|
||||
openSettingsTarget,
|
||||
settings: { mobileEmulatorEnabled: true } as AppState['settings'],
|
||||
unifiedTabsByWorktree: {
|
||||
'worktree-1': [
|
||||
{ id: 'simulator-tab', contentType: 'simulator' },
|
||||
{ id: 'terminal-tab', contentType: 'terminal' }
|
||||
]
|
||||
} as unknown as AppState['unifiedTabsByWorktree'],
|
||||
updateSettings: overrides.updateSettings
|
||||
})
|
||||
|
||||
return {
|
||||
closeUnifiedTab,
|
||||
dismissMobileEmulatorTabIntro,
|
||||
openSettingsPage,
|
||||
openSettingsTarget
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount()
|
||||
})
|
||||
}
|
||||
root = null
|
||||
container?.remove()
|
||||
container = null
|
||||
latestActions = null
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useMobileEmulatorTabIntroActions', () => {
|
||||
it('hides the feature, dismisses the intro, and closes simulator tabs after settings apply', async () => {
|
||||
const updateSettings = vi.fn<AppState['updateSettings']>(async () => {
|
||||
useAppStore.setState({
|
||||
settings: { mobileEmulatorEnabled: false } as AppState['settings']
|
||||
})
|
||||
})
|
||||
const { closeUnifiedTab, dismissMobileEmulatorTabIntro } = configureStoreForHideAction({
|
||||
updateSettings
|
||||
})
|
||||
|
||||
await renderProbe()
|
||||
|
||||
latestActions?.hideIntro()
|
||||
await flushAsyncAction()
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({ mobileEmulatorEnabled: false })
|
||||
expect(dismissMobileEmulatorTabIntro).toHaveBeenCalledTimes(1)
|
||||
expect(closeUnifiedTab).toHaveBeenCalledTimes(1)
|
||||
expect(closeUnifiedTab).toHaveBeenCalledWith('simulator-tab')
|
||||
expect(toast.info).toHaveBeenCalledWith(
|
||||
'Mobile Emulator hidden',
|
||||
expect.objectContaining({ id: 'mobile-emulator-hidden' })
|
||||
)
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not dismiss or close tabs when the setting write does not stick', async () => {
|
||||
const updateSettings = vi.fn<AppState['updateSettings']>(async () => {})
|
||||
const { closeUnifiedTab, dismissMobileEmulatorTabIntro } = configureStoreForHideAction({
|
||||
updateSettings
|
||||
})
|
||||
|
||||
await renderProbe()
|
||||
|
||||
latestActions?.hideIntro()
|
||||
await flushAsyncAction()
|
||||
|
||||
expect(dismissMobileEmulatorTabIntro).not.toHaveBeenCalled()
|
||||
expect(closeUnifiedTab).not.toHaveBeenCalled()
|
||||
expect(toast.info).not.toHaveBeenCalled()
|
||||
expect(toast.error).toHaveBeenCalledWith('Could not hide Mobile Emulator.')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { showMobileEmulatorHiddenToast } from './mobile-emulator-hidden-toast'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
function closeAllSimulatorTabs(): void {
|
||||
const state = useAppStore.getState()
|
||||
for (const tabs of Object.values(state.unifiedTabsByWorktree)) {
|
||||
for (const tab of tabs) {
|
||||
if (tab.contentType === 'simulator') {
|
||||
state.closeUnifiedTab(tab.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isMobileEmulatorHidden(): boolean {
|
||||
return useAppStore.getState().settings?.mobileEmulatorEnabled === false
|
||||
}
|
||||
|
||||
export function useMobileEmulatorTabIntroActions(): {
|
||||
keepIntro: () => void
|
||||
hideIntro: () => void
|
||||
dismissIntro: () => void
|
||||
} {
|
||||
const dismissMobileEmulatorTabIntro = useAppStore((s) => s.dismissMobileEmulatorTabIntro)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
|
||||
const dismissIntro = useCallback((): void => {
|
||||
dismissMobileEmulatorTabIntro()
|
||||
}, [dismissMobileEmulatorTabIntro])
|
||||
|
||||
const keepIntro = useCallback((): void => {
|
||||
dismissIntro()
|
||||
}, [dismissIntro])
|
||||
|
||||
const hideIntro = useCallback((): void => {
|
||||
void (async () => {
|
||||
try {
|
||||
await updateSettings({ mobileEmulatorEnabled: false })
|
||||
// Why: updateSettings catches write failures; only close tabs once the
|
||||
// persisted setting is reflected in state.
|
||||
if (!isMobileEmulatorHidden()) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.emulator.pane.use.mobile.emulator.tab.intro.actions.68a5dc6604',
|
||||
'Could not hide Mobile Emulator.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
dismissIntro()
|
||||
closeAllSimulatorTabs()
|
||||
showMobileEmulatorHiddenToast({ openSettingsPage, openSettingsTarget })
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate(
|
||||
'auto.components.emulator.pane.use.mobile.emulator.tab.intro.actions.68a5dc6604',
|
||||
'Could not hide Mobile Emulator.'
|
||||
)
|
||||
)
|
||||
}
|
||||
})()
|
||||
}, [dismissIntro, openSettingsPage, openSettingsTarget, updateSettings])
|
||||
|
||||
return { keepIntro, hideIntro, dismissIntro }
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ type AgentSkillSetupPanelProps = {
|
|||
installedInstallLabel?: string
|
||||
actionHint?: ReactNode
|
||||
footer?: ReactNode
|
||||
onRecheck: () => void | Promise<void>
|
||||
onRecheck: () => void | Promise<unknown>
|
||||
}
|
||||
|
||||
export function AgentSkillSetupPanel({
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ type Props = {
|
|||
disabled?: boolean
|
||||
preInstallNotice?: ReactNode
|
||||
onBeforeOpenTerminal?: () => void | Promise<void>
|
||||
onRecheck: () => void | Promise<void>
|
||||
onRecheck: () => void | Promise<unknown>
|
||||
}
|
||||
|
||||
export function BrowserUseSkillStep({
|
||||
|
|
|
|||
|
|
@ -1,22 +1,11 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Import, Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
|
||||
import {
|
||||
ORCA_CLI_SKILL_INSTALL_COMMAND,
|
||||
ORCA_CLI_SKILL_NAME
|
||||
} from '@/lib/agent-feature-install-commands'
|
||||
import { ORCA_CLI_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands'
|
||||
import {
|
||||
AGENT_SKILL_CLI_PREREQUISITE_NOTICE,
|
||||
ensureOrcaCliAvailableForAgentSkillTerminal,
|
||||
isOrcaCliAvailableOnPath
|
||||
ensureOrcaCliAvailableForAgentSkillTerminal
|
||||
} from '@/lib/agent-skill-cli-prerequisite'
|
||||
import {
|
||||
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
|
||||
useInstalledAgentSkill
|
||||
} from '@/hooks/useInstalledAgentSkills'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useMobileEmulatorAgentSetupState } from '../emulator-pane/use-mobile-emulator-agent-setup-state'
|
||||
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
|
||||
import { StepBadge } from './BrowserUseStepBadge'
|
||||
import { MobileEmulatorExamples } from './MobileEmulatorExamples'
|
||||
|
|
@ -31,84 +20,11 @@ const EMULATOR_CLI_COMMANDS = [
|
|||
'orca emulator type "hello" --json'
|
||||
] as const
|
||||
|
||||
function getCliActionLabel(status: CliInstallStatus | null, busy: boolean): string {
|
||||
if (busy) {
|
||||
return 'Registering...'
|
||||
}
|
||||
if (isOrcaCliAvailableOnPath(status)) {
|
||||
return 'Enabled'
|
||||
}
|
||||
if (status?.state === 'installed') {
|
||||
return 'Fix PATH'
|
||||
}
|
||||
return 'Enable'
|
||||
}
|
||||
|
||||
export function MobileEmulatorAgentControlRow(): React.JSX.Element {
|
||||
const [cliInstallStatus, setCliInstallStatus] = useState<CliInstallStatus | null>(null)
|
||||
const [cliLoading, setCliLoading] = useState(true)
|
||||
const [cliBusy, setCliBusy] = useState(false)
|
||||
const mountedRef = useMountedRef()
|
||||
const {
|
||||
installed: cliSkillInstalled,
|
||||
loading: cliSkillLoading,
|
||||
error: cliSkillError,
|
||||
refresh: refreshCliSkill
|
||||
} = useInstalledAgentSkill(ORCA_CLI_SKILL_NAME, {
|
||||
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
|
||||
})
|
||||
|
||||
const refreshCliStatus = useCallback(async (): Promise<void> => {
|
||||
setCliLoading(true)
|
||||
try {
|
||||
setCliInstallStatus(await window.api.cli.getInstallStatus())
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate(
|
||||
'auto.components.settings.MobileEmulatorAgentControlRow.1861982430',
|
||||
'Failed to load CLI status.'
|
||||
)
|
||||
)
|
||||
}
|
||||
setCliInstallStatus(null)
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setCliLoading(false)
|
||||
}
|
||||
}
|
||||
}, [mountedRef])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCliStatus()
|
||||
}, [refreshCliStatus])
|
||||
|
||||
const cliEnabled = isOrcaCliAvailableOnPath(cliInstallStatus)
|
||||
const cliSupported = cliInstallStatus?.supported ?? false
|
||||
const completedCount = [cliEnabled, cliSkillInstalled].filter(Boolean).length
|
||||
const step2Blocked = !cliEnabled && !cliSkillInstalled
|
||||
const setup = useMobileEmulatorAgentSetupState(true)
|
||||
|
||||
const handleEnableCli = async (): Promise<void> => {
|
||||
setCliBusy(true)
|
||||
try {
|
||||
const next = await ensureOrcaCliAvailableForAgentSkillTerminal({
|
||||
onStatusChange: setCliInstallStatus
|
||||
})
|
||||
if (mountedRef.current && isOrcaCliAvailableOnPath(next)) {
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.settings.MobileEmulatorAgentControlRow.cdeaed9e37',
|
||||
'Registered the Orca CLI in PATH.'
|
||||
)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setCliBusy(false)
|
||||
}
|
||||
}
|
||||
await setup.handleEnableCli()
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -130,18 +46,21 @@ export function MobileEmulatorAgentControlRow(): React.JSX.Element {
|
|||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||
completedCount === 2
|
||||
setup.completedCount === 2
|
||||
? 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{completedCount}/2
|
||||
{setup.completedCount}/2
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 divide-y divide-border/40">
|
||||
<div className="flex items-start gap-3 py-3">
|
||||
<StepBadge index={1} state={cliEnabled ? 'done' : cliBusy ? 'in-progress' : 'pending'} />
|
||||
<StepBadge
|
||||
index={1}
|
||||
state={setup.cliEnabled ? 'done' : setup.cliBusy ? 'in-progress' : 'pending'}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{translate(
|
||||
|
|
@ -155,17 +74,19 @@ export function MobileEmulatorAgentControlRow(): React.JSX.Element {
|
|||
'Registers the Orca CLI command so agents can control the active emulator from their shell.'
|
||||
)}
|
||||
</p>
|
||||
{cliInstallStatus?.commandPath && cliEnabled ? (
|
||||
{setup.cliInstallStatus?.commandPath && setup.cliEnabled ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.MobileEmulatorAgentControlRow.aaf62a3dd2',
|
||||
'Installed at'
|
||||
)}{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5">{cliInstallStatus.commandPath}</code>
|
||||
<code className="rounded bg-muted px-1 py-0.5">
|
||||
{setup.cliInstallStatus.commandPath}
|
||||
</code>
|
||||
</p>
|
||||
) : null}
|
||||
{!cliEnabled && cliInstallStatus?.detail ? (
|
||||
<p className="text-[11px] text-muted-foreground">{cliInstallStatus.detail}</p>
|
||||
{!setup.cliEnabled && setup.cliInstallStatus?.detail ? (
|
||||
<p className="text-[11px] text-muted-foreground">{setup.cliInstallStatus.detail}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<TooltipProvider delayDuration={250}>
|
||||
|
|
@ -175,25 +96,27 @@ export function MobileEmulatorAgentControlRow(): React.JSX.Element {
|
|||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={cliEnabled ? 'outline' : 'default'}
|
||||
disabled={cliLoading || cliBusy || !cliSupported || cliEnabled}
|
||||
variant={setup.cliEnabled ? 'outline' : 'default'}
|
||||
disabled={
|
||||
setup.cliLoading || setup.cliBusy || !setup.cliSupported || setup.cliEnabled
|
||||
}
|
||||
onClick={() => void handleEnableCli()}
|
||||
>
|
||||
{cliLoading ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
{getCliActionLabel(cliInstallStatus, cliBusy)}
|
||||
{setup.cliLoading ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
{setup.cliActionLabel}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!cliSupported && !cliLoading && cliInstallStatus?.detail ? (
|
||||
{!setup.cliSupported && !setup.cliLoading && setup.cliInstallStatus?.detail ? (
|
||||
<TooltipContent side="left" sideOffset={6}>
|
||||
{cliInstallStatus.detail}
|
||||
{setup.cliInstallStatus.detail}
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div className={cn('py-3', step2Blocked && 'opacity-60')}>
|
||||
<div className={cn('py-3', setup.step2Blocked && 'opacity-60')}>
|
||||
<AgentSkillSetupPanel
|
||||
variant="inline"
|
||||
title={translate(
|
||||
|
|
@ -208,18 +131,16 @@ export function MobileEmulatorAgentControlRow(): React.JSX.Element {
|
|||
terminalTitle="Orca CLI skill setup"
|
||||
terminalAriaLabel="Orca CLI skill install terminal"
|
||||
terminalWorktreeId="settings-mobile-emulator-orca-cli-skill-terminal"
|
||||
installed={cliSkillInstalled}
|
||||
loading={cliSkillLoading}
|
||||
error={cliSkillError}
|
||||
installDisabled={step2Blocked}
|
||||
leading={<StepBadge index={2} state={cliSkillInstalled ? 'done' : 'pending'} />}
|
||||
installed={setup.cliSkillInstalled}
|
||||
loading={setup.cliSkillLoading}
|
||||
error={setup.cliSkillError}
|
||||
installDisabled={setup.step2Blocked}
|
||||
leading={<StepBadge index={2} state={setup.cliSkillInstalled ? 'done' : 'pending'} />}
|
||||
preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE}
|
||||
onBeforeOpenTerminal={async () => {
|
||||
await ensureOrcaCliAvailableForAgentSkillTerminal({
|
||||
onStatusChange: setCliInstallStatus
|
||||
})
|
||||
await ensureOrcaCliAvailableForAgentSkillTerminal()
|
||||
}}
|
||||
onRecheck={refreshCliSkill}
|
||||
onRecheck={setup.refreshCliSkill}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ import { Button } from '@/components/ui/button'
|
|||
import type { TabCreateEntryArgs } from './tab-create-entry-action'
|
||||
import { buildTabAgentLaunchOptions, orderTabLaunchAgents } from './tab-agent-launch-options'
|
||||
import { buildTabCreateMenuOptions, type TabCreateMenuOption } from './tab-create-menu-options'
|
||||
import { MobileEmulatorTabIntroCallout } from '../emulator-pane/MobileEmulatorTabIntroCallout'
|
||||
import { shouldShowMobileEmulatorTabIntro } from '../emulator-pane/mobile-emulator-tab-intro-visibility'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useTabStripOverflowNavigation } from './tab-strip-overflow-navigation'
|
||||
|
||||
|
|
@ -259,6 +261,14 @@ function TabBarInner({
|
|||
const newFileShortcut = useShortcutLabel('tab.newMarkdown')
|
||||
const generatedTabTitlesEnabled = useAppStore((s) => s.settings?.tabAutoGenerateTitle === true)
|
||||
const mobileEmulatorEnabled = useAppStore((s) => s.settings?.mobileEmulatorEnabled !== false)
|
||||
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
|
||||
const mobileEmulatorTabIntroDismissed = useAppStore((s) => s.mobileEmulatorTabIntroDismissed)
|
||||
const showMobileEmulatorIntroCallout = shouldShowMobileEmulatorTabIntro({
|
||||
persistedUIReady,
|
||||
mobileEmulatorTabIntroDismissed,
|
||||
mobileEmulatorEnabled,
|
||||
isMacOs
|
||||
})
|
||||
const gitStatusEntries = useAppStore(
|
||||
(s) => s.gitStatusByWorktree[worktreeId] ?? EMPTY_GIT_STATUS_ENTRIES
|
||||
)
|
||||
|
|
@ -689,6 +699,14 @@ function TabBarInner({
|
|||
{translate('auto.components.tab.bar.TabBar.4f327c8b3d', 'Open Markdown...')}
|
||||
</DropdownMenuItem>
|
||||
) : null
|
||||
const mobileEmulatorIntroMenuBlock =
|
||||
showMobileEmulatorIntroCallout &&
|
||||
!terminalOnly &&
|
||||
isMacOs &&
|
||||
mobileEmulatorEnabled &&
|
||||
onNewSimulatorTab ? (
|
||||
<MobileEmulatorTabIntroCallout onAction={() => setNewTabMenuOpen(false)} />
|
||||
) : null
|
||||
const standardCreateMenuItems =
|
||||
newTabMenuOrder === 'markdown-first' ? (
|
||||
<>
|
||||
|
|
@ -697,6 +715,7 @@ function TabBarInner({
|
|||
{defaultTerminalMenuItems}
|
||||
{newBrowserMenuItem}
|
||||
{newSimulatorMenuItem}
|
||||
{mobileEmulatorIntroMenuBlock}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -705,6 +724,7 @@ function TabBarInner({
|
|||
{newMarkdownMenuItem}
|
||||
{openMarkdownMenuItem}
|
||||
{newSimulatorMenuItem}
|
||||
{mobileEmulatorIntroMenuBlock}
|
||||
</>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export type InstalledAgentSkillState = {
|
|||
loading: boolean
|
||||
error: string | null
|
||||
skills: readonly DiscoveredSkill[]
|
||||
refresh: () => Promise<void>
|
||||
refresh: () => Promise<boolean>
|
||||
}
|
||||
|
||||
let cachedDiscoveryByTarget = new Map<string, SkillDiscoveryResult>()
|
||||
|
|
@ -165,13 +165,31 @@ export function useInstalledAgentSkill(
|
|||
const [error, setError] = useState<string | null>(null)
|
||||
const currentDiscoveryTargetKeyRef = useRef(discoveryTargetKey)
|
||||
const refreshGenerationRef = useRef(0)
|
||||
const stateResetInputRef = useRef({ discoveryTargetKey, enabled })
|
||||
currentDiscoveryTargetKeyRef.current = discoveryTargetKey
|
||||
// Why: skill scans can outlive transient settings/onboarding panels; keep
|
||||
// the module cache update but skip React state writes after unmount.
|
||||
const mountedRef = useMountedRef()
|
||||
let resultForRender = result
|
||||
let loadingForRender = loading
|
||||
let errorForRender = error
|
||||
if (
|
||||
stateResetInputRef.current.discoveryTargetKey !== discoveryTargetKey ||
|
||||
stateResetInputRef.current.enabled !== enabled
|
||||
) {
|
||||
const nextCachedDiscovery = cachedDiscoveryByTarget.get(discoveryTargetKey) ?? null
|
||||
const nextLoading = enabled && !nextCachedDiscovery
|
||||
stateResetInputRef.current = { discoveryTargetKey, enabled }
|
||||
resultForRender = nextCachedDiscovery
|
||||
loadingForRender = nextLoading
|
||||
errorForRender = null
|
||||
setResult(nextCachedDiscovery)
|
||||
setLoading(nextLoading)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const refresh = useCallback(
|
||||
async (force = true): Promise<void> => {
|
||||
async (force = true): Promise<boolean> => {
|
||||
const requestDiscoveryTargetKey = discoveryTargetKey
|
||||
const requestGeneration = ++refreshGenerationRef.current
|
||||
const writeIfCurrent = (write: () => void): void => {
|
||||
|
|
@ -188,13 +206,15 @@ export function useInstalledAgentSkill(
|
|||
writeIfCurrent(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
return
|
||||
return false
|
||||
}
|
||||
writeIfCurrent(() => {
|
||||
setLoading(true)
|
||||
})
|
||||
let installedAfterRefresh = false
|
||||
try {
|
||||
const next = await discoverInstalledAgentSkills(force, discoveryTarget)
|
||||
installedAfterRefresh = hasInstalledAgentSkill(next.skills, skillName, { sourceKinds })
|
||||
writeIfCurrent(() => {
|
||||
setResult(next)
|
||||
setError(null)
|
||||
|
|
@ -212,17 +232,11 @@ export function useInstalledAgentSkill(
|
|||
setLoading(false)
|
||||
})
|
||||
}
|
||||
return installedAfterRefresh
|
||||
},
|
||||
[discoveryTarget, discoveryTargetKey, enabled, mountedRef]
|
||||
[discoveryTarget, discoveryTargetKey, enabled, mountedRef, skillName, sourceKinds]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const nextCachedDiscovery = cachedDiscoveryByTarget.get(discoveryTargetKey) ?? null
|
||||
setResult(nextCachedDiscovery)
|
||||
setLoading(enabled && !nextCachedDiscovery)
|
||||
setError(null)
|
||||
}, [discoveryTargetKey, enabled])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh(false)
|
||||
}, [refresh])
|
||||
|
|
@ -244,7 +258,10 @@ export function useInstalledAgentSkill(
|
|||
}
|
||||
}, [enabled, refresh])
|
||||
|
||||
const skills = useMemo(() => (enabled && result ? result.skills : []), [enabled, result])
|
||||
const skills = useMemo(
|
||||
() => (enabled && resultForRender ? resultForRender.skills : []),
|
||||
[enabled, resultForRender]
|
||||
)
|
||||
|
||||
const installed = useMemo(
|
||||
() => (enabled ? hasInstalledAgentSkill(skills, skillName, { sourceKinds }) : false),
|
||||
|
|
@ -263,8 +280,8 @@ export function useInstalledAgentSkill(
|
|||
|
||||
return {
|
||||
installed,
|
||||
loading,
|
||||
error,
|
||||
loading: loadingForRender,
|
||||
error: errorForRender,
|
||||
skills,
|
||||
refresh: forceRefresh
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9837,6 +9837,68 @@
|
|||
"f1c0179002": "Stream is not producing frames."
|
||||
}
|
||||
}
|
||||
},
|
||||
"mobile": {
|
||||
"emulator": {
|
||||
"agent": {
|
||||
"setup": {
|
||||
"state": {
|
||||
"fdcca1ec75": "Registering...",
|
||||
"69fb2c2289": "Enabled",
|
||||
"c6705092ba": "Fix PATH",
|
||||
"7c1b6bdb1e": "Enable",
|
||||
"51074ccb05": "Failed to load CLI status.",
|
||||
"35dea1ae12": "Agent control is ready.",
|
||||
"9dff3a6338": "Skill is installed. Enable the Orca CLI to finish setup.",
|
||||
"15986a1080": "Orca CLI is ready. Install the skill to finish setup.",
|
||||
"4c26913def": "Still not set up. Complete both steps to enable agent control.",
|
||||
"c94ff11e91": "Could not re-check setup status.",
|
||||
"2b519eed94": "Registered the Orca CLI in PATH."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
"intro": {
|
||||
"actions": {
|
||||
"68a5dc6604": "Could not hide Mobile Emulator."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"MobileEmulatorAgentSetupGuide": {
|
||||
"2fda9ff015": "Set up agent control",
|
||||
"0ac0fef514": "Agent control is ready.",
|
||||
"2bdfff8763": "Agent control (optional).",
|
||||
"72736b051f": "Set up Orca CLI + skill when you want agents to drive this simulator.",
|
||||
"d10ae98046": "Done",
|
||||
"3756cbeca7": "Not now",
|
||||
"6d950431d2": "Hide",
|
||||
"ebceac65a4": "Set up",
|
||||
"3f003507f4": "Open full setup in Settings"
|
||||
},
|
||||
"MobileEmulatorAgentSetupGuideSteps": {
|
||||
"9b49d892e3": "Enable Orca CLI",
|
||||
"3d8dc52c93": "Registers the orca command for emulator control in agent shells.",
|
||||
"21f5687c07": "Orca CLI skill",
|
||||
"64fb057667": "Teaches agents the orca emulator commands for this worktree."
|
||||
},
|
||||
"MobileEmulatorTabIntroCallout": {
|
||||
"1924982130": "Dismiss",
|
||||
"5789936d9a": "Preview iOS simulators while agents drive the screen.",
|
||||
"8014b4b80b": "Keep",
|
||||
"6e051a40b7": "Hide"
|
||||
},
|
||||
"mobile": {
|
||||
"emulator": {
|
||||
"hidden": {
|
||||
"toast": {
|
||||
"e8f098a870": "Mobile Emulator hidden",
|
||||
"c46c979c1d": "Re-enable Mobile Emulator anytime in",
|
||||
"600f9a745a": "Settings › Mobile Emulator"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9837,6 +9837,68 @@
|
|||
"f1c0179002": "La transmisión no produce fotogramas."
|
||||
}
|
||||
}
|
||||
},
|
||||
"mobile": {
|
||||
"emulator": {
|
||||
"agent": {
|
||||
"setup": {
|
||||
"state": {
|
||||
"fdcca1ec75": "Registrando...",
|
||||
"69fb2c2289": "Habilitado",
|
||||
"c6705092ba": "Corregir PATH",
|
||||
"7c1b6bdb1e": "Habilitar",
|
||||
"51074ccb05": "Error al cargar el estado del CLI.",
|
||||
"35dea1ae12": "El control del agent está listo.",
|
||||
"9dff3a6338": "La skill está instalada. Habilita el CLI de Orca para completar la configuración.",
|
||||
"15986a1080": "El CLI de Orca está listo. Instala la skill para completar la configuración.",
|
||||
"4c26913def": "Aún no está configurado. Completa ambos pasos para habilitar el control del agent.",
|
||||
"c94ff11e91": "No se pudo volver a verificar el estado de la configuración.",
|
||||
"2b519eed94": "Se registró el CLI de Orca en PATH."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
"intro": {
|
||||
"actions": {
|
||||
"68a5dc6604": "No se pudo ocultar el Emulador Móvil."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"MobileEmulatorAgentSetupGuide": {
|
||||
"2fda9ff015": "Configurar control del agent",
|
||||
"0ac0fef514": "El control del agent está listo.",
|
||||
"2bdfff8763": "Control del agent (opcional).",
|
||||
"72736b051f": "Configura el CLI de Orca + skill cuando quieras que los agents controlen este simulador.",
|
||||
"d10ae98046": "Listo",
|
||||
"3756cbeca7": "Ahora no",
|
||||
"6d950431d2": "Ocultar",
|
||||
"ebceac65a4": "Configurar",
|
||||
"3f003507f4": "Abrir configuración completa en Ajustes"
|
||||
},
|
||||
"MobileEmulatorAgentSetupGuideSteps": {
|
||||
"9b49d892e3": "Habilitar CLI de Orca",
|
||||
"3d8dc52c93": "Registra el comando orca para control del emulador en los shells de los agents.",
|
||||
"21f5687c07": "Skill del CLI de Orca",
|
||||
"64fb057667": "Enseña a los agents los comandos del emulador orca para este worktree."
|
||||
},
|
||||
"MobileEmulatorTabIntroCallout": {
|
||||
"1924982130": "Descartar",
|
||||
"5789936d9a": "Vista previa de simuladores iOS mientras los agents controlan la pantalla.",
|
||||
"8014b4b80b": "Mantener",
|
||||
"6e051a40b7": "Ocultar"
|
||||
},
|
||||
"mobile": {
|
||||
"emulator": {
|
||||
"hidden": {
|
||||
"toast": {
|
||||
"e8f098a870": "Emulador Móvil oculto",
|
||||
"c46c979c1d": "Vuelve a habilitar el Emulador Móvil en cualquier momento en",
|
||||
"600f9a745a": "Ajustes › Emulador Móvil"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9837,6 +9837,68 @@
|
|||
"f1c0179002": "ストリームはフレームを生成していません。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mobile": {
|
||||
"emulator": {
|
||||
"agent": {
|
||||
"setup": {
|
||||
"state": {
|
||||
"fdcca1ec75": "登録中...",
|
||||
"69fb2c2289": "有効",
|
||||
"c6705092ba": "PATH を修正",
|
||||
"7c1b6bdb1e": "有効化",
|
||||
"51074ccb05": "CLI ステータスの読み込みに失敗しました。",
|
||||
"35dea1ae12": "agent 制御の準備ができました。",
|
||||
"9dff3a6338": "スキルがインストールされています。Orca CLI を有効化してセットアップを完了してください。",
|
||||
"15986a1080": "Orca CLI の準備ができました。スキルをインストールしてセットアップを完了してください。",
|
||||
"4c26913def": "まだセットアップが完了していません。agent 制御を有効にするには、両方のステップを完了してください。",
|
||||
"c94ff11e91": "セットアップ状況を再確認できませんでした。",
|
||||
"2b519eed94": "Orca CLI を PATH に登録しました。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
"intro": {
|
||||
"actions": {
|
||||
"68a5dc6604": "モバイルエミュレーターを非表示にできませんでした。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"MobileEmulatorAgentSetupGuide": {
|
||||
"2fda9ff015": "agent 制御をセットアップ",
|
||||
"0ac0fef514": "agent 制御の準備ができました。",
|
||||
"2bdfff8763": "agent 制御(オプション)。",
|
||||
"72736b051f": "agents にこのシミュレーターを操作させたい場合は、Orca CLI + スキルをセットアップしてください。",
|
||||
"d10ae98046": "完了",
|
||||
"3756cbeca7": "後で",
|
||||
"6d950431d2": "非表示",
|
||||
"ebceac65a4": "セットアップ",
|
||||
"3f003507f4": "設定で完全なセットアップを開く"
|
||||
},
|
||||
"MobileEmulatorAgentSetupGuideSteps": {
|
||||
"9b49d892e3": "Orca CLI を有効化",
|
||||
"3d8dc52c93": "agent シェルでエミュレーター制御用の orca コマンドを登録します。",
|
||||
"21f5687c07": "Orca CLI スキル",
|
||||
"64fb057667": "このワークツリーの orca エミュレーターコマンドを agents に教えます。"
|
||||
},
|
||||
"MobileEmulatorTabIntroCallout": {
|
||||
"1924982130": "閉じる",
|
||||
"5789936d9a": "agents が画面を操作している間に iOS シミュレーターをプレビューできます。",
|
||||
"8014b4b80b": "保持",
|
||||
"6e051a40b7": "非表示"
|
||||
},
|
||||
"mobile": {
|
||||
"emulator": {
|
||||
"hidden": {
|
||||
"toast": {
|
||||
"e8f098a870": "モバイルエミュレーターを非表示にしました",
|
||||
"c46c979c1d": "いつでもモバイルエミュレーターを再有効化できます",
|
||||
"600f9a745a": "設定 › モバイルエミュレーター"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9837,6 +9837,68 @@
|
|||
"f1c0179002": "스트림이 프레임을 생성하지 않습니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
"mobile": {
|
||||
"emulator": {
|
||||
"agent": {
|
||||
"setup": {
|
||||
"state": {
|
||||
"fdcca1ec75": "등록 중...",
|
||||
"69fb2c2289": "활성화됨",
|
||||
"c6705092ba": "PATH 수정",
|
||||
"7c1b6bdb1e": "활성화",
|
||||
"51074ccb05": "CLI 상태를 불러오지 못했습니다.",
|
||||
"35dea1ae12": "agent 제어가 준비되었습니다.",
|
||||
"9dff3a6338": "스킬이 설치되었습니다. Orca CLI를 활성화하여 설정을 완료하세요.",
|
||||
"15986a1080": "Orca CLI가 준비되었습니다. 스킬을 설치하여 설정을 완료하세요.",
|
||||
"4c26913def": "아직 설정되지 않았습니다. agent 제어를 활성화하려면 두 단계를 모두 완료하세요.",
|
||||
"c94ff11e91": "설정 상태를 다시 확인할 수 없습니다.",
|
||||
"2b519eed94": "Orca CLI가 PATH에 등록되었습니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
"intro": {
|
||||
"actions": {
|
||||
"68a5dc6604": "모바일 에뮬레이터를 숨길 수 없습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"MobileEmulatorAgentSetupGuide": {
|
||||
"2fda9ff015": "agent 제어 설정",
|
||||
"0ac0fef514": "agent 제어가 준비되었습니다.",
|
||||
"2bdfff8763": "agent 제어(선택 사항).",
|
||||
"72736b051f": "agents가 이 시뮬레이터를 제어하도록 하려면 Orca CLI + 스킬을 설정하세요.",
|
||||
"d10ae98046": "완료",
|
||||
"3756cbeca7": "나중에",
|
||||
"6d950431d2": "숨기기",
|
||||
"ebceac65a4": "설정",
|
||||
"3f003507f4": "설정에서 전체 설정 열기"
|
||||
},
|
||||
"MobileEmulatorAgentSetupGuideSteps": {
|
||||
"9b49d892e3": "Orca CLI 활성화",
|
||||
"3d8dc52c93": "agents 셸에서 에뮬레이터 제어를 위해 orca 명령을 등록합니다.",
|
||||
"21f5687c07": "Orca CLI 스킬",
|
||||
"64fb057667": "이 워크트리의 orca 에뮬레이터 명령을 agents에게 알려줍니다."
|
||||
},
|
||||
"MobileEmulatorTabIntroCallout": {
|
||||
"1924982130": "닫기",
|
||||
"5789936d9a": "agents가 화면을 제어하는 동안 iOS 시뮬레이터를 미리 볼 수 있습니다.",
|
||||
"8014b4b80b": "유지",
|
||||
"6e051a40b7": "숨기기"
|
||||
},
|
||||
"mobile": {
|
||||
"emulator": {
|
||||
"hidden": {
|
||||
"toast": {
|
||||
"e8f098a870": "모바일 에뮬레이터가 숨겨졌습니다",
|
||||
"c46c979c1d": "모바일 에뮬레이터를 언제든지 다시 활성화할 수 있습니다",
|
||||
"600f9a745a": "설정 › 모바일 에뮬레이터"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9837,6 +9837,68 @@
|
|||
"f1c0179002": "流不生成帧。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mobile": {
|
||||
"emulator": {
|
||||
"agent": {
|
||||
"setup": {
|
||||
"state": {
|
||||
"fdcca1ec75": "正在注册...",
|
||||
"69fb2c2289": "已启用",
|
||||
"c6705092ba": "修复 PATH",
|
||||
"7c1b6bdb1e": "启用",
|
||||
"51074ccb05": "无法加载 CLI 状态。",
|
||||
"35dea1ae12": "agent控制已就绪。",
|
||||
"9dff3a6338": "技能已安装。启用 Orca CLI 以完成设置。",
|
||||
"15986a1080": "Orca CLI 已就绪。安装技能以完成设置。",
|
||||
"4c26913def": "尚未设置完成。请完成两个步骤以启用agent控制。",
|
||||
"c94ff11e91": "无法重新检查设置状态。",
|
||||
"2b519eed94": "已在 PATH 中注册 Orca CLI。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
"intro": {
|
||||
"actions": {
|
||||
"68a5dc6604": "无法隐藏移动模拟器。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"MobileEmulatorAgentSetupGuide": {
|
||||
"2fda9ff015": "设置Agent控制",
|
||||
"0ac0fef514": "Agent控制已就绪。",
|
||||
"2bdfff8763": "Agent控制(可选)。",
|
||||
"72736b051f": "当您希望Agent控制此模拟器时,设置 Orca CLI + 技能。",
|
||||
"d10ae98046": "完成",
|
||||
"3756cbeca7": "暂不",
|
||||
"6d950431d2": "隐藏",
|
||||
"ebceac65a4": "设置",
|
||||
"3f003507f4": "在设置中打开完整设置"
|
||||
},
|
||||
"MobileEmulatorAgentSetupGuideSteps": {
|
||||
"9b49d892e3": "启用 Orca CLI",
|
||||
"3d8dc52c93": "在 agent shell 中注册用于模拟器控制的 orca 命令。",
|
||||
"21f5687c07": "Orca CLI 技能",
|
||||
"64fb057667": "教授 agents 此工作区的 orca 模拟器命令。"
|
||||
},
|
||||
"MobileEmulatorTabIntroCallout": {
|
||||
"1924982130": "关闭",
|
||||
"5789936d9a": "在 agents 控制屏幕时预览 iOS 模拟器。",
|
||||
"8014b4b80b": "保留",
|
||||
"6e051a40b7": "隐藏"
|
||||
},
|
||||
"mobile": {
|
||||
"emulator": {
|
||||
"hidden": {
|
||||
"toast": {
|
||||
"e8f098a870": "移动模拟器已隐藏",
|
||||
"c46c979c1d": "随时重新启用移动模拟器",
|
||||
"600f9a745a": "设置 › 移动模拟器"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2000,6 +2000,74 @@ describe('createUISlice setup guide sidebar dismissal', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('createUISlice mobile emulator agent setup dismissal', () => {
|
||||
it('persists mobile emulator agent setup dismissal once', () => {
|
||||
const setMock = vi.fn(() => Promise.resolve())
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
ui: {
|
||||
set: setMock
|
||||
}
|
||||
}
|
||||
})
|
||||
const store = createUIStore()
|
||||
|
||||
store.getState().dismissMobileEmulatorAgentSetup()
|
||||
store.getState().dismissMobileEmulatorAgentSetup()
|
||||
|
||||
expect(store.getState().mobileEmulatorAgentSetupDismissed).toBe(true)
|
||||
expect(setMock).toHaveBeenCalledTimes(1)
|
||||
expect(setMock).toHaveBeenCalledWith({ mobileEmulatorAgentSetupDismissed: true })
|
||||
})
|
||||
|
||||
it('hydrates only explicit mobile emulator agent setup dismissals', () => {
|
||||
const store = createUIStore()
|
||||
|
||||
store
|
||||
.getState()
|
||||
.hydratePersistedUI(makePersistedUI({ mobileEmulatorAgentSetupDismissed: true }))
|
||||
expect(store.getState().mobileEmulatorAgentSetupDismissed).toBe(true)
|
||||
|
||||
store
|
||||
.getState()
|
||||
.hydratePersistedUI(makePersistedUI({ mobileEmulatorAgentSetupDismissed: undefined }))
|
||||
expect(store.getState().mobileEmulatorAgentSetupDismissed).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createUISlice mobile emulator tab intro dismissal', () => {
|
||||
it('persists mobile emulator tab intro dismissal once', () => {
|
||||
const setMock = vi.fn(() => Promise.resolve())
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
ui: {
|
||||
set: setMock
|
||||
}
|
||||
}
|
||||
})
|
||||
const store = createUIStore()
|
||||
|
||||
store.getState().dismissMobileEmulatorTabIntro()
|
||||
store.getState().dismissMobileEmulatorTabIntro()
|
||||
|
||||
expect(store.getState().mobileEmulatorTabIntroDismissed).toBe(true)
|
||||
expect(setMock).toHaveBeenCalledTimes(1)
|
||||
expect(setMock).toHaveBeenCalledWith({ mobileEmulatorTabIntroDismissed: true })
|
||||
})
|
||||
|
||||
it('hydrates only explicit mobile emulator tab intro dismissals', () => {
|
||||
const store = createUIStore()
|
||||
|
||||
store.getState().hydratePersistedUI(makePersistedUI({ mobileEmulatorTabIntroDismissed: true }))
|
||||
expect(store.getState().mobileEmulatorTabIntroDismissed).toBe(true)
|
||||
|
||||
store
|
||||
.getState()
|
||||
.hydratePersistedUI(makePersistedUI({ mobileEmulatorTabIntroDismissed: undefined }))
|
||||
expect(store.getState().mobileEmulatorTabIntroDismissed).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createUISlice browser import hint dismissal', () => {
|
||||
it('persists browser import hint dismissal changes once', () => {
|
||||
const setMock = vi.fn(() => Promise.resolve())
|
||||
|
|
|
|||
|
|
@ -753,6 +753,10 @@ export type UISlice = {
|
|||
markSetupGuideBrowserMilestoneMigrated: (legacyComplete: boolean) => void
|
||||
browserImportHintHidden: boolean
|
||||
setBrowserImportHintHidden: (hidden: boolean) => void
|
||||
mobileEmulatorTabIntroDismissed: boolean
|
||||
dismissMobileEmulatorTabIntro: () => void
|
||||
mobileEmulatorAgentSetupDismissed: boolean
|
||||
dismissMobileEmulatorAgentSetup: () => void
|
||||
projectOrderManualDefaultNoticeDismissed: boolean
|
||||
dismissProjectOrderManualDefaultNotice: () => void
|
||||
usageEmptyStateDismissed: boolean
|
||||
|
|
@ -1776,6 +1780,24 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
|||
window.api.ui.set({ browserImportHintHidden: hidden }).catch(console.error)
|
||||
return { browserImportHintHidden: hidden }
|
||||
}),
|
||||
mobileEmulatorTabIntroDismissed: false,
|
||||
dismissMobileEmulatorTabIntro: () =>
|
||||
set((s) => {
|
||||
if (s.mobileEmulatorTabIntroDismissed) {
|
||||
return s
|
||||
}
|
||||
window.api.ui.set({ mobileEmulatorTabIntroDismissed: true }).catch(console.error)
|
||||
return { mobileEmulatorTabIntroDismissed: true }
|
||||
}),
|
||||
mobileEmulatorAgentSetupDismissed: false,
|
||||
dismissMobileEmulatorAgentSetup: () =>
|
||||
set((s) => {
|
||||
if (s.mobileEmulatorAgentSetupDismissed) {
|
||||
return s
|
||||
}
|
||||
window.api.ui.set({ mobileEmulatorAgentSetupDismissed: true }).catch(console.error)
|
||||
return { mobileEmulatorAgentSetupDismissed: true }
|
||||
}),
|
||||
projectOrderManualDefaultNoticeDismissed: true,
|
||||
dismissProjectOrderManualDefaultNotice: () =>
|
||||
set((s) => {
|
||||
|
|
@ -2221,6 +2243,8 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
|||
setupGuideBrowserMilestoneLegacyComplete:
|
||||
ui.setupGuideBrowserMilestoneLegacyComplete === true,
|
||||
browserImportHintHidden: ui.browserImportHintHidden === true,
|
||||
mobileEmulatorTabIntroDismissed: ui.mobileEmulatorTabIntroDismissed === true,
|
||||
mobileEmulatorAgentSetupDismissed: ui.mobileEmulatorAgentSetupDismissed === true,
|
||||
projectOrderManualDefaultNoticeDismissed:
|
||||
ui.projectOrderManualDefaultNoticeDismissed === true,
|
||||
// Why: default false when undefined so existing users still see the CTA;
|
||||
|
|
|
|||
|
|
@ -459,6 +459,8 @@ export function getDefaultUIState(): PersistedUIState {
|
|||
setupGuideBrowserMilestoneMigrated: true,
|
||||
setupGuideBrowserMilestoneLegacyComplete: false,
|
||||
browserImportHintHidden: false,
|
||||
mobileEmulatorTabIntroDismissed: false,
|
||||
mobileEmulatorAgentSetupDismissed: false,
|
||||
// Why: brand-new profiles never saw recent project ordering; only upgraded
|
||||
// profiles get the one-time sidebar notice on first launch.
|
||||
projectOrderManualDefaultNoticeDismissed: true,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export type FeatureInteractionId =
|
|||
| 'agent-browser-use'
|
||||
| 'agent-orchestration-setup'
|
||||
| 'agent-orchestration'
|
||||
| 'mobile-emulator-agent-setup'
|
||||
| 'ai-commit-generation'
|
||||
| 'ai-pr-generation'
|
||||
| 'claude-account-switching'
|
||||
|
|
@ -100,6 +101,10 @@ export const FEATURE_INTERACTIONS = [
|
|||
interaction: 'Agent Orchestration setup enabled or opened'
|
||||
},
|
||||
{ id: 'agent-orchestration', interaction: 'agent orchestration runtime method used' },
|
||||
{
|
||||
id: 'mobile-emulator-agent-setup',
|
||||
interaction: 'Mobile Emulator agent CLI or skill setup opened'
|
||||
},
|
||||
{
|
||||
id: 'ai-commit-generation',
|
||||
interaction: 'AI commit message generation enabled or used'
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export const FEATURE_INTERACTION_CATEGORY_BY_ID = {
|
|||
'agent-browser-use': 'agent',
|
||||
'agent-orchestration-setup': 'setup',
|
||||
'agent-orchestration': 'collaboration',
|
||||
'mobile-emulator-agent-setup': 'setup',
|
||||
'ai-commit-generation': 'source_control',
|
||||
'ai-pr-generation': 'source_control',
|
||||
'claude-account-switching': 'settings',
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ describe('feature interactions', () => {
|
|||
'agent-browser-use',
|
||||
'agent-orchestration-setup',
|
||||
'agent-orchestration',
|
||||
'mobile-emulator-agent-setup',
|
||||
'ai-commit-generation',
|
||||
'ai-pr-generation',
|
||||
'claude-account-switching',
|
||||
|
|
|
|||
|
|
@ -3027,6 +3027,11 @@ export type PersistedUIState = {
|
|||
/** User-dismissed browser import hint in the browser toolbar. Import remains
|
||||
* available from Settings > Browser and the toolbar overflow menu. */
|
||||
browserImportHintHidden?: boolean
|
||||
/** User dismissed the first-run Mobile Emulator intro (Keep, Hide, or close).
|
||||
* Reversible only by re-enabling the feature in Settings. */
|
||||
mobileEmulatorTabIntroDismissed?: boolean
|
||||
/** User deferred the in-pane Mobile Emulator CLI + skill setup guide. */
|
||||
mobileEmulatorAgentSetupDismissed?: boolean
|
||||
/** One-shot rollout notice for manual project ordering becoming the default.
|
||||
* Absent or true means the sidebar callout stays hidden. */
|
||||
projectOrderManualDefaultNoticeDismissed?: boolean
|
||||
|
|
|
|||
Loading…
Reference in New Issue