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:
2026-03-12 07:24:57 +00:00
co-authored by Claude Opus 4.6
parent 24ac7ac796
commit 0f1e1f35f1
17 changed files with 460 additions and 1640 deletions
@@ -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 &quot;{selected.name}&quot;? 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 &quot;{detail?.name}&quot;? 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;