Workspaces layout, automation page
This commit is contained in:
@@ -48,6 +48,7 @@
|
||||
"@uiw/react-textarea-code-editor": "^3.1.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"apps": "workspace:*",
|
||||
"argon2": "^0.44.0",
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
"check-password-strength": "^3.0.0",
|
||||
@@ -144,6 +145,9 @@
|
||||
"drizzle-kit": "^0.31.8",
|
||||
},
|
||||
},
|
||||
"src/workspaces/apps": {
|
||||
"name": "apps",
|
||||
},
|
||||
"src/workspaces/components": {
|
||||
"name": "components",
|
||||
"version": "0.0.1",
|
||||
@@ -1052,6 +1056,8 @@
|
||||
|
||||
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
|
||||
"apps": ["apps@workspace:src/workspaces/apps"],
|
||||
|
||||
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||
|
||||
"argon2": ["argon2@0.44.0", "", { "dependencies": { "@phc/format": "^1.0.0", "cross-env": "^10.0.0", "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" } }, "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig=="],
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"three": "^0.182.0",
|
||||
"types": "workspace:*",
|
||||
"vaul": "^1.1.2",
|
||||
"apps": "workspace:*",
|
||||
"widgets": "workspace:*",
|
||||
"ws": "^8.18.1",
|
||||
"zod": "^4.2.1"
|
||||
|
||||
@@ -40,6 +40,7 @@ export function App() {
|
||||
<Route path="/settings/ai" element={<Dashboard.AISettings />} />
|
||||
<Route path="/settings/server" element={<Dashboard.ServerSettings />} />
|
||||
<Route path="/settings/resources" element={<Dashboard.ResourceSettings />} />
|
||||
<Route path="/automation" element={<Dashboard.Automation />} />
|
||||
<Route path="/chat" element={<Dashboard.SessionList />} />
|
||||
<Route path="/chat/new" element={<Dashboard.NewChat />} />
|
||||
<Route path="/chat/:sessionId" element={<Dashboard.ClaudeChat />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useVisibleClaudeModels } from '@/state/useModels';
|
||||
import { Card } from '@/components/Card';
|
||||
import type { ChatMessage } from 'widgets/Chat';
|
||||
import type { ChatMessage } from 'apps/Chat';
|
||||
import { useClaude } from '@/Screens/Dashboard/Chat/useClaude';
|
||||
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
||||
type CapabilitySummary = {
|
||||
@@ -20,13 +20,25 @@ type CapabilitySummary = {
|
||||
scope: 'global' | 'user';
|
||||
};
|
||||
|
||||
type CapabilityDetail = CapabilitySummary & {
|
||||
export type CapabilityDetail = CapabilitySummary & {
|
||||
body: string;
|
||||
rawFrontmatter: string;
|
||||
filePath: string;
|
||||
chatSessionId: string | null;
|
||||
};
|
||||
|
||||
type CapabilityListProps = {
|
||||
kind: string;
|
||||
endpoint: string;
|
||||
queryKey: string;
|
||||
selected: string | null;
|
||||
onSelect: (dirName: string) => void;
|
||||
onCreate?: (dirName: string) => void;
|
||||
search?: string;
|
||||
showCreate?: boolean;
|
||||
onShowCreateChange?: (value: boolean) => void;
|
||||
};
|
||||
|
||||
type CapabilityPageProps = {
|
||||
kind: string;
|
||||
endpoint: string;
|
||||
@@ -41,10 +53,11 @@ type CapabilityChatProps = {
|
||||
resourceDir: string;
|
||||
chatSessionId: string | null;
|
||||
isNew?: boolean;
|
||||
description?: string;
|
||||
onResponseEnd?: () => void;
|
||||
};
|
||||
|
||||
const CapabilityChat = ({
|
||||
export const CapabilityChat = ({
|
||||
kind,
|
||||
endpoint,
|
||||
dirName,
|
||||
@@ -52,6 +65,7 @@ const CapabilityChat = ({
|
||||
resourceDir,
|
||||
chatSessionId,
|
||||
isNew,
|
||||
description,
|
||||
onResponseEnd,
|
||||
}: CapabilityChatProps) => {
|
||||
const client = useClient();
|
||||
@@ -63,7 +77,7 @@ const CapabilityChat = ({
|
||||
const defaultInput = chatSessionId
|
||||
? undefined
|
||||
: isNew
|
||||
? `Help me create the content for this new ${kind} file`
|
||||
? description ?? `Help me create the content for this new ${kind} file`
|
||||
: `Help me understand and improve this ${kind} file`;
|
||||
|
||||
const storage = useMemo(
|
||||
@@ -110,7 +124,7 @@ const CapabilityChat = ({
|
||||
);
|
||||
};
|
||||
|
||||
const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
|
||||
export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -129,17 +143,15 @@ const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const CapabilityPage = ({ kind, endpoint, queryKey }: CapabilityPageProps) => {
|
||||
export const CapabilityList = ({ kind, endpoint, queryKey, selected, onSelect, onCreate, search: externalSearch, showCreate, onShowCreateChange }: CapabilityListProps) => {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [isNew, setIsNew] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [internalCreating, setInternalCreating] = useState(false);
|
||||
const creating = showCreate ?? internalCreating;
|
||||
const setCreating = onShowCreateChange ?? setInternalCreating;
|
||||
const [newName, setNewName] = useState('');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [internalSearch, setInternalSearch] = useState('');
|
||||
const search = externalSearch ?? internalSearch;
|
||||
const newNameRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const { data: items = [] } = useQuery<CapabilitySummary[]>({
|
||||
@@ -147,25 +159,6 @@ export const CapabilityPage = ({ kind, endpoint, queryKey }: CapabilityPageProps
|
||||
queryFn: () => client.get<CapabilitySummary[]>(endpoint),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length > 0 && !selected) {
|
||||
setSelected(items[0]!.dirName);
|
||||
}
|
||||
}, [items, selected]);
|
||||
|
||||
const { data: detail } = useQuery<CapabilityDetail>({
|
||||
queryKey: [queryKey, selected],
|
||||
queryFn: () => client.get<CapabilityDetail>(`${endpoint}/${selected}`),
|
||||
enabled: !!selected,
|
||||
});
|
||||
|
||||
const selectItem = (dirName: string) => {
|
||||
setSelected(dirName);
|
||||
setShowDetail(true);
|
||||
setIsNew(false);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
@@ -174,29 +167,12 @@ export const CapabilityPage = ({ kind, endpoint, queryKey }: CapabilityPageProps
|
||||
await qc.invalidateQueries({ queryKey: [queryKey] });
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
setSelected(res.dirName);
|
||||
setShowDetail(true);
|
||||
setIsNew(true);
|
||||
setEditing(true);
|
||||
(onCreate ?? onSelect)(res.dirName);
|
||||
} catch {
|
||||
toast.error(`Failed to create ${kind}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
await client.delete(`${endpoint}/${selected}`);
|
||||
setDeleteConfirm(false);
|
||||
setEditing(false);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
await qc.invalidateQueries({ queryKey: [queryKey] });
|
||||
} catch {
|
||||
toast.error(`Failed to delete ${kind}`);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = items.filter(
|
||||
(item) =>
|
||||
!search ||
|
||||
@@ -206,11 +182,6 @@ export const CapabilityPage = ({ kind, endpoint, queryKey }: CapabilityPageProps
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full p-2 md:p-4 gap-2 md:gap-4">
|
||||
{/* Left panel — list */}
|
||||
<Card
|
||||
className={`md:w-72 shrink-0 overflow-hidden flex flex-col ${showDetail ? 'hidden md:flex' : 'flex-1 md:flex-none'}`}
|
||||
>
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-duck-dark/70">{kind}s</span>
|
||||
{!creating && (
|
||||
@@ -262,22 +233,24 @@ export const CapabilityPage = ({ kind, endpoint, queryKey }: CapabilityPageProps
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{externalSearch === undefined && (
|
||||
<div className="shrink-0 px-3 py-2 border-b border-duck-dark/10">
|
||||
<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)}
|
||||
value={internalSearch}
|
||||
onChange={(ev) => setInternalSearch(ev.target.value)}
|
||||
placeholder={`Search ${kind.toLowerCase()}s...`}
|
||||
className="w-full rounded border border-duck-dark/15 bg-white/80 pl-7 pr-2 py-1 text-base md: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="overflow-y-auto flex-1">
|
||||
{filtered.map((item) => (
|
||||
<button
|
||||
key={item.dirName}
|
||||
onClick={() => selectItem(item.dirName)}
|
||||
onClick={() => onSelect(item.dirName)}
|
||||
className={`w-full text-left px-4 py-3 border-b border-duck-dark/5 cursor-pointer transition-colors ${
|
||||
selected === item.dirName ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
|
||||
}`}
|
||||
@@ -302,6 +275,79 @@ export const CapabilityPage = ({ kind, endpoint, queryKey }: CapabilityPageProps
|
||||
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No matches</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const CapabilityPage = ({ kind, endpoint, queryKey }: CapabilityPageProps) => {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [isNew, setIsNew] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
|
||||
const { data: items = [] } = useQuery<CapabilitySummary[]>({
|
||||
queryKey: [queryKey],
|
||||
queryFn: () => client.get<CapabilitySummary[]>(endpoint),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length > 0 && !selected) {
|
||||
setSelected(items[0]!.dirName);
|
||||
}
|
||||
}, [items, selected]);
|
||||
|
||||
const { data: detail } = useQuery<CapabilityDetail>({
|
||||
queryKey: [queryKey, selected],
|
||||
queryFn: () => client.get<CapabilityDetail>(`${endpoint}/${selected}`),
|
||||
enabled: !!selected,
|
||||
});
|
||||
|
||||
const selectItem = (dirName: string) => {
|
||||
setSelected(dirName);
|
||||
setShowDetail(true);
|
||||
setIsNew(false);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleCreate = (dirName: string) => {
|
||||
setSelected(dirName);
|
||||
setShowDetail(true);
|
||||
setIsNew(true);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
await client.delete(`${endpoint}/${selected}`);
|
||||
setDeleteConfirm(false);
|
||||
setEditing(false);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
await qc.invalidateQueries({ queryKey: [queryKey] });
|
||||
} catch {
|
||||
toast.error(`Failed to delete ${kind}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full p-2 md:p-4 gap-2 md:gap-4">
|
||||
{/* Left panel — list */}
|
||||
<Card
|
||||
className={`md:w-72 shrink-0 overflow-hidden flex flex-col ${showDetail ? 'hidden md:flex' : 'flex-1 md:flex-none'}`}
|
||||
>
|
||||
<CapabilityList
|
||||
kind={kind}
|
||||
endpoint={endpoint}
|
||||
queryKey={queryKey}
|
||||
selected={selected}
|
||||
onSelect={selectItem}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Right panel — detail + chat */}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useRef, useEffect, useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import { useSlashCommands } from '@/state/useSlashCommands';
|
||||
import { SessionBar } from 'widgets/ChatHistory';
|
||||
import { SessionBar } from 'apps/ChatHistory';
|
||||
import type { ModelOption } from '@/state/useModels';
|
||||
import type { useClaude } from './useClaude';
|
||||
import { EmbeddableChat, type Attachment } from './EmbeddableChat';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { ModelOption } from '@/state/useModels';
|
||||
import type { useClaude } from './useClaude';
|
||||
import { MessageList } from 'widgets/Chat';
|
||||
import { MessageList } from 'apps/Chat';
|
||||
import { InputArea } from './InputArea';
|
||||
|
||||
export type Attachment =
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { ModelOption } from '@/state/useModels';
|
||||
import type { ChatMessage } from 'widgets/Chat';
|
||||
import type { ChatMessage } from 'apps/Chat';
|
||||
import type { Attachment } from './EmbeddableChat';
|
||||
import { Settings } from './Settings';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import type { ModelOption } from '@/state/useModels';
|
||||
import type { ChatMessage } from 'widgets/Chat';
|
||||
import type { ChatMessage } from 'apps/Chat';
|
||||
import { OpenCodeModelPicker } from './OpenCodeModelPicker';
|
||||
|
||||
type SettingsProps = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { SessionEntry } from 'widgets/Chat';
|
||||
import type { SessionEntry } from 'apps/Chat';
|
||||
import { useClaude } from './useClaude';
|
||||
import { useOpenCode } from './useOpenCode';
|
||||
import { ChatPanel } from './ChatPanel';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import type { ChatMessage, ServerMessage, TaskInfo } from 'widgets/Chat';
|
||||
import type { ChatMessage, ServerMessage, TaskInfo } from 'apps/Chat';
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 1000;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import { useVisibleOpenCodeModels } from '@/state/useModels';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import type { ChatMessage, ServerMessage, TaskInfo } from 'widgets/Chat';
|
||||
import type { ChatMessage, ServerMessage, TaskInfo } from 'apps/Chat';
|
||||
|
||||
const SYSTEM_RE = /^<system>([\s\S]*?)<\/system>\s*/;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link } from 'react-router';
|
||||
import { MessageSquare, Trash2 } from 'lucide-react';
|
||||
import { Widget } from '@/components/Widget';
|
||||
import { Widget } from 'widgets/Widget';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
|
||||
export const ChatHistory = () => {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { ChatHistory as ChatHistoryWidget } from './Widget';
|
||||
export { ChatHistory as ChatHistoryApp } from './Widget';
|
||||
export { SessionList } from './Screen';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CodeEditorView } from 'widgets/CodeEditor';
|
||||
import { Widget } from '@/components/Widget';
|
||||
import { CodeEditorView } from 'apps/CodeEditor';
|
||||
import { Widget } from 'widgets/Widget';
|
||||
|
||||
export const CodeEditor = () => (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useCallback, useMemo, useState, useEffect } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { DirEntry, TaskSummary } from 'widgets/FileBrowser';
|
||||
import type { DirEntry, TaskSummary } from 'apps/FileBrowser';
|
||||
import { FileItem } from './FileItem';
|
||||
|
||||
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu';
|
||||
import { cardStyle } from '@/components/Card';
|
||||
import type { DirEntry, TaskSummary } from 'widgets/FileBrowser';
|
||||
import type { DirEntry, TaskSummary } from 'apps/FileBrowser';
|
||||
|
||||
export type FileItemProps = {
|
||||
entry: DirEntry;
|
||||
|
||||
@@ -26,7 +26,7 @@ import rehypeSlug from 'rehype-slug';
|
||||
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { cardStyle } from '@/components/Card';
|
||||
import { useFiles } from 'widgets/FileBrowser';
|
||||
import { useFiles } from 'apps/FileBrowser';
|
||||
import { getHeaders } from 'hooks/useClient';
|
||||
import { config } from 'config';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -3,13 +3,13 @@ import { X } from 'lucide-react';
|
||||
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { cardStyle } from '@/components/Card';
|
||||
import type { TaskInfo } from 'widgets/Chat';
|
||||
import type { TaskInfo } from 'apps/Chat';
|
||||
import { useClaude } from '@/Screens/Dashboard/Chat/useClaude';
|
||||
import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode';
|
||||
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
||||
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import type { TaskSummary } from 'widgets/FileBrowser';
|
||||
import type { TaskSummary } from 'apps/FileBrowser';
|
||||
|
||||
const playDing = () => {
|
||||
const ctx = new AudioContext();
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||
import { useFiles, type DirEntry, useTasks, type TaskSummary, Breadcrumb, Toolbar } from 'widgets/FileBrowser';
|
||||
import { useFiles, type DirEntry, useTasks, type TaskSummary, Breadcrumb, Toolbar } from 'apps/FileBrowser';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Folder, Pin, PinOff, Search, Clock, FolderOpen, Loader2, X } from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
import { useFiles, type DirEntry, Breadcrumb } from 'widgets/FileBrowser';
|
||||
import { useFiles, type DirEntry, Breadcrumb } from 'apps/FileBrowser';
|
||||
import { useRecentFiles } from './state/useRecentFiles';
|
||||
import { usePinnedFiles } from './state/usePinnedFiles';
|
||||
import { Widget } from '@/components/Widget';
|
||||
import { Widget } from 'widgets/Widget';
|
||||
|
||||
type Tab = 'browse' | 'recent' | 'pinned';
|
||||
|
||||
|
||||
@@ -11,4 +11,4 @@ export const FilesPage = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
export { FileBrowser as FileBrowserWidget } from './Widget';
|
||||
export { FileBrowser as FileBrowserApp } from './Widget';
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Widget } from '@/components/Widget';
|
||||
import { Widget } from 'widgets/Widget';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
||||
@@ -1,52 +1,24 @@
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import { WorkspaceView } from '@/components/Workspace';
|
||||
import { useState } from 'react';
|
||||
import type { LayoutNode } from '@/components/Workspace';
|
||||
import { widgetRegistry } from '@/Screens/Dashboard/Workspaces/widget-registry';
|
||||
import { WorkspaceLayout } from '@/components/Workspace';
|
||||
import { appRegistry } from '../Workspaces/app-registry';
|
||||
|
||||
const DEFAULT_HOME_LAYOUT: LayoutNode = {
|
||||
const initialLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'home-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{
|
||||
size: 50,
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'home-left',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ size: 50, node: { type: 'panel', id: 'home-tl', widgetType: null } },
|
||||
{ size: 50, node: { type: 'panel', id: 'home-bl', widgetType: null } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
size: 50,
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'home-right',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ size: 50, node: { type: 'panel', id: 'home-tr', widgetType: null } },
|
||||
{ size: 50, node: { type: 'panel', id: 'home-br', widgetType: null } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ node: { type: 'panel', id: 'homepage-widget-panel', appType: 'widget-panel' }, size: 60 },
|
||||
{ node: { type: 'panel', id: 'home-bottom', appType: 'chat-launcher' }, size: 40 },
|
||||
],
|
||||
};
|
||||
|
||||
export const HomeScreen = () => {
|
||||
const [layout, setLayout] = useUserState<LayoutNode>('home-workspace-layout', DEFAULT_HOME_LAYOUT);
|
||||
const [layout, setLayout] = useState<LayoutNode>(initialLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView
|
||||
workspace={null}
|
||||
name="Home"
|
||||
layout={layout}
|
||||
onLayoutChange={setLayout}
|
||||
registry={widgetRegistry}
|
||||
/>
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={setLayout} registry={appRegistry} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -47,7 +47,7 @@ export const Dock = ({ items, className }: DockProps) => {
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={`fixed bottom-4 left-1/2 -translate-x-1/2 z-5 items-end gap-2 md:gap-6 px-2 py-1.5 md:px-3 md:py-2 rounded-2xl border backdrop-blur-xl shadow-lg ${className ?? 'flex'}`}
|
||||
style={{ backgroundColor: 'var(--dock-bg)', borderColor: 'var(--dock-border)' }}
|
||||
style={{ backgroundColor: 'transparent', borderColor: 'transparent' }}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const iconCenter = DOCK_PADDING + index * (ICON_SIZE + ICON_GAP) + ICON_SIZE / 2;
|
||||
@@ -91,18 +91,15 @@ export const Dock = ({ items, className }: DockProps) => {
|
||||
};
|
||||
|
||||
|
||||
import { Terminal, TerminalSquare, FileText, FolderOpen, Code, LayoutGrid } from 'lucide-react';
|
||||
import { Sparkles, ClipboardList, ScrollText, Workflow } from 'lucide-react';
|
||||
import { MessageCircle, TerminalSquare, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText } from 'lucide-react';
|
||||
|
||||
export const dockItems: DockItem[] = [
|
||||
{ label: 'Chat', to: '/chat', icon: Terminal, color: '#60a5fa' },
|
||||
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
||||
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
||||
{ label: 'Terminal', to: '/terminal', icon: TerminalSquare, color: '#34d399' },
|
||||
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
||||
{ label: 'Skills', to: '/skills', icon: Sparkles, color: '#c084fc' },
|
||||
{ label: 'Tasks', to: '/tasks', icon: ClipboardList, color: '#fb923c' },
|
||||
{ label: 'Processes', to: '/processes', icon: Workflow, color: '#2dd4bf' },
|
||||
{ label: 'Automation', to: '/automation', icon: Bot, color: '#2dd4bf' },
|
||||
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
||||
{ label: 'Workspaces', to: '/workspaces', icon: LayoutGrid, color: '#8b5cf6' },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { RefreshCw, Download, Circle, Copy, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type AppStatus = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
running: boolean | null;
|
||||
hasInstall: boolean;
|
||||
hasUpdate: boolean;
|
||||
manualInstallCommand: string | null;
|
||||
manualUpdateCommand: string | null;
|
||||
};
|
||||
|
||||
const CopyCommand = ({ command }: { command: string }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<code className="flex-1 bg-duck-dark/5 rounded px-2 py-1 text-xs text-duck-dark/70">{command}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-600" /> : <Copy className="h-3.5 w-3.5 text-duck-dark/50" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Applications = () => {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
|
||||
|
||||
const { data: apps, isLoading } = useQuery({
|
||||
queryKey: ['APPLICATIONS'],
|
||||
queryFn: () => client.get<AppStatus[]>('/server-settings/applications'),
|
||||
});
|
||||
|
||||
const runAction = async (id: string, action: 'install' | 'update') => {
|
||||
setActionInProgress(id);
|
||||
try {
|
||||
await client.post<AppStatus>(`/server-settings/applications/${id}/${action}`);
|
||||
await queryClient.invalidateQueries({ queryKey: ['APPLICATIONS'] });
|
||||
toast.success(`${action === 'install' ? 'Installed' : 'Updated'} successfully`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : `${action} failed`;
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setActionInProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getManualCommand = (app: AppStatus): string | null => {
|
||||
if (!app.installed) return app.manualInstallCommand;
|
||||
return app.manualUpdateCommand ?? app.manualInstallCommand;
|
||||
};
|
||||
|
||||
const hasAutoAction = (app: AppStatus): boolean => {
|
||||
if (!app.installed) return app.hasInstall && !app.manualInstallCommand;
|
||||
return app.hasUpdate && !(app.manualUpdateCommand ?? app.manualInstallCommand);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
<h2 className="text-lg font-bold text-duck-dark mb-4" title="System tools and dependencies used by Officer.dev">Applications</h2>
|
||||
|
||||
{isLoading && <p className="text-sm text-duck-dark/50">Checking applications...</p>}
|
||||
|
||||
{apps && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{apps.map((app: AppStatus) => {
|
||||
const manualCmd = getManualCommand(app);
|
||||
const canAutoRun = hasAutoAction(app);
|
||||
|
||||
return (
|
||||
<div key={app.id} className="flex flex-col rounded-lg border border-duck-dark/10 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-duck-dark">{app.name}</span>
|
||||
{app.installed && (
|
||||
<span className="text-xs bg-green-100 text-green-700 rounded-full px-2 py-0.5">
|
||||
{app.version}
|
||||
</span>
|
||||
)}
|
||||
{!app.installed && (
|
||||
<span className="text-xs bg-duck-dark/5 text-duck-dark/40 rounded-full px-2 py-0.5">
|
||||
Not installed
|
||||
</span>
|
||||
)}
|
||||
{app.running !== null && (
|
||||
<Circle
|
||||
className={`h-2.5 w-2.5 ${app.running ? 'fill-green-500 text-green-500' : 'fill-duck-dark/20 text-duck-dark/20'}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-duck-dark/50 mt-0.5">{app.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
{canAutoRun && !app.installed && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
|
||||
disabled={actionInProgress === app.id}
|
||||
onClick={() => runAction(app.id, 'install')}
|
||||
>
|
||||
{actionInProgress === app.id ? (
|
||||
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{actionInProgress === app.id ? 'Installing...' : 'Install'}
|
||||
</Button>
|
||||
)}
|
||||
{canAutoRun && app.installed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={actionInProgress === app.id}
|
||||
onClick={() => runAction(app.id, 'update')}
|
||||
>
|
||||
{actionInProgress === app.id ? (
|
||||
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{actionInProgress === app.id ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{manualCmd && (
|
||||
<div className="mt-2 text-xs text-duck-dark/50">
|
||||
{app.installed ? 'Update' : 'Install'} manually:
|
||||
<CopyCommand command={manualCmd} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Box, AppWindow } from 'lucide-react';
|
||||
|
||||
const sidebarItems = [
|
||||
{ id: 'applications', label: 'Applications', icon: AppWindow },
|
||||
] as const;
|
||||
|
||||
export const ResourceSidebar = () => {
|
||||
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" />
|
||||
Resources
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 px-3 pt-3">
|
||||
{sidebarItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
className="flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium bg-duck-teal/10 text-duck-dark cursor-default"
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 text-left">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,212 +1,32 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { RefreshCw, Download, Circle, Copy, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useClaude } from '@/Screens/Dashboard/Chat/useClaude';
|
||||
import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode';
|
||||
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
||||
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
|
||||
import { useMemo } from 'react';
|
||||
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
||||
import { WorkspaceLayout } from '@/components/Workspace';
|
||||
import { appRegistry } from '../../Workspaces/app-registry';
|
||||
import { Applications } from './Applications';
|
||||
import { ResourceSidebar } from './ResourceSidebar';
|
||||
|
||||
type AppStatus = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
running: boolean | null;
|
||||
hasInstall: boolean;
|
||||
hasUpdate: boolean;
|
||||
manualInstallCommand: string | null;
|
||||
manualUpdateCommand: string | null;
|
||||
};
|
||||
|
||||
const CopyCommand = ({ command }: { command: string }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<code className="flex-1 bg-duck-dark/5 rounded px-2 py-1 text-xs text-duck-dark/70">{command}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-600" /> : <Copy className="h-3.5 w-3.5 text-duck-dark/50" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
const layout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'resources-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'resources-left', appType: null }, size: 20 },
|
||||
{ node: { type: 'panel', id: 'resources-right', appType: null }, size: 80 },
|
||||
],
|
||||
};
|
||||
|
||||
export const ResourceSettings = () => {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
|
||||
|
||||
const { data: apps, isLoading } = useQuery({
|
||||
queryKey: ['APPLICATIONS'],
|
||||
queryFn: () => client.get<AppStatus[]>('/server-settings/applications'),
|
||||
});
|
||||
|
||||
const runAction = async (id: string, action: 'install' | 'update') => {
|
||||
setActionInProgress(id);
|
||||
try {
|
||||
await client.post<AppStatus>(`/server-settings/applications/${id}/${action}`);
|
||||
await queryClient.invalidateQueries({ queryKey: ['APPLICATIONS'] });
|
||||
toast.success(`${action === 'install' ? 'Installed' : 'Updated'} successfully`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : `${action} failed`;
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setActionInProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getManualCommand = (app: AppStatus): string | null => {
|
||||
if (!app.installed) return app.manualInstallCommand;
|
||||
return app.manualUpdateCommand ?? app.manualInstallCommand;
|
||||
};
|
||||
|
||||
const hasAutoAction = (app: AppStatus): boolean => {
|
||||
if (!app.installed) return app.hasInstall && !app.manualInstallCommand;
|
||||
return app.hasUpdate && !(app.manualUpdateCommand ?? app.manualInstallCommand);
|
||||
};
|
||||
|
||||
const [provider, setProvider] = useState<'claude' | 'opencode'>('claude');
|
||||
|
||||
return (
|
||||
<div className="flex h-full gap-4 px-4 py-8 overflow-hidden">
|
||||
<Card className="w-full max-w-2xl h-fit p-6 overflow-y-auto">
|
||||
<h2 className="text-lg font-bold text-duck-dark mb-1">Applications</h2>
|
||||
<p className="text-sm text-duck-dark/60 mb-6">System tools and dependencies used by Officer.dev</p>
|
||||
|
||||
{isLoading && <p className="text-sm text-duck-dark/50">Checking applications...</p>}
|
||||
|
||||
{apps && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{apps.map((app: AppStatus) => {
|
||||
const manualCmd = getManualCommand(app);
|
||||
const canAutoRun = hasAutoAction(app);
|
||||
|
||||
return (
|
||||
<div key={app.id} className="flex flex-col rounded-lg border border-duck-dark/10 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-duck-dark">{app.name}</span>
|
||||
{app.installed && (
|
||||
<span className="text-xs bg-green-100 text-green-700 rounded-full px-2 py-0.5">
|
||||
{app.version}
|
||||
</span>
|
||||
)}
|
||||
{!app.installed && (
|
||||
<span className="text-xs bg-duck-dark/5 text-duck-dark/40 rounded-full px-2 py-0.5">
|
||||
Not installed
|
||||
</span>
|
||||
)}
|
||||
{app.running !== null && (
|
||||
<Circle
|
||||
className={`h-2.5 w-2.5 ${app.running ? 'fill-green-500 text-green-500' : 'fill-duck-dark/20 text-duck-dark/20'}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-duck-dark/50 mt-0.5">{app.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
{canAutoRun && !app.installed && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
|
||||
disabled={actionInProgress === app.id}
|
||||
onClick={() => runAction(app.id, 'install')}
|
||||
>
|
||||
{actionInProgress === app.id ? (
|
||||
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{actionInProgress === app.id ? 'Installing...' : 'Install'}
|
||||
</Button>
|
||||
)}
|
||||
{canAutoRun && app.installed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={actionInProgress === app.id}
|
||||
onClick={() => runAction(app.id, 'update')}
|
||||
>
|
||||
{actionInProgress === app.id ? (
|
||||
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{actionInProgress === app.id ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{manualCmd && (
|
||||
<div className="mt-2 text-xs text-duck-dark/50">
|
||||
{app.installed ? 'Update' : 'Install'} manually:
|
||||
<CopyCommand command={manualCmd} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
const panelComponents: PanelComponents = useMemo(
|
||||
() => ({
|
||||
'resources-left': ResourceSidebar,
|
||||
'resources-right': Applications,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||
{provider === 'claude' ? (
|
||||
<ClaudeChat key="claude" onProviderChange={setProvider} />
|
||||
) : (
|
||||
<OpenCodeChat key="opencode" onProviderChange={setProvider} />
|
||||
)}
|
||||
</Card>
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ChatInnerProps = {
|
||||
onProviderChange: (p: 'claude' | 'opencode') => void;
|
||||
};
|
||||
|
||||
const ClaudeChat = ({ onProviderChange }: ChatInnerProps) => {
|
||||
const chat = useClaude(undefined, undefined, { replaceUrl: false });
|
||||
const models = useVisibleClaudeModels();
|
||||
return (
|
||||
<EmbeddableChat
|
||||
chat={chat}
|
||||
provider="claude"
|
||||
availableModels={models}
|
||||
onProviderChange={onProviderChange}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const OpenCodeChat = ({ onProviderChange }: ChatInnerProps) => {
|
||||
const chat = useOpenCode(undefined, undefined, { replaceUrl: false });
|
||||
const models = useVisibleOpenCodeModels();
|
||||
return (
|
||||
<EmbeddableChat
|
||||
chat={chat}
|
||||
provider="opencode"
|
||||
availableModels={models}
|
||||
onProviderChange={onProviderChange}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { Search, AlertCircle, CheckCircle2, Clock, ArrowLeft } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Card } from '@/components/Card';
|
||||
import { MessageBubble, type ChatMessage } from 'widgets/Chat';
|
||||
import { MessageBubble, type ChatMessage } from 'apps/Chat';
|
||||
|
||||
type LogMetadata = {
|
||||
filename: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TerminalView } from 'widgets/Terminal';
|
||||
import { TerminalView } from 'apps/Terminal';
|
||||
|
||||
export const Terminal = () => {
|
||||
return <TerminalView className="h-full w-full p-2" />;
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { LayoutGrid, ArrowRight } from 'lucide-react';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import type { WorkspaceDefinition } from '@/components/Workspace';
|
||||
|
||||
export const WorkspaceListWidget = () => {
|
||||
export const WorkspaceListApp = () => {
|
||||
const [workspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
|
||||
|
||||
return (
|
||||
@@ -3,7 +3,7 @@ import { Link } from 'react-router';
|
||||
import { Plus, Trash2, LayoutGrid, ArrowRight } from 'lucide-react';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import type { WorkspaceDefinition } from '@/components/Workspace';
|
||||
import { Widget } from '@/components/Widget';
|
||||
import { Widget } from 'widgets/Widget';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export const WorkspaceListScreen = () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useParams, Navigate } from 'react-router';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
||||
import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace';
|
||||
import { widgetRegistry } from './widget-registry';
|
||||
import { appRegistry } from './app-registry';
|
||||
|
||||
export const WorkspaceScreen = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -23,7 +23,7 @@ const WorkspaceScreenInner = ({ workspace }: { workspace: WorkspaceDefinition })
|
||||
workspace={workspace}
|
||||
layout={layout}
|
||||
onLayoutChange={setLayout}
|
||||
registry={widgetRegistry}
|
||||
registry={appRegistry}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+14
-8
@@ -1,16 +1,19 @@
|
||||
import { useState } from 'react';
|
||||
import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, LayoutGrid } from 'lucide-react';
|
||||
import type { WidgetRegistry } from '@/components/Workspace';
|
||||
import { CodeEditorView } from 'widgets/CodeEditor';
|
||||
import { TerminalView } from 'widgets/Terminal';
|
||||
import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, LayoutGrid, LayoutDashboard, Sparkles } from 'lucide-react';
|
||||
import type { AppRegistry } from '@/components/Workspace';
|
||||
import { CodeEditorView } from 'apps/CodeEditor';
|
||||
import { TerminalView } from 'apps/Terminal';
|
||||
import { useClaude } from '../Chat/useClaude';
|
||||
import { useOpenCode } from '../Chat/useOpenCode';
|
||||
import { ChatPanel } from '../Chat/ChatPanel';
|
||||
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
|
||||
import { ChatHistoryWidget as ChatHistory } from '../ChatHistory';
|
||||
import { ChatHistoryApp as ChatHistory } from '../ChatHistory';
|
||||
import { Files } from '../Files';
|
||||
import { Catalog } from 'sounds';
|
||||
import { WorkspaceListWidget } from './WorkspaceListWidget';
|
||||
import { WorkspaceListApp } from './WorkspaceListApp';
|
||||
import { ChatLauncher } from '../Home/ChatLauncher';
|
||||
import { widgetRegistry } from 'widgets/widget-registry';
|
||||
import { WidgetPanel } from 'widgets/WidgetPanel';
|
||||
|
||||
const ChatWidget = () => {
|
||||
const [provider, setProvider] = useState<'claude' | 'opencode'>('claude');
|
||||
@@ -37,12 +40,15 @@ const CodeEditorWrapper = () => <CodeEditorView className="h-full w-full" />;
|
||||
|
||||
const TerminalWrapper = () => <TerminalView className="h-full w-full p-2" />;
|
||||
|
||||
export const widgetRegistry: WidgetRegistry = {
|
||||
export const appRegistry: AppRegistry = {
|
||||
'chat': { name: 'Chat', icon: MessageSquare, component: ChatWidget },
|
||||
'file-browser': { name: 'File Browser', icon: FolderOpen, component: () => <Files /> },
|
||||
'chat-history': { name: 'Chat History', icon: History, component: () => <ChatHistory /> },
|
||||
'sound-library': { name: 'Sound Library', icon: Music, component: () => <Catalog /> },
|
||||
'code-editor': { name: 'Code Editor', icon: Code, component: CodeEditorWrapper },
|
||||
'terminal': { name: 'Terminal', icon: TerminalSquare, component: TerminalWrapper },
|
||||
'workspace-list': { name: 'Workspaces', icon: LayoutGrid, component: () => <WorkspaceListWidget /> },
|
||||
'workspace-list': { name: 'Workspaces', icon: LayoutGrid, component: () => <WorkspaceListApp /> },
|
||||
'chat-launcher': { name: 'Chat Launcher', icon: Sparkles, component: () => <ChatLauncher />, fixedHeight: 180 },
|
||||
'widget-panel': { name: 'Widget Panel', icon: LayoutDashboard, component: WidgetPanel, transparent: true },
|
||||
...widgetRegistry,
|
||||
};
|
||||
@@ -7,6 +7,7 @@ export * from './Plans';
|
||||
export * from './Processes';
|
||||
export * from './CapabilityPage';
|
||||
export * from './Settings';
|
||||
export * from './Automation';
|
||||
export * from './Skills';
|
||||
export * from './TaskLogs';
|
||||
export * from './Tasks';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SessionEntry, ChatMessage } from 'widgets/Chat';
|
||||
import type { SessionEntry, ChatMessage } from 'apps/Chat';
|
||||
import type { SlashCommandResult } from './useSlashCommands';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import Editor, { type OnMount } from '@monaco-editor/react';
|
||||
import type { editor as MonacoEditor } from 'monaco-editor';
|
||||
import { toast } from 'sonner';
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||||
import { useFiles } from 'widgets/FileBrowser';
|
||||
import { useFiles } from 'apps/FileBrowser';
|
||||
import { FileTree } from './FileTree';
|
||||
import { EditorTabs } from './EditorTabs';
|
||||
import { useEditorState } from './useEditorState';
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { ChevronRight, ChevronDown, Folder } from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
import { useFiles, type DirEntry } from 'widgets/FileBrowser';
|
||||
import { useFiles, type DirEntry } from 'apps/FileBrowser';
|
||||
|
||||
type FileTreeProps = {
|
||||
root: string;
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "apps",
|
||||
"private": true,
|
||||
"exports": {
|
||||
"./Terminal": "./Terminal/index.ts",
|
||||
"./FileBrowser": "./FileBrowser/index.ts",
|
||||
"./ChatHistory": "./ChatHistory/index.ts",
|
||||
"./Chat": "./Chat/index.ts",
|
||||
"./CodeEditor": "./CodeEditor/index.ts"
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
import type { WidgetRegistry } from './types';
|
||||
import type { AppRegistry } from './types';
|
||||
|
||||
type WidgetPickerProps = {
|
||||
registry: WidgetRegistry;
|
||||
onSelect: (widgetType: string) => void;
|
||||
type AppPickerProps = {
|
||||
registry: AppRegistry;
|
||||
onSelect: (appType: string) => void;
|
||||
};
|
||||
|
||||
export const WidgetPicker = ({ registry, onSelect }: WidgetPickerProps) => {
|
||||
export const AppPicker = ({ registry, onSelect }: AppPickerProps) => {
|
||||
const entries = Object.entries(registry);
|
||||
|
||||
return (
|
||||
@@ -6,10 +6,10 @@ type LayoutEditorProps = {
|
||||
isLastPanel: boolean;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
onClearWidget: () => void;
|
||||
onClearApp: () => void;
|
||||
};
|
||||
|
||||
export const LayoutEditor = ({ panelId, hasWidget, isLastPanel, onSplit, onRemove, onClearWidget }: LayoutEditorProps) => (
|
||||
export const LayoutEditor = ({ panelId, hasWidget, isLastPanel, onSplit, onRemove, onClearApp }: LayoutEditorProps) => (
|
||||
<div className="absolute top-1 right-1 flex gap-1 z-10">
|
||||
<button
|
||||
type="button"
|
||||
@@ -31,7 +31,7 @@ export const LayoutEditor = ({ panelId, hasWidget, isLastPanel, onSplit, onRemov
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded bg-duck-teal/10 border border-duck-teal/20 text-duck-teal/70 hover:text-duck-teal hover:bg-duck-teal/20 cursor-pointer"
|
||||
onClick={onClearWidget}
|
||||
onClick={onClearApp}
|
||||
title="Clear Widget"
|
||||
>
|
||||
<X size={14} />
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
import type { LayoutPanel, WidgetRegistry } from './types';
|
||||
import type { LayoutPanel, AppRegistry, PanelComponents } from './types';
|
||||
import { Card } from '../Card';
|
||||
import { WidgetPicker } from './WidgetPicker';
|
||||
import { AppPicker } from './AppPicker';
|
||||
import { LayoutEditor } from './LayoutEditor';
|
||||
|
||||
type PanelSlotProps = {
|
||||
panel: LayoutPanel;
|
||||
registry: WidgetRegistry;
|
||||
registry: AppRegistry;
|
||||
components?: PanelComponents;
|
||||
editing: boolean;
|
||||
isLastPanel: boolean;
|
||||
onSetWidget: (panelId: string, widgetType: string | null) => void;
|
||||
onSetApp: (panelId: string, appType: string | null) => void;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
};
|
||||
|
||||
export const PanelSlot = ({ panel, registry, editing, isLastPanel, onSetWidget, onSplit, onRemove }: PanelSlotProps) => {
|
||||
const entry = panel.widgetType ? registry[panel.widgetType] : null;
|
||||
const WidgetComponent = entry?.component;
|
||||
export const PanelSlot = ({ panel, registry, components, editing, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
|
||||
const PanelComponent = components?.[panel.id];
|
||||
const entry = panel.appType ? registry[panel.appType] : null;
|
||||
const AppComponent = PanelComponent ?? entry?.component;
|
||||
|
||||
if (!editing && !WidgetComponent) return null;
|
||||
|
||||
if (!WidgetComponent) {
|
||||
if (!editing && !AppComponent) {
|
||||
return (
|
||||
<div className="h-full w-full p-1">
|
||||
<div className="h-full w-full rounded-lg border-3 border-duck-teal/50" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!AppComponent) {
|
||||
return (
|
||||
<div className="h-full w-full p-1">
|
||||
<Card className="relative h-full w-full flex flex-col items-center justify-center gap-3 p-4">
|
||||
<WidgetPicker registry={registry} onSelect={(type) => onSetWidget(panel.id, type)} />
|
||||
<AppPicker registry={registry} onSelect={(type) => onSetApp(panel.id, type)} />
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -49,16 +58,38 @@ export const PanelSlot = ({ panel, registry, editing, isLastPanel, onSetWidget,
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry?.transparent) {
|
||||
return (
|
||||
<div className="h-full w-full p-1">
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<AppComponent panelId={panel.id} />
|
||||
{editing && (
|
||||
<LayoutEditor
|
||||
panelId={panel.id}
|
||||
hasWidget
|
||||
isLastPanel={isLastPanel}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
onClearApp={() => onSetApp(panel.id, null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-1">
|
||||
<div
|
||||
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2"
|
||||
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
|
||||
>
|
||||
<Card className="h-full w-full overflow-hidden p-0 [&>*]:!h-full [&>*]:!flex [&>*]:!flex-col [&>*]:!rounded-none [&>*]:!border-0 [&>*]:!shadow-none [&>*>*:last-child]:!flex-1 [&>*>*:last-child]:!min-h-0 [&>*>*:last-child]:!max-h-none [&>*>*:last-child]:!overflow-hidden">
|
||||
<WidgetComponent panelId={panel.id} />
|
||||
<Card className="h-full w-full overflow-hidden p-0 [&>*]:!h-full [&>*]:!flex [&>*]:!flex-col [&>*]:!rounded-none [&>*]:!border-0 [&>*]:!shadow-none [&>*>*:last-child]:!flex-1 [&>*>*:last-child]:!min-h-0 [&>*>*:last-child]:!max-h-none [&>*>*:last-child]:!overflow-auto">
|
||||
<AppComponent panelId={panel.id} />
|
||||
</Card>
|
||||
{editing && (
|
||||
<LayoutEditor
|
||||
@@ -67,9 +98,10 @@ export const PanelSlot = ({ panel, registry, editing, isLastPanel, onSetWidget,
|
||||
isLastPanel={isLastPanel}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
onClearWidget={() => onSetWidget(panel.id, null)}
|
||||
onClearApp={() => onSetApp(panel.id, null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { LayoutNode, AppRegistry, PanelComponents } from './types';
|
||||
import { updateSizes } from './layout-utils';
|
||||
import { WorkspaceProvider } from './WorkspaceContext';
|
||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||
|
||||
type WorkspaceLayoutProps = {
|
||||
layout: LayoutNode;
|
||||
onLayoutChange: (layout: LayoutNode) => void;
|
||||
registry: AppRegistry;
|
||||
components?: PanelComponents;
|
||||
workspaceId?: string;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
export const WorkspaceLayout = ({ layout, onLayoutChange, registry, components, workspaceId, cwd }: WorkspaceLayoutProps) => {
|
||||
const handleResized = useCallback(
|
||||
(groupId: string, sizes: number[]) => {
|
||||
onLayoutChange(updateSizes(layout, groupId, sizes));
|
||||
},
|
||||
[layout, onLayoutChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', editing: false }}>
|
||||
<WorkspaceRenderer
|
||||
layout={layout}
|
||||
registry={registry}
|
||||
components={components}
|
||||
editing={false}
|
||||
onSetApp={noop}
|
||||
onSplit={noop}
|
||||
onRemove={noop}
|
||||
onResized={handleResized}
|
||||
/>
|
||||
</WorkspaceProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '../ui/resizable';
|
||||
import type { LayoutNode, WidgetRegistry } from './types';
|
||||
import { pruneEmptyPanels, countPanels } from './layout-utils';
|
||||
import type { LayoutNode, AppRegistry, PanelComponents } from './types';
|
||||
import { countPanels } from './layout-utils';
|
||||
import { PanelSlot } from './PanelSlot';
|
||||
|
||||
type WorkspaceRendererProps = {
|
||||
layout: LayoutNode;
|
||||
registry: WidgetRegistry;
|
||||
registry: AppRegistry;
|
||||
components?: PanelComponents;
|
||||
editing: boolean;
|
||||
onSetWidget: (panelId: string, widgetType: string | null) => void;
|
||||
onSetApp: (panelId: string, appType: string | null) => void;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
onResized: (groupId: string, sizes: number[]) => void;
|
||||
@@ -17,27 +18,24 @@ type WorkspaceRendererProps = {
|
||||
export const WorkspaceRenderer = ({
|
||||
layout,
|
||||
registry,
|
||||
components,
|
||||
editing,
|
||||
onSetWidget,
|
||||
onSetApp,
|
||||
onSplit,
|
||||
onRemove,
|
||||
onResized,
|
||||
}: WorkspaceRendererProps) => {
|
||||
const displayLayout = editing ? layout : pruneEmptyPanels(layout);
|
||||
if (!displayLayout) {
|
||||
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">No widgets</div>;
|
||||
}
|
||||
|
||||
const totalPanels = countPanels(layout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<LayoutNodeRenderer
|
||||
node={displayLayout}
|
||||
node={layout}
|
||||
registry={registry}
|
||||
components={components}
|
||||
editing={editing}
|
||||
totalPanels={totalPanels}
|
||||
onSetWidget={onSetWidget}
|
||||
onSetApp={onSetApp}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
onResized={onResized}
|
||||
@@ -48,30 +46,42 @@ export const WorkspaceRenderer = ({
|
||||
|
||||
type LayoutNodeRendererProps = {
|
||||
node: LayoutNode;
|
||||
registry: WidgetRegistry;
|
||||
registry: AppRegistry;
|
||||
components?: PanelComponents;
|
||||
editing: boolean;
|
||||
totalPanels: number;
|
||||
onSetWidget: (panelId: string, widgetType: string | null) => void;
|
||||
onSetApp: (panelId: string, appType: string | null) => void;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
onResized: (groupId: string, sizes: number[]) => void;
|
||||
};
|
||||
|
||||
const getFixedHeight = (node: LayoutNode, registry: AppRegistry): number | undefined => {
|
||||
if (node.type === 'panel' && node.appType) return registry[node.appType]?.fixedHeight;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const LayoutNodeRenderer = ({
|
||||
node,
|
||||
registry,
|
||||
components,
|
||||
editing,
|
||||
totalPanels,
|
||||
onSetWidget,
|
||||
onSetApp,
|
||||
onSplit,
|
||||
onRemove,
|
||||
onResized,
|
||||
}: LayoutNodeRendererProps) => {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(sizes: number[]) => {
|
||||
if (node.type !== 'group') return;
|
||||
if (!mountedRef.current) {
|
||||
mountedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onResized(node.id, sizes);
|
||||
@@ -85,27 +95,56 @@ const LayoutNodeRenderer = ({
|
||||
<PanelSlot
|
||||
panel={node}
|
||||
registry={registry}
|
||||
components={components}
|
||||
editing={editing}
|
||||
isLastPanel={totalPanels <= 1}
|
||||
onSetWidget={onSetWidget}
|
||||
onSetApp={onSetApp}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const hasFixedChild = node.direction === 'vertical' && node.children.some((c) => getFixedHeight(c.node, registry) !== undefined);
|
||||
|
||||
if (hasFixedChild) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{node.children.map((child) => {
|
||||
const fixed = getFixedHeight(child.node, registry);
|
||||
return (
|
||||
<div key={child.node.id} className={fixed !== undefined ? 'shrink-0' : 'min-h-0 flex-1'} style={fixed !== undefined ? { height: fixed } : undefined}>
|
||||
<LayoutNodeRenderer
|
||||
node={child.node}
|
||||
registry={registry}
|
||||
components={components}
|
||||
editing={editing}
|
||||
totalPanels={totalPanels}
|
||||
onSetApp={onSetApp}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
onResized={onResized}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup direction={node.direction} onLayout={handleLayout} className="h-full w-full">
|
||||
{node.children.map((child, i) => (
|
||||
<ChildEntry key={child.node.id} index={i} total={node.children.length}>
|
||||
<ResizablePanel defaultSize={child.size} minSize={5}>
|
||||
<div className="h-full w-full p-1">
|
||||
<div className="h-full w-full">
|
||||
<LayoutNodeRenderer
|
||||
node={child.node}
|
||||
registry={registry}
|
||||
components={components}
|
||||
editing={editing}
|
||||
totalPanels={totalPanels}
|
||||
onSetWidget={onSetWidget}
|
||||
onSetApp={onSetApp}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
onResized={onResized}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { LayoutNode, WorkspaceDefinition, WidgetRegistry } from './types';
|
||||
import { splitPanel, removePanel, setWidget, updateSizes, countPanels, hasAnyWidget } from './layout-utils';
|
||||
import type { LayoutNode, WorkspaceDefinition, AppRegistry } from './types';
|
||||
import { splitPanel, removePanel, setApp, updateSizes, countPanels, hasAnyApp } from './layout-utils';
|
||||
import { WorkspaceProvider } from './WorkspaceContext';
|
||||
import { WorkspaceHeader } from './WorkspaceHeader';
|
||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||
@@ -10,15 +10,15 @@ type WorkspaceViewProps = {
|
||||
name?: string;
|
||||
layout: LayoutNode;
|
||||
onLayoutChange: (layout: LayoutNode) => void;
|
||||
registry: WidgetRegistry;
|
||||
registry: AppRegistry;
|
||||
};
|
||||
|
||||
export const WorkspaceView = ({ workspace, name, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
|
||||
const [editing, setEditing] = useState(() => !hasAnyWidget(layout));
|
||||
const [editing, setEditing] = useState(() => !hasAnyApp(layout));
|
||||
|
||||
const handleSetWidget = useCallback(
|
||||
(panelId: string, widgetType: string | null) => {
|
||||
onLayoutChange(setWidget(layout, panelId, widgetType));
|
||||
const handleSetApp = useCallback(
|
||||
(panelId: string, appType: string | null) => {
|
||||
onLayoutChange(setApp(layout, panelId, appType));
|
||||
},
|
||||
[layout, onLayoutChange],
|
||||
);
|
||||
@@ -56,7 +56,7 @@ export const WorkspaceView = ({ workspace, name, layout, onLayoutChange, registr
|
||||
layout={layout}
|
||||
registry={registry}
|
||||
editing={editing}
|
||||
onSetWidget={handleSetWidget}
|
||||
onSetApp={handleSetApp}
|
||||
onSplit={handleSplit}
|
||||
onRemove={handleRemove}
|
||||
onResized={handleResized}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WidgetRegistry, WidgetRegistryEntry } from './types';
|
||||
export { createDefaultLayout, splitPanel, removePanel, setWidget, updateSizes, pruneEmptyPanels, countPanels, hasAnyWidget } from './layout-utils';
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, AppRegistry, AppRegistryEntry, PanelComponents } from './types';
|
||||
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
|
||||
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
||||
export { WorkspaceView } from './WorkspaceView';
|
||||
export { WorkspaceLayout } from './WorkspaceLayout';
|
||||
|
||||
@@ -6,14 +6,14 @@ const uid = () => `p-${Date.now()}-${++counter}`;
|
||||
export const createDefaultLayout = (): LayoutPanel => ({
|
||||
type: 'panel',
|
||||
id: uid(),
|
||||
widgetType: null,
|
||||
appType: null,
|
||||
});
|
||||
|
||||
export function splitPanel(root: LayoutNode, panelId: string, direction: 'horizontal' | 'vertical'): LayoutNode {
|
||||
return mapNode(root, (node, parent) => {
|
||||
if (node.type !== 'panel' || node.id !== panelId) return node;
|
||||
|
||||
const newPanel: LayoutPanel = { type: 'panel', id: uid(), widgetType: null };
|
||||
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType: null };
|
||||
|
||||
if (parent && parent.direction === direction) {
|
||||
return null;
|
||||
@@ -40,7 +40,7 @@ function mapNode(
|
||||
const result = fn(node, parent);
|
||||
|
||||
if (result === null && parent !== null && node.type === 'panel') {
|
||||
const newPanel: LayoutPanel = { type: 'panel', id: uid(), widgetType: null };
|
||||
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType: null };
|
||||
const idx = parent.children.findIndex((c) => c.node.id === node.id);
|
||||
const newChildren = [
|
||||
...parent.children.slice(0, idx + 1),
|
||||
@@ -104,13 +104,13 @@ export function removePanel(root: LayoutNode, panelId: string): LayoutNode {
|
||||
}
|
||||
}
|
||||
|
||||
export function setWidget(root: LayoutNode, panelId: string, widgetType: string | null): LayoutNode {
|
||||
export function setApp(root: LayoutNode, panelId: string, appType: string | null): LayoutNode {
|
||||
if (root.type === 'panel') {
|
||||
return root.id === panelId ? { ...root, widgetType } : root;
|
||||
return root.id === panelId ? { ...root, appType } : root;
|
||||
}
|
||||
const newChildren = root.children.map((child) => ({
|
||||
...child,
|
||||
node: setWidget(child.node, panelId, widgetType),
|
||||
node: setApp(child.node, panelId, appType),
|
||||
}));
|
||||
return { ...root, children: newChildren };
|
||||
}
|
||||
@@ -132,7 +132,7 @@ export function updateSizes(root: LayoutNode, groupId: string, sizes: number[]):
|
||||
|
||||
export function pruneEmptyPanels(root: LayoutNode): LayoutNode | null {
|
||||
if (root.type === 'panel') {
|
||||
return root.widgetType ? root : null;
|
||||
return root.appType ? root : null;
|
||||
}
|
||||
|
||||
const pruned = root.children
|
||||
@@ -157,7 +157,7 @@ export function countPanels(node: LayoutNode): number {
|
||||
return node.children.reduce((sum, child) => sum + countPanels(child.node), 0);
|
||||
}
|
||||
|
||||
export function hasAnyWidget(node: LayoutNode): boolean {
|
||||
if (node.type === 'panel') return node.widgetType !== null;
|
||||
return node.children.some((child) => hasAnyWidget(child.node));
|
||||
export function hasAnyApp(node: LayoutNode): boolean {
|
||||
if (node.type === 'panel') return node.appType !== null;
|
||||
return node.children.some((child) => hasAnyApp(child.node));
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export type LayoutGroup = {
|
||||
export type LayoutPanel = {
|
||||
type: 'panel';
|
||||
id: string;
|
||||
widgetType: string | null;
|
||||
appType: string | null;
|
||||
};
|
||||
|
||||
export type LayoutNode = LayoutGroup | LayoutPanel;
|
||||
@@ -22,10 +22,14 @@ export type WorkspaceDefinition = {
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
export type WidgetRegistryEntry = {
|
||||
export type AppRegistryEntry = {
|
||||
name: string;
|
||||
icon: LucideIcon;
|
||||
component: ComponentType<{ panelId: string }>;
|
||||
transparent?: boolean;
|
||||
fixedHeight?: number;
|
||||
};
|
||||
|
||||
export type WidgetRegistry = Record<string, WidgetRegistryEntry>;
|
||||
export type AppRegistry = Record<string, AppRegistryEntry>;
|
||||
|
||||
export type PanelComponents = Record<string, ComponentType>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { useGlobal } from './useGlobal';
|
||||
export { usePanelChannel } from './usePanelChannel';
|
||||
export { useClient, createClient } from './useClient';
|
||||
export { useDebounce } from './useDebounce';
|
||||
export { useDragAndDrop } from './useDragAndDrop';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { useGlobal } from './useGlobal';
|
||||
|
||||
export const usePanelChannel = <T>(channel: string, initialData: T) => {
|
||||
return useGlobal<T>(['PANEL_CHANNEL', channel], initialData);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
import { Search, Play, Square, AudioLines } from 'lucide-react';
|
||||
import { Widget } from '@/components/Widget';
|
||||
import { Widget } from 'widgets/Widget';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { categories, allSounds } from './catalog-data';
|
||||
import type { SoundAsset } from './types';
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Widget } from '../Widget';
|
||||
|
||||
export const Clock = () => {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const time = now.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const date = now.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
|
||||
return (
|
||||
<Widget title="Clock">
|
||||
<div className="flex flex-col items-center gap-1 px-4 pb-4">
|
||||
<span className="text-3xl font-bold tabular-nums tracking-tight">{time}</span>
|
||||
<span className="text-sm text-muted-foreground">{date}</span>
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
@@ -3,20 +3,30 @@ import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, Minus, Plus, X } from 'lucide-react';
|
||||
import { cn } from 'helpers/cn';
|
||||
import { Card } from './Card';
|
||||
import { Card } from '@/components/Card';
|
||||
|
||||
type Position = { x: number; y: number };
|
||||
|
||||
type WidgetProps = ComponentPropsWithoutRef<'div'> & {
|
||||
title?: string;
|
||||
resizable?: boolean;
|
||||
collapsible?: boolean | { title: string; icon?: LucideIcon };
|
||||
moveable?: boolean;
|
||||
position?: Position;
|
||||
onPositionChange?: (pos: Position) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const Widget = ({ title, className, style, resizable, collapsible, moveable, onClose, children, ...props }: WidgetProps) => {
|
||||
export const Widget = ({ title, className, style, resizable, collapsible, moveable, position: controlledPosition, onPositionChange, onClose, children, ...props }: WidgetProps) => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [internalPosition, setInternalPosition] = useState<Position>({ x: 0, y: 0 });
|
||||
const isControlled = controlledPosition !== undefined;
|
||||
const position = isControlled ? controlledPosition : internalPosition;
|
||||
const setPosition = isControlled ? (pos: Position | ((prev: Position) => Position)) => {
|
||||
const next = typeof pos === 'function' ? pos(controlledPosition) : pos;
|
||||
onPositionChange?.(next);
|
||||
} : setInternalPosition;
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<{
|
||||
startX: number;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { widgetRegistry } from '../widget-registry';
|
||||
|
||||
type WidgetPickerProps = {
|
||||
onSelect: (widgetType: string) => void;
|
||||
};
|
||||
|
||||
export const WidgetPicker = ({ onSelect }: WidgetPickerProps) => {
|
||||
const entries = Object.entries(widgetRegistry);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 max-w-xs">
|
||||
{entries.map(([key, entry]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className="flex flex-col items-center gap-1.5 rounded-lg border border-border/50 bg-card/80 backdrop-blur-sm px-3 py-3 text-foreground hover:border-border hover:bg-card transition-colors cursor-pointer"
|
||||
onClick={() => onSelect(key)}
|
||||
>
|
||||
<entry.icon className="h-5 w-5" />
|
||||
<span className="text-xs font-medium leading-tight text-center">{entry.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { widgetRegistry } from '../widget-registry';
|
||||
import { WidgetPicker } from './WidgetPicker';
|
||||
|
||||
type Position = { x: number; y: number };
|
||||
|
||||
type WidgetInstance = {
|
||||
id: string;
|
||||
widgetType: string;
|
||||
position: Position;
|
||||
};
|
||||
|
||||
type WidgetPanelConfig = {
|
||||
instances: WidgetInstance[];
|
||||
nextId: number;
|
||||
};
|
||||
|
||||
const USER_STATE_KEY = ['USER_STATE'];
|
||||
|
||||
function useWidgetPanelState(panelId: string) {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const stateKey = `widget-panel:${panelId}`;
|
||||
|
||||
const { data: state = {} } = useQuery<Record<string, unknown>>({
|
||||
queryKey: USER_STATE_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get('/user/state'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const config = (state[stateKey] as WidgetPanelConfig | undefined) ?? { instances: [], nextId: 0 };
|
||||
|
||||
const setConfig = useCallback(
|
||||
(update: WidgetPanelConfig | ((prev: WidgetPanelConfig) => WidgetPanelConfig)) => {
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(USER_STATE_KEY) ?? {};
|
||||
const current = (currentState[stateKey] as WidgetPanelConfig | undefined) ?? { instances: [], nextId: 0 };
|
||||
const next = typeof update === 'function' ? update(current) : update;
|
||||
|
||||
queryClient.setQueryData(USER_STATE_KEY, { ...currentState, [stateKey]: next });
|
||||
clientRef.current.patch('/user/state', { [stateKey]: next }).catch(() => {});
|
||||
},
|
||||
[stateKey, queryClient],
|
||||
);
|
||||
|
||||
return [config, setConfig] as const;
|
||||
}
|
||||
|
||||
// --- DraggableWidget ---
|
||||
|
||||
type DraggableWidgetProps = {
|
||||
instance: WidgetInstance;
|
||||
panelRef: React.RefObject<HTMLDivElement | null>;
|
||||
onMove: (pos: Position) => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
type DragState = {
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
widgetW: number;
|
||||
widgetH: number;
|
||||
panelW: number;
|
||||
panelH: number;
|
||||
};
|
||||
|
||||
const DraggableWidget = ({ instance, panelRef, onMove, onRemove }: DraggableWidgetProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(ev: ReactPointerEvent) => {
|
||||
const target = ev.target as HTMLElement;
|
||||
if (!target.closest('[data-widget-header]') || target.closest('button')) return;
|
||||
const widgetRect = containerRef.current?.getBoundingClientRect();
|
||||
const panelRect = panelRef.current?.getBoundingClientRect();
|
||||
if (!widgetRect || !panelRect) return;
|
||||
dragRef.current = {
|
||||
startX: ev.clientX,
|
||||
startY: ev.clientY,
|
||||
originX: instance.position.x,
|
||||
originY: instance.position.y,
|
||||
widgetW: widgetRect.width,
|
||||
widgetH: widgetRect.height,
|
||||
panelW: panelRect.width,
|
||||
panelH: panelRect.height,
|
||||
};
|
||||
containerRef.current?.setPointerCapture(ev.pointerId);
|
||||
},
|
||||
[instance.position, panelRef],
|
||||
);
|
||||
|
||||
const endDrag = useCallback(
|
||||
(ev: ReactPointerEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
dragRef.current = null;
|
||||
containerRef.current?.releasePointerCapture(ev.pointerId);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(ev: ReactPointerEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const panelRect = panelRef.current?.getBoundingClientRect();
|
||||
if (panelRect && (ev.clientX < panelRect.left || ev.clientX > panelRect.right || ev.clientY < panelRect.top || ev.clientY > panelRect.bottom)) {
|
||||
endDrag(ev);
|
||||
return;
|
||||
}
|
||||
const d = dragRef.current;
|
||||
const rawX = d.originX + (ev.clientX - d.startX);
|
||||
const rawY = d.originY + (ev.clientY - d.startY);
|
||||
onMove({
|
||||
x: Math.max(0, Math.min(rawX, d.panelW - d.widgetW)),
|
||||
y: Math.max(0, Math.min(rawY, d.panelH - d.widgetH)),
|
||||
});
|
||||
},
|
||||
[onMove, panelRef, endDrag],
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback(
|
||||
(ev: ReactPointerEvent) => {
|
||||
endDrag(ev);
|
||||
},
|
||||
[endDrag],
|
||||
);
|
||||
|
||||
const entry = widgetRegistry[instance.widgetType];
|
||||
if (!entry) return null;
|
||||
const WidgetComponent = entry.component;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute group"
|
||||
style={{ left: instance.position.x, top: instance.position.y }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -top-2 -right-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-destructive text-destructive-foreground opacity-0 shadow transition-opacity cursor-pointer group-hover:opacity-100"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
<WidgetComponent panelId={instance.id} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- WidgetPanel ---
|
||||
|
||||
export const WidgetPanel = ({ panelId }: { panelId: string }) => {
|
||||
const [config, setConfig] = useWidgetPanelState(panelId);
|
||||
const [instances, setInstances] = useState<WidgetInstance[]>(config.instances);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const nextId = useRef(config.nextId);
|
||||
const initialized = useRef(false);
|
||||
|
||||
// Sync from persisted state on first load
|
||||
useEffect(() => {
|
||||
if (initialized.current) return;
|
||||
if (config.instances.length > 0 || config.nextId > 0) {
|
||||
setInstances(config.instances);
|
||||
nextId.current = config.nextId;
|
||||
initialized.current = true;
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// Persist whenever instances change (skip the initial mount)
|
||||
const mounted = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!mounted.current) {
|
||||
mounted.current = true;
|
||||
return;
|
||||
}
|
||||
setConfig({ instances, nextId: nextId.current });
|
||||
}, [instances, setConfig]);
|
||||
|
||||
const addWidget = useCallback((widgetType: string) => {
|
||||
nextId.current++;
|
||||
const id = `widget-${nextId.current}`;
|
||||
const offset = (nextId.current % 5) * 30;
|
||||
setInstances((prev) => [...prev, { id, widgetType, position: { x: 20 + offset, y: 20 + offset } }]);
|
||||
setShowPicker(false);
|
||||
}, []);
|
||||
|
||||
const removeWidget = useCallback((id: string) => {
|
||||
setInstances((prev) => prev.filter((w) => w.id !== id));
|
||||
}, []);
|
||||
|
||||
const updatePosition = useCallback((id: string, pos: Position) => {
|
||||
setInstances((prev) => prev.map((w) => (w.id === id ? { ...w, position: pos } : w)));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className="relative h-full w-full overflow-hidden">
|
||||
{instances.map((instance) => (
|
||||
<DraggableWidget
|
||||
key={instance.id}
|
||||
instance={instance}
|
||||
panelRef={panelRef}
|
||||
onMove={(pos) => updatePosition(instance.id, pos)}
|
||||
onRemove={() => removeWidget(instance.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="absolute bottom-4 right-4 flex flex-col items-end">
|
||||
{showPicker && (
|
||||
<div className="mb-2 rounded-xl border border-border bg-card p-3 shadow-lg">
|
||||
<WidgetPicker onSelect={addWidget} />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-colors cursor-pointer"
|
||||
onClick={() => setShowPicker((v) => !v)}
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Link } from 'react-router';
|
||||
import { LayoutGrid, ArrowRight } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import type { WorkspaceDefinition } from '@/components/Workspace';
|
||||
import { Widget } from '../Widget';
|
||||
|
||||
export const Workspaces = () => {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: state = {} } = useQuery<Record<string, unknown>>({
|
||||
queryKey: ['USER_STATE'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get('/user/state'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const workspaces = (state.workspaces ?? []) as WorkspaceDefinition[];
|
||||
|
||||
return (
|
||||
<Widget title="Workspaces">
|
||||
<div className="px-2 pb-3 max-h-52 overflow-y-auto">
|
||||
{workspaces.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-4 text-muted-foreground">
|
||||
<LayoutGrid className="h-5 w-5" />
|
||||
<p className="text-xs">No workspaces</p>
|
||||
<Link to="/workspaces" className="text-xs text-primary hover:underline">
|
||||
Create one
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{workspaces.map((ws) => (
|
||||
<li key={ws.id} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-muted/50 group">
|
||||
<Link to={`/workspaces/${ws.id}`} className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<LayoutGrid className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm truncate">{ws.name}</span>
|
||||
</Link>
|
||||
<Link
|
||||
to={`/workspaces/${ws.id}`}
|
||||
className="shrink-0 p-1 rounded text-muted-foreground/40 md:opacity-0 md:group-hover:opacity-100 hover:text-primary transition-opacity"
|
||||
>
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
@@ -2,10 +2,10 @@
|
||||
"name": "widgets",
|
||||
"private": true,
|
||||
"exports": {
|
||||
"./Terminal": "./Terminal/index.ts",
|
||||
"./FileBrowser": "./FileBrowser/index.ts",
|
||||
"./ChatHistory": "./ChatHistory/index.ts",
|
||||
"./Chat": "./Chat/index.ts",
|
||||
"./CodeEditor": "./CodeEditor/index.ts"
|
||||
"./Widget": "./Widget.tsx",
|
||||
"./Clock": "./Clock/index.tsx",
|
||||
"./widget-registry": "./widget-registry.tsx",
|
||||
"./WidgetPanel": "./WidgetPanel/index.tsx",
|
||||
"./Workspaces": "./Workspaces/index.tsx"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Clock as ClockIcon, LayoutGrid } from 'lucide-react';
|
||||
import type { AppRegistryEntry } from '@/components/Workspace';
|
||||
import { Clock } from './Clock/index';
|
||||
import { Workspaces } from './Workspaces/index';
|
||||
|
||||
export const widgetRegistry: Record<string, AppRegistryEntry> = {
|
||||
'clock': { name: 'Clock', icon: ClockIcon, component: () => <Clock /> },
|
||||
'workspaces': { name: 'Workspaces', icon: LayoutGrid, component: () => <Workspaces /> },
|
||||
};
|
||||
Reference in New Issue
Block a user