Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 80 additions & 33 deletions src/renderer/src/components/OrchestratorLaunchModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ import { buildNewWorkspaceCreateTargetOptions } from '@/lib/new-workspace-projec
import { getComposerEligibleRepos } from '@/lib/new-workspace-composer-repo'
import { getAgentCatalog } from '@/lib/agent-catalog'
import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection'
import { launchOrchestratorForProject } from '@/lib/orchestrator-launch'
import { DirectorTypePicker } from '@/components/director/DirectorTypePicker'
import {
LlmDirectorBackend,
RecipeDirectorBackend,
type DirectorKind
} from '@/lib/director-backend'
import { getRecipes } from '@/lib/recipe-director-recipes'
import { translate } from '@/i18n/i18n'
import type { TuiAgent } from '../../../shared/types'

Expand Down Expand Up @@ -49,6 +55,11 @@ export default function OrchestratorLaunchModal(): React.JSX.Element | null {
const detectedAgentIds = useAppStore((s) => s.detectedAgentIds)
const disabledTuiAgents = useAppStore((s) => s.settings?.disabledTuiAgents)
const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null)
// Why: #11 owns the gate — the Recipe director option only appears under the
// experimental flag; with it off the modal behaves exactly as it did before.
const experimentalOrchestrators = useAppStore(
(s) => s.settings?.experimentalOrchestrators ?? false
)

const nameId = useId()
const promptId = useId()
Expand Down Expand Up @@ -90,25 +101,31 @@ export default function OrchestratorLaunchModal(): React.JSX.Element | null {
: null,
[projectOptions, prefillProjectId]
)
const recipes = useMemo(() => getRecipes(), [])
const [selectedOptionId, setSelectedOptionId] = useState<string | null>(null)
const [name, setName] = useState('')
const [agent, setAgent] = useState<TuiAgent | null>(null)
const [prompt, setPrompt] = useState('')
const [directorKind, setDirectorKind] = useState<DirectorKind>('llm')
const [recipeName, setRecipeName] = useState<string | null>(null)

useEffect(() => {
if (visible) {
setSelectedOptionId(prefilledProjectOptionId ?? firstProjectOptionId)
setAgent(defaultTuiAgent && defaultTuiAgent !== 'blank' ? defaultTuiAgent : null)
setName(prefillName)
setPrompt(prefillPrompt)
setDirectorKind('llm')
setRecipeName(recipes[0]?.name ?? null)
}
}, [
visible,
firstProjectOptionId,
prefilledProjectOptionId,
defaultTuiAgent,
prefillName,
prefillPrompt
prefillPrompt,
recipes
])

if (!visible) {
Expand All @@ -121,15 +138,28 @@ export default function OrchestratorLaunchModal(): React.JSX.Element | null {
? (projects.find((p) => p.id === selectedOption.projectId) ?? null)
: null

// The Recipe director is gated; if the flag is off, only the Smart path is live.
const isRecipe = directorKind === 'recipe' && experimentalOrchestrators

const handleLaunch = (): void => {
if (!project) {
return
}
void launchOrchestratorForProject(project, {
name: name.trim() || undefined,
agent: agent ?? undefined,
prompt: prompt.trim() || undefined
})
// Dispatch through the DirectorBackend abstraction (#8) instead of branching
// on the kind at the call site.
if (isRecipe) {
const recipe = recipes.find((entry) => entry.name === recipeName)
if (!recipe) {
return
}
void new RecipeDirectorBackend(recipe).launch(project, { name: name.trim() || undefined })
} else {
void new LlmDirectorBackend().launch(project, {
name: name.trim() || undefined,
agent: agent ?? undefined,
prompt: prompt.trim() || undefined
})
}
closeModal()
}

Expand Down Expand Up @@ -184,6 +214,16 @@ export default function OrchestratorLaunchModal(): React.JSX.Element | null {
)}
/>
</div>
{experimentalOrchestrators && (
<DirectorTypePicker
kind={directorKind}
onKindChange={setDirectorKind}
showRecipeOption={experimentalOrchestrators}
recipes={recipes}
selectedRecipeName={recipeName}
onRecipeChange={setRecipeName}
/>
)}
<div className="space-y-2">
<Label htmlFor={nameId} className="text-xs">
{translate('auto.components.OrchestratorLaunchModal.name', 'Name')}
Expand All @@ -199,36 +239,43 @@ export default function OrchestratorLaunchModal(): React.JSX.Element | null {
}
/>
</div>
<div className="space-y-2">
<Label className="text-xs">
{translate('auto.components.OrchestratorLaunchModal.agent', 'Agent')}
</Label>
<AgentCombobox agents={agents} value={agent} onValueChange={setAgent} />
</div>
<div className="space-y-2">
<Label htmlFor={promptId} className="text-xs">
{translate(
'auto.components.OrchestratorLaunchModal.task',
'What should it orchestrate?'
)}
</Label>
<textarea
id={promptId}
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
rows={4}
placeholder={translate(
'auto.components.OrchestratorLaunchModal.task_placeholder',
'Describe the work — the director plans how to split it into worktrees/PRs. Optional.'
)}
className="flex w-full resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</div>
{/* Why: agent + task are the Smart director's coordinator-LLM inputs. The
Recipe director runs a fixed, token-free workflow with no director LLM
to seed, so these are hidden in recipe mode. */}
{!isRecipe && (
<>
<div className="space-y-2">
<Label className="text-xs">
{translate('auto.components.OrchestratorLaunchModal.agent', 'Agent')}
</Label>
<AgentCombobox agents={agents} value={agent} onValueChange={setAgent} />
</div>
<div className="space-y-2">
<Label htmlFor={promptId} className="text-xs">
{translate(
'auto.components.OrchestratorLaunchModal.task',
'What should it orchestrate?'
)}
</Label>
<textarea
id={promptId}
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
rows={4}
placeholder={translate(
'auto.components.OrchestratorLaunchModal.task_placeholder',
'Describe the work — the director plans how to split it into worktrees/PRs. Optional.'
)}
className="flex w-full resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</div>
</>
)}
<DialogFooter>
<Button type="button" variant="ghost" onClick={closeModal}>
{translate('auto.components.OrchestratorLaunchModal.cancel', 'Cancel')}
</Button>
<Button type="submit" disabled={!project}>
<Button type="submit" disabled={!project || (isRecipe && !recipeName)}>
{translate('auto.components.OrchestratorLaunchModal.launch', 'Launch Orcastrator')}
</Button>
</DialogFooter>
Expand Down
74 changes: 74 additions & 0 deletions src/renderer/src/components/director/DirectorTypePicker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// @vitest-environment happy-dom

import type { ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { DirectorTypePicker } from './DirectorTypePicker'
import { getRecipes } from '@/lib/recipe-director-recipes'

vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))

// Render the Radix Select as plain inline markup so its items are assertable
// without opening the portal (the picker only needs the option list to be present).
vi.mock('@/components/ui/select', () => ({
Select: ({ children }: { children: ReactNode }) => <div data-select>{children}</div>,
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({ value, children }: { value: string; children: ReactNode }) => (
<div data-recipe-item={value}>{children}</div>
),
SelectTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectValue: ({ placeholder }: { placeholder?: string }) => <span>{placeholder}</span>
}))

const noop = (): void => {}

function render(props: Partial<Parameters<typeof DirectorTypePicker>[0]>): string {
return renderToStaticMarkup(
<DirectorTypePicker
kind="llm"
onKindChange={noop}
showRecipeOption={false}
recipes={getRecipes()}
selectedRecipeName={getRecipes()[0]?.name ?? null}
onRecipeChange={noop}
{...props}
/>
)
}

describe('DirectorTypePicker', () => {
it('always offers the Smart director option', () => {
const html = render({ showRecipeOption: false })
expect(html).toContain('Smart director')
})

it('hides the Recipe director option when the experimental flag is off', () => {
const html = render({ showRecipeOption: false })
expect(html).not.toContain('Recipe director')
})

it('offers the Recipe director option only when the flag is on', () => {
const html = render({ showRecipeOption: true })
expect(html).toContain('Smart director')
expect(html).toContain('Recipe director')
// The copy hook that justifies a token-free director.
expect(html).toContain('No director LLM; runs a fixed workflow.')
})

it('does not render the recipe dropdown while Smart is selected', () => {
const html = render({ kind: 'llm', showRecipeOption: true })
expect(html).not.toContain('data-select')
})

it('lists every recipe from getRecipes() when Recipe is selected', () => {
const recipes = getRecipes()
expect(recipes.length).toBeGreaterThan(0)
const html = render({ kind: 'recipe', showRecipeOption: true, recipes })
for (const recipe of recipes) {
expect(html).toContain(`data-recipe-item="${recipe.name}"`)
expect(html).toContain(recipe.name)
}
})
})
Loading