mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-03 18:38:35 +02:00
feat: add workflow picker for new session creation
When creating a new session with >1 workflow available, a searchable popover appears letting users choose which workflow to use. Workflows are grouped by orchestration mode with mode badges, agent counts, and favorite indicators. Keyboard navigation (↑↓ Enter Escape) and fuzzy search are supported. If only one workflow exists, session creation works immediately without showing the picker. Scratchpad auto-selects as before. Wired into all session creation paths: - Sidebar 'New Session' button - Ctrl/Cmd+N keyboard shortcut - Command palette 'New Session' action Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+37
-15
@@ -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<string | null>(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() {
|
||||
<Sidebar
|
||||
onAddProject={() => 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 && (
|
||||
<WorkflowPicker
|
||||
workflows={workspace.workflows}
|
||||
onSelect={handleWorkflowPicked}
|
||||
onClose={() => setWorkflowPickerProjectId(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<WorkflowOrchestrationMode, {
|
||||
label: string;
|
||||
icon: typeof Bot;
|
||||
accent: string;
|
||||
bg: string;
|
||||
}> = {
|
||||
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<WorkflowDefinition>;
|
||||
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<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useClickOutside<HTMLDivElement>(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<WorkflowOrchestrationMode, AnnotatedWorkflow[]>();
|
||||
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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] backdrop-blur-[2px]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Select a workflow"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="w-full max-w-[420px] animate-[palette-enter_0.18s_ease-out] overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-[0_24px_80px_rgba(0,0,0,0.45),0_0_0_1px_rgba(36,92,249,0.08)]"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Search bar */}
|
||||
<div className="flex items-center gap-2.5 border-b border-[var(--color-border)] px-4 py-3">
|
||||
<Search className="size-4 shrink-0 text-[var(--color-text-muted)]" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="flex-1 bg-transparent text-[14px] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-muted)]"
|
||||
placeholder="Pick a workflow…"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
aria-label="Search workflows"
|
||||
/>
|
||||
<kbd className="hidden rounded border border-[var(--color-border)] bg-[var(--color-surface-3)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)] sm:inline">
|
||||
esc
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Workflow list */}
|
||||
<div ref={listRef} className="max-h-[52vh] overflow-y-auto overscroll-contain py-1.5" role="listbox">
|
||||
{groups.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-[13px] text-[var(--color-text-muted)]">
|
||||
No workflows match your search.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map((group) => {
|
||||
const meta = modeMeta[group.mode];
|
||||
const GroupIcon = meta.icon;
|
||||
|
||||
return (
|
||||
<div key={group.mode}>
|
||||
{/* Group header */}
|
||||
<div className="flex items-center gap-2 px-4 pb-1 pt-2.5">
|
||||
<GroupIcon className={`size-3 ${meta.accent}`} />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.1em] text-[var(--color-text-muted)]">
|
||||
{meta.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
{group.items.map((item) => {
|
||||
const isActive = flatIdx === activeIndex;
|
||||
const currentIdx = flatIdx;
|
||||
flatIdx++;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.workflow.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
data-active={isActive}
|
||||
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors duration-100 ${
|
||||
isActive
|
||||
? 'bg-[var(--color-accent-muted)]'
|
||||
: 'hover:bg-[var(--color-surface-2)]'
|
||||
}`}
|
||||
onClick={() => onSelect(item.workflow.id)}
|
||||
onMouseEnter={() => setActiveIndex(currentIdx)}
|
||||
>
|
||||
{/* Mode badge */}
|
||||
<div className={`flex size-8 shrink-0 items-center justify-center rounded-lg ${meta.bg}`}>
|
||||
<GroupIcon className={`size-3.5 ${meta.accent}`} />
|
||||
</div>
|
||||
|
||||
{/* Name + description */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`truncate text-[13px] font-medium ${
|
||||
isActive ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'
|
||||
}`}>
|
||||
{item.workflow.name}
|
||||
</span>
|
||||
{item.workflow.isFavorite && (
|
||||
<Star className="size-3 shrink-0 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
</div>
|
||||
{item.workflow.description && (
|
||||
<p className="truncate text-[11px] text-[var(--color-text-muted)]">
|
||||
{item.workflow.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agent count pill */}
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-md bg-[var(--color-surface-3)] px-1.5 py-0.5">
|
||||
<Bot className="size-2.5 text-[var(--color-text-muted)]" />
|
||||
<span className="text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||
{item.agentCount}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="flex items-center gap-3 border-t border-[var(--color-border)] px-4 py-2">
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">
|
||||
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-3)] px-1 py-px text-[10px]">↑↓</kbd>
|
||||
{' '}navigate
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">
|
||||
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-3)] px-1 py-px text-[10px]">↵</kbd>
|
||||
{' '}select
|
||||
</span>
|
||||
<span className="ml-auto text-[11px] text-[var(--color-text-muted)]">
|
||||
{flatItems.length} workflow{flatItems.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user