Workspaces layout, automation page
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { 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 { 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 });
|
||||
};
|
||||
|
||||
if (!selection || !detail?.filePath) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="text-sm text-duck-dark/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 bg-white/60 flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-duck-dark/50 flex-1">{detail.name ?? 'Chat'}</span>
|
||||
<button onClick={closeChat} className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors">
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
||||
</button>
|
||||
</div>
|
||||
<CapabilityChat
|
||||
kind={selection.kind.toLowerCase()}
|
||||
key={detail.filePath}
|
||||
endpoint={selection.endpoint}
|
||||
dirName={selection.dirName}
|
||||
filePath={detail.filePath}
|
||||
resourceDir={detail.filePath.replace(/\/[^/]+$/, '')}
|
||||
chatSessionId={detail.chatSessionId}
|
||||
isNew={selection.isNew}
|
||||
description={selection.description}
|
||||
onResponseEnd={() => qc.invalidateQueries({ queryKey: [selection.queryKey, selection.dirName] })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
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';
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
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} />;
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Box, Clock, Cpu, GitBranch, ListTodo, Server, Sparkles, Workflow } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import type { AutomationSelection } from './AutomationRightPanel';
|
||||
|
||||
const capabilityItems = [
|
||||
{ id: 'processes', label: 'Processes', icon: Cpu, kind: 'Process', endpoint: '/processes', queryKey: 'processes' },
|
||||
{ id: 'tasks', label: 'Tasks', icon: ListTodo, kind: 'Task', endpoint: '/tasks', queryKey: 'tasks' },
|
||||
{ id: 'skills', label: 'Skills', icon: Sparkles, kind: 'Skill', endpoint: '/skills', queryKey: 'skills' },
|
||||
{ id: 'pipelines', label: 'Pipelines', icon: Workflow, kind: 'Pipeline', endpoint: '/pipelines', queryKey: 'pipelines' },
|
||||
{ id: 'crons', label: 'Crons', icon: Clock, kind: 'Cron', endpoint: '/crons', queryKey: 'crons' },
|
||||
{ id: 'services', label: 'Services', icon: Server, kind: 'Service', endpoint: '/services', queryKey: 'services' },
|
||||
{ id: 'workflows', label: 'Workflows', icon: GitBranch, kind: 'Workflow', endpoint: '/workflows', queryKey: 'workflows' },
|
||||
] 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } 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, 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 { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { Card } from '@/components/Card';
|
||||
import { FrontmatterBlock } from '../CapabilityPage';
|
||||
import type { CapabilityDetail } from '../CapabilityPage';
|
||||
import type { AutomationSelection } from './AutomationRightPanel';
|
||||
|
||||
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 [selection, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
|
||||
const { data: detail } = useQuery<CapabilityDetail>({
|
||||
queryKey: [queryKey, dirName],
|
||||
queryFn: () => client.get<CapabilityDetail>(`${endpoint}/${dirName}`),
|
||||
});
|
||||
|
||||
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 bg-white/60 flex items-center gap-2">
|
||||
<button onClick={() => setSelection({ kind, endpoint, queryKey, dirName: '' })} className="p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer">
|
||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">{detail?.name ?? `Loading...`}</span>
|
||||
{detail && (
|
||||
<>
|
||||
<button
|
||||
onClick={toggleEditing}
|
||||
className={`p-1 rounded hover:bg-duck-dark/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'}`} />
|
||||
</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 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 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
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 bg-white/60 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">{kind}s</span>
|
||||
<button
|
||||
onClick={() => setSelection({ kind, endpoint, queryKey, dirName: '', creating: true })}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-duck-dark/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" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full rounded border border-duck-dark/15 bg-white/80 pl-7 pr-2 py-1.5 text-sm text-duck-dark placeholder:text-duck-dark/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'
|
||||
: 'text-duck-dark/60 hover:bg-duck-dark/5 hover:text-duck-dark'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{c.name}</span>
|
||||
{c.description && (
|
||||
<p className="text-xs text-duck-dark/40 mt-0.5 line-clamp-1">{c.description}</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-xs text-duck-dark/40 px-3 py-4 text-center">
|
||||
{items.length === 0 ? `No ${kind.toLowerCase()}s yet` : 'No matches'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
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 bg-white/60 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Cron</span>
|
||||
<button
|
||||
onClick={() => setSelection(null)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/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">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 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/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">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 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
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 bg-white/60 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Pipeline</span>
|
||||
<button
|
||||
onClick={() => setSelection(null)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/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">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 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/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">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 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
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 bg-white/60 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Process</span>
|
||||
<button
|
||||
onClick={() => setSelection(null)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/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">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 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/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">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 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
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 bg-white/60 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Service</span>
|
||||
<button
|
||||
onClick={() => setSelection(null)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/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">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 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/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">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 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
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 bg-white/60 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Skill</span>
|
||||
<button
|
||||
onClick={() => setSelection(null)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/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">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 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/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">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 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
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 bg-white/60 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Task</span>
|
||||
<button
|
||||
onClick={() => setSelection(null)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/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">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 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/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">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 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
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 bg-white/60 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Workflow</span>
|
||||
<button
|
||||
onClick={() => setSelection(null)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/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">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 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/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">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 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
||||
import { WorkspaceLayout } from '@/components/Workspace';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import { appRegistry } from '../Workspaces/app-registry';
|
||||
import { AutomationRightPanel } from './AutomationRightPanel';
|
||||
import type { AutomationSelection } from './AutomationRightPanel';
|
||||
import { AutomationEditChat } from './AutomationEditChat';
|
||||
import { AutomationSidebar } from './AutomationSidebar';
|
||||
|
||||
const CHAT_PANEL_ID = 'automation-chat';
|
||||
|
||||
const baseLayout: 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 },
|
||||
],
|
||||
};
|
||||
|
||||
const splitLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'automation-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'automation-left', appType: null }, size: 20 },
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'automation-right-group',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'automation-right', appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: CHAT_PANEL_ID, appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 80,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const Automation = () => {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { isFetched } = useQuery({
|
||||
queryKey: ['USER_STATE'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get('/user/state'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
const [savedSizes, setSavedSizes] = useUserState<number[] | null>('automation:chat-sizes', null);
|
||||
const [selection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||
const editing = selection?.editing ?? false;
|
||||
const prevEditing = useRef(editing);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
if (!editing) return baseLayout;
|
||||
const l = structuredClone(splitLayout);
|
||||
if (savedSizes && l.type === 'group') {
|
||||
const rightGroup = l.children[1]!.node;
|
||||
if (rightGroup.type === 'group') {
|
||||
rightGroup.children[0]!.size = savedSizes[0] ?? 50;
|
||||
rightGroup.children[1]!.size = savedSizes[1] ?? 50;
|
||||
}
|
||||
}
|
||||
return l;
|
||||
}, [editing, savedSizes]);
|
||||
|
||||
const handleLayoutChange = (newLayout: LayoutNode) => {
|
||||
if (!editing || newLayout.type !== 'group') return;
|
||||
const rightChild = newLayout.children[1]?.node;
|
||||
if (rightChild?.type === 'group') {
|
||||
const sizes = rightChild.children.map((c) => c.size);
|
||||
setSavedSizes(sizes);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
prevEditing.current = editing;
|
||||
}, [editing]);
|
||||
|
||||
const panelComponents: PanelComponents = useMemo(
|
||||
() => ({
|
||||
'automation-left': AutomationSidebar,
|
||||
'automation-right': AutomationRightPanel,
|
||||
[CHAT_PANEL_ID]: AutomationEditChat,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
if (!isFetched) return null;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={handleLayoutChange} registry={appRegistry} components={panelComponents} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user