From a1f693b3ff2477271aaf94bf33e65100caca54fe Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 8 Jun 2026 01:05:04 -0700 Subject: [PATCH] feat: improve orchestration settings pane with skill coverage and usage examples (#4869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../settings/AgentSkillSetupPanel.test.tsx | 4 +- .../settings/AgentSkillSetupPanel.tsx | 18 ++- .../settings/OrchestrationExamplesDialog.tsx | 110 ++++++++++++++ .../settings/OrchestrationPane.test.tsx | 46 +++++- .../components/settings/OrchestrationPane.tsx | 98 +++++++++++- .../settings/OrchestrationSetupCard.tsx | 1 - .../OrchestrationSkillAgentCoverage.test.tsx | 44 ++++++ .../OrchestrationSkillAgentCoverage.tsx | 104 +++++++++++++ .../OrchestrationSkillPromptDialog.tsx | 73 +++++++++ .../settings/orchestration-search.ts | 7 +- .../src/hooks/useInstalledAgentSkills.ts | 9 +- .../lib/orchestration-skill-coverage.test.ts | 112 ++++++++++++++ .../src/lib/orchestration-skill-coverage.ts | 143 ++++++++++++++++++ .../src/lib/orchestration-usage-examples.ts | 44 ++++++ 14 files changed, 795 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/components/settings/OrchestrationExamplesDialog.tsx create mode 100644 src/renderer/src/components/settings/OrchestrationSkillAgentCoverage.test.tsx create mode 100644 src/renderer/src/components/settings/OrchestrationSkillAgentCoverage.tsx create mode 100644 src/renderer/src/components/settings/OrchestrationSkillPromptDialog.tsx create mode 100644 src/renderer/src/lib/orchestration-skill-coverage.test.ts create mode 100644 src/renderer/src/lib/orchestration-skill-coverage.ts create mode 100644 src/renderer/src/lib/orchestration-usage-examples.ts diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx index 6fc30d7e400..e3974da03c2 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx @@ -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') }) diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index 37429cd5343..f575ae269a6 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -34,6 +34,10 @@ type AgentSkillSetupPanelProps = { onBeforeOpenTerminal?: () => void | Promise showInstallWhenInstalled?: boolean showRecheckWhenInstalled?: boolean + installLabel?: string + installedInstallLabel?: string + actionHint?: ReactNode + footer?: ReactNode onRecheck: () => void | Promise } @@ -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} > - Install + {installed ? installedInstallLabel : installLabel} ) : null} {!installed || showRecheckWhenInstalled ? ( @@ -188,12 +196,20 @@ export function AgentSkillSetupPanel({

{description}

{actionRow} + {actionHint ?
{actionHint}
: null} {!installed && preInstallNotice && preInstallNoticeVisible ? (

{preInstallNotice}

) : null}
+ {footer ? ( +
+ {footer} +
+ ) : null} {terminalOpen ? (
diff --git a/src/renderer/src/components/settings/OrchestrationExamplesDialog.tsx b/src/renderer/src/components/settings/OrchestrationExamplesDialog.tsx new file mode 100644 index 00000000000..706e6ec4cd2 --- /dev/null +++ b/src/renderer/src/components/settings/OrchestrationExamplesDialog.tsx @@ -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) => ( + + {part} + {index < parts.length - 1 ? ( + + {ORCHESTRATION_SKILL_SLASH_COMMAND} + + ) : null} + + ))} + + ) +} + +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 => { + 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 ( + + +
+ +
+ {Icon ? ( +
+ +
+ ) : null} +
+ {example.title} + + {example.summary} + +
+
+
+
+ +
+
+

+ +

+ +
+
+ + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/OrchestrationPane.test.tsx b/src/renderer/src/components/settings/OrchestrationPane.test.tsx index 9e49981e79e..891779a1d51 100644 --- a/src/renderer/src/components/settings/OrchestrationPane.test.tsx +++ b/src/renderer/src/components/settings/OrchestrationPane.test.tsx @@ -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() expect(markup).toContain('Orchestration skill') expect(markup).toContain('Installed') - expect(markup).not.toContain('rounded-xl') - expect(markup).not.toMatch(/]*>[\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(/]*>[\s\S]*?Update[\s\S]*?<\/button>/) + expect(markup).toContain('Re-check') }) }) diff --git a/src/renderer/src/components/settings/OrchestrationPane.tsx b/src/renderer/src/components/settings/OrchestrationPane.tsx index a814fc8b5aa..9b1c1b8fcd4 100644 --- a/src/renderer/src/components/settings/OrchestrationPane.tsx +++ b/src/renderer/src/components/settings/OrchestrationPane.tsx @@ -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(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 ( + Prefer your own terminal?{' '} + +

+ } + footer={ + + } onRecheck={refreshOrchestrationSkill} /> + + + +
+
+

How to use it

+

+ Ask a coordinator agent to use orchestration for handoffs, worktree handovers, and + sequential or parallel child agents. +

+
+ +
+ {ORCHESTRATION_USAGE_EXAMPLES.map((example) => { + const Icon = EXAMPLE_ICONS[example.id as keyof typeof EXAMPLE_ICONS] ?? Workflow + return ( + + ) + })} +
+
+ + {ORCHESTRATION_USAGE_EXAMPLES.map((example) => { + const Icon = EXAMPLE_ICONS[example.id as keyof typeof EXAMPLE_ICONS] ?? Workflow + return ( + setSelectedExampleId(open ? example.id : null)} + /> + ) + })}
) } diff --git a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx index f4a7ca9acbe..3bd12cae6d5 100644 --- a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx +++ b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx @@ -33,7 +33,6 @@ export function OrchestrationSetupCard(props: { useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup') await ensureOrcaCliAvailableForAgentSkillTerminal() }} - showRecheckWhenInstalled={false} onRecheck={skill.refresh} /> ) diff --git a/src/renderer/src/components/settings/OrchestrationSkillAgentCoverage.test.tsx b/src/renderer/src/components/settings/OrchestrationSkillAgentCoverage.test.tsx new file mode 100644 index 00000000000..b6310e6188c --- /dev/null +++ b/src/renderer/src/components/settings/OrchestrationSkillAgentCoverage.test.tsx @@ -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( + + ) + + expect(markup).toContain('Claude') + expect(markup).toContain('Codex') + expect(markup).toContain('Ready') + expect(markup).toContain('Missing') + expect(markup).not.toContain('View details') + }) +}) diff --git a/src/renderer/src/components/settings/OrchestrationSkillAgentCoverage.tsx b/src/renderer/src/components/settings/OrchestrationSkillAgentCoverage.tsx new file mode 100644 index 00000000000..aaa575ea288 --- /dev/null +++ b/src/renderer/src/components/settings/OrchestrationSkillAgentCoverage.tsx @@ -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 ( + + + {status.label} + + {status.installed ? 'Ready' : 'Missing'} + + + ) +} + +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 ( +
+
+

Agent coverage

+

{summary}

+
+ + {showAgentChips ? ( +
+ {agentStatuses.map((status) => ( + + ))} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/OrchestrationSkillPromptDialog.tsx b/src/renderer/src/components/settings/OrchestrationSkillPromptDialog.tsx new file mode 100644 index 00000000000..2bfd9b62680 --- /dev/null +++ b/src/renderer/src/components/settings/OrchestrationSkillPromptDialog.tsx @@ -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 => { + 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 ( + + +
+ + + Install orchestration skill + + + Run this command in a terminal to install the orchestration skill for your agents. + + +
+ +
+
+

+ {command} +

+ +
+
+ + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/orchestration-search.ts b/src/renderer/src/components/settings/orchestration-search.ts index 47e91c5f902..026d40709f8 100644 --- a/src/renderer/src/components/settings/orchestration-search.ts +++ b/src/renderer/src/components/settings/orchestration-search.ts @@ -15,7 +15,12 @@ export const ORCHESTRATION_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ 'task', 'DAG', 'worker', - 'coordinator' + 'coordinator', + 'claude', + 'codex', + 'examples', + 'handoff', + 'child agents' ] } ] diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.ts b/src/renderer/src/hooks/useInstalledAgentSkills.ts index 66e09ee67ca..a31d4857ddf 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.ts +++ b/src/renderer/src/hooks/useInstalledAgentSkills.ts @@ -28,6 +28,7 @@ export type InstalledAgentSkillState = { installed: boolean loading: boolean error: string | null + skills: readonly DiscoveredSkill[] refresh: () => Promise } @@ -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 } } diff --git a/src/renderer/src/lib/orchestration-skill-coverage.test.ts b/src/renderer/src/lib/orchestration-skill-coverage.test.ts new file mode 100644 index 00000000000..aea4a26a975 --- /dev/null +++ b/src/renderer/src/lib/orchestration-skill-coverage.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/renderer/src/lib/orchestration-skill-coverage.ts b/src/renderer/src/lib/orchestration-skill-coverage.ts new file mode 100644 index 00000000000..a3d1e2d2af6 --- /dev/null +++ b/src/renderer/src/lib/orchestration-skill-coverage.ts @@ -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 +> = { + 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) + })) +} diff --git a/src/renderer/src/lib/orchestration-usage-examples.ts b/src/renderer/src/lib/orchestration-usage-examples.ts new file mode 100644 index 00000000000..3c897a2dcfd --- /dev/null +++ b/src/renderer/src/lib/orchestration-usage-examples.ts @@ -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.' + } +]