Workspaces layout, automation page

This commit is contained in:
2026-02-18 17:04:32 +00:00
parent 89f23f9426
commit 485ae4c3d7
89 changed files with 2168 additions and 514 deletions
+1
View File
@@ -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 &quot;{detail?.name}&quot;? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 mt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer">
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete} className="cursor-pointer">
Delete
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
};
@@ -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,18 +143,150 @@ const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
);
};
export const CapabilityList = ({ kind, endpoint, queryKey, selected, onSelect, onCreate, search: externalSearch, showCreate, onShowCreateChange }: CapabilityListProps) => {
const client = useClient();
const qc = useQueryClient();
const [internalCreating, setInternalCreating] = useState(false);
const creating = showCreate ?? internalCreating;
const setCreating = onShowCreateChange ?? setInternalCreating;
const [newName, setNewName] = useState('');
const [internalSearch, setInternalSearch] = useState('');
const search = externalSearch ?? internalSearch;
const newNameRef = useRef<HTMLInputElement | null>(null);
const { data: items = [] } = useQuery<CapabilitySummary[]>({
queryKey: [queryKey],
queryFn: () => client.get<CapabilitySummary[]>(endpoint),
});
const handleCreate = async () => {
const name = newName.trim();
if (!name) return;
try {
const res = await client.post<{ name: string; dirName: string }>(endpoint, { name });
await qc.invalidateQueries({ queryKey: [queryKey] });
setCreating(false);
setNewName('');
(onCreate ?? onSelect)(res.dirName);
} catch {
toast.error(`Failed to create ${kind}`);
}
};
const filtered = items.filter(
(item) =>
!search ||
item.name.toLowerCase().includes(search.toLowerCase()) ||
item.description?.toLowerCase().includes(search.toLowerCase()),
);
return (
<>
<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 && (
<button
onClick={() => {
setCreating(true);
setTimeout(() => newNameRef.current?.focus(), 0);
}}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
>
<Plus className="h-4 w-4 text-duck-dark/50" />
</button>
)}
</div>
{creating && (
<div className="shrink-0 px-3 py-2 border-b border-duck-dark/10 bg-duck-teal/5 flex items-center gap-1.5">
<input
ref={newNameRef}
value={newName}
onChange={(ev) => setNewName(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
handleCreate();
}
if (ev.key === 'Escape') {
setCreating(false);
setNewName('');
}
}}
placeholder={`${kind} name...`}
className="flex-1 min-w-0 rounded border border-duck-dark/20 bg-white px-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"
/>
<button
onClick={handleCreate}
disabled={!newName.trim()}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors disabled:opacity-30"
>
<Check className="h-3.5 w-3.5 text-duck-teal" />
</button>
<button
onClick={() => {
setCreating(false);
setNewName('');
}}
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>
)}
{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={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={() => 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'
}`}
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark truncate">{item.name}</span>
<span
className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
item.scope === 'user' ? 'bg-duck-teal/20 text-duck-teal' : 'bg-duck-dark/10 text-duck-dark/60'
}`}
>
{item.scope}
</span>
</div>
{item.description && <p className="text-xs text-duck-dark/50 mt-1 line-clamp-2">{item.description}</p>}
</button>
))}
{items.length === 0 && (
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No {kind.toLowerCase()}s found</p>
)}
{items.length > 0 && filtered.length === 0 && (
<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 [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [search, setSearch] = useState('');
const [showDetail, setShowDetail] = useState(false);
const newNameRef = useRef<HTMLInputElement | null>(null);
const { data: items = [] } = useQuery<CapabilitySummary[]>({
queryKey: [queryKey],
@@ -166,21 +312,11 @@ export const CapabilityPage = ({ kind, endpoint, queryKey }: CapabilityPageProps
setEditing(false);
};
const handleCreate = async () => {
const name = newName.trim();
if (!name) return;
try {
const res = await client.post<{ name: string; dirName: string }>(endpoint, { name });
await qc.invalidateQueries({ queryKey: [queryKey] });
setCreating(false);
setNewName('');
setSelected(res.dirName);
setShowDetail(true);
setIsNew(true);
setEditing(true);
} catch {
toast.error(`Failed to create ${kind}`);
}
const handleCreate = (dirName: string) => {
setSelected(dirName);
setShowDetail(true);
setIsNew(true);
setEditing(true);
};
const handleDelete = async () => {
@@ -197,13 +333,6 @@ export const CapabilityPage = ({ kind, endpoint, queryKey }: CapabilityPageProps
}
};
const filtered = items.filter(
(item) =>
!search ||
item.name.toLowerCase().includes(search.toLowerCase()) ||
item.description?.toLowerCase().includes(search.toLowerCase()),
);
return (
<>
<div className="flex h-full p-2 md:p-4 gap-2 md:gap-4">
@@ -211,97 +340,14 @@ export const CapabilityPage = ({ kind, endpoint, queryKey }: CapabilityPageProps
<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 && (
<button
onClick={() => {
setCreating(true);
setTimeout(() => newNameRef.current?.focus(), 0);
}}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
>
<Plus className="h-4 w-4 text-duck-dark/50" />
</button>
)}
</div>
{creating && (
<div className="shrink-0 px-3 py-2 border-b border-duck-dark/10 bg-duck-teal/5 flex items-center gap-1.5">
<input
ref={newNameRef}
value={newName}
onChange={(ev) => setNewName(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
handleCreate();
}
if (ev.key === 'Escape') {
setCreating(false);
setNewName('');
}
}}
placeholder={`${kind} name...`}
className="flex-1 min-w-0 rounded border border-duck-dark/20 bg-white px-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"
/>
<button
onClick={handleCreate}
disabled={!newName.trim()}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors disabled:opacity-30"
>
<Check className="h-3.5 w-3.5 text-duck-teal" />
</button>
<button
onClick={() => {
setCreating(false);
setNewName('');
}}
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="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)}
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)}
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'
}`}
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark truncate">{item.name}</span>
<span
className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
item.scope === 'user' ? 'bg-duck-teal/20 text-duck-teal' : 'bg-duck-dark/10 text-duck-dark/60'
}`}
>
{item.scope}
</span>
</div>
{item.description && <p className="text-xs text-duck-dark/50 mt-1 line-clamp-2">{item.description}</p>}
</button>
))}
{items.length === 0 && (
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No {kind.toLowerCase()}s found</p>
)}
{items.length > 0 && filtered.length === 0 && (
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No matches</p>
)}
</div>
<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',
direction: 'vertical',
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');
const panelComponents: PanelComponents = useMemo(
() => ({
'resources-left': ResourceSidebar,
'resources-right': Applications,
}),
[],
);
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>
);
})}
</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>
<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" />;
@@ -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>
);
@@ -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';