diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index 6f9369c5aad..00fe9381314 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -55,4 +55,43 @@ describe('skill discovery', () => { expect(roots.map((root) => root.path)).not.toContain('/remote/repo/.claude/skills') expect(roots.map((root) => root.path)).toContain('/workspace/current/.claude/skills') }) + + it('discovers skill packages through symlinked skill directories', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const realSkill = join(root, 'central-skills', 'orca-cli') + const linkedSkill = join(home, '.agents', 'skills', 'orca-cli') + await mkdir(realSkill, { recursive: true }) + await mkdir(join(home, '.agents', 'skills'), { recursive: true }) + await writeFile(join(realSkill, 'SKILL.md'), '# Orca CLI\n\nUse the Orca CLI.') + await symlink(realSkill, linkedSkill, process.platform === 'win32' ? 'junction' : 'dir') + + const result = await discoverSkills({ + homeDir: home, + cwd: join(root, 'missing-cwd') + }) + + const skill = result.skills.find((entry) => entry.name === 'Orca CLI') + expect(skill?.sourceKind).toBe('home') + expect(skill?.directoryPath).toBe(linkedSkill) + }) + + it('does not loop through recursive symlinked skill directories', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const skillRoot = join(home, '.agents', 'skills') + await mkdir(skillRoot, { recursive: true }) + await symlink( + skillRoot, + join(skillRoot, 'loop'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + + const result = await discoverSkills({ + homeDir: home, + cwd: join(root, 'missing-cwd') + }) + + expect(result.skills).toEqual([]) + }) }) diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index 91ef680f52e..7e9f140bfda 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import type { Dirent } from 'node:fs' -import { open, readdir, stat } from 'node:fs/promises' +import { open, readdir, realpath, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, dirname, join, relative, sep } from 'node:path' import type { Repo } from '../../shared/types' @@ -67,10 +67,22 @@ function sourceLabelForSkill(root: SkillScanRoot, sourceKind: SkillSourceKind): async function findSkillFiles(rootPath: string, maxDepth: number): Promise { const out: string[] = [] + const visitedDirectoryPaths = new Set() async function visit(dirPath: string): Promise { if (!isWithinDepth(rootPath, dirPath, maxDepth)) { return } + let resolvedDirPath: string + try { + resolvedDirPath = await realpath(dirPath) + } catch { + return + } + if (visitedDirectoryPaths.has(resolvedDirPath)) { + return + } + visitedDirectoryPaths.add(resolvedDirPath) + let entries: Dirent[] try { entries = await readdir(dirPath, { withFileTypes: true }) @@ -79,12 +91,36 @@ async function findSkillFiles(rootPath: string, maxDepth: number): Promise { let count = 0 + const visitedDirectoryPaths = new Set() async function visit(currentPath: string): Promise { if (count >= MAX_SKILL_FILES) { return } + let resolvedPath: string + try { + resolvedPath = await realpath(currentPath) + } catch { + return + } + if (visitedDirectoryPaths.has(resolvedPath)) { + return + } + visitedDirectoryPaths.add(resolvedPath) + let entries: Dirent[] try { entries = await readdir(currentPath, { withFileTypes: true }) @@ -113,6 +161,14 @@ async function countFiles(dirPath: string): Promise { count += 1 } else if (entry.isDirectory()) { await visit(entryPath) + } else if (entry.isSymbolicLink()) { + try { + if ((await stat(entryPath)).isFile()) { + count += 1 + } + } catch { + // Broken links do not contribute to the skill package file count. + } } } } diff --git a/src/renderer/src/components/AgentSkillInstalledIndicator.tsx b/src/renderer/src/components/AgentSkillInstalledIndicator.tsx new file mode 100644 index 00000000000..7c53bf64122 --- /dev/null +++ b/src/renderer/src/components/AgentSkillInstalledIndicator.tsx @@ -0,0 +1,25 @@ +import { Check } from 'lucide-react' +import { cn } from '@/lib/utils' + +type AgentSkillInstalledIndicatorProps = { + className?: string + showLabel?: boolean +} + +export function AgentSkillInstalledIndicator({ + className, + showLabel = true +}: AgentSkillInstalledIndicatorProps): React.JSX.Element { + return ( + + + {showLabel ? Installed : Installed} + + ) +} diff --git a/src/renderer/src/components/integration-status-pill.tsx b/src/renderer/src/components/integration-status-pill.tsx new file mode 100644 index 00000000000..418826cbe6b --- /dev/null +++ b/src/renderer/src/components/integration-status-pill.tsx @@ -0,0 +1,38 @@ +import { cn } from '@/lib/utils' + +export type IntegrationStatusTone = 'connected' | 'attention' | 'neutral' + +const TONE_CLASSES: Record = { + connected: { + pill: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300', + dot: 'bg-emerald-500' + }, + attention: { + pill: 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300', + dot: 'bg-amber-500' + }, + neutral: { + pill: 'border-border bg-background text-muted-foreground', + dot: 'bg-muted-foreground' + } +} + +export function IntegrationStatusPill({ + tone, + children +}: { + tone: IntegrationStatusTone + children: React.ReactNode +}): React.JSX.Element { + return ( + + + {children} + + ) +} diff --git a/src/renderer/src/components/onboarding/IntegrationsStep.tsx b/src/renderer/src/components/onboarding/IntegrationsStep.tsx index 1efa9c38775..a155d118dfd 100644 --- a/src/renderer/src/components/onboarding/IntegrationsStep.tsx +++ b/src/renderer/src/components/onboarding/IntegrationsStep.tsx @@ -12,7 +12,7 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { useAppStore } from '@/store' -import { cn } from '@/lib/utils' +import { IntegrationStatusPill } from '@/components/integration-status-pill' import { OnboardingInlineCommandTerminal } from './OnboardingInlineCommandTerminal' type GitHubSetupState = 'checking' | 'connected' | 'not-installed' | 'not-authenticated' @@ -29,43 +29,6 @@ function getGitHubSetupState( return status.gh.authenticated ? 'connected' : 'not-authenticated' } -type StatusTone = 'connected' | 'attention' | 'neutral' - -const statusToneClassNames: Record = { - connected: { - pill: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300', - dot: 'bg-emerald-500' - }, - attention: { - pill: 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300', - dot: 'bg-amber-500' - }, - neutral: { - pill: 'border-border bg-background text-muted-foreground', - dot: 'bg-muted-foreground' - } -} - -function StatusPill({ - tone, - children -}: { - tone: StatusTone - children: React.ReactNode -}): React.JSX.Element { - return ( - - - {children} - - ) -} - function GitHubRow(): React.JSX.Element { const preflightStatus = useAppStore((s) => s.preflightStatus) const preflightStatusLoading = useAppStore((s) => s.preflightStatusLoading) @@ -86,13 +49,13 @@ function GitHubRow(): React.JSX.Element {

GitHub

{state === 'connected' ? ( - Connected + Connected ) : state === 'not-installed' ? ( - CLI not installed + CLI not installed ) : state === 'not-authenticated' ? ( - Sign in needed + Sign in needed ) : ( - Checking… + Checking… )}

@@ -189,7 +152,9 @@ function LinearRow(): React.JSX.Element {

Linear

- {linearStatus.connected ? Connected : null} + {linearStatus.connected ? ( + Connected + ) : null}

{linearStatus.connected @@ -334,7 +299,7 @@ export function IntegrationsStep(): React.JSX.Element { Issues, sprints, and assignees.

- Coming soon + Coming soon diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx index 3b1072208f7..a1fa6e5ba7e 100644 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx @@ -6,15 +6,16 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { useAppStore } from '@/store' const ONBOARDING_INLINE_TERMINAL_WORKTREE_ID = 'onboarding-inline-terminal' -const AUTO_INSERT_DELAY_MS = 700 +const AUTO_INSERT_DELAY_MS = 250 const READY_RETRY_MS = 100 -const READY_MAX_ATTEMPTS = 50 +const PTY_TEXT_FALLBACK_MS = 750 type OnboardingInlineCommandTerminalProps = { command: string title: string description: string ariaLabel: string + worktreeId?: string onOpened?: () => void onInteracted?: (method: 'keyboard' | 'pointer', event?: KeyboardEvent) => void } @@ -24,6 +25,7 @@ export function OnboardingInlineCommandTerminal({ title, description, ariaLabel, + worktreeId = ONBOARDING_INLINE_TERMINAL_WORKTREE_ID, onOpened, onInteracted }: OnboardingInlineCommandTerminalProps): React.JSX.Element { @@ -56,13 +58,18 @@ export function OnboardingInlineCommandTerminal({ }, []) useEffect(() => { - const tab = createTab(ONBOARDING_INLINE_TERMINAL_WORKTREE_ID, undefined, undefined, { + const tab = createTab(worktreeId, undefined, undefined, { activate: false }) - setActiveTabForWorktree(ONBOARDING_INLINE_TERMINAL_WORKTREE_ID, tab.id) + setActiveTabForWorktree(worktreeId, tab.id) setTabCustomTitle(tab.id, title) setTabId(tab.id) - }, [createTab, setActiveTabForWorktree, setTabCustomTitle, title]) + return () => { + // Why: inline setup panels can disappear after detection succeeds; close + // the backing tab so installer shells do not keep running invisibly. + closeTab(tab.id) + } + }, [closeTab, createTab, setActiveTabForWorktree, setTabCustomTitle, title, worktreeId]) useEffect(() => { if (prefersReducedMotion) { @@ -119,38 +126,62 @@ export function OnboardingInlineCommandTerminal({ }, [command, tabId]) useEffect(() => { - if (!tabId || autoInsertedRef.current === command) { + if (!tabId || !cwd || autoInsertedRef.current === command) { return } let canceled = false let insertionTimer: number | null = null + let retryTimer: number | null = null + let ptyFirstSeenAt: number | null = null - const waitForTerminal = (attempt: number): void => { + const scheduleInsert = (): void => { + if (insertionTimer !== null) { + return + } + insertionTimer = window.setTimeout(() => { + if (!canceled) { + autoInsertedRef.current = command + insertCommand() + } + }, AUTO_INSERT_DELAY_MS) + } + + const waitForTerminal = (): void => { if (canceled) { return } - if (findTerminalTabElement(tabId)?.querySelector('[data-pty-id]')) { - insertionTimer = window.setTimeout(() => { - if (!canceled) { - autoInsertedRef.current = command - insertCommand() - } - }, AUTO_INSERT_DELAY_MS) + const terminalElement = findTerminalTabElement(tabId) + const hasPty = Boolean(terminalElement?.querySelector('[data-pty-id]')) + if (terminalReadyForCommand(terminalElement)) { + scheduleInsert() return } - if (attempt < READY_MAX_ATTEMPTS) { - window.setTimeout(() => waitForTerminal(attempt + 1), READY_RETRY_MS) + if (hasPty) { + ptyFirstSeenAt ??= Date.now() + // Why: GPU/canvas terminal renderers may not expose visible prompt text + // in .xterm-rows. Once the PTY has settled briefly, paste the draft + // instead of waiting on a DOM signal that may never arrive. + if (Date.now() - ptyFirstSeenAt >= PTY_TEXT_FALLBACK_MS) { + scheduleInsert() + return + } + } else { + ptyFirstSeenAt = null } + retryTimer = window.setTimeout(waitForTerminal, READY_RETRY_MS) } - waitForTerminal(0) + waitForTerminal() return () => { canceled = true + if (retryTimer !== null) { + window.clearTimeout(retryTimer) + } if (insertionTimer !== null) { window.clearTimeout(insertionTimer) } } - }, [command, insertCommand, tabId]) + }, [command, cwd, insertCommand, tabId]) // Why: grid 0fr → 1fr animates to the child's natural height without a // hardcoded max-height, so we don't leave dead space if the terminal @@ -182,7 +213,7 @@ export function OnboardingInlineCommandTerminal({ {cwd && tabId ? ( 0 +} diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx new file mode 100644 index 00000000000..30903f3b512 --- /dev/null +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -0,0 +1,124 @@ +import { useEffect, useState, type ReactNode } from 'react' +import { Terminal } from 'lucide-react' +import { IntegrationStatusPill } from '../integration-status-pill' +import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal' +import { Button } from '../ui/button' +import { cn } from '@/lib/utils' + +type AgentSkillSetupPanelVariant = 'card' | 'inline' + +type AgentSkillSetupPanelProps = { + title: string + detectedDescription: string + missingDescription: string + command: string + terminalTitle: string + terminalAriaLabel: string + terminalWorktreeId: string + installed: boolean + detected: boolean + loading: boolean + error: string | null + installDisabled?: boolean + leading?: ReactNode + icon?: ReactNode + variant?: AgentSkillSetupPanelVariant + className?: string + onRecheck: () => void | Promise +} + +export function AgentSkillSetupPanel({ + title, + detectedDescription, + missingDescription, + command, + terminalTitle, + terminalAriaLabel, + terminalWorktreeId, + installed, + detected, + loading, + error, + installDisabled = false, + leading, + icon, + variant = 'card', + className, + onRecheck +}: AgentSkillSetupPanelProps): React.JSX.Element { + const [terminalOpen, setTerminalOpen] = useState(false) + + useEffect(() => { + if (installed) { + setTerminalOpen(false) + } + }, [installed]) + + const body = detected ? detectedDescription : missingDescription + + return ( +
+
+ {leading} + {icon ? ( +
+ {icon} +
+ ) : null} +
+
+

{title}

+ {loading && !installed ? ( + Checking... + ) : installed ? ( + Installed + ) : ( + Not installed + )} +
+

{body}

+ {error ?

{error}

: null} +
+
+ {!installed ? ( + + ) : null} + +
+
+ {!installed && terminalOpen ? ( +
+ +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/BrowserUsePane.tsx b/src/renderer/src/components/settings/BrowserUsePane.tsx index 056e7932aec..2cdb1a17ed4 100644 --- a/src/renderer/src/components/settings/BrowserUsePane.tsx +++ b/src/renderer/src/components/settings/BrowserUsePane.tsx @@ -2,11 +2,15 @@ import { useEffect, useState } from 'react' import { Import, Loader2, MousePointerClick } from 'lucide-react' import { toast } from 'sonner' import type { CliInstallStatus } from '../../../../shared/cli-install-types' -import { ORCA_CLI_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands' import { - BROWSER_USE_ENABLED_STORAGE_KEY, - BROWSER_USE_SKILL_INSTALLED_STORAGE_KEY -} from '@/lib/browser-use-setup-state' + ORCA_CLI_SKILL_INSTALL_COMMAND, + ORCA_CLI_SKILL_NAME +} from '@/lib/agent-feature-install-commands' +import { BROWSER_USE_ENABLED_STORAGE_KEY } from '@/lib/browser-use-setup-state' +import { + GLOBAL_AGENT_SKILL_SOURCE_KINDS, + useInstalledAgentSkill +} from '@/hooks/useInstalledAgentSkills' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { @@ -93,19 +97,16 @@ export function BrowserUseSetup({ const cliEnabled = cliStatus?.state === 'installed' const cliSupported = cliStatus?.supported ?? false - // Why: the skill install step is a copy-and-run command that happens in the - // user's terminal. We cannot detect completion from the app, so we let the - // user mark it done explicitly after copying — this avoids falsely implying - // progress and keeps the guided flow honest. - const [skillInstalled, setSkillInstalled] = useState(() => { - return localStorage.getItem(BROWSER_USE_SKILL_INSTALLED_STORAGE_KEY) === '1' + const { + installed: skillDetected, + loading: skillLoading, + error: skillError, + refresh: refreshSkill + } = useInstalledAgentSkill(ORCA_CLI_SKILL_NAME, { + enabled: browserUseEnabled, + sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) - const markSkillInstalled = (value: boolean): void => { - setSkillInstalled(value) - localStorage.setItem(BROWSER_USE_SKILL_INSTALLED_STORAGE_KEY, value ? '1' : '0') - } - const handleEnableCli = async (): Promise => { setCliBusy(true) try { @@ -119,15 +120,6 @@ export function BrowserUseSetup({ } } - const handleCopySkillCommand = async (): Promise => { - try { - await window.api.ui.writeClipboardText(ORCA_CLI_SKILL_INSTALL_COMMAND) - toast.success('Copied install command. Run it on this computer.') - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to copy command.') - } - } - const handleImportFromBrowser = async ( browserFamily: string, browserProfile?: string @@ -162,8 +154,7 @@ export function BrowserUseSetup({ const showStep1 = matchesSettingsSearch(searchQuery, [BROWSER_USE_PANE_SEARCH_ENTRIES[0]]) const showStep2 = matchesSettingsSearch(searchQuery, [BROWSER_USE_PANE_SEARCH_ENTRIES[1]]) const showStep3 = matchesSettingsSearch(searchQuery, [BROWSER_USE_PANE_SEARCH_ENTRIES[2]]) - - const completedCount = [cliEnabled, skillInstalled, cookiesImported].filter(Boolean).length + const completedCount = [cliEnabled, skillDetected, cookiesImported].filter(Boolean).length const sourceLabel = defaultProfile?.source ? `${BROWSER_FAMILY_LABELS[defaultProfile.source.browserFamily] ?? defaultProfile.source.browserFamily}${defaultProfile.source.profileName ? ` (${defaultProfile.source.profileName})` : ''}` @@ -310,10 +301,11 @@ export function BrowserUseSetup({ > void handleCopySkillCommand()} - onToggleInstalled={() => markSkillInstalled(!skillInstalled)} + onRecheck={refreshSkill} /> ) : null} @@ -324,7 +316,7 @@ export function BrowserUseSetup({ description="Import cookies from Chrome, Edge, or other browsers so agents can reuse your logins." keywords={BROWSER_USE_PANE_SEARCH_ENTRIES[2].keywords} className={`rounded-xl border border-border/60 bg-card/50 p-4 ${ - cliEnabled && skillInstalled ? '' : 'opacity-60' + cliEnabled && skillDetected ? '' : 'opacity-60' }`} >
diff --git a/src/renderer/src/components/settings/BrowserUseSkillStep.tsx b/src/renderer/src/components/settings/BrowserUseSkillStep.tsx index 09bcedd429a..1365e9d6c05 100644 --- a/src/renderer/src/components/settings/BrowserUseSkillStep.tsx +++ b/src/renderer/src/components/settings/BrowserUseSkillStep.tsx @@ -1,72 +1,40 @@ -import { Copy } from 'lucide-react' -import { Button } from '../ui/button' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' +import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' import { StepBadge } from './BrowserUseStepBadge' type Props = { command: string - skillInstalled: boolean + skillDetected: boolean + skillLoading: boolean + skillError: string | null disabled?: boolean - onCopy: () => void - onToggleInstalled: () => void + onRecheck: () => void | Promise } export function BrowserUseSkillStep({ command, - skillInstalled, + skillDetected, + skillLoading, + skillError, disabled = false, - onCopy, - onToggleInstalled + onRecheck }: Props): React.JSX.Element { return ( -
- -
-
-

Install Browser Use Skill

-

- Run this once on your computer so Claude Code, Codex, and other agents learn to drive - Orca's browser. -

-
-
- - {command} - - - - - - - - Copy - - - -
-
- - {skillInstalled - ? 'Marked as installed on this machine.' - : "Check off once you've run it on this computer."} - - -
-
-
+ } + onRecheck={onRecheck} + /> ) } diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 924cedd1212..2a271ffbfa1 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -1,8 +1,15 @@ import { useEffect, useState } from 'react' -import { Copy, FolderOpen, RefreshCw } from 'lucide-react' +import { FolderOpen, RefreshCw } from 'lucide-react' import { toast } from 'sonner' import type { CliInstallStatus } from '../../../../shared/cli-install-types' -import { ORCA_CLI_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands' +import { + ORCA_CLI_SKILL_INSTALL_COMMAND, + ORCA_CLI_SKILL_NAME +} from '@/lib/agent-feature-install-commands' +import { + GLOBAL_AGENT_SKILL_SOURCE_KINDS, + useInstalledAgentSkill +} from '@/hooks/useInstalledAgentSkills' import { Button } from '../ui/button' import { Dialog, @@ -14,6 +21,7 @@ import { } from '../ui/dialog' import { Label } from '../ui/label' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' +import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' import { WslCliRegistration } from './WslCliRegistration' type CliSectionProps = { @@ -48,6 +56,14 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem const [loading, setLoading] = useState(true) const [dialogOpen, setDialogOpen] = useState(false) const [busyAction, setBusyAction] = useState<'install' | 'remove' | null>(null) + const { + installed: cliSkillDetected, + loading: cliSkillLoading, + error: cliSkillError, + refresh: refreshCliSkill + } = useInstalledAgentSkill(ORCA_CLI_SKILL_NAME, { + sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS + }) const refreshStatus = async (): Promise => { setLoading(true) @@ -99,15 +115,6 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem } } - const handleCopySkillInstallCommand = async (command: string): Promise => { - try { - await window.api.ui.writeClipboardText(command) - toast.success('Copied skill install command.') - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to copy install command.') - } - } - return (
@@ -214,35 +221,22 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem

-
-
-

CLI skill

-
- - {ORCA_CLI_SKILL_INSTALL_COMMAND} - - - - - - - - Copy - - - -
-
-
+
) : null} diff --git a/src/renderer/src/components/settings/ComputerUsePane.tsx b/src/renderer/src/components/settings/ComputerUsePane.tsx index bf31a526a4b..44d8e70177d 100644 --- a/src/renderer/src/components/settings/ComputerUsePane.tsx +++ b/src/renderer/src/components/settings/ComputerUsePane.tsx @@ -1,14 +1,28 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' -import { Accessibility, Camera, Copy, ExternalLink, RefreshCw, ShieldCheck } from 'lucide-react' +import { + Accessibility, + Camera, + ExternalLink, + MonitorCog, + RefreshCw, + ShieldCheck +} from 'lucide-react' import { toast } from 'sonner' import type { ComputerUsePermissionId, ComputerUsePermissionState, ComputerUsePermissionStatus } from '../../../../shared/computer-use-permissions-types' -import { COMPUTER_USE_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands' +import { + COMPUTER_USE_SKILL_INSTALL_COMMAND, + COMPUTER_USE_SKILL_NAME +} from '@/lib/agent-feature-install-commands' +import { + GLOBAL_AGENT_SKILL_SOURCE_KINDS, + useInstalledAgentSkill +} from '@/hooks/useInstalledAgentSkills' import { Button } from '../ui/button' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' +import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' import type { SettingsSearchEntry } from './settings-search' export const COMPUTER_USE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ @@ -73,6 +87,14 @@ export function ComputerUsePane(): React.JSX.Element { const [loading, setLoading] = useState(true) const [pendingId, setPendingId] = useState(null) const [helperUnavailableReason, setHelperUnavailableReason] = useState(null) + const { + installed: computerUseSkillDetected, + loading: computerUseSkillLoading, + error: computerUseSkillError, + refresh: refreshComputerUseSkill + } = useInstalledAgentSkill(COMPUTER_USE_SKILL_NAME, { + sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS + }) const stateById = useMemo( () => new Map(states.map((state) => [state.id, state.status] as const)), @@ -127,15 +149,6 @@ export function ComputerUsePane(): React.JSX.Element { } } - const copySkillInstallCommand = async (): Promise => { - try { - await window.api.ui.writeClipboardText(COMPUTER_USE_SKILL_INSTALL_COMMAND) - toast.success('Copied skill install command.') - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to copy install command.') - } - } - const isMac = platform === null || platform === 'darwin' return ( @@ -209,36 +222,21 @@ export function ComputerUsePane(): React.JSX.Element { ) : null} -
-
-

Install Computer Use Skill

-

- Run this once on your computer so agents know how to use Orca's computer controls. -

-
-
- - {COMPUTER_USE_SKILL_INSTALL_COMMAND} - - - - - - - - Copy - - - -
-
+ } + onRecheck={refreshComputerUseSkill} + /> ) } diff --git a/src/renderer/src/components/settings/OrchestrationPane.tsx b/src/renderer/src/components/settings/OrchestrationPane.tsx index c5a2756e4c7..c73889e018b 100644 --- a/src/renderer/src/components/settings/OrchestrationPane.tsx +++ b/src/renderer/src/components/settings/OrchestrationPane.tsx @@ -1,22 +1,23 @@ import { useEffect, useState } from 'react' -import { Copy } from 'lucide-react' -import { toast } from 'sonner' -import { Button } from '../ui/button' +import { Workflow } from 'lucide-react' import { Label } from '../ui/label' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' +import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands' import { ORCHESTRATION_SKILL_INSTALL_COMMAND } from '@/lib/orchestration-install-command' +import { + GLOBAL_AGENT_SKILL_SOURCE_KINDS, + useInstalledAgentSkill +} from '@/hooks/useInstalledAgentSkills' import { ORCHESTRATION_ENABLED_STORAGE_KEY, - ORCHESTRATION_SKILL_INSTALLED_STORAGE_KEY, ORCHESTRATION_SETUP_STATE_EVENT, isOrchestrationSetupEnabled, - isOrchestrationSkillMarkedInstalled, notifyOrchestrationSetupStateChanged } from '@/lib/orchestration-setup-state' import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch } from './settings-search' import { useAppStore } from '../../store' import { ORCHESTRATION_PANE_SEARCH_ENTRIES } from './orchestration-search' +import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' export function OrchestrationPane(): React.JSX.Element { const searchQuery = useAppStore((s) => s.settingsSearchQuery) @@ -26,14 +27,19 @@ export function OrchestrationPane(): React.JSX.Element { return isOrchestrationSetupEnabled() }) - const [orchestrationSkillInstalled, setOrchestrationSkillInstalled] = useState(() => { - return isOrchestrationSkillMarkedInstalled() + const { + installed: orchestrationSkillDetected, + loading: orchestrationSkillLoading, + error: orchestrationSkillError, + refresh: refreshOrchestrationSkill + } = useInstalledAgentSkill(ORCHESTRATION_SKILL_NAME, { + enabled: orchestrationEnabled, + sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) useEffect(() => { const syncSetupState = (): void => { setOrchestrationEnabled(isOrchestrationSetupEnabled()) - setOrchestrationSkillInstalled(isOrchestrationSkillMarkedInstalled()) } window.addEventListener(ORCHESTRATION_SETUP_STATE_EVENT, syncSetupState) return () => { @@ -47,19 +53,8 @@ export function OrchestrationPane(): React.JSX.Element { notifyOrchestrationSetupStateChanged() } - const markOrchestrationSkillInstalled = (value: boolean): void => { - setOrchestrationSkillInstalled(value) - localStorage.setItem(ORCHESTRATION_SKILL_INSTALLED_STORAGE_KEY, value ? '1' : '0') - notifyOrchestrationSetupStateChanged() - } - - const handleCopyOrchestrationCommand = async (): Promise => { - try { - await window.api.ui.writeClipboardText(ORCHESTRATION_SKILL_INSTALL_COMMAND) - toast.success('Copied install command. Run it on this computer.') - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to copy command.') - } + const handleRecheckOrchestrationSkill = async (): Promise => { + await refreshOrchestrationSkill() } if (!showOrchestration) { @@ -98,50 +93,21 @@ export function OrchestrationPane(): React.JSX.Element { {orchestrationEnabled ? ( -
-
-

Install Orchestration Skill

-

- Run this once on your computer so agents learn to use inter-agent orchestration. -

-
-
- - {ORCHESTRATION_SKILL_INSTALL_COMMAND} - - - - - - - - Copy - - - -
-
- - {orchestrationSkillInstalled - ? 'Marked as installed on this machine.' - : "Check off once you've run it on this computer."} - - -
-
+ } + onRecheck={handleRecheckOrchestrationSkill} + /> ) : null} ) diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.test.ts b/src/renderer/src/hooks/useInstalledAgentSkills.test.ts new file mode 100644 index 00000000000..53bca81d066 --- /dev/null +++ b/src/renderer/src/hooks/useInstalledAgentSkills.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../shared/skills' +import { + GLOBAL_AGENT_SKILL_SOURCE_KINDS, + _installedAgentSkillDiscoveryInternalsForTests, + hasInstalledAgentSkill +} from './useInstalledAgentSkills' + +afterEach(() => { + _installedAgentSkillDiscoveryInternalsForTests.reset() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +function skill(overrides: Partial): DiscoveredSkill { + return { + id: 'skill-1', + name: 'Example Skill', + description: null, + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + rootPath: '/Users/test/.agents/skills', + directoryPath: '/Users/test/.agents/skills/example-skill', + skillFilePath: '/Users/test/.agents/skills/example-skill/SKILL.md', + installed: true, + fileCount: 1, + updatedAt: null, + ...overrides + } +} + +function discoveryResult(skills: DiscoveredSkill[] = []): SkillDiscoveryResult { + return { + skills, + sources: [], + scannedAt: Date.now() + } +} + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (reason?: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +describe('hasInstalledAgentSkill', () => { + it('matches installed skills by summarized name', () => { + expect(hasInstalledAgentSkill([skill({ name: 'orca-cli' })], 'orca-cli')).toBe(true) + }) + + it('matches installed skills by directory name when frontmatter has a display name', () => { + expect( + hasInstalledAgentSkill( + [ + skill({ + name: 'Orca CLI', + directoryPath: 'C:\\Users\\test\\.agents\\skills\\orca-cli' + }) + ], + 'orca-cli' + ) + ).toBe(true) + }) + + it('ignores non-installed discovery entries', () => { + expect( + hasInstalledAgentSkill([skill({ name: 'orca-cli', installed: false })], 'orca-cli') + ).toBe(false) + }) + + it('does not count repo or plugin skills when matching global installs', () => { + expect( + hasInstalledAgentSkill( + [ + skill({ + name: 'orca-cli', + sourceKind: 'repo', + sourceLabel: 'Repo test .agents', + rootPath: '/repo/.agents/skills', + directoryPath: '/repo/.agents/skills/orca-cli', + skillFilePath: '/repo/.agents/skills/orca-cli/SKILL.md' + }), + skill({ + id: 'skill-2', + name: 'orca-cli', + sourceKind: 'plugin', + sourceLabel: 'Codex plugin cache', + rootPath: '/Users/test/.codex/plugins/cache', + directoryPath: '/Users/test/.codex/plugins/cache/vendor/orca-cli', + skillFilePath: '/Users/test/.codex/plugins/cache/vendor/orca-cli/SKILL.md' + }) + ], + 'orca-cli', + { sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS } + ) + ).toBe(false) + }) + + it('counts home skills when matching global installs', () => { + expect( + hasInstalledAgentSkill([skill({ name: 'orca-cli' })], 'orca-cli', { + sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS + }) + ).toBe(true) + }) +}) + +describe('discoverInstalledAgentSkills', () => { + it('starts a fresh scan when a forced refresh arrives during a background scan', async () => { + const firstScan = deferred() + const secondScan = deferred() + const discover = vi.fn<() => Promise>() + discover.mockReturnValueOnce(firstScan.promise) + discover.mockReturnValueOnce(secondScan.promise) + vi.stubGlobal('window', { + api: { skills: { discover } } + }) + + const backgroundRefresh = + _installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(false) + const forcedRefresh = + _installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(true) + + expect(discover).toHaveBeenCalledTimes(1) + + const staleResult = discoveryResult([]) + firstScan.resolve(staleResult) + await expect(backgroundRefresh).resolves.toBe(staleResult) + + expect(discover).toHaveBeenCalledTimes(2) + + const freshResult = discoveryResult([skill({ name: 'orca-cli' })]) + secondScan.resolve(freshResult) + await expect(forcedRefresh).resolves.toBe(freshResult) + }) +}) diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.ts b/src/renderer/src/hooks/useInstalledAgentSkills.ts new file mode 100644 index 00000000000..1b5bbf8294a --- /dev/null +++ b/src/renderer/src/hooks/useInstalledAgentSkills.ts @@ -0,0 +1,179 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { DiscoveredSkill, SkillDiscoveryResult, SkillSourceKind } from '../../../shared/skills' + +const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed' +export const GLOBAL_AGENT_SKILL_SOURCE_KINDS = [ + 'home' +] as const satisfies readonly SkillSourceKind[] + +type InstalledAgentSkillOptions = { + enabled?: boolean + sourceKinds?: readonly SkillSourceKind[] +} + +type InstalledAgentSkillMatchOptions = { + sourceKinds?: readonly SkillSourceKind[] +} + +let cachedDiscovery: SkillDiscoveryResult | null = null +let pendingDiscovery: Promise | null = null +let pendingDiscoverySatisfiesForcedRefresh = false + +function normalizeSkillName(value: string): string { + return value.trim().toLowerCase() +} + +function basenameFromPath(pathValue: string): string { + return pathValue.split(/[\\/]/).filter(Boolean).at(-1) ?? pathValue +} + +export function hasInstalledAgentSkill( + skills: readonly DiscoveredSkill[], + skillName: string, + options: InstalledAgentSkillMatchOptions = {} +): boolean { + const expected = normalizeSkillName(skillName) + return skills.some((skill) => { + if (!skill.installed) { + return false + } + if (options.sourceKinds && !options.sourceKinds.includes(skill.sourceKind)) { + return false + } + return ( + normalizeSkillName(skill.name) === expected || + normalizeSkillName(basenameFromPath(skill.directoryPath)) === expected + ) + }) +} + +export function notifyInstalledAgentSkillsChanged(): void { + cachedDiscovery = null + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(INSTALLED_AGENT_SKILLS_CHANGED_EVENT)) + } +} + +function startInstalledAgentSkillDiscovery(force: boolean): Promise { + const discovery = window.api.skills + .discover() + .then((result) => { + cachedDiscovery = result + return result + }) + .finally(() => { + if (pendingDiscovery === discovery) { + pendingDiscovery = null + pendingDiscoverySatisfiesForcedRefresh = false + } + }) + pendingDiscovery = discovery + pendingDiscoverySatisfiesForcedRefresh = force + return discovery +} + +async function discoverInstalledAgentSkills(force: boolean): Promise { + if (!force && cachedDiscovery) { + return cachedDiscovery + } + + const inFlightDiscovery = pendingDiscovery + if (inFlightDiscovery) { + if (!force || pendingDiscoverySatisfiesForcedRefresh) { + return inFlightDiscovery + } + try { + await inFlightDiscovery + } catch { + // Why: an explicit re-check should still read current disk state even if + // the older background scan failed. + } + if (pendingDiscovery && pendingDiscovery !== inFlightDiscovery) { + return pendingDiscovery + } + } + + return startInstalledAgentSkillDiscovery(force) +} + +export const _installedAgentSkillDiscoveryInternalsForTests = { + discoverInstalledAgentSkills, + reset(): void { + cachedDiscovery = null + pendingDiscovery = null + pendingDiscoverySatisfiesForcedRefresh = false + } +} + +export function useInstalledAgentSkill( + skillName: string, + options: InstalledAgentSkillOptions = {} +): { + installed: boolean + loading: boolean + error: string | null + refresh: () => Promise +} { + const { enabled = true, sourceKinds } = options + const [result, setResult] = useState(cachedDiscovery) + const [loading, setLoading] = useState(enabled && !cachedDiscovery) + const [error, setError] = useState(null) + + const refresh = useCallback( + async (force = true): Promise => { + if (!enabled) { + setLoading(false) + return + } + setLoading(true) + try { + const next = await discoverInstalledAgentSkills(force) + setResult(next) + setError(null) + } catch (refreshError) { + setError( + refreshError instanceof Error ? refreshError.message : 'Could not scan installed skills.' + ) + } finally { + setLoading(false) + } + }, + [enabled] + ) + + useEffect(() => { + void refresh(false) + }, [refresh]) + + useEffect(() => { + if (!enabled) { + return + } + const refreshFromExternalChange = (): void => { + void refresh(true) + } + // Why: skill install commands run outside React state, often in a terminal. + // Refresh on focus and explicit install events so completion is detected. + window.addEventListener('focus', refreshFromExternalChange) + window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, refreshFromExternalChange) + return () => { + window.removeEventListener('focus', refreshFromExternalChange) + window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, refreshFromExternalChange) + } + }, [enabled, refresh]) + + const installed = useMemo( + () => + enabled && result ? hasInstalledAgentSkill(result.skills, skillName, { sourceKinds }) : false, + [enabled, result, skillName, sourceKinds] + ) + + const forceRefresh = useCallback(() => refresh(true), [refresh]) + + return { + installed, + loading, + error, + refresh: forceRefresh + } +} diff --git a/src/renderer/src/lib/browser-use-setup-state.ts b/src/renderer/src/lib/browser-use-setup-state.ts index a1a62113933..5f42108e37b 100644 --- a/src/renderer/src/lib/browser-use-setup-state.ts +++ b/src/renderer/src/lib/browser-use-setup-state.ts @@ -1,2 +1 @@ export const BROWSER_USE_ENABLED_STORAGE_KEY = 'orca.browserUse.enabled' -export const BROWSER_USE_SKILL_INSTALLED_STORAGE_KEY = 'orca.browserUse.skillInstalled' diff --git a/src/renderer/src/lib/orchestration-setup-state.ts b/src/renderer/src/lib/orchestration-setup-state.ts index 52243c7a9b9..eea19bc90c5 100644 --- a/src/renderer/src/lib/orchestration-setup-state.ts +++ b/src/renderer/src/lib/orchestration-setup-state.ts @@ -1,18 +1,13 @@ export const ORCHESTRATION_SETUP_STATE_EVENT = 'orca:orchestration-setup-state' export const ORCHESTRATION_ENABLED_STORAGE_KEY = 'orca.orchestration.enabled' -export const ORCHESTRATION_SKILL_INSTALLED_STORAGE_KEY = 'orca.orchestration.skillInstalled' export const ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY = 'orca.orchestration.setupDismissed' export function isOrchestrationSetupEnabled(): boolean { return localStorage.getItem(ORCHESTRATION_ENABLED_STORAGE_KEY) === '1' } -export function isOrchestrationSkillMarkedInstalled(): boolean { - return localStorage.getItem(ORCHESTRATION_SKILL_INSTALLED_STORAGE_KEY) === '1' -} - export function hasOrchestrationSetupMarker(): boolean { - return isOrchestrationSetupEnabled() || isOrchestrationSkillMarkedInstalled() + return isOrchestrationSetupEnabled() } export function isOrchestrationSetupDismissed(): boolean { diff --git a/tests/e2e/settings-skill-detection.spec.ts b/tests/e2e/settings-skill-detection.spec.ts new file mode 100644 index 00000000000..d7b50402fe5 --- /dev/null +++ b/tests/e2e/settings-skill-detection.spec.ts @@ -0,0 +1,120 @@ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import type { + DiscoveredSkill, + SkillDiscoveryResult, + SkillSourceKind +} from '../../src/shared/skills' +import { ORCHESTRATION_ENABLED_STORAGE_KEY } from '../../src/renderer/src/lib/orchestration-setup-state' + +type MockSkillDiscoveryGlobal = typeof globalThis & { + __orcaSettingsSkillDiscoveryResult?: SkillDiscoveryResult +} + +function makeSkill(sourceKind: SkillSourceKind, directoryPath: string): DiscoveredSkill { + return { + id: `${sourceKind}-orca-cli`, + name: 'orchestration', + description: null, + providers: ['agent-skills'], + sourceKind, + sourceLabel: sourceKind, + rootPath: directoryPath.replace(/[\\/]orchestration$/, ''), + directoryPath, + skillFilePath: `${directoryPath}/SKILL.md`, + installed: true, + fileCount: 1, + updatedAt: null + } +} + +function discoveryResult(skills: DiscoveredSkill[]): SkillDiscoveryResult { + return { + skills, + sources: [], + scannedAt: Date.now() + } +} + +async function installMockSkillDiscovery( + app: ElectronApplication, + result: SkillDiscoveryResult +): Promise { + await app.evaluate((electron, initialResult) => { + const global = globalThis as MockSkillDiscoveryGlobal + global.__orcaSettingsSkillDiscoveryResult = initialResult + electron.ipcMain.removeHandler('skills:discover') + electron.ipcMain.handle('skills:discover', () => { + const latest = (globalThis as MockSkillDiscoveryGlobal).__orcaSettingsSkillDiscoveryResult + if (!latest) { + throw new Error('Missing mocked skill discovery result') + } + return latest + }) + }, result) +} + +async function setMockSkillDiscovery( + app: ElectronApplication, + result: SkillDiscoveryResult +): Promise { + await app.evaluate((_, nextResult) => { + ;(globalThis as MockSkillDiscoveryGlobal).__orcaSettingsSkillDiscoveryResult = nextResult + }, result) +} + +async function openOrchestrationSettings(page: Page): Promise { + await page.evaluate( + ({ enabledKey }) => { + localStorage.removeItem(enabledKey) + const state = window.__store!.getState() + state.setSettingsSearchQuery('orchestration') + state.openSettingsPage() + }, + { + enabledKey: ORCHESTRATION_ENABLED_STORAGE_KEY + } + ) + await expect(page.getByPlaceholder('Search settings')).toBeVisible({ timeout: 10_000 }) + await expect( + page + .locator('[data-settings-section="orchestration"]') + .getByRole('heading', { name: 'Orchestration', exact: true }) + ).toBeInViewport({ timeout: 10_000 }) +} + +test.describe('Settings skill detection', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + }) + + test('shows installed only for global orchestration skill installs', async ({ + electronApp, + orcaPage + }) => { + await installMockSkillDiscovery( + electronApp, + discoveryResult([ + makeSkill('repo', '/workspace/.agents/skills/orchestration'), + makeSkill('plugin', '/Users/test/.codex/plugins/cache/vendor/orchestration') + ]) + ) + + await openOrchestrationSettings(orcaPage) + const section = orcaPage.locator('[data-settings-section="orchestration"]') + await section.getByRole('switch').click() + + await expect(section.getByText('Not installed', { exact: true })).toBeVisible() + await expect(section.getByText('Agents need this skill', { exact: false })).toBeVisible() + + await setMockSkillDiscovery( + electronApp, + discoveryResult([makeSkill('home', '/Users/test/.agents/skills/orchestration')]) + ) + await section.getByRole('button', { name: 'Re-check' }).click() + + await expect(section.getByText('Installed', { exact: true })).toBeVisible() + await expect(section.getByText('Detected on this machine', { exact: false })).toBeVisible() + }) +})