diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 657f1c3..1438ddd 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -51,6 +51,7 @@ import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent'; import type { WorkspaceState } from '@shared/domain/workspace'; import type { UpdateStatus } from '@shared/contracts/ipc'; import { createId, nowIso } from '@shared/utils/ids'; +import { WorkflowPicker } from '@renderer/components/workflow/WorkflowPicker'; function createDraftMcpServer(): McpServerDefinition { const timestamp = nowIso(); @@ -159,6 +160,9 @@ export default function App() { const [showSearch, setShowSearch] = useState(false); const [showBookmarks, setShowBookmarks] = useState(false); + // Workflow picker state — holds the projectId we're creating a session for + const [workflowPickerProjectId, setWorkflowPickerProjectId] = useState(null); + // Commit composer state const [commitComposerCtx, setCommitComposerCtx] = useState<{ projectId: string; sessionId: string; runId?: string }>(); @@ -424,9 +428,11 @@ export default function App() { ws.selectedProjectId ?? ws.projects.find((p) => !isScratchpadProject(p))?.id; if (defaultProjectId) { - const defaultWorkflow = ws.workflows.find((w) => w.isFavorite) ?? ws.workflows[0]; - if (defaultWorkflow) { - void api.createSession({ projectId: defaultProjectId, workflowId: defaultWorkflow.id }); + if (ws.workflows.length <= 1) { + const wf = ws.workflows[0]; + if (wf) void api.createSession({ projectId: defaultProjectId, workflowId: wf.id }); + } else { + setWorkflowPickerProjectId(defaultProjectId); } } } @@ -565,6 +571,24 @@ export default function App() { } }, [api, workspace]); + /** Opens the workflow picker, or creates immediately if ≤1 workflow. */ + const handleNewSession = useCallback((projectId: string) => { + if (!workspace) return; + if (workspace.workflows.length <= 1) { + const wf = workspace.workflows[0]; + if (wf) void api.createSession({ projectId, workflowId: wf.id }); + return; + } + setWorkflowPickerProjectId(projectId); + }, [api, workspace]); + + /** Called when a workflow is picked from the picker. */ + const handleWorkflowPicked = useCallback((workflowId: string) => { + if (!workflowPickerProjectId) return; + void api.createSession({ projectId: workflowPickerProjectId, workflowId }); + setWorkflowPickerProjectId(null); + }, [api, workflowPickerProjectId]); + const handleOpenSettingsAt = useCallback((section?: SettingsSection) => { setSettingsSection(section); setShowSettings(true); @@ -832,12 +856,7 @@ export default function App() { void api.addProject()} onCreateScratchpad={() => handleCreateScratchpad()} - onNewProjectSession={(projectId) => { - const defaultWorkflow = workspace.workflows.find((w) => w.isFavorite) ?? workspace.workflows[0]; - if (defaultWorkflow) { - void api.createSession({ projectId, workflowId: defaultWorkflow.id }); - } - }} + onNewProjectSession={(projectId) => handleNewSession(projectId)} onOpenSettings={() => setShowSettings(true)} onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)} onProjectSelect={(projectId) => { @@ -922,12 +941,7 @@ export default function App() { onSelectProject={(projectId) => { void api.selectProject(projectId); }} - onNewSession={(projectId) => { - const defaultWorkflow = workspace.workflows.find((w) => w.isFavorite) ?? workspace.workflows[0]; - if (defaultWorkflow) { - void api.createSession({ projectId, workflowId: defaultWorkflow.id }); - } - }} + onNewSession={(projectId) => handleNewSession(projectId)} onCreateScratchpad={handleCreateScratchpad} onOpenSettings={() => setShowSettings(true)} onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)} @@ -985,6 +999,14 @@ export default function App() { sessionId={commitComposerCtx.sessionId} /> )} + + {workflowPickerProjectId && workspace && ( + setWorkflowPickerProjectId(null)} + /> + )} ); } diff --git a/src/renderer/components/workflow/WorkflowPicker.tsx b/src/renderer/components/workflow/WorkflowPicker.tsx new file mode 100644 index 0000000..dbd9019 --- /dev/null +++ b/src/renderer/components/workflow/WorkflowPicker.tsx @@ -0,0 +1,288 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ArrowRightLeft, + Bot, + Layers, + MessageCircle, + Route, + Search, + Star, + User, +} from 'lucide-react'; + +import type { WorkflowDefinition, WorkflowOrchestrationMode } from '@shared/domain/workflow'; +import { inferWorkflowOrchestrationMode } from '@shared/domain/workflow'; +import { useClickOutside } from '@renderer/hooks/useClickOutside'; + +/* ── Mode visual metadata ──────────────────────────────────── */ + +const modeMeta: Record = { + single: { label: 'Single', icon: User, accent: 'text-emerald-400', bg: 'bg-emerald-400/10' }, + sequential: { label: 'Sequential', icon: ArrowRightLeft, accent: 'text-sky-400', bg: 'bg-sky-400/10' }, + concurrent: { label: 'Concurrent', icon: Layers, accent: 'text-amber-400', bg: 'bg-amber-400/10' }, + handoff: { label: 'Handoff', icon: Route, accent: 'text-violet-400', bg: 'bg-violet-400/10' }, + 'group-chat': { label: 'Group Chat', icon: MessageCircle, accent: 'text-rose-400', bg: 'bg-rose-400/10' }, +}; + +const modeOrder: WorkflowOrchestrationMode[] = ['single', 'sequential', 'concurrent', 'handoff', 'group-chat']; + +/* ── Types ─────────────────────────────────────────────────── */ + +interface WorkflowPickerProps { + workflows: ReadonlyArray; + onSelect: (workflowId: string) => void; + onClose: () => void; +} + +interface AnnotatedWorkflow { + workflow: WorkflowDefinition; + mode: WorkflowOrchestrationMode; + agentCount: number; +} + +/* ── Helpers ───────────────────────────────────────────────── */ + +function annotateWorkflow(workflow: WorkflowDefinition): AnnotatedWorkflow { + const mode = inferWorkflowOrchestrationMode(workflow); + const agentCount = workflow.graph.nodes.filter((n) => n.kind === 'agent').length; + return { workflow, mode, agentCount }; +} + +function fuzzyMatch(query: string, text: string): boolean { + const q = query.toLowerCase(); + const t = text.toLowerCase(); + if (t.includes(q)) return true; + let qi = 0; + for (let ti = 0; ti < t.length && qi < q.length; ti++) { + if (t[ti] === q[qi]) qi++; + } + return qi === q.length; +} + +/* ── Component ─────────────────────────────────────────────── */ + +export function WorkflowPicker({ workflows, onSelect, onClose }: WorkflowPickerProps) { + const [query, setQuery] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + const panelRef = useClickOutside(onClose, true); + + // Annotate and group workflows + const annotated = useMemo( + () => workflows.map(annotateWorkflow), + [workflows], + ); + + const filtered = useMemo(() => { + if (!query.trim()) return annotated; + return annotated.filter((a) => + fuzzyMatch(query, a.workflow.name) + || fuzzyMatch(query, a.workflow.description) + || fuzzyMatch(query, modeMeta[a.mode].label), + ); + }, [annotated, query]); + + // Group by mode, preserving order + const groups = useMemo(() => { + const map = new Map(); + for (const item of filtered) { + const existing = map.get(item.mode); + if (existing) { + existing.push(item); + } else { + map.set(item.mode, [item]); + } + } + return modeOrder + .filter((mode) => map.has(mode)) + .map((mode) => ({ mode, items: map.get(mode)! })); + }, [filtered]); + + // Flat list of items for keyboard navigation + const flatItems = useMemo( + () => groups.flatMap((g) => g.items), + [groups], + ); + + // Clamp active index on filter change + useEffect(() => { + setActiveIndex((prev) => Math.min(prev, Math.max(0, flatItems.length - 1))); + }, [flatItems.length]); + + // Auto-focus input + useEffect(() => { + inputRef.current?.focus(); + }, []); + + // Scroll active item into view + useEffect(() => { + const active = listRef.current?.querySelector('[data-active="true"]'); + active?.scrollIntoView({ block: 'nearest' }); + }, [activeIndex]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setActiveIndex((prev) => Math.min(prev + 1, flatItems.length - 1)); + break; + case 'ArrowUp': + e.preventDefault(); + setActiveIndex((prev) => Math.max(prev - 1, 0)); + break; + case 'Enter': + e.preventDefault(); + if (flatItems[activeIndex]) { + onSelect(flatItems[activeIndex].workflow.id); + } + break; + case 'Escape': + e.preventDefault(); + onClose(); + break; + } + }, + [flatItems, activeIndex, onSelect, onClose], + ); + + let flatIdx = 0; + + return ( +
+
+ {/* Search bar */} +
+ + { + setQuery(e.target.value); + setActiveIndex(0); + }} + aria-label="Search workflows" + /> + + esc + +
+ + {/* Workflow list */} +
+ {groups.length === 0 && ( +
+ No workflows match your search. +
+ )} + + {groups.map((group) => { + const meta = modeMeta[group.mode]; + const GroupIcon = meta.icon; + + return ( +
+ {/* Group header */} +
+ + + {meta.label} + +
+ + {/* Items */} + {group.items.map((item) => { + const isActive = flatIdx === activeIndex; + const currentIdx = flatIdx; + flatIdx++; + + return ( + + ); + })} +
+ ); + })} +
+ + {/* Footer hint */} +
+ + ↑↓ + {' '}navigate + + + + {' '}select + + + {flatItems.length} workflow{flatItems.length !== 1 ? 's' : ''} + +
+
+
+ ); +}