mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
feat: improve orchestration settings pane with skill coverage and usage examples (#4869)
* feat: improve orchestration settings pane with skill coverage and usage - Add per-agent skill coverage panel showing which detected agents have the orchestration skill installed, with Ready/Missing status chips - Add five usage example cards (handoff, worktree handoff, phased workflow, parallel, worktree PRs) with copy-prompt dialogs - Add "Copy install command" link that opens a dialog for users who prefer their own terminal - Expose `skills` array from `useInstalledAgentSkill` so callers can inspect discovered paths - Rename install button to "Update" when skill is already installed - Add dev-only preview flags for testing the uninstalled and CLI-ready states * feat: remove dev-only CLI preview scaffolding from OrchestrationPane Dead code cleanup — the dev-preview flag helpers and mock CliInstallStatus factories were never shipped to users and added noise around the real skill-detection path. Props now flow directly from useInstalledAgentSkill without the preview indirection layer.
This commit is contained in:
@@ -41,7 +41,7 @@ describe('AgentSkillSetupPanel', () => {
|
||||
const html = renderPanel({ installed: true })
|
||||
|
||||
expect(html).toContain('Installed')
|
||||
expect(buttonLabels(html)).toContain('Install')
|
||||
expect(buttonLabels(html)).toContain('Update')
|
||||
expect(buttonLabels(html)).toContain('Re-check')
|
||||
})
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('AgentSkillSetupPanel', () => {
|
||||
const html = renderPanel({ installed: true, showRecheckWhenInstalled: false })
|
||||
|
||||
expect(html).toContain('Installed')
|
||||
expect(buttonLabels(html)).toContain('Install')
|
||||
expect(buttonLabels(html)).toContain('Update')
|
||||
expect(buttonLabels(html)).not.toContain('Re-check')
|
||||
})
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ type AgentSkillSetupPanelProps = {
|
||||
onBeforeOpenTerminal?: () => void | Promise<void>
|
||||
showInstallWhenInstalled?: boolean
|
||||
showRecheckWhenInstalled?: boolean
|
||||
installLabel?: string
|
||||
installedInstallLabel?: string
|
||||
actionHint?: ReactNode
|
||||
footer?: ReactNode
|
||||
onRecheck: () => void | Promise<void>
|
||||
}
|
||||
|
||||
@@ -60,6 +64,10 @@ export function AgentSkillSetupPanel({
|
||||
onBeforeOpenTerminal,
|
||||
showInstallWhenInstalled = true,
|
||||
showRecheckWhenInstalled = true,
|
||||
installLabel = 'Install',
|
||||
installedInstallLabel = 'Update',
|
||||
actionHint,
|
||||
footer,
|
||||
onRecheck
|
||||
}: AgentSkillSetupPanelProps): React.JSX.Element {
|
||||
const [terminalOpen, setTerminalOpen] = useState(false)
|
||||
@@ -135,7 +143,7 @@ export function AgentSkillSetupPanel({
|
||||
disabled={terminalOpen || installDisabled}
|
||||
>
|
||||
<Terminal className="size-3.5" />
|
||||
Install
|
||||
{installed ? installedInstallLabel : installLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
{!installed || showRecheckWhenInstalled ? (
|
||||
@@ -188,12 +196,20 @@ export function AgentSkillSetupPanel({
|
||||
<div className="mt-3 max-w-none">
|
||||
<p className="text-[13px] leading-snug text-muted-foreground">{description}</p>
|
||||
{actionRow}
|
||||
{actionHint ? <div className="mt-2">{actionHint}</div> : null}
|
||||
{!installed && preInstallNotice && preInstallNoticeVisible ? (
|
||||
<p className="mt-3 text-[12px] leading-snug text-muted-foreground">
|
||||
{preInstallNotice}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div
|
||||
className={cn('border-t border-border/60', terminalOpen ? 'mt-2 pt-4' : 'mt-5 pt-5')}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{terminalOpen ? (
|
||||
<div className={cn(variant === 'card' ? 'px-5 pb-5' : 'mt-2')}>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Fragment } from 'react'
|
||||
import { Copy, type LucideIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import type { OrchestrationUsageExample } from '@/lib/orchestration-usage-examples'
|
||||
|
||||
const ORCHESTRATION_SKILL_SLASH_COMMAND = `/${ORCHESTRATION_SKILL_NAME}`
|
||||
|
||||
function OrchestrationExamplePromptText(props: { prompt: string }): React.JSX.Element {
|
||||
const { prompt } = props
|
||||
const parts = prompt.split(ORCHESTRATION_SKILL_SLASH_COMMAND)
|
||||
|
||||
if (parts.length === 1) {
|
||||
return <>{prompt}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => (
|
||||
<Fragment key={index}>
|
||||
{part}
|
||||
{index < parts.length - 1 ? (
|
||||
<span className="font-semibold text-foreground">
|
||||
{ORCHESTRATION_SKILL_SLASH_COMMAND}
|
||||
</span>
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function OrchestrationExampleDialog(props: {
|
||||
example: OrchestrationUsageExample
|
||||
icon?: LucideIcon
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
const { example, icon: Icon, open, onOpenChange } = props
|
||||
|
||||
const copyPrompt = async (prompt: string): Promise<void> => {
|
||||
try {
|
||||
await window.api.ui.writeClipboardText(prompt)
|
||||
toast.success('Copied example prompt.')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to copy prompt.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-[560px]">
|
||||
<div className="px-6 pt-6 pr-14">
|
||||
<DialogHeader className="gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{Icon ? (
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border/70 bg-muted/30 text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<DialogTitle className="text-base leading-snug">{example.title}</DialogTitle>
|
||||
<DialogDescription className="text-xs leading-relaxed">
|
||||
{example.summary}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5">
|
||||
<div className="group relative rounded-md border border-border/70 bg-editor-surface shadow-xs">
|
||||
<p className="px-3 py-3 pr-11 font-mono text-[12px] leading-relaxed text-foreground">
|
||||
<OrchestrationExamplePromptText prompt={example.prompt} />
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="absolute top-2 right-2 shrink-0 opacity-70 transition-opacity group-hover:opacity-100"
|
||||
aria-label={`Copy ${example.title} example prompt`}
|
||||
onClick={() => void copyPrompt(example.prompt)}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 border-t border-border/60 bg-muted/10 px-6 py-4">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
||||
Done
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={() => void copyPrompt(example.prompt)}>
|
||||
<Copy className="size-4" />
|
||||
Copy prompt
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +1,61 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ORCHESTRATION_USAGE_EXAMPLES } from '@/lib/orchestration-usage-examples'
|
||||
import { OrchestrationPane } from './OrchestrationPane'
|
||||
|
||||
vi.mock('@/hooks/useInstalledAgentSkills', () => ({
|
||||
GLOBAL_AGENT_SKILL_SOURCE_KINDS: ['global'],
|
||||
GLOBAL_AGENT_SKILL_SOURCE_KINDS: ['home'],
|
||||
useInstalledAgentSkill: () => ({
|
||||
installed: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
skills: [
|
||||
{
|
||||
id: 'claude',
|
||||
name: 'orchestration',
|
||||
description: null,
|
||||
providers: ['claude'],
|
||||
sourceKind: 'home',
|
||||
sourceLabel: 'Claude home',
|
||||
rootPath: '/Users/test/.claude/skills',
|
||||
directoryPath: '/Users/test/.claude/skills/orchestration',
|
||||
skillFilePath: '/Users/test/.claude/skills/orchestration/SKILL.md',
|
||||
installed: true,
|
||||
fileCount: 1,
|
||||
updatedAt: null
|
||||
}
|
||||
],
|
||||
refresh: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useDetectedAgents', () => ({
|
||||
useDetectedAgents: () => ({
|
||||
detectedIds: ['claude', 'codex', 'gemini'],
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
refresh: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
describe('OrchestrationPane', () => {
|
||||
it('shows skill install status without a separate enable switch', () => {
|
||||
it('keeps skill setup visible after install and shows agent coverage plus examples', () => {
|
||||
const markup = renderToStaticMarkup(<OrchestrationPane />)
|
||||
|
||||
expect(markup).toContain('Orchestration skill')
|
||||
expect(markup).toContain('Installed')
|
||||
expect(markup).not.toContain('rounded-xl')
|
||||
expect(markup).not.toMatch(/<button\b[^>]*>[\s\S]*?Install[\s\S]*?<\/button>/)
|
||||
expect(markup).not.toContain('role="switch"')
|
||||
expect(markup).toContain('Agent coverage')
|
||||
expect(markup).toContain('Copy install command')
|
||||
expect(markup).toContain('detected agents')
|
||||
expect(markup).toContain('Gemini')
|
||||
expect(markup).toContain('Ready')
|
||||
expect(markup).toContain('How to use it')
|
||||
expect(markup).not.toContain('See examples')
|
||||
expect(ORCHESTRATION_USAGE_EXAMPLES).toHaveLength(5)
|
||||
for (const example of ORCHESTRATION_USAGE_EXAMPLES) {
|
||||
expect(markup).toContain(example.title)
|
||||
}
|
||||
expect(markup).toMatch(/<button\b[^>]*>[\s\S]*?Update[\s\S]*?<\/button>/)
|
||||
expect(markup).toContain('Re-check')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Workflow } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { ArrowRightLeft, GitBranch, ListChecks, Workflow } from 'lucide-react'
|
||||
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands'
|
||||
import {
|
||||
AGENT_SKILL_CLI_PREREQUISITE_NOTICE,
|
||||
ensureOrcaCliAvailableForAgentSkillTerminal
|
||||
} from '@/lib/agent-skill-cli-prerequisite'
|
||||
import { ORCHESTRATION_SKILL_INSTALL_COMMAND } from '@/lib/orchestration-install-command'
|
||||
import { ORCHESTRATION_USAGE_EXAMPLES } from '@/lib/orchestration-usage-examples'
|
||||
import {
|
||||
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
|
||||
useInstalledAgentSkill
|
||||
@@ -14,15 +16,29 @@ import { matchesSettingsSearch } from './settings-search'
|
||||
import { useAppStore } from '../../store'
|
||||
import { ORCHESTRATION_PANE_SEARCH_ENTRIES } from './orchestration-search'
|
||||
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
|
||||
import { OrchestrationSkillAgentCoverage } from './OrchestrationSkillAgentCoverage'
|
||||
import { OrchestrationExampleDialog } from './OrchestrationExamplesDialog'
|
||||
import { OrchestrationSkillPromptDialog } from './OrchestrationSkillPromptDialog'
|
||||
|
||||
const EXAMPLE_ICONS = {
|
||||
handoff: ArrowRightLeft,
|
||||
'worktree-handoff': ArrowRightLeft,
|
||||
'child-sequence': ListChecks,
|
||||
'child-parallel': GitBranch,
|
||||
'child-worktrees': Workflow
|
||||
} as const
|
||||
|
||||
export function OrchestrationPane(): React.JSX.Element {
|
||||
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
|
||||
const showOrchestration = matchesSettingsSearch(searchQuery, ORCHESTRATION_PANE_SEARCH_ENTRIES)
|
||||
const [selectedExampleId, setSelectedExampleId] = useState<string | null>(null)
|
||||
const [skillPromptOpen, setSkillPromptOpen] = useState(false)
|
||||
|
||||
const {
|
||||
installed: orchestrationSkillDetected,
|
||||
loading: orchestrationSkillLoading,
|
||||
error: orchestrationSkillError,
|
||||
skills: discoveredSkills,
|
||||
refresh: refreshOrchestrationSkill
|
||||
} = useInstalledAgentSkill(ORCHESTRATION_SKILL_NAME, {
|
||||
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
|
||||
@@ -35,12 +51,11 @@ export function OrchestrationPane(): React.JSX.Element {
|
||||
return (
|
||||
<SearchableSetting
|
||||
title="Agent Orchestration"
|
||||
description="Coordinate multiple coding agents via messaging, task DAGs, dispatch, and decision gates."
|
||||
description="Coordinate coding agents across handoffs, worktree handovers, and child-agent work."
|
||||
keywords={ORCHESTRATION_PANE_SEARCH_ENTRIES[0].keywords}
|
||||
className="space-y-3 py-2"
|
||||
className="space-y-5 py-2"
|
||||
>
|
||||
<AgentSkillSetupPanel
|
||||
variant="inline"
|
||||
title="Orchestration skill"
|
||||
description="Enables agents to hand off context and coordinate work through Orca."
|
||||
command={ORCHESTRATION_SKILL_INSTALL_COMMAND}
|
||||
@@ -56,9 +71,82 @@ export function OrchestrationPane(): React.JSX.Element {
|
||||
useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup')
|
||||
await ensureOrcaCliAvailableForAgentSkillTerminal()
|
||||
}}
|
||||
showInstallWhenInstalled={false}
|
||||
actionHint={
|
||||
<p className="text-[12px] leading-snug text-muted-foreground">
|
||||
Prefer your own terminal?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium text-foreground underline-offset-2 hover:underline"
|
||||
onClick={() => setSkillPromptOpen(true)}
|
||||
>
|
||||
Copy install command
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
footer={
|
||||
<OrchestrationSkillAgentCoverage
|
||||
embedded
|
||||
skills={discoveredSkills}
|
||||
loading={orchestrationSkillLoading}
|
||||
/>
|
||||
}
|
||||
onRecheck={refreshOrchestrationSkill}
|
||||
/>
|
||||
|
||||
<OrchestrationSkillPromptDialog
|
||||
command={ORCHESTRATION_SKILL_INSTALL_COMMAND}
|
||||
open={skillPromptOpen}
|
||||
onOpenChange={setSkillPromptOpen}
|
||||
/>
|
||||
|
||||
<div className="space-y-4 border-t border-border/60 pt-6">
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-foreground">How to use it</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ask a coordinator agent to use orchestration for handoffs, worktree handovers, and
|
||||
sequential or parallel child agents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{ORCHESTRATION_USAGE_EXAMPLES.map((example) => {
|
||||
const Icon = EXAMPLE_ICONS[example.id as keyof typeof EXAMPLE_ICONS] ?? Workflow
|
||||
return (
|
||||
<button
|
||||
key={example.id}
|
||||
type="button"
|
||||
className="rounded-md border border-border/60 bg-muted/20 px-4 py-3 text-left transition-colors hover:bg-muted/35 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
onClick={() => setSelectedExampleId(example.id)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-background text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">{example.title}</p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{example.summary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ORCHESTRATION_USAGE_EXAMPLES.map((example) => {
|
||||
const Icon = EXAMPLE_ICONS[example.id as keyof typeof EXAMPLE_ICONS] ?? Workflow
|
||||
return (
|
||||
<OrchestrationExampleDialog
|
||||
key={`${example.id}-dialog`}
|
||||
example={example}
|
||||
icon={Icon}
|
||||
open={selectedExampleId === example.id}
|
||||
onOpenChange={(open) => setSelectedExampleId(open ? example.id : null)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</SearchableSetting>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ export function OrchestrationSetupCard(props: {
|
||||
useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup')
|
||||
await ensureOrcaCliAvailableForAgentSkillTerminal()
|
||||
}}
|
||||
showRecheckWhenInstalled={false}
|
||||
onRecheck={skill.refresh}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrchestrationSkillAgentCoverage } from './OrchestrationSkillAgentCoverage'
|
||||
|
||||
vi.mock('@/hooks/useDetectedAgents', () => ({
|
||||
useDetectedAgents: () => ({
|
||||
detectedIds: ['claude', 'codex'],
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
refresh: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
describe('OrchestrationSkillAgentCoverage', () => {
|
||||
it('shows each detected agent with an explicit skill status', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<OrchestrationSkillAgentCoverage
|
||||
loading={false}
|
||||
skills={[
|
||||
{
|
||||
id: 'claude-skill',
|
||||
name: 'orchestration',
|
||||
description: null,
|
||||
providers: ['claude'],
|
||||
sourceKind: 'home',
|
||||
sourceLabel: 'Claude home',
|
||||
rootPath: '/Users/test/.claude/skills',
|
||||
directoryPath: '/Users/test/.claude/skills/orchestration',
|
||||
skillFilePath: '/Users/test/.claude/skills/orchestration/SKILL.md',
|
||||
installed: true,
|
||||
fileCount: 1,
|
||||
updatedAt: null
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('Claude')
|
||||
expect(markup).toContain('Codex')
|
||||
expect(markup).toContain('Ready')
|
||||
expect(markup).toContain('Missing')
|
||||
expect(markup).not.toContain('View details')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { DiscoveredSkill } from '../../../../shared/skills'
|
||||
import type { OrchestrationSkillAgentStatus } from '@/lib/orchestration-skill-coverage'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { useDetectedAgents } from '@/hooks/useDetectedAgents'
|
||||
import { getOrchestrationSkillAgentStatuses } from '@/lib/orchestration-skill-coverage'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function getAgentCoverageSummary(props: {
|
||||
loading: boolean
|
||||
totalCount: number
|
||||
installedCount: number
|
||||
fullCoverage: boolean
|
||||
noCoverage: boolean
|
||||
}): string {
|
||||
const { loading, totalCount, installedCount, fullCoverage, noCoverage } = props
|
||||
|
||||
if (loading) {
|
||||
return 'Checking installed agents and skill paths…'
|
||||
}
|
||||
if (totalCount === 0) {
|
||||
return 'No agent CLIs detected on PATH. Install agents in Settings → Agents, then re-check.'
|
||||
}
|
||||
if (fullCoverage) {
|
||||
return `All ${totalCount} detected agents have the skill.`
|
||||
}
|
||||
if (noCoverage) {
|
||||
return 'Install the skill above, then re-check.'
|
||||
}
|
||||
return `${installedCount} of ${totalCount} detected agents have the skill.`
|
||||
}
|
||||
|
||||
function AgentCoverageChip({
|
||||
status
|
||||
}: {
|
||||
status: OrchestrationSkillAgentStatus
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs',
|
||||
status.installed
|
||||
? 'border-status-success-border bg-status-success-background text-foreground'
|
||||
: 'border-border/60 bg-muted/20 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<AgentIcon agent={status.agent} size={12} />
|
||||
<span className="font-medium text-foreground">{status.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium',
|
||||
status.installed ? 'text-status-success' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{status.installed ? 'Ready' : 'Missing'}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function OrchestrationSkillAgentCoverage(props: {
|
||||
skills: readonly DiscoveredSkill[]
|
||||
loading: boolean
|
||||
embedded?: boolean
|
||||
className?: string
|
||||
}): React.JSX.Element {
|
||||
const { skills, loading: skillsLoading, embedded = false, className } = props
|
||||
const { detectedIds, isLoading: agentsLoading } = useDetectedAgents()
|
||||
const loading = skillsLoading || agentsLoading || detectedIds === null
|
||||
const agentStatuses = getOrchestrationSkillAgentStatuses(skills, detectedIds ?? [])
|
||||
const installedCount = agentStatuses.filter((status) => status.installed).length
|
||||
const totalCount = agentStatuses.length
|
||||
const fullCoverage = !loading && totalCount > 0 && installedCount === totalCount
|
||||
const noCoverage = !loading && totalCount > 0 && installedCount === 0
|
||||
const showAgentChips = !loading && totalCount > 0 && !fullCoverage
|
||||
const summary = getAgentCoverageSummary({
|
||||
loading,
|
||||
totalCount,
|
||||
installedCount,
|
||||
fullCoverage,
|
||||
noCoverage
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
embedded ? 'space-y-2.5' : 'space-y-4 border-t border-border/60 pt-6',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium text-foreground">Agent coverage</h3>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">{summary}</p>
|
||||
</div>
|
||||
|
||||
{showAgentChips ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{agentStatuses.map((status) => (
|
||||
<AgentCoverageChip key={status.agent} status={status} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Copy } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
export function OrchestrationSkillPromptDialog(props: {
|
||||
command: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
const { command, open, onOpenChange } = props
|
||||
|
||||
const copyCommand = async (): Promise<void> => {
|
||||
try {
|
||||
await window.api.ui.writeClipboardText(command)
|
||||
toast.success('Copied install command.')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to copy install command.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-[560px]">
|
||||
<div className="px-6 pt-6 pr-14">
|
||||
<DialogHeader className="gap-2">
|
||||
<DialogTitle className="text-base leading-snug">
|
||||
Install orchestration skill
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs leading-relaxed">
|
||||
Run this command in a terminal to install the orchestration skill for your agents.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5">
|
||||
<div className="group relative rounded-md border border-border/70 bg-editor-surface shadow-xs">
|
||||
<p className="px-3 py-3 pr-11 font-mono text-[12px] leading-relaxed break-all text-foreground">
|
||||
{command}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="absolute top-2 right-2 shrink-0 opacity-70 transition-opacity group-hover:opacity-100"
|
||||
aria-label="Copy orchestration skill install command"
|
||||
onClick={() => void copyCommand()}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 border-t border-border/60 bg-muted/10 px-6 py-4">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
||||
Done
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={() => void copyCommand()}>
|
||||
<Copy className="size-4" />
|
||||
Copy command
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,12 @@ export const ORCHESTRATION_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
'task',
|
||||
'DAG',
|
||||
'worker',
|
||||
'coordinator'
|
||||
'coordinator',
|
||||
'claude',
|
||||
'codex',
|
||||
'examples',
|
||||
'handoff',
|
||||
'child agents'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -28,6 +28,7 @@ export type InstalledAgentSkillState = {
|
||||
installed: boolean
|
||||
loading: boolean
|
||||
error: string | null
|
||||
skills: readonly DiscoveredSkill[]
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -227,10 +228,11 @@ export function useInstalledAgentSkill(
|
||||
}
|
||||
}, [enabled, refresh])
|
||||
|
||||
const skills = useMemo(() => (enabled && result ? result.skills : []), [enabled, result])
|
||||
|
||||
const installed = useMemo(
|
||||
() =>
|
||||
enabled && result ? hasInstalledAgentSkill(result.skills, skillName, { sourceKinds }) : false,
|
||||
[enabled, result, skillName, sourceKinds]
|
||||
() => (enabled ? hasInstalledAgentSkill(skills, skillName, { sourceKinds }) : false),
|
||||
[enabled, skills, skillName, sourceKinds]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -247,6 +249,7 @@ export function useInstalledAgentSkill(
|
||||
installed,
|
||||
loading,
|
||||
error,
|
||||
skills,
|
||||
refresh: forceRefresh
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { DiscoveredSkill } from '../../../shared/skills'
|
||||
import {
|
||||
agentHasOrchestrationSkill,
|
||||
getOrchestrationSkillAgentStatuses
|
||||
} from './orchestration-skill-coverage'
|
||||
|
||||
function skill(overrides: Partial<DiscoveredSkill>): DiscoveredSkill {
|
||||
return {
|
||||
id: 'skill-1',
|
||||
name: 'orchestration',
|
||||
description: null,
|
||||
providers: ['agent-skills'],
|
||||
sourceKind: 'home',
|
||||
sourceLabel: 'Agent skills home',
|
||||
rootPath: '/Users/test/.agents/skills',
|
||||
directoryPath: '/Users/test/.agents/skills/orchestration',
|
||||
skillFilePath: '/Users/test/.agents/skills/orchestration/SKILL.md',
|
||||
installed: true,
|
||||
fileCount: 1,
|
||||
updatedAt: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('orchestration skill agent coverage', () => {
|
||||
it('marks shared-path agents from the global ~/.agents/skills install', () => {
|
||||
const skills = [
|
||||
skill({
|
||||
providers: ['agent-skills'],
|
||||
sourceKind: 'home',
|
||||
rootPath: '/Users/test/.agents/skills',
|
||||
directoryPath: '/Users/test/.agents/skills/orchestration'
|
||||
})
|
||||
]
|
||||
|
||||
expect(getOrchestrationSkillAgentStatuses(skills, ['codex', 'gemini', 'droid'])).toEqual([
|
||||
{ agent: 'codex', label: 'Codex', installed: true },
|
||||
{ agent: 'gemini', label: 'Gemini', installed: true },
|
||||
{ agent: 'droid', label: 'Droid', installed: true }
|
||||
])
|
||||
})
|
||||
|
||||
it('marks Claude from ~/.claude/skills without requiring a dedicated Codex path', () => {
|
||||
const skills = [
|
||||
skill({
|
||||
providers: ['claude'],
|
||||
sourceKind: 'home',
|
||||
rootPath: '/Users/test/.claude/skills',
|
||||
directoryPath: '/Users/test/.claude/skills/orchestration'
|
||||
})
|
||||
]
|
||||
|
||||
expect(agentHasOrchestrationSkill('claude', skills)).toBe(true)
|
||||
expect(agentHasOrchestrationSkill('codex', skills)).toBe(false)
|
||||
expect(agentHasOrchestrationSkill('gemini', skills)).toBe(false)
|
||||
})
|
||||
|
||||
it('marks Codex from plugin cache installs', () => {
|
||||
expect(
|
||||
agentHasOrchestrationSkill('codex', [
|
||||
skill({
|
||||
providers: ['codex', 'agent-skills'],
|
||||
sourceKind: 'plugin',
|
||||
sourceLabel: 'Codex plugin cache',
|
||||
rootPath: '/Users/test/.codex/plugins/cache',
|
||||
directoryPath: '/Users/test/.codex/plugins/cache/vendor/orchestration'
|
||||
})
|
||||
])
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores repo-scoped orchestration installs', () => {
|
||||
expect(
|
||||
agentHasOrchestrationSkill('gemini', [
|
||||
skill({
|
||||
providers: ['agent-skills'],
|
||||
sourceKind: 'repo',
|
||||
rootPath: '/workspace/.agents/skills',
|
||||
directoryPath: '/workspace/.agents/skills/orchestration'
|
||||
})
|
||||
])
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('matches orchestration by directory name when frontmatter uses a display name', () => {
|
||||
expect(
|
||||
agentHasOrchestrationSkill('claude', [
|
||||
skill({
|
||||
name: 'Orca Orchestration',
|
||||
providers: ['claude'],
|
||||
sourceKind: 'home',
|
||||
rootPath: '/Users/test/.claude/skills',
|
||||
directoryPath: '/Users/test/.claude/skills/orchestration'
|
||||
})
|
||||
])
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('matches Windows skill paths', () => {
|
||||
expect(
|
||||
agentHasOrchestrationSkill('codex', [
|
||||
skill({
|
||||
providers: ['codex'],
|
||||
sourceKind: 'home',
|
||||
rootPath: 'C:\\Users\\test\\.codex\\skills',
|
||||
directoryPath: 'C:\\Users\\test\\.codex\\skills\\orchestration'
|
||||
})
|
||||
])
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { DiscoveredSkill } from '../../../shared/skills'
|
||||
import type { TuiAgent } from '../../../shared/types'
|
||||
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands'
|
||||
import { getAgentLabel } from '@/lib/agent-catalog'
|
||||
import { TUI_AGENT_AUTO_PICK_ORDER } from '../../../shared/tui-agent-selection'
|
||||
|
||||
export type OrchestrationSkillLocationId =
|
||||
| 'claude-home'
|
||||
| 'codex-home'
|
||||
| 'codex-plugin-cache'
|
||||
| 'agents-home'
|
||||
|
||||
export type OrchestrationSkillAgentStatus = {
|
||||
agent: TuiAgent
|
||||
label: string
|
||||
installed: boolean
|
||||
}
|
||||
|
||||
type OrchestrationSkillLocationDefinition = {
|
||||
id: OrchestrationSkillLocationId
|
||||
matchesSkill: (skill: DiscoveredSkill) => boolean
|
||||
}
|
||||
|
||||
const ORCHESTRATION_SKILL_LOCATIONS: readonly OrchestrationSkillLocationDefinition[] = [
|
||||
{
|
||||
id: 'claude-home',
|
||||
matchesSkill: (skill) =>
|
||||
isGlobalOrchestrationSkill(skill) &&
|
||||
pathContainsSegments(skill.rootPath, ['.claude', 'skills'])
|
||||
},
|
||||
{
|
||||
id: 'codex-home',
|
||||
matchesSkill: (skill) =>
|
||||
isGlobalOrchestrationSkill(skill) &&
|
||||
pathContainsSegments(skill.rootPath, ['.codex', 'skills'])
|
||||
},
|
||||
{
|
||||
id: 'codex-plugin-cache',
|
||||
matchesSkill: (skill) =>
|
||||
isGlobalOrchestrationSkill(skill) &&
|
||||
pathContainsSegments(skill.rootPath, ['.codex', 'plugins', 'cache'])
|
||||
},
|
||||
{
|
||||
id: 'agents-home',
|
||||
matchesSkill: (skill) =>
|
||||
isGlobalOrchestrationSkill(skill) &&
|
||||
pathContainsSegments(skill.rootPath, ['.agents', 'skills'])
|
||||
}
|
||||
]
|
||||
|
||||
const ORCHESTRATION_SKILL_LOCATION_IDS_BY_AGENT: Partial<
|
||||
Record<TuiAgent, readonly OrchestrationSkillLocationId[]>
|
||||
> = {
|
||||
claude: ['claude-home', 'agents-home'],
|
||||
openclaude: ['claude-home', 'agents-home'],
|
||||
codex: ['codex-home', 'codex-plugin-cache', 'agents-home']
|
||||
}
|
||||
|
||||
function normalizeSkillName(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function basenameFromPath(pathValue: string): string {
|
||||
return pathValue.split(/[\\/]/).filter(Boolean).at(-1) ?? pathValue
|
||||
}
|
||||
|
||||
function normalizePath(pathValue: string): string {
|
||||
return pathValue.replace(/\\/g, '/').toLowerCase()
|
||||
}
|
||||
|
||||
function pathContainsSegments(pathValue: string, segments: readonly string[]): boolean {
|
||||
const parts = normalizePath(pathValue).split('/').filter(Boolean)
|
||||
const target = segments.map((segment) => segment.toLowerCase())
|
||||
if (target.length === 0 || parts.length < target.length) {
|
||||
return false
|
||||
}
|
||||
for (let index = 0; index <= parts.length - target.length; index += 1) {
|
||||
if (target.every((segment, offset) => parts[index + offset] === segment)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isOrchestrationSkill(skill: DiscoveredSkill): boolean {
|
||||
if (!skill.installed) {
|
||||
return false
|
||||
}
|
||||
const expected = normalizeSkillName(ORCHESTRATION_SKILL_NAME)
|
||||
return (
|
||||
normalizeSkillName(skill.name) === expected ||
|
||||
normalizeSkillName(basenameFromPath(skill.directoryPath)) === expected
|
||||
)
|
||||
}
|
||||
|
||||
function isGlobalOrchestrationSkill(skill: DiscoveredSkill): boolean {
|
||||
return isOrchestrationSkill(skill) && skill.sourceKind !== 'repo'
|
||||
}
|
||||
|
||||
function getOrchestrationSkillLocationIdsForAgent(
|
||||
agent: TuiAgent
|
||||
): readonly OrchestrationSkillLocationId[] {
|
||||
return ORCHESTRATION_SKILL_LOCATION_IDS_BY_AGENT[agent] ?? ['agents-home']
|
||||
}
|
||||
|
||||
function isOrchestrationSkillInstalledAtLocation(
|
||||
skills: readonly DiscoveredSkill[],
|
||||
locationId: OrchestrationSkillLocationId
|
||||
): boolean {
|
||||
const location = ORCHESTRATION_SKILL_LOCATIONS.find((entry) => entry.id === locationId)
|
||||
if (!location) {
|
||||
return false
|
||||
}
|
||||
return skills.some((skill) => location.matchesSkill(skill))
|
||||
}
|
||||
|
||||
export function agentHasOrchestrationSkill(
|
||||
agent: TuiAgent,
|
||||
skills: readonly DiscoveredSkill[]
|
||||
): boolean {
|
||||
return getOrchestrationSkillLocationIdsForAgent(agent).some((locationId) =>
|
||||
isOrchestrationSkillInstalledAtLocation(skills, locationId)
|
||||
)
|
||||
}
|
||||
|
||||
export function sortOrchestrationAgents(agents: readonly TuiAgent[]): TuiAgent[] {
|
||||
const order = new Map(TUI_AGENT_AUTO_PICK_ORDER.map((agent, index) => [agent, index]))
|
||||
return [...agents].sort(
|
||||
(left, right) =>
|
||||
(order.get(left) ?? Number.MAX_SAFE_INTEGER) - (order.get(right) ?? Number.MAX_SAFE_INTEGER)
|
||||
)
|
||||
}
|
||||
|
||||
export function getOrchestrationSkillAgentStatuses(
|
||||
skills: readonly DiscoveredSkill[],
|
||||
detectedAgents: readonly TuiAgent[]
|
||||
): OrchestrationSkillAgentStatus[] {
|
||||
return sortOrchestrationAgents(detectedAgents).map((agent) => ({
|
||||
agent,
|
||||
label: getAgentLabel(agent),
|
||||
installed: agentHasOrchestrationSkill(agent, skills)
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export type OrchestrationUsageExample = {
|
||||
id: string
|
||||
title: string
|
||||
summary: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
export const ORCHESTRATION_USAGE_EXAMPLES: readonly OrchestrationUsageExample[] = [
|
||||
{
|
||||
id: 'handoff',
|
||||
title: 'Hand off an active task',
|
||||
summary: 'Move ownership to another agent with enough context to continue.',
|
||||
prompt:
|
||||
'Use /orchestration to hand this billing settings task to the idle Claude agent. Include the goal, current context, and what they should finish next.'
|
||||
},
|
||||
{
|
||||
id: 'worktree-handoff',
|
||||
title: 'Hand off to another worktree',
|
||||
summary: 'Move work to an agent that is already running in a different branch.',
|
||||
prompt:
|
||||
'Use /orchestration to hand this settings cleanup to the agent in the settings-polish worktree. Send the goal, relevant files, and expected result.'
|
||||
},
|
||||
{
|
||||
id: 'child-sequence',
|
||||
title: 'Run a phased workflow',
|
||||
summary: 'Use child agents one after another when each phase depends on the last.',
|
||||
prompt:
|
||||
'Use /orchestration to run this auth refactor in phases: plan, backend, UI, then tests. Start each child agent after the previous phase is done.'
|
||||
},
|
||||
{
|
||||
id: 'child-parallel',
|
||||
title: 'Run independent work in parallel',
|
||||
summary: 'Split non-overlapping investigation or implementation tasks across child agents.',
|
||||
prompt:
|
||||
'Use /orchestration to split this auth refactor across parallel child agents: API contract, backend call sites, UI flow, and test gaps.'
|
||||
},
|
||||
{
|
||||
id: 'child-worktrees',
|
||||
title: 'Split a large change into smaller PRs',
|
||||
summary: 'Give each child agent its own worktree so parallel implementation stays reviewable.',
|
||||
prompt:
|
||||
'Use /orchestration to split this onboarding update into smaller PRs, each in its own child worktree: setup state, settings UI, copy, and tests.'
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user