mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Detect installed agent skills in settings (#2613)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string[]> {
|
||||
const out: string[] = []
|
||||
const visitedDirectoryPaths = new Set<string>()
|
||||
async function visit(dirPath: string): Promise<void> {
|
||||
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<strin
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(dirPath, entry.name)
|
||||
if (entry.isFile() && entry.name === SKILL_FILE_NAME) {
|
||||
out.push(entryPath)
|
||||
if (entry.name === SKILL_FILE_NAME) {
|
||||
if (entry.isFile()) {
|
||||
out.push(entryPath)
|
||||
continue
|
||||
}
|
||||
if (entry.isSymbolicLink()) {
|
||||
try {
|
||||
if ((await stat(entryPath)).isFile()) {
|
||||
out.push(entryPath)
|
||||
}
|
||||
} catch {
|
||||
// Broken links are not valid skill files.
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
await visit(entryPath)
|
||||
continue
|
||||
}
|
||||
if (entry.isSymbolicLink()) {
|
||||
// Why: users commonly symlink agent skill dirs across providers; follow
|
||||
// directory links but guard by realpath so recursive links cannot loop.
|
||||
try {
|
||||
if ((await stat(entryPath)).isDirectory()) {
|
||||
await visit(entryPath)
|
||||
}
|
||||
} catch {
|
||||
// Broken links are not valid skill directories.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,10 +130,22 @@ async function findSkillFiles(rootPath: string, maxDepth: number): Promise<strin
|
||||
|
||||
async function countFiles(dirPath: string): Promise<number> {
|
||||
let count = 0
|
||||
const visitedDirectoryPaths = new Set<string>()
|
||||
async function visit(currentPath: string): Promise<void> {
|
||||
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<number> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<span
|
||||
aria-label="Skill installed"
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-1.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-300',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Check className="size-3.5" aria-hidden />
|
||||
{showLabel ? <span>Installed</span> : <span className="sr-only">Installed</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type IntegrationStatusTone = 'connected' | 'attention' | 'neutral'
|
||||
|
||||
const TONE_CLASSES: Record<IntegrationStatusTone, { pill: string; dot: string }> = {
|
||||
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 (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium',
|
||||
TONE_CLASSES[tone].pill
|
||||
)}
|
||||
>
|
||||
<span className={cn('size-1.5 rounded-full', TONE_CLASSES[tone].dot)} />
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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<StatusTone, { pill: string; dot: string }> = {
|
||||
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 (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium',
|
||||
statusToneClassNames[tone].pill
|
||||
)}
|
||||
>
|
||||
<span className={cn('size-1.5 rounded-full', statusToneClassNames[tone].dot)} />
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[15px] font-semibold leading-tight text-foreground">GitHub</h3>
|
||||
{state === 'connected' ? (
|
||||
<StatusPill tone="connected">Connected</StatusPill>
|
||||
<IntegrationStatusPill tone="connected">Connected</IntegrationStatusPill>
|
||||
) : state === 'not-installed' ? (
|
||||
<StatusPill tone="attention">CLI not installed</StatusPill>
|
||||
<IntegrationStatusPill tone="attention">CLI not installed</IntegrationStatusPill>
|
||||
) : state === 'not-authenticated' ? (
|
||||
<StatusPill tone="attention">Sign in needed</StatusPill>
|
||||
<IntegrationStatusPill tone="attention">Sign in needed</IntegrationStatusPill>
|
||||
) : (
|
||||
<StatusPill tone="neutral">Checking…</StatusPill>
|
||||
<IntegrationStatusPill tone="neutral">Checking…</IntegrationStatusPill>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-[13px] leading-relaxed text-muted-foreground">
|
||||
@@ -189,7 +152,9 @@ function LinearRow(): React.JSX.Element {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[15px] font-semibold leading-tight text-foreground">Linear</h3>
|
||||
{linearStatus.connected ? <StatusPill tone="connected">Connected</StatusPill> : null}
|
||||
{linearStatus.connected ? (
|
||||
<IntegrationStatusPill tone="connected">Connected</IntegrationStatusPill>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-[13px] leading-relaxed text-muted-foreground">
|
||||
{linearStatus.connected
|
||||
@@ -334,7 +299,7 @@ export function IntegrationsStep(): React.JSX.Element {
|
||||
Issues, sprints, and assignees.
|
||||
</span>
|
||||
</div>
|
||||
<StatusPill tone="neutral">Coming soon</StatusPill>
|
||||
<IntegrationStatusPill tone="neutral">Coming soon</IntegrationStatusPill>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLElement>) => 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 ? (
|
||||
<TerminalPane
|
||||
tabId={tabId}
|
||||
worktreeId={ONBOARDING_INLINE_TERMINAL_WORKTREE_ID}
|
||||
worktreeId={worktreeId}
|
||||
cwd={cwd}
|
||||
isActive
|
||||
isVisible
|
||||
@@ -209,3 +240,13 @@ function findTerminalTabElement(tabId: string): HTMLElement | null {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function terminalReadyForCommand(element: HTMLElement | null): boolean {
|
||||
if (!element?.querySelector('[data-pty-id]')) {
|
||||
return false
|
||||
}
|
||||
// Why: pasting before the login shell renders a prompt can double-echo the
|
||||
// draft command. Visible terminal text is the least intrusive readiness signal.
|
||||
const renderedText = element.querySelector('.xterm-rows')?.textContent?.trim() ?? ''
|
||||
return renderedText.length > 0
|
||||
}
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
variant === 'card' ? 'rounded-xl border border-border bg-muted/20' : null,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex items-start gap-4', variant === 'card' ? 'p-5' : null)}>
|
||||
{leading}
|
||||
{icon ? (
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-background text-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-[15px] font-semibold leading-tight text-foreground">{title}</h3>
|
||||
{loading && !installed ? (
|
||||
<IntegrationStatusPill tone="neutral">Checking...</IntegrationStatusPill>
|
||||
) : installed ? (
|
||||
<IntegrationStatusPill tone="connected">Installed</IntegrationStatusPill>
|
||||
) : (
|
||||
<IntegrationStatusPill tone="attention">Not installed</IntegrationStatusPill>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-[13px] leading-relaxed text-muted-foreground">{body}</p>
|
||||
{error ? <p className="mt-1 text-[12px] text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{!installed ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setTerminalOpen(true)}
|
||||
disabled={terminalOpen || installDisabled}
|
||||
>
|
||||
<Terminal className="size-3.5" />
|
||||
Install
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void onRecheck()}
|
||||
disabled={loading}
|
||||
>
|
||||
Re-check
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!installed && terminalOpen ? (
|
||||
<div className={cn(variant === 'card' ? 'px-5 pb-5' : 'mt-3')}>
|
||||
<OnboardingInlineCommandTerminal
|
||||
worktreeId={terminalWorktreeId}
|
||||
command={command}
|
||||
title={terminalTitle}
|
||||
ariaLabel={terminalAriaLabel}
|
||||
description="Press Enter to run the installer. If you already installed this skill, skip this terminal and click Re-check instead."
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<boolean>(() => {
|
||||
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<void> => {
|
||||
setCliBusy(true)
|
||||
try {
|
||||
@@ -119,15 +120,6 @@ export function BrowserUseSetup({
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopySkillCommand = async (): Promise<void> => {
|
||||
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({
|
||||
>
|
||||
<BrowserUseSkillStep
|
||||
command={ORCA_CLI_SKILL_INSTALL_COMMAND}
|
||||
skillInstalled={skillInstalled}
|
||||
skillDetected={skillDetected}
|
||||
skillLoading={skillLoading}
|
||||
skillError={skillError}
|
||||
disabled={!cliEnabled}
|
||||
onCopy={() => void handleCopySkillCommand()}
|
||||
onToggleInstalled={() => markSkillInstalled(!skillInstalled)}
|
||||
onRecheck={refreshSkill}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
) : 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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
export function BrowserUseSkillStep({
|
||||
command,
|
||||
skillInstalled,
|
||||
skillDetected,
|
||||
skillLoading,
|
||||
skillError,
|
||||
disabled = false,
|
||||
onCopy,
|
||||
onToggleInstalled
|
||||
onRecheck
|
||||
}: Props): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<StepBadge index={2} state={skillInstalled ? 'done' : 'pending'} />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Install Browser Use Skill</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Run this once on your computer so Claude Code, Codex, and other agents learn to drive
|
||||
Orca's browser.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex max-w-full items-center gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2">
|
||||
<code className="flex-1 overflow-x-auto whitespace-nowrap text-[11px] text-muted-foreground">
|
||||
{command}
|
||||
</code>
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={onCopy}
|
||||
aria-label="Copy skill install command"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Copy
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{skillInstalled
|
||||
? 'Marked as installed on this machine.'
|
||||
: "Check off once you've run it on this computer."}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="underline-offset-2 hover:text-foreground hover:underline disabled:cursor-not-allowed disabled:no-underline disabled:hover:text-muted-foreground"
|
||||
onClick={onToggleInstalled}
|
||||
disabled={disabled}
|
||||
>
|
||||
{skillInstalled ? 'Undo' : 'I ran it'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AgentSkillSetupPanel
|
||||
variant="inline"
|
||||
title="Browser Use skill"
|
||||
detectedDescription="Detected on this machine. Agents can drive Orca's browser."
|
||||
missingDescription="Agents need this skill before they can drive Orca's browser. If you already installed it, click Re-check instead of running the installer again."
|
||||
command={command}
|
||||
terminalTitle="Browser Use setup"
|
||||
terminalAriaLabel="Browser Use skill install terminal"
|
||||
terminalWorktreeId="settings-browser-use-skill-terminal"
|
||||
installed={skillDetected}
|
||||
detected={skillDetected}
|
||||
loading={skillLoading}
|
||||
error={skillError}
|
||||
installDisabled={disabled}
|
||||
leading={<StepBadge index={2} state={skillDetected ? 'done' : 'pending'} />}
|
||||
onRecheck={onRecheck}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<void> => {
|
||||
setLoading(true)
|
||||
@@ -99,15 +115,6 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopySkillInstallCommand = async (command: string): Promise<void> => {
|
||||
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 (
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
@@ -214,35 +221,22 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">CLI skill</p>
|
||||
<div className="inline-flex max-w-full items-center gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2">
|
||||
<code className="overflow-x-auto whitespace-nowrap text-[11px] text-muted-foreground">
|
||||
{ORCA_CLI_SKILL_INSTALL_COMMAND}
|
||||
</code>
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() =>
|
||||
void handleCopySkillInstallCommand(ORCA_CLI_SKILL_INSTALL_COMMAND)
|
||||
}
|
||||
aria-label="Copy CLI skill install command"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Copy
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AgentSkillSetupPanel
|
||||
className="mt-3"
|
||||
variant="inline"
|
||||
title="CLI skill"
|
||||
detectedDescription="Detected on this machine. Agents know how to use Orca and report status."
|
||||
missingDescription="Agents need this skill before they can use Orca and report status. If you already installed it, click Re-check instead of running the installer again."
|
||||
command={ORCA_CLI_SKILL_INSTALL_COMMAND}
|
||||
terminalTitle="CLI skill setup"
|
||||
terminalAriaLabel="CLI skill install terminal"
|
||||
terminalWorktreeId="settings-cli-skill-terminal"
|
||||
installed={cliSkillDetected}
|
||||
detected={cliSkillDetected}
|
||||
loading={cliSkillLoading}
|
||||
error={cliSkillError}
|
||||
onRecheck={refreshCliSkill}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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<ComputerUsePermissionId | null>(null)
|
||||
const [helperUnavailableReason, setHelperUnavailableReason] = useState<string | null>(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<void> => {
|
||||
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}
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-border/60 px-4 py-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Install Computer Use Skill</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Run this once on your computer so agents know how to use Orca's computer controls.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex max-w-full items-center gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2">
|
||||
<code className="flex-1 overflow-x-auto whitespace-nowrap text-[11px] text-muted-foreground">
|
||||
{COMPUTER_USE_SKILL_INSTALL_COMMAND}
|
||||
</code>
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => void copySkillInstallCommand()}
|
||||
aria-label="Copy Computer Use skill install command"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Copy
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<AgentSkillSetupPanel
|
||||
title="Computer Use skill"
|
||||
detectedDescription="Detected on this machine. Agents can use Orca's computer controls."
|
||||
missingDescription="Agents need this skill before they can use Orca's computer controls. If you already installed it, click Re-check instead of running the installer again."
|
||||
command={COMPUTER_USE_SKILL_INSTALL_COMMAND}
|
||||
terminalTitle="Computer Use setup"
|
||||
terminalAriaLabel="Computer Use skill install terminal"
|
||||
terminalWorktreeId="settings-computer-use-skill-terminal"
|
||||
installed={computerUseSkillDetected}
|
||||
detected={computerUseSkillDetected}
|
||||
loading={computerUseSkillLoading}
|
||||
error={computerUseSkillError}
|
||||
icon={<MonitorCog className="size-5" />}
|
||||
onRecheck={refreshComputerUseSkill}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<boolean>(() => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
await refreshOrchestrationSkill()
|
||||
}
|
||||
|
||||
if (!showOrchestration) {
|
||||
@@ -98,50 +93,21 @@ export function OrchestrationPane(): React.JSX.Element {
|
||||
</div>
|
||||
|
||||
{orchestrationEnabled ? (
|
||||
<div className="space-y-3 rounded-xl border border-border/60 bg-card/50 p-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Install Orchestration Skill</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Run this once on your computer so agents learn to use inter-agent orchestration.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex max-w-full items-center gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2">
|
||||
<code className="flex-1 overflow-x-auto whitespace-nowrap text-[11px] text-muted-foreground">
|
||||
{ORCHESTRATION_SKILL_INSTALL_COMMAND}
|
||||
</code>
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => void handleCopyOrchestrationCommand()}
|
||||
aria-label="Copy orchestration skill install command"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Copy
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{orchestrationSkillInstalled
|
||||
? 'Marked as installed on this machine.'
|
||||
: "Check off once you've run it on this computer."}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="underline-offset-2 hover:text-foreground hover:underline"
|
||||
onClick={() => markOrchestrationSkillInstalled(!orchestrationSkillInstalled)}
|
||||
>
|
||||
{orchestrationSkillInstalled ? 'Undo' : 'I ran it'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AgentSkillSetupPanel
|
||||
title="Orchestration skill"
|
||||
detectedDescription="Detected on this machine. Agents can use inter-agent orchestration."
|
||||
missingDescription="Agents need this skill before they can use inter-agent orchestration. If you already installed it, click Re-check instead of running the installer again."
|
||||
command={ORCHESTRATION_SKILL_INSTALL_COMMAND}
|
||||
terminalTitle="Orchestration setup"
|
||||
terminalAriaLabel="Orchestration skill install terminal"
|
||||
terminalWorktreeId="settings-orchestration-skill-terminal"
|
||||
installed={orchestrationSkillDetected}
|
||||
detected={orchestrationSkillDetected}
|
||||
loading={orchestrationSkillLoading}
|
||||
error={orchestrationSkillError}
|
||||
icon={<Workflow className="size-5" />}
|
||||
onRecheck={handleRecheckOrchestrationSkill}
|
||||
/>
|
||||
) : null}
|
||||
</SearchableSetting>
|
||||
)
|
||||
|
||||
@@ -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>): 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<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (reason?: unknown) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((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<SkillDiscoveryResult>()
|
||||
const secondScan = deferred<SkillDiscoveryResult>()
|
||||
const discover = vi.fn<() => Promise<SkillDiscoveryResult>>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<SkillDiscoveryResult> | 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<SkillDiscoveryResult> {
|
||||
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<SkillDiscoveryResult> {
|
||||
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<void>
|
||||
} {
|
||||
const { enabled = true, sourceKinds } = options
|
||||
const [result, setResult] = useState<SkillDiscoveryResult | null>(cachedDiscovery)
|
||||
const [loading, setLoading] = useState(enabled && !cachedDiscovery)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const refresh = useCallback(
|
||||
async (force = true): Promise<void> => {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
export const BROWSER_USE_ENABLED_STORAGE_KEY = 'orca.browserUse.enabled'
|
||||
export const BROWSER_USE_SKILL_INSTALLED_STORAGE_KEY = 'orca.browserUse.skillInstalled'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await app.evaluate((_, nextResult) => {
|
||||
;(globalThis as MockSkillDiscoveryGlobal).__orcaSettingsSkillDiscoveryResult = nextResult
|
||||
}, result)
|
||||
}
|
||||
|
||||
async function openOrchestrationSettings(page: Page): Promise<void> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user