redesign automation page with simple list + detail panels
Replace 8-category layout with flat task list and detail view using WorkspaceView. Searchable list with mode badges, run/delete actions, create dialog. Remove 13 unused component files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { Play, Trash2, Terminal, Bot, Workflow } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { Card } from '@/components/Card';
|
||||
import { TaskRunnerModal } from 'officerdev';
|
||||
import type { TaskSummary } from 'officerdev';
|
||||
|
||||
type TaskDetail = {
|
||||
id: number;
|
||||
dirName: string;
|
||||
name: string;
|
||||
description: string;
|
||||
scope: string;
|
||||
mode: string;
|
||||
language: string | null;
|
||||
body: string | null;
|
||||
inputs: Record<string, unknown> | null;
|
||||
config: { steps?: Array<{ task: string; foreach?: string }> } | null;
|
||||
version: number | null;
|
||||
userId: number | null;
|
||||
};
|
||||
|
||||
const modeLabels: Record<string, { label: string; icon: typeof Terminal; color: string }> = {
|
||||
script: { label: 'Script', icon: Terminal, color: 'bg-emerald-500/10 text-emerald-600' },
|
||||
agentic: { label: 'Agentic', icon: Bot, color: 'bg-violet-500/10 text-violet-600' },
|
||||
pipeline: { label: 'Pipeline', icon: Workflow, color: 'bg-amber-500/10 text-amber-600' },
|
||||
};
|
||||
|
||||
export const AutomationDetail = () => {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const [selected, setSelected] = usePanelChannel<TaskSummary | null>('automation:selected-task', null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
const [runTask, setRunTask] = useState<TaskSummary | null>(null);
|
||||
|
||||
// Reset delete confirm when selection changes
|
||||
useEffect(() => { setDeleteConfirm(false); }, [selected?.dirName]);
|
||||
|
||||
const { data: detail } = useQuery<TaskDetail>({
|
||||
queryKey: ['tasks', selected?.dirName],
|
||||
queryFn: () => client.get<TaskDetail>(`/tasks/${selected!.dirName}`),
|
||||
enabled: !!selected?.dirName,
|
||||
});
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
await client.delete(`/tasks/${selected.dirName}`);
|
||||
await qc.invalidateQueries({ queryKey: ['tasks'] });
|
||||
setDeleteConfirm(false);
|
||||
setSelected(null);
|
||||
toast.success('Automation deleted');
|
||||
} catch {
|
||||
toast.error('Failed to delete automation');
|
||||
}
|
||||
};
|
||||
|
||||
if (!selected) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">Select an automation to view details</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const canModify = selected.scope === 'user' || user?.role === 'Super Admin';
|
||||
const mode = modeLabels[selected.mode] ?? modeLabels.agentic!;
|
||||
const ModeIcon = mode.icon;
|
||||
|
||||
const hasSteps = detail?.config?.steps && detail.config.steps.length > 0;
|
||||
const hasInputs = detail?.inputs && Object.keys(detail.inputs).length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="h-full overflow-hidden flex flex-col min-h-0">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1 truncate">
|
||||
{detail?.name ?? selected.name}
|
||||
</span>
|
||||
<span className={`shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium ${mode.color}`}>
|
||||
<ModeIcon className="h-2.5 w-2.5" />
|
||||
{mode.label}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setRunTask(selected)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||
title="Run"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 text-duck-teal" />
|
||||
</button>
|
||||
{canModify && (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(true)}
|
||||
className="p-1 rounded hover:bg-red-50 dark:hover:bg-red-500/10 cursor-pointer transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50 hover:text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="overflow-y-auto flex-1 p-6">
|
||||
{/* Description */}
|
||||
{detail?.description && (
|
||||
<p className="text-sm text-duck-dark/60 dark:text-foreground/60 mb-4">{detail.description}</p>
|
||||
)}
|
||||
|
||||
{/* Meta badges */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{detail?.scope && detail.scope !== 'user' && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-duck-dark/8 dark:bg-foreground/8 text-duck-dark/50 dark:text-foreground/50 font-medium">
|
||||
{detail.scope}
|
||||
</span>
|
||||
)}
|
||||
{detail?.language && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-duck-dark/8 dark:bg-foreground/8 text-duck-dark/50 dark:text-foreground/50 font-medium">
|
||||
{detail.language}
|
||||
</span>
|
||||
)}
|
||||
{detail?.version && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-duck-dark/8 dark:bg-foreground/8 text-duck-dark/50 dark:text-foreground/50 font-medium">
|
||||
v{detail.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pipeline steps */}
|
||||
{hasSteps && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wider mb-2">Pipeline Steps</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{detail!.config!.steps!.map((step, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm text-duck-dark/70 dark:text-foreground/70 px-2 py-1 rounded bg-duck-dark/3 dark:bg-foreground/3">
|
||||
<span className="w-5 text-center font-mono text-xs text-duck-dark/40 dark:text-foreground/40">{i + 1}</span>
|
||||
<span className="font-medium">{step.task}</span>
|
||||
{step.foreach && <span className="text-xs text-duck-dark/40 dark:text-foreground/40">(foreach: {step.foreach})</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inputs */}
|
||||
{hasInputs && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wider mb-2">Inputs</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{Object.entries(detail!.inputs!).map(([key, def]) => {
|
||||
const d = def as { type?: string; description?: string; default?: string };
|
||||
return (
|
||||
<div key={key} className="flex items-baseline gap-2 text-sm px-2 py-1 rounded bg-duck-dark/3 dark:bg-foreground/3">
|
||||
<span className="font-mono text-xs text-duck-teal">{key}</span>
|
||||
{d.type && <span className="text-[10px] text-duck-dark/40 dark:text-foreground/40">{d.type}</span>}
|
||||
{d.description && <span className="text-xs text-duck-dark/50 dark:text-foreground/50 flex-1">{d.description}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body (agent instructions) */}
|
||||
{detail?.body ? (
|
||||
<article className="skill-md">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{detail.body}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
) : detail && !hasSteps ? (
|
||||
<p className="text-sm text-duck-dark/40 dark:text-foreground/40 text-center mt-12">No instructions</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Dialog open={deleteConfirm} onOpenChange={setDeleteConfirm}>
|
||||
<DialogContent className="sm:max-w-md z-[700]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Automation</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{selected.name}"? This cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} className="cursor-pointer">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Run modal */}
|
||||
{runTask && (
|
||||
<TaskRunnerModal
|
||||
open
|
||||
onOpenChange={(open) => { if (!open) setRunTask(null); }}
|
||||
task={runTask}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Trash2, X } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { CapabilityChat } from '../CapabilityPage';
|
||||
import type { CapabilityDetail } from '../CapabilityPage';
|
||||
import type { AutomationSelection } from './AutomationRightPanel';
|
||||
|
||||
export const AutomationEditChat = () => {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const [selection, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||
const [chatKey, setChatKey] = useState(0);
|
||||
|
||||
const { data: detail } = useQuery<CapabilityDetail>({
|
||||
queryKey: [selection?.queryKey, selection?.dirName],
|
||||
queryFn: () => client.get<CapabilityDetail>(`${selection!.endpoint}/${selection!.dirName}`),
|
||||
enabled: !!selection,
|
||||
});
|
||||
|
||||
const closeChat = () => {
|
||||
if (!selection) return;
|
||||
setSelection({ ...selection, editing: false });
|
||||
};
|
||||
|
||||
const deleteChat = async () => {
|
||||
if (!selection) return;
|
||||
try {
|
||||
await client.delete(`${selection.endpoint}/${selection.dirName}/chat`);
|
||||
await qc.invalidateQueries({ queryKey: [selection.queryKey, selection.dirName] });
|
||||
setChatKey((k) => k + 1);
|
||||
} catch {
|
||||
toast.error('Failed to delete chat');
|
||||
}
|
||||
};
|
||||
|
||||
if (!selection || !detail?.filePath) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">Select a resource and click edit to chat</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 flex-1">{detail.name ?? 'Chat'}</span>
|
||||
<button
|
||||
onClick={deleteChat}
|
||||
className="p-1 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
|
||||
title="Delete chat history"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50 hover:text-red-500" />
|
||||
</button>
|
||||
<button onClick={closeChat} className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors">
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||
</button>
|
||||
</div>
|
||||
<CapabilityChat
|
||||
kind={selection.kind.toLowerCase()}
|
||||
key={`${detail.filePath}-${chatKey}`}
|
||||
endpoint={selection.endpoint}
|
||||
dirName={selection.dirName}
|
||||
filePath={detail.filePath}
|
||||
resourceDir={detail.filePath.replace(/\/[^/]+$/, '')}
|
||||
chatSessionId={chatKey > 0 ? null : detail.chatSessionId}
|
||||
isNew={selection.isNew}
|
||||
description={selection.description}
|
||||
onResponseEnd={() => {
|
||||
qc.invalidateQueries({ queryKey: [selection.queryKey, selection.dirName] });
|
||||
qc.invalidateQueries({ queryKey: [selection.queryKey] });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Plus, Search, Play, Terminal, Bot, Workflow } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { TaskRunnerModal } from 'officerdev';
|
||||
import type { TaskSummary } from 'officerdev';
|
||||
|
||||
const modeIcons = {
|
||||
script: Terminal,
|
||||
agentic: Bot,
|
||||
pipeline: Workflow,
|
||||
} as const;
|
||||
|
||||
const modeColors = {
|
||||
script: 'bg-emerald-500/10 text-emerald-600',
|
||||
agentic: 'bg-violet-500/10 text-violet-600',
|
||||
pipeline: 'bg-amber-500/10 text-amber-600',
|
||||
} as const;
|
||||
|
||||
export const AutomationList = () => {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const [selected, setSelected] = usePanelChannel<TaskSummary | null>('automation:selected-task', null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createName, setCreateName] = useState('');
|
||||
const [createDesc, setCreateDesc] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [runTask, setRunTask] = useState<TaskSummary | null>(null);
|
||||
|
||||
const { data: tasks = [] } = useQuery<TaskSummary[]>({
|
||||
queryKey: ['tasks'],
|
||||
queryFn: () => client.get('/tasks'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const q = search.toLowerCase();
|
||||
const filtered = search
|
||||
? tasks.filter((t) => t.name.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q))
|
||||
: tasks;
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = createName.trim();
|
||||
if (!name) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await client.post('/tasks', { name, description: createDesc.trim() || undefined });
|
||||
await qc.invalidateQueries({ queryKey: ['tasks'] });
|
||||
setShowCreate(false);
|
||||
setCreateName('');
|
||||
setCreateDesc('');
|
||||
toast.success('Automation created');
|
||||
} catch {
|
||||
toast.error('Failed to create automation');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">Automations</span>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-3 pt-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30 dark:text-foreground/30" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full rounded border border-duck-dark/15 dark:border-foreground/15 bg-background/80 pl-7 pr-2 py-1.5 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task list */}
|
||||
<div className="flex-1 overflow-y-auto px-3 pt-2 pb-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{filtered.map((task) => {
|
||||
const ModeIcon = modeIcons[task.mode] ?? Bot;
|
||||
const modeColor = modeColors[task.mode] ?? modeColors.agentic;
|
||||
const isSelected = selected?.dirName === task.dirName;
|
||||
return (
|
||||
<div
|
||||
key={task.dirName}
|
||||
className={`group flex items-center gap-2 w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
isSelected
|
||||
? 'bg-duck-teal/10 text-duck-dark dark:text-foreground'
|
||||
: 'text-duck-dark/60 dark:text-foreground/60 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelected(task)}
|
||||
className="flex-1 min-w-0 text-left cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium truncate">{task.name}</span>
|
||||
<span className={`shrink-0 inline-flex items-center gap-0.5 px-1 py-0.5 rounded text-[9px] font-medium ${modeColor}`}>
|
||||
<ModeIcon className="h-2.5 w-2.5" />
|
||||
</span>
|
||||
</div>
|
||||
{task.description && (
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40 mt-0.5 line-clamp-1">{task.description}</p>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(ev) => { ev.stopPropagation(); setRunTask(task); }}
|
||||
className="p-1 rounded hover:bg-duck-teal/10 transition-colors cursor-pointer opacity-0 group-hover:opacity-100 shrink-0"
|
||||
title="Run"
|
||||
>
|
||||
<Play className="h-3 w-3 text-duck-teal" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{tasks.length === 0 && (
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40 px-3 py-4 text-center">
|
||||
No automations yet
|
||||
</p>
|
||||
)}
|
||||
{tasks.length > 0 && filtered.length === 0 && (
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40 px-3 py-4 text-center">
|
||||
No matches
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create dialog */}
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogContent className="sm:max-w-md z-[700]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Automation</DialogTitle>
|
||||
<DialogDescription>Give your automation a name and optional description.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(ev) => { ev.preventDefault(); handleCreate(); }}
|
||||
className="flex flex-col gap-3 mt-1"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
|
||||
<input
|
||||
value={createName}
|
||||
onChange={(ev) => setCreateName(ev.target.value)}
|
||||
placeholder="e.g. Batch Resize Images"
|
||||
autoFocus
|
||||
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
|
||||
<textarea
|
||||
value={createDesc}
|
||||
onChange={(ev) => setCreateDesc(ev.target.value)}
|
||||
placeholder="What does this automation do?"
|
||||
rows={3}
|
||||
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-1">
|
||||
<Button type="button" variant="outline" onClick={() => setShowCreate(false)} className="cursor-pointer">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!createName.trim() || creating}
|
||||
className="bg-duck-teal text-white hover:bg-duck-teal/90 cursor-pointer"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Run modal */}
|
||||
{runTask && (
|
||||
<TaskRunnerModal
|
||||
open
|
||||
onOpenChange={(open) => { if (!open) setRunTask(null); }}
|
||||
task={runTask}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { CapabilityDetailView } from './CapabilityDetailView';
|
||||
import { CapabilityList } from './CapabilityList';
|
||||
import { NewTask } from './NewTask';
|
||||
import { NewSkill } from './NewSkill';
|
||||
import { NewProcess } from './NewProcess';
|
||||
import { NewPipeline } from './NewPipeline';
|
||||
import { NewCron } from './NewCron';
|
||||
import { NewService } from './NewService';
|
||||
import { NewWorkflow } from './NewWorkflow';
|
||||
import { NewTool } from './NewTool';
|
||||
|
||||
export type AutomationSelection = {
|
||||
kind: string;
|
||||
endpoint: string;
|
||||
queryKey: string;
|
||||
dirName: string;
|
||||
isNew?: boolean;
|
||||
editing?: boolean;
|
||||
creating?: boolean;
|
||||
description?: string;
|
||||
} | null;
|
||||
|
||||
const newComponentMap: Record<string, React.ComponentType<{ selection: NonNullable<AutomationSelection> }>> = {
|
||||
Task: NewTask,
|
||||
Skill: NewSkill,
|
||||
Process: NewProcess,
|
||||
Pipeline: NewPipeline,
|
||||
Cron: NewCron,
|
||||
Service: NewService,
|
||||
Workflow: NewWorkflow,
|
||||
Tool: NewTool,
|
||||
};
|
||||
|
||||
export const AutomationRightPanel = () => {
|
||||
const [selection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||
|
||||
if (!selection) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="text-sm text-duck-dark/40">Select a category from the sidebar</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selection.creating) {
|
||||
const NewComponent = newComponentMap[selection.kind];
|
||||
if (NewComponent) return <NewComponent selection={selection} />;
|
||||
}
|
||||
|
||||
if (!selection.dirName) {
|
||||
return <CapabilityList key={selection.queryKey} kind={selection.kind} endpoint={selection.endpoint} queryKey={selection.queryKey} />;
|
||||
}
|
||||
|
||||
return <CapabilityDetailView key={`${selection.queryKey}:${selection.dirName}`} {...selection} />;
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Box, Clock, Cpu, GitBranch, ListTodo, Server, Sparkles, Workflow, Wrench } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import type { AutomationSelection } from './AutomationRightPanel';
|
||||
|
||||
const capabilityItems = [
|
||||
{ id: 'skills', label: 'Skills', icon: Sparkles, kind: 'Skill', endpoint: '/skills', queryKey: 'skills' },
|
||||
{ id: 'tools', label: 'Tools', icon: Wrench, kind: 'Tool', endpoint: '/tools', queryKey: 'tools' },
|
||||
{ id: 'tasks', label: 'Tasks', icon: ListTodo, kind: 'Task', endpoint: '/tasks', queryKey: 'tasks' },
|
||||
{ id: 'pipelines', label: 'Pipelines', icon: Workflow, kind: 'Pipeline', endpoint: '/pipelines', queryKey: 'pipelines' },
|
||||
{ id: 'processes', label: 'Processes', icon: Cpu, kind: 'Process', endpoint: '/processes', queryKey: 'processes' },
|
||||
{ id: 'workflows', label: 'Workflows', icon: GitBranch, kind: 'Workflow', endpoint: '/workflows', queryKey: 'workflows' },
|
||||
{ id: 'crons', label: 'Crons', icon: Clock, kind: 'Cron', endpoint: '/crons', queryKey: 'crons' },
|
||||
{ id: 'services', label: 'Services', icon: Server, kind: 'Service', endpoint: '/services', queryKey: 'services' },
|
||||
] as const;
|
||||
|
||||
export const AutomationSidebar = () => {
|
||||
const [selection, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
<div className="p-3 pb-0">
|
||||
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
|
||||
<Box className="h-4 w-4" />
|
||||
Automation
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 px-3 pt-3">
|
||||
{capabilityItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = selection?.queryKey === item.queryKey;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() =>
|
||||
setSelection({ kind: item.kind, endpoint: item.endpoint, queryKey: item.queryKey, dirName: '' })
|
||||
}
|
||||
className={`flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium transition-all cursor-pointer ${
|
||||
active
|
||||
? 'bg-duck-teal/10 text-duck-dark'
|
||||
: 'text-duck-dark/60 hover:bg-duck-dark/5 hover:text-duck-dark'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 text-left">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,275 +2,48 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The `/automation` route is the hub for managing AI agent capabilities: skills, tools, tasks, and processes. Each capability is a markdown file (with YAML frontmatter) stored on the filesystem. There is no relational database for automation data.
|
||||
The `/automation` route shows all user automations (tasks) in a standard `WorkspaceView` layout: left panel for the list, right panel for task details.
|
||||
|
||||
The page uses a resizable `WorkspaceView` layout with three panels: a sidebar to pick categories, a right panel for browsing/viewing, and an optional bottom-right chat panel for AI-assisted editing.
|
||||
## Route
|
||||
|
||||
```
|
||||
+------------------+------------------------------------------+
|
||||
| Sidebar (20%) | Right Panel (80%) |
|
||||
| | - CapabilityList (browse) |
|
||||
| 8 categories | - CapabilityDetailView (selected item) |
|
||||
| | - New* form (creating) |
|
||||
| +------------------------------------------+
|
||||
| | AutomationEditChat (when editing, 50%) |
|
||||
+------------------+------------------------------------------+
|
||||
```
|
||||
|
||||
## Route & Entry Point
|
||||
|
||||
Defined in `App.tsx`:
|
||||
```tsx
|
||||
<Route path="/automation" element={<Dashboard.Automation />} />
|
||||
<Route path="/new-automation" element={<Dashboard.NewAutomation />} /> // stub, not implemented
|
||||
```
|
||||
|
||||
Entry component: `index.tsx` (`Automation`). Manages the `WorkspaceView` layout and dynamically adds/removes the chat panel based on `selection.editing`.
|
||||
## Layout
|
||||
|
||||
## Capability Categories
|
||||
|
||||
| Category | Kind | Backend Route | Status |
|
||||
|----------|------|---------------|--------|
|
||||
| Skills | `Skill` | `GET/POST/DELETE /api/skills` | Working |
|
||||
| Tools | `Tool` | `GET/POST/DELETE /api/tools` | Working |
|
||||
| Tasks | `Task` | `GET/POST/DELETE /api/tasks` | Working |
|
||||
| Processes | `Process` | `GET/POST/DELETE /api/processes` | Working |
|
||||
| Pipelines | `Pipeline` | None | Placeholder (404) |
|
||||
| Workflows | `Workflow` | None | Placeholder (404) |
|
||||
| Crons | `Cron` | None | Placeholder (404) |
|
||||
| Services | `Service` | None | Placeholder (404) |
|
||||
|
||||
Defined as `capabilityItems` in `AutomationSidebar.tsx`.
|
||||
|
||||
## State Management
|
||||
|
||||
All cross-panel communication uses a single shared channel:
|
||||
|
||||
```ts
|
||||
usePanelChannel<AutomationSelection>('automation:selected-capability', null)
|
||||
```
|
||||
+------------------+------------------------------------------+
|
||||
| List (30%) | Detail (70%) |
|
||||
| | |
|
||||
| Search | Task name, mode, description |
|
||||
| Task items | Pipeline steps / Inputs / Body |
|
||||
| + Create | Run / Delete actions |
|
||||
+------------------+------------------------------------------+
|
||||
```
|
||||
|
||||
`AutomationSelection` (defined in `AutomationRightPanel.tsx`):
|
||||
```ts
|
||||
type AutomationSelection = {
|
||||
kind: string; // 'Skill' | 'Task' | 'Tool' | 'Process' | etc.
|
||||
endpoint: string; // '/skills' | '/tasks' | etc.
|
||||
queryKey: string; // React Query cache key
|
||||
dirName: string; // '' = list view, non-empty = detail view
|
||||
isNew?: boolean; // just created, AI chat opens in creation mode
|
||||
editing?: boolean; // chat panel is open
|
||||
creating?: boolean; // New* form is shown
|
||||
description?: string; // passed to AI as initial context
|
||||
} | null;
|
||||
```
|
||||
|
||||
Layout state persisted via `useDashboardState('screens/automation')`.
|
||||
Uses `useDashboardState('screens/automation')` for persistent layout and `usePanelChannel<TaskSummary>('automation:selected-task')` for cross-panel selection.
|
||||
|
||||
## Components
|
||||
|
||||
### `AutomationSidebar.tsx`
|
||||
Left nav with the 8 category buttons. Clicking sets selection to `{ kind, endpoint, queryKey, dirName: '' }` (list view).
|
||||
### `index.tsx`
|
||||
Entry point. Sets up `WorkspaceView` with two panels, manages mobile panel switching.
|
||||
|
||||
### `AutomationRightPanel.tsx`
|
||||
Routes to one of three views based on selection state:
|
||||
- `creating === true` -> `New*` form (mapped via `newComponentMap[kind]`)
|
||||
- `dirName === ''` -> `CapabilityList` (browse items)
|
||||
- `dirName !== ''` -> `CapabilityDetailView` (detail for specific item)
|
||||
### `AutomationList.tsx`
|
||||
Left panel. Fetches tasks from `GET /tasks`, shows searchable list with mode badges (Script/Agentic/Pipeline). Has create dialog and run button per task.
|
||||
|
||||
### `CapabilityList.tsx`
|
||||
Fetches items from `GET {endpoint}`, renders filterable list. Has `+` button to set `creating: true`.
|
||||
### `AutomationDetail.tsx`
|
||||
Right panel. Fetches task detail from `GET /tasks/:name`. Shows description, pipeline steps, input definitions, and markdown body. Run and delete actions.
|
||||
|
||||
### `CapabilityDetailView.tsx`
|
||||
Shows a selected capability's detail (frontmatter + markdown body). Header actions:
|
||||
- **Back arrow** - returns to list view
|
||||
- **Run** (tasks only) - parses `inputs:` from frontmatter YAML, opens `TaskRunnerModal`
|
||||
- **Edit** - toggles `selection.editing` to open/close chat panel
|
||||
- **Delete** - confirmation dialog, then `DELETE {endpoint}/{dirName}`
|
||||
## Task Data
|
||||
|
||||
Task input parsing (`parseInputs`) supports: `string`, `number`, `boolean`, `select` types.
|
||||
Tasks come from `officerdb` database. Modes:
|
||||
- **script** (green) — deterministic bash/python/ts
|
||||
- **agentic** (purple) — AI-powered with MCP tools
|
||||
- **pipeline** (amber) — multi-step orchestration
|
||||
|
||||
### `AutomationEditChat.tsx`
|
||||
Bottom chat panel for AI-assisted editing. Uses `CapabilityChat` from `CapabilityPage.tsx`. Features:
|
||||
- Delete chat history button (`DELETE {endpoint}/{dirName}/chat`)
|
||||
- Close button (sets `editing: false`)
|
||||
- On AI response end, invalidates both individual and list query caches
|
||||
## Related
|
||||
|
||||
### `New*.tsx` (8 components)
|
||||
`NewTask`, `NewSkill`, `NewTool`, `NewProcess`, `NewPipeline`, `NewCron`, `NewService`, `NewWorkflow`. All structurally identical:
|
||||
1. Name + description form
|
||||
2. `POST {endpoint}` with `{ name }`
|
||||
3. On success: transitions to detail view with `isNew: true, editing: true` (opens AI chat in creation mode)
|
||||
|
||||
## Shared Components (`CapabilityPage.tsx`)
|
||||
|
||||
Located at `../CapabilityPage.tsx`. Exports used by automation:
|
||||
|
||||
- **`CapabilityChat`** - AI chat wired to Pi agent via `usePiChat()` + `EmbeddableChat`. Injects `promptFrontmatter` prefix before every message. For `isNew === true`, uses rich creation guides (`buildTaskCreationPrefix`, `buildSkillCreationPrefix`, `buildToolCreationPrefix`).
|
||||
- **`FrontmatterBlock`** - Collapsible YAML frontmatter display.
|
||||
- **`CapabilityDetail`** type - `{ dirName, name, description, scope, body, rawFrontmatter, filePath, chatSessionId }`.
|
||||
- **`CapabilityPage`** - Legacy standalone two-panel page (used by `/skills`, `/tasks`, `/processes` routes, not by `/automation`).
|
||||
|
||||
## Backend
|
||||
|
||||
All four working routers (`skills`, `tools`, `tasks`, `processes`) follow the exact same pattern. Registered on the protected router in `hono.ts`.
|
||||
|
||||
### API Endpoints (per capability type)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/{type}` | List all items (native + global + user merged) |
|
||||
| `GET` | `/{type}/:name` | Get detail: frontmatter, body, rawYaml, filePath, chatSessionId |
|
||||
| `POST` | `/{type}` | Create new item (dir + stub `.md`) |
|
||||
| `DELETE` | `/{type}/:name` | Delete item directory |
|
||||
| `GET` | `/{type}/:name/chat` | Get saved chat messages |
|
||||
| `PUT` | `/{type}/:name/chat` | Save chat messages + session meta |
|
||||
| `DELETE` | `/{type}/:name/chat` | Delete chat directory |
|
||||
|
||||
Route files:
|
||||
- `src/servers/api/skills/skills.ts`
|
||||
- `src/servers/api/tools/tools.ts`
|
||||
- `src/servers/api/tasks/tasks.ts` (also parses `trigger:` block from frontmatter)
|
||||
- `src/servers/api/processes/processes.ts`
|
||||
|
||||
### Task Logs
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/task-logs` | List all log metadata (newest first) |
|
||||
| `GET` | `/task-logs/:filename` | Get full log with messages |
|
||||
|
||||
Route file: `src/servers/api/task-logs/task-logs.ts`
|
||||
|
||||
## Storage Model
|
||||
|
||||
Everything is stored on the filesystem. No database tables.
|
||||
|
||||
```
|
||||
DATA_PATH/
|
||||
skills/ <- global scope
|
||||
{skill-name}/
|
||||
SKILL.md <- frontmatter + markdown body
|
||||
chat/
|
||||
meta.json <- { id: sessionId }
|
||||
messages.json <- ChatMessage[]
|
||||
tasks/ <- global scope
|
||||
{task-name}/TASK.md
|
||||
tools/ <- global scope
|
||||
{tool-name}/TOOL.md
|
||||
processes/ <- global scope
|
||||
{process-name}/PROCESS.md
|
||||
{user-email}/ <- user scope
|
||||
skills/
|
||||
tasks/
|
||||
tools/
|
||||
processes/
|
||||
logs/tasks/ <- task execution logs
|
||||
{timestamp}-{dirName}.json
|
||||
|
||||
SEED_PATH/ <- native scope (read-only, shipped with app)
|
||||
skills/ (sharp, whisper-cpp, google-mail-api, ffmpeg, mutagen, fizzy-cli, mlxaudio)
|
||||
tasks/ (convert-to-mp3, sync-gmail-inbox, tiktok-trends, transcribe-audio-file, ...)
|
||||
tools/ (apify, browser, email-db, ffmpeg, gmail, ocr, web-fetch, web-search, ...)
|
||||
```
|
||||
|
||||
Path helpers: `src/servers/data-path.ts` (`getNativeSkillsDir`, `getGlobalSkillsDir`, `getUserSkillsDir`, etc.)
|
||||
|
||||
### Scope & Permissions
|
||||
|
||||
Three tiers with override resolution: **user > global > native**.
|
||||
|
||||
- `native`: seed directory, read-only, shipped with the app
|
||||
- `global`: shared data directory, writable by Super Admin
|
||||
- `user`: per-user directory, writable by the owning user
|
||||
|
||||
Regular users (`Member`) can only create/delete `user`-scoped items. `Super Admin` can also create/delete `global` items.
|
||||
|
||||
## Markdown File Formats
|
||||
|
||||
### TASK.md
|
||||
```yaml
|
||||
---
|
||||
name: Task Name
|
||||
description: What the task does.
|
||||
version: 1
|
||||
author: pastilhas
|
||||
tags: [tag1, tag2]
|
||||
skills: [skill-name] # optional, skills the agent can consult
|
||||
tools: [tool_name] # optional, tools the agent can call
|
||||
trigger: # optional, when omitted: only runnable from Automation page
|
||||
- type: file
|
||||
extensions: [mp3, flac]
|
||||
- type: directory
|
||||
inputs: # optional, parameters the user fills in before running
|
||||
- name: param_name
|
||||
type: string # string | number | boolean | select
|
||||
required: true
|
||||
default: some value
|
||||
---
|
||||
|
||||
(Agent instructions in markdown)
|
||||
```
|
||||
|
||||
### SKILL.md
|
||||
```yaml
|
||||
---
|
||||
name: skill-name
|
||||
description: When to use this skill.
|
||||
---
|
||||
|
||||
(Knowledge base / reference documentation in markdown)
|
||||
```
|
||||
|
||||
### TOOL.md
|
||||
```yaml
|
||||
---
|
||||
name: tool_name
|
||||
label: Tool Display Name
|
||||
description: What the tool does.
|
||||
language: typescript # typescript | bash | python
|
||||
inputs:
|
||||
param_name:
|
||||
type: string # string | number | boolean | enum | object
|
||||
description: What this parameter is for.
|
||||
optional: true
|
||||
sensitive: true
|
||||
---
|
||||
|
||||
(Usage notes, output format, examples in markdown)
|
||||
```
|
||||
|
||||
### PROCESS.md
|
||||
Same structure as SKILL.md (name + description frontmatter, markdown body).
|
||||
|
||||
## Task Execution Flow
|
||||
|
||||
1. User clicks "Run" on a task in `CapabilityDetailView`
|
||||
2. `parseInputs()` extracts `inputs:` from YAML frontmatter
|
||||
3. If inputs exist: shows input form dialog; if none: runs immediately
|
||||
4. Opens `TaskRunnerModal` with prompt: `"Read the task instructions at {filePath} and execute them"` (+ input values if any)
|
||||
5. Modal connects to Pi agent WebSocket via `usePiChat()`
|
||||
6. Agent reads the `TASK.md`, resolves skill/tool references, executes in the user's sandboxed container
|
||||
7. `task-logger.ts` (`createTaskLog` -> `appendToLog` -> `finalizeLog`) writes execution log to `DATA_PATH/{email}/logs/tasks/`
|
||||
|
||||
## Task Trigger Integration
|
||||
|
||||
Tasks with `trigger:` in their frontmatter appear in the file browser context menu:
|
||||
- `type: file` + `extensions: [mp3]` -> right-click on `.mp3` files shows this task
|
||||
- `type: directory` -> right-click on directories shows this task
|
||||
|
||||
Implemented via `useTasks` hook in the `officerdev` workspace, which provides `getMatchingTasks(fileName, entryType)`.
|
||||
|
||||
## Seed Sync (Startup)
|
||||
|
||||
On server startup (`bootstrap.ts`):
|
||||
- `syncSeedSkills()` and `syncSeedTools()` copy/update native seeds to global based on `version:` in frontmatter
|
||||
- `syncSeedTasks()` exists in code but is **not called** in bootstrap
|
||||
|
||||
## Legacy Routes
|
||||
|
||||
Standalone pages still exist using the older `CapabilityPage` component:
|
||||
- `/skills` -> `CapabilityPage` (two-panel, list + detail/chat)
|
||||
- `/tasks` -> `CapabilityPage`
|
||||
- `/processes` -> `CapabilityPage`
|
||||
|
||||
These share the same API endpoints but use the single-component layout instead of the workspace view.
|
||||
- `TaskRunnerModal` in `officerdev` workspace handles execution for all modes
|
||||
- `CapabilityPage.tsx` — legacy standalone pages for `/skills`, `/tasks`, `/processes`
|
||||
- File browser context menu triggers via `useTasks` hook
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { toast } from 'sonner';
|
||||
import { ArrowLeft, Pencil, Play, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { Card } from '@/components/Card';
|
||||
import { FrontmatterBlock } from '../CapabilityPage';
|
||||
import type { CapabilityDetail } from '../CapabilityPage';
|
||||
import type { AutomationSelection } from './AutomationRightPanel';
|
||||
import { TaskRunnerModal } from 'officerdev';
|
||||
|
||||
type SelectOption = { value: string; label: string };
|
||||
|
||||
type TaskInput = {
|
||||
name: string;
|
||||
description: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'select';
|
||||
required: boolean;
|
||||
default: string;
|
||||
options: SelectOption[];
|
||||
};
|
||||
|
||||
function parseInputs(rawYaml: string): TaskInput[] {
|
||||
// Match the inputs block: everything indented after "inputs:" until next top-level key or end
|
||||
const inputsMatch = rawYaml.match(/^inputs:\s*\n((?:[ \t]+.*\n?)*)/m);
|
||||
if (!inputsMatch) return [];
|
||||
const block = inputsMatch[1]!;
|
||||
// Split on list items that have a "name:" field (e.g. " - name: country")
|
||||
const items = block.split(/(?=[ \t]+-\s*name\s*:)/);
|
||||
return items
|
||||
.filter((item) => /name\s*:/.test(item))
|
||||
.map((item) => {
|
||||
const name = item.match(/name\s*:\s*(.+)/)?.[1]?.trim() ?? '';
|
||||
const description = item.match(/description\s*:\s*(.+)/)?.[1]?.trim() ?? '';
|
||||
const rawType = item.match(/type\s*:\s*(.+)/)?.[1]?.trim() ?? 'string';
|
||||
const type = (['string', 'number', 'boolean', 'select'] as const).includes(rawType as 'string')
|
||||
? (rawType as TaskInput['type'])
|
||||
: 'string';
|
||||
const required = item.match(/required\s*:\s*(.+)/)?.[1]?.trim() === 'true';
|
||||
const defaultFromField = item.match(/default\s*:\s*(.+)/)?.[1]?.trim();
|
||||
const defaultFromDesc = description.match(/[Dd]efaults?\s+to\s+(\S+?)\.?\s*$/)?.[1];
|
||||
|
||||
const options: SelectOption[] = [];
|
||||
const optionsMatch = item.match(/options\s*:\s*\n((?:[ \t]+.*\n?)*)/);
|
||||
if (optionsMatch) {
|
||||
const optionEntries = optionsMatch[1]!.split(/(?=[ \t]*- \s*value\s*:)/);
|
||||
for (const entry of optionEntries) {
|
||||
const value = entry.match(/value\s*:\s*(.+)/)?.[1]?.trim() ?? '';
|
||||
const label = entry.match(/label\s*:\s*(.+)/)?.[1]?.trim() ?? value;
|
||||
if (value) options.push({ value, label });
|
||||
}
|
||||
}
|
||||
|
||||
return { name, description, type, required, default: defaultFromField ?? defaultFromDesc ?? '', options };
|
||||
});
|
||||
}
|
||||
|
||||
type CapabilityDetailViewProps = {
|
||||
kind: string;
|
||||
endpoint: string;
|
||||
queryKey: string;
|
||||
dirName: string;
|
||||
isNew?: boolean;
|
||||
editing?: boolean;
|
||||
};
|
||||
|
||||
export const CapabilityDetailView = ({ kind, endpoint, queryKey, dirName, editing }: CapabilityDetailViewProps) => {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const [selection, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
const [showInputForm, setShowInputForm] = useState(false);
|
||||
const [runPrompt, setRunPrompt] = useState<string | null>(null);
|
||||
const [inputValues, setInputValues] = useState<Record<string, string>>({});
|
||||
|
||||
const { data: detail } = useQuery<CapabilityDetail>({
|
||||
queryKey: [queryKey, dirName],
|
||||
queryFn: () => client.get<CapabilityDetail>(`${endpoint}/${dirName}`),
|
||||
});
|
||||
|
||||
const taskInputs = useMemo(() => (detail?.rawFrontmatter ? parseInputs(detail.rawFrontmatter) : []), [detail?.rawFrontmatter]);
|
||||
|
||||
const handleRunClick = () => {
|
||||
if (taskInputs.length > 0) {
|
||||
const defaults: Record<string, string> = {};
|
||||
for (const input of taskInputs) {
|
||||
if (input.type === 'boolean') {
|
||||
defaults[input.name] = input.default === 'true' ? 'true' : 'false';
|
||||
} else if (input.type === 'select') {
|
||||
defaults[input.name] = input.default || (input.options[0]?.value ?? '');
|
||||
} else {
|
||||
defaults[input.name] = input.default;
|
||||
}
|
||||
}
|
||||
setInputValues(defaults);
|
||||
setShowInputForm(true);
|
||||
} else {
|
||||
setRunPrompt(`Read the task instructions at ${detail!.filePath} and execute them`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputSubmit = () => {
|
||||
const parts = Object.entries(inputValues)
|
||||
.filter(([, v]) => v !== '')
|
||||
.map(([k, v]) => `- ${k}: ${v}`);
|
||||
const suffix = parts.length > 0 ? `\n\nInputs:\n${parts.join('\n')}` : '';
|
||||
setRunPrompt(`Read the task instructions at ${detail!.filePath} and execute them${suffix}`);
|
||||
setShowInputForm(false);
|
||||
};
|
||||
|
||||
const toggleEditing = () => {
|
||||
if (!selection) return;
|
||||
setSelection({ ...selection, editing: !editing });
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await client.delete(`${endpoint}/${dirName}`);
|
||||
setDeleteConfirm(false);
|
||||
await qc.invalidateQueries({ queryKey: [queryKey] });
|
||||
setSelection(null);
|
||||
} catch {
|
||||
toast.error(`Failed to delete ${kind}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="h-full overflow-hidden flex flex-col min-h-0">
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||
<button onClick={() => setSelection({ kind, endpoint, queryKey, dirName: '' })} className="p-1 -ml-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer">
|
||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60" />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">{detail?.name ?? `Loading...`}</span>
|
||||
{detail && (
|
||||
<>
|
||||
{kind === 'Task' && (
|
||||
<button
|
||||
onClick={handleRunClick}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||
title="Run task"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 text-duck-teal" />
|
||||
</button>
|
||||
)}
|
||||
{(detail.scope === 'user' || user?.role === 'Super Admin') && (
|
||||
<>
|
||||
<button
|
||||
onClick={toggleEditing}
|
||||
className={`p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors ${editing ? 'bg-duck-teal/10' : ''}`}
|
||||
>
|
||||
<Pencil className={`h-3.5 w-3.5 ${editing ? 'text-duck-teal' : 'text-duck-dark/50 dark:text-foreground/50'}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(true)}
|
||||
className="p-1 rounded hover:bg-red-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50 hover:text-red-500" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 p-6">
|
||||
{detail?.rawFrontmatter && <FrontmatterBlock yaml={detail.rawFrontmatter} />}
|
||||
{detail?.body ? (
|
||||
<article className="skill-md">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{detail.body}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
) : detail ? (
|
||||
<p className="text-sm text-duck-dark/40 dark:text-foreground/40 text-center mt-12">Empty file</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Dialog open={deleteConfirm} onOpenChange={setDeleteConfirm}>
|
||||
<DialogContent className="sm:max-w-md z-[700]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete {kind}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{detail?.name}"? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} className="cursor-pointer">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showInputForm} onOpenChange={setShowInputForm}>
|
||||
<DialogContent className="sm:max-w-lg z-[700]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Run {detail?.name}</DialogTitle>
|
||||
<DialogDescription>Configure inputs before running.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
handleInputSubmit();
|
||||
}}
|
||||
className="flex flex-col gap-3 mt-1"
|
||||
>
|
||||
{taskInputs.map((input) => (
|
||||
<div key={input.name} className="flex flex-col gap-1">
|
||||
{input.type === 'boolean' ? (
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-foreground/70 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inputValues[input.name] === 'true'}
|
||||
onChange={(ev) => setInputValues((prev) => ({ ...prev, [input.name]: ev.target.checked ? 'true' : 'false' }))}
|
||||
className="h-4 w-4 rounded border-foreground/20 accent-duck-teal"
|
||||
/>
|
||||
{input.name}
|
||||
{input.required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<label className="text-sm font-medium text-foreground/70">
|
||||
{input.name}
|
||||
{input.required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
{input.type === 'select' ? (
|
||||
<select
|
||||
value={inputValues[input.name] ?? ''}
|
||||
onChange={(ev) => setInputValues((prev) => ({ ...prev, [input.name]: ev.target.value }))}
|
||||
required={input.required}
|
||||
className="rounded border border-foreground/20 bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
>
|
||||
{input.options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={input.type === 'number' ? 'number' : 'text'}
|
||||
value={inputValues[input.name] ?? ''}
|
||||
onChange={(ev) => setInputValues((prev) => ({ ...prev, [input.name]: ev.target.value }))}
|
||||
placeholder={input.default || undefined}
|
||||
required={input.required}
|
||||
className="rounded border border-foreground/20 bg-background px-3 py-2 text-sm text-foreground placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{input.description && (
|
||||
<span className="text-xs text-foreground/40">{input.description}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setShowInputForm(false)} className="cursor-pointer">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer">
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{runPrompt !== null && detail && (
|
||||
<TaskRunnerModal
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRunPrompt(null);
|
||||
}}
|
||||
task={{ dirName, name: detail.name, description: detail.description ?? '', scope: detail.scope === 'user' ? 'user' : 'global', triggers: [], filePath: detail.filePath }}
|
||||
promptOverride={runPrompt}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import type { AutomationSelection } from './AutomationRightPanel';
|
||||
|
||||
type CapabilitySummary = {
|
||||
dirName: string;
|
||||
name: string;
|
||||
description: string;
|
||||
scope: string;
|
||||
};
|
||||
|
||||
type CapabilityListProps = {
|
||||
kind: string;
|
||||
endpoint: string;
|
||||
queryKey: string;
|
||||
};
|
||||
|
||||
export const CapabilityList = ({ kind, endpoint, queryKey }: CapabilityListProps) => {
|
||||
const client = useClient();
|
||||
const [selection, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const { data: items = [] } = useQuery<CapabilitySummary[]>({
|
||||
queryKey: [queryKey],
|
||||
queryFn: () => client.get<CapabilitySummary[]>(endpoint),
|
||||
});
|
||||
|
||||
const q = search.toLowerCase();
|
||||
const filtered = search
|
||||
? items.filter((c) => c.name.toLowerCase().includes(q) || c.description?.toLowerCase().includes(q))
|
||||
: items;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">{kind}s</span>
|
||||
<button
|
||||
onClick={() => setSelection({ kind, endpoint, queryKey, dirName: '', creating: true })}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 pt-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30 dark:text-foreground/30" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full rounded border border-duck-dark/15 dark:border-foreground/15 bg-background/80 pl-7 pr-2 py-1.5 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 pt-2 pb-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{filtered.map((c) => (
|
||||
<button
|
||||
key={c.dirName}
|
||||
onClick={() => setSelection({ kind, endpoint, queryKey, dirName: c.dirName })}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm cursor-pointer transition-colors ${
|
||||
selection?.queryKey === queryKey && selection.dirName === c.dirName
|
||||
? 'bg-duck-teal/10 text-duck-dark dark:text-foreground'
|
||||
: 'text-duck-dark/60 dark:text-foreground/60 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{c.name}</span>
|
||||
{c.description && (
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40 mt-0.5 line-clamp-1">{c.description}</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40 px-3 py-4 text-center">
|
||||
{items.length === 0 ? `No ${kind.toLowerCase()}s yet` : 'No matches'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import type { AutomationSelection } from './AutomationRightPanel';
|
||||
|
||||
type NewCronProps = {
|
||||
selection: NonNullable<AutomationSelection>;
|
||||
};
|
||||
|
||||
export const NewCron = ({ selection }: NewCronProps) => {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const [, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await client.post<{ name: string; dirName: string }>(selection.endpoint, { name: trimmed });
|
||||
await qc.invalidateQueries({ queryKey: [selection.queryKey] });
|
||||
setSelection({
|
||||
kind: selection.kind,
|
||||
endpoint: selection.endpoint,
|
||||
queryKey: selection.queryKey,
|
||||
dirName: res.dirName,
|
||||
isNew: true,
|
||||
editing: true,
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to create cron');
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Cron</span>
|
||||
<button
|
||||
onClick={() => setSelection(null)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
placeholder="Cron name..."
|
||||
autoFocus
|
||||
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
placeholder="Describe what this cron job schedules..."
|
||||
rows={4}
|
||||
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!name.trim() || submitting}
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import type { AutomationSelection } from './AutomationRightPanel';
|
||||
|
||||
type NewPipelineProps = {
|
||||
selection: NonNullable<AutomationSelection>;
|
||||
};
|
||||
|
||||
export const NewPipeline = ({ selection }: NewPipelineProps) => {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const [, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await client.post<{ name: string; dirName: string }>(selection.endpoint, { name: trimmed });
|
||||
await qc.invalidateQueries({ queryKey: [selection.queryKey] });
|
||||
setSelection({
|
||||
kind: selection.kind,
|
||||
endpoint: selection.endpoint,
|
||||
queryKey: selection.queryKey,
|
||||
dirName: res.dirName,
|
||||
isNew: true,
|
||||
editing: true,
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to create pipeline');
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Pipeline</span>
|
||||
<button
|
||||
onClick={() => setSelection(null)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
placeholder="Pipeline name..."
|
||||
autoFocus
|
||||
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
placeholder="Describe what this pipeline orchestrates..."
|
||||
rows={4}
|
||||
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!name.trim() || submitting}
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import type { AutomationSelection } from './AutomationRightPanel';
|
||||
|
||||
type NewProcessProps = {
|
||||
selection: NonNullable<AutomationSelection>;
|
||||
};
|
||||
|
||||
export const NewProcess = ({ selection }: NewProcessProps) => {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const [, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await client.post<{ name: string; dirName: string }>(selection.endpoint, { name: trimmed });
|
||||
await qc.invalidateQueries({ queryKey: [selection.queryKey] });
|
||||
setSelection({
|
||||
kind: selection.kind,
|
||||
endpoint: selection.endpoint,
|
||||
queryKey: selection.queryKey,
|
||||
dirName: res.dirName,
|
||||
isNew: true,
|
||||
editing: true,
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to create process');
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Process</span>
|
||||
<button
|
||||
onClick={() => setSelection(null)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
placeholder="Process name..."
|
||||
autoFocus
|
||||
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
placeholder="Describe what this process manages..."
|
||||
rows={4}
|
||||
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!name.trim() || submitting}
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import type { AutomationSelection } from './AutomationRightPanel';
|
||||
|
||||
type NewServiceProps = {
|
||||
selection: NonNullable<AutomationSelection>;
|
||||
};
|
||||
|
||||
export const NewService = ({ selection }: NewServiceProps) => {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const [, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await client.post<{ name: string; dirName: string }>(selection.endpoint, { name: trimmed });
|
||||
await qc.invalidateQueries({ queryKey: [selection.queryKey] });
|
||||
setSelection({
|
||||
kind: selection.kind,
|
||||
endpoint: selection.endpoint,
|
||||
queryKey: selection.queryKey,
|
||||
dirName: res.dirName,
|
||||
isNew: true,
|
||||
editing: true,
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to create service');
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Service</span>
|
||||
<button
|
||||
onClick={() => setSelection(null)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
placeholder="Service name..."
|
||||
autoFocus
|
||||
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
placeholder="Describe what this service provides..."
|
||||
rows={4}
|
||||
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!name.trim() || submitting}
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import type { AutomationSelection } from './AutomationRightPanel';
|
||||
|
||||
type NewSkillProps = {
|
||||
selection: NonNullable<AutomationSelection>;
|
||||
};
|
||||
|
||||
export const NewSkill = ({ selection }: NewSkillProps) => {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const [, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await client.post<{ name: string; dirName: string }>(selection.endpoint, { name: trimmed });
|
||||
await qc.invalidateQueries({ queryKey: [selection.queryKey] });
|
||||
setSelection({
|
||||
kind: selection.kind,
|
||||
endpoint: selection.endpoint,
|
||||
queryKey: selection.queryKey,
|
||||
dirName: res.dirName,
|
||||
isNew: true,
|
||||
editing: true,
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to create skill');
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Skill</span>
|
||||
<button
|
||||
onClick={() => setSelection(null)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
placeholder="Skill name..."
|
||||
autoFocus
|
||||
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
placeholder="Describe what this skill does..."
|
||||
rows={4}
|
||||
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!name.trim() || submitting}
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import type { AutomationSelection } from './AutomationRightPanel';
|
||||
|
||||
type NewTaskProps = {
|
||||
selection: NonNullable<AutomationSelection>;
|
||||
};
|
||||
|
||||
export const NewTask = ({ selection }: NewTaskProps) => {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const [, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await client.post<{ name: string; dirName: string }>(selection.endpoint, { name: trimmed });
|
||||
await qc.invalidateQueries({ queryKey: [selection.queryKey] });
|
||||
setSelection({
|
||||
kind: selection.kind,
|
||||
endpoint: selection.endpoint,
|
||||
queryKey: selection.queryKey,
|
||||
dirName: res.dirName,
|
||||
isNew: true,
|
||||
editing: true,
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to create task');
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Task</span>
|
||||
<button
|
||||
onClick={() => setSelection(null)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
placeholder="Task name..."
|
||||
autoFocus
|
||||
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
placeholder="Describe what this task automates..."
|
||||
rows={4}
|
||||
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!name.trim() || submitting}
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import type { AutomationSelection } from './AutomationRightPanel';
|
||||
|
||||
type NewToolProps = {
|
||||
selection: NonNullable<AutomationSelection>;
|
||||
};
|
||||
|
||||
export const NewTool = ({ selection }: NewToolProps) => {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const [, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await client.post<{ name: string; dirName: string }>(selection.endpoint, { name: trimmed });
|
||||
await qc.invalidateQueries({ queryKey: [selection.queryKey] });
|
||||
setSelection({
|
||||
kind: selection.kind,
|
||||
endpoint: selection.endpoint,
|
||||
queryKey: selection.queryKey,
|
||||
dirName: res.dirName,
|
||||
isNew: true,
|
||||
editing: true,
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to create tool');
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Tool</span>
|
||||
<button
|
||||
onClick={() => setSelection(null)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
placeholder="Tool name..."
|
||||
autoFocus
|
||||
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
placeholder="Describe what this tool does..."
|
||||
rows={4}
|
||||
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!name.trim() || submitting}
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import type { AutomationSelection } from './AutomationRightPanel';
|
||||
|
||||
type NewWorkflowProps = {
|
||||
selection: NonNullable<AutomationSelection>;
|
||||
};
|
||||
|
||||
export const NewWorkflow = ({ selection }: NewWorkflowProps) => {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const [, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await client.post<{ name: string; dirName: string }>(selection.endpoint, { name: trimmed });
|
||||
await qc.invalidateQueries({ queryKey: [selection.queryKey] });
|
||||
setSelection({
|
||||
kind: selection.kind,
|
||||
endpoint: selection.endpoint,
|
||||
queryKey: selection.queryKey,
|
||||
dirName: res.dirName,
|
||||
isNew: true,
|
||||
editing: true,
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to create workflow');
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Workflow</span>
|
||||
<button
|
||||
onClick={() => setSelection(null)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
placeholder="Workflow name..."
|
||||
autoFocus
|
||||
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
placeholder="Describe what this workflow automates..."
|
||||
rows={4}
|
||||
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!name.trim() || submitting}
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,100 +1,41 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useIsMobile } from 'hooks/useIsMobile';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useIsMobile } from 'hooks/useIsMobile';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import type { TaskSummary } from 'officerdev';
|
||||
|
||||
import { AutomationRightPanel } from './AutomationRightPanel';
|
||||
import type { AutomationSelection } from './AutomationRightPanel';
|
||||
import { AutomationEditChat } from './AutomationEditChat';
|
||||
import { AutomationSidebar } from './AutomationSidebar';
|
||||
import { AutomationList } from './AutomationList';
|
||||
import { AutomationDetail } from './AutomationDetail';
|
||||
|
||||
const CHAT_PANEL_ID = 'automation-chat';
|
||||
export type { TaskSummary as SelectedTask };
|
||||
|
||||
const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'automation-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'automation-left', appType: null }, size: 20 },
|
||||
{ node: { type: 'panel', id: 'automation-right', appType: null }, size: 80 },
|
||||
{ node: { type: 'panel', id: 'automation-list', appType: null }, size: 30 },
|
||||
{ node: { type: 'panel', id: 'automation-detail', appType: null }, size: 70 },
|
||||
],
|
||||
};
|
||||
|
||||
const hasChatPanel = (layout: LayoutNode): boolean => {
|
||||
if (layout.type === 'panel') return layout.id === CHAT_PANEL_ID;
|
||||
return layout.children.some((c) => hasChatPanel(c.node));
|
||||
};
|
||||
|
||||
const addChatPanel = (layout: LayoutNode): LayoutNode => {
|
||||
if (layout.type !== 'group') return layout;
|
||||
const clone = structuredClone(layout);
|
||||
const rightChild = clone.children[1];
|
||||
if (!rightChild) return clone;
|
||||
|
||||
// Wrap the right panel in a vertical group with the chat panel
|
||||
const rightSize = rightChild.size;
|
||||
rightChild.size = rightSize;
|
||||
clone.children[1] = {
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'automation-right-group',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: rightChild.node, size: 50 },
|
||||
{ node: { type: 'panel', id: CHAT_PANEL_ID, appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: rightSize,
|
||||
};
|
||||
return clone;
|
||||
};
|
||||
|
||||
const removeChatPanel = (layout: LayoutNode): LayoutNode => {
|
||||
if (layout.type !== 'group') return layout;
|
||||
const clone = structuredClone(layout);
|
||||
const rightChild = clone.children[1];
|
||||
if (!rightChild || rightChild.node.type !== 'group') return clone;
|
||||
|
||||
// Unwrap: pull the right panel out of the vertical group
|
||||
const rightGroup = rightChild.node;
|
||||
const mainPanel = rightGroup.children.find((c) => c.node.type === 'panel' && (c.node as { id: string }).id !== CHAT_PANEL_ID);
|
||||
if (mainPanel) {
|
||||
clone.children[1] = { node: mainPanel.node, size: rightChild.size };
|
||||
}
|
||||
return clone;
|
||||
};
|
||||
|
||||
export const Automation = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/automation', defaultLayout);
|
||||
const [selection, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||
const [selected] = usePanelChannel<TaskSummary | null>('automation:selected-task', null);
|
||||
const isMobile = useIsMobile();
|
||||
const editing = selection?.editing ?? false;
|
||||
const prevEditing = useRef(editing);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing === prevEditing.current) return;
|
||||
prevEditing.current = editing;
|
||||
|
||||
if (editing && !hasChatPanel(workspace.value)) {
|
||||
workspace.setValue(addChatPanel(workspace.value));
|
||||
} else if (!editing && hasChatPanel(workspace.value)) {
|
||||
workspace.setValue(removeChatPanel(workspace.value));
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
const panelComponents: PanelComponents = useMemo(
|
||||
() => ({
|
||||
'automation-left': AutomationSidebar,
|
||||
'automation-right': AutomationRightPanel,
|
||||
[CHAT_PANEL_ID]: AutomationEditChat,
|
||||
'automation-list': AutomationList,
|
||||
'automation-detail': AutomationDetail,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const mobilePanelId = isMobile && selection ? 'automation-right' : undefined;
|
||||
const onMobileBack = useCallback(() => setSelection(null), [setSelection]);
|
||||
const mobilePanelId = isMobile && selected ? 'automation-detail' : undefined;
|
||||
const onMobileBack = useCallback(() => {}, []);
|
||||
|
||||
if (!workspace.isLoaded) return null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user