mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-27 21:33:58 +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 { WorkspaceState } from '@shared/domain/workspace';
|
||||||
import type { UpdateStatus } from '@shared/contracts/ipc';
|
import type { UpdateStatus } from '@shared/contracts/ipc';
|
||||||
import { createId, nowIso } from '@shared/utils/ids';
|
import { createId, nowIso } from '@shared/utils/ids';
|
||||||
|
import { WorkflowPicker } from '@renderer/components/workflow/WorkflowPicker';
|
||||||
|
|
||||||
function createDraftMcpServer(): McpServerDefinition {
|
function createDraftMcpServer(): McpServerDefinition {
|
||||||
const timestamp = nowIso();
|
const timestamp = nowIso();
|
||||||
@@ -159,6 +160,9 @@ export default function App() {
|
|||||||
const [showSearch, setShowSearch] = useState(false);
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
const [showBookmarks, setShowBookmarks] = 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
|
// Commit composer state
|
||||||
const [commitComposerCtx, setCommitComposerCtx] = useState<{ projectId: string; sessionId: string; runId?: string }>();
|
const [commitComposerCtx, setCommitComposerCtx] = useState<{ projectId: string; sessionId: string; runId?: string }>();
|
||||||
|
|
||||||
@@ -424,9 +428,11 @@ export default function App() {
|
|||||||
ws.selectedProjectId ??
|
ws.selectedProjectId ??
|
||||||
ws.projects.find((p) => !isScratchpadProject(p))?.id;
|
ws.projects.find((p) => !isScratchpadProject(p))?.id;
|
||||||
if (defaultProjectId) {
|
if (defaultProjectId) {
|
||||||
const defaultWorkflow = ws.workflows.find((w) => w.isFavorite) ?? ws.workflows[0];
|
if (ws.workflows.length <= 1) {
|
||||||
if (defaultWorkflow) {
|
const wf = ws.workflows[0];
|
||||||
void api.createSession({ projectId: defaultProjectId, workflowId: defaultWorkflow.id });
|
if (wf) void api.createSession({ projectId: defaultProjectId, workflowId: wf.id });
|
||||||
|
} else {
|
||||||
|
setWorkflowPickerProjectId(defaultProjectId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -565,6 +571,24 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [api, workspace]);
|
}, [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) => {
|
const handleOpenSettingsAt = useCallback((section?: SettingsSection) => {
|
||||||
setSettingsSection(section);
|
setSettingsSection(section);
|
||||||
setShowSettings(true);
|
setShowSettings(true);
|
||||||
@@ -832,12 +856,7 @@ export default function App() {
|
|||||||
<Sidebar
|
<Sidebar
|
||||||
onAddProject={() => void api.addProject()}
|
onAddProject={() => void api.addProject()}
|
||||||
onCreateScratchpad={() => handleCreateScratchpad()}
|
onCreateScratchpad={() => handleCreateScratchpad()}
|
||||||
onNewProjectSession={(projectId) => {
|
onNewProjectSession={(projectId) => handleNewSession(projectId)}
|
||||||
const defaultWorkflow = workspace.workflows.find((w) => w.isFavorite) ?? workspace.workflows[0];
|
|
||||||
if (defaultWorkflow) {
|
|
||||||
void api.createSession({ projectId, workflowId: defaultWorkflow.id });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onOpenSettings={() => setShowSettings(true)}
|
onOpenSettings={() => setShowSettings(true)}
|
||||||
onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)}
|
onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)}
|
||||||
onProjectSelect={(projectId) => {
|
onProjectSelect={(projectId) => {
|
||||||
@@ -922,12 +941,7 @@ export default function App() {
|
|||||||
onSelectProject={(projectId) => {
|
onSelectProject={(projectId) => {
|
||||||
void api.selectProject(projectId);
|
void api.selectProject(projectId);
|
||||||
}}
|
}}
|
||||||
onNewSession={(projectId) => {
|
onNewSession={(projectId) => handleNewSession(projectId)}
|
||||||
const defaultWorkflow = workspace.workflows.find((w) => w.isFavorite) ?? workspace.workflows[0];
|
|
||||||
if (defaultWorkflow) {
|
|
||||||
void api.createSession({ projectId, workflowId: defaultWorkflow.id });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onCreateScratchpad={handleCreateScratchpad}
|
onCreateScratchpad={handleCreateScratchpad}
|
||||||
onOpenSettings={() => setShowSettings(true)}
|
onOpenSettings={() => setShowSettings(true)}
|
||||||
onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)}
|
onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)}
|
||||||
@@ -985,6 +999,14 @@ export default function App() {
|
|||||||
sessionId={commitComposerCtx.sessionId}
|
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