diff --git a/bun.lock b/bun.lock index 5b199c84..4bf82bee 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index e19dafb6..6a2fde68 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 4bfc259f..c1aa3d20 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -40,6 +40,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Automation/AutomationEditChat.tsx b/src/apps/officer-web/Screens/Dashboard/Automation/AutomationEditChat.tsx new file mode 100644 index 00000000..3eb6d4da --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Automation/AutomationEditChat.tsx @@ -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('automation:selected-capability', null); + + const { data: detail } = useQuery({ + queryKey: [selection?.queryKey, selection?.dirName], + queryFn: () => client.get(`${selection!.endpoint}/${selection!.dirName}`), + enabled: !!selection, + }); + + const closeChat = () => { + if (!selection) return; + setSelection({ ...selection, editing: false }); + }; + + if (!selection || !detail?.filePath) { + return ( +
+

Select a resource and click edit to chat

+
+ ); + } + + return ( +
+
+ {detail.name ?? 'Chat'} + +
+ qc.invalidateQueries({ queryKey: [selection.queryKey, selection.dirName] })} + /> +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Automation/AutomationRightPanel.tsx b/src/apps/officer-web/Screens/Dashboard/Automation/AutomationRightPanel.tsx new file mode 100644 index 00000000..6053f7f9 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Automation/AutomationRightPanel.tsx @@ -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 }>> = { + Task: NewTask, + Skill: NewSkill, + Process: NewProcess, + Pipeline: NewPipeline, + Cron: NewCron, + Service: NewService, + Workflow: NewWorkflow, +}; + +export const AutomationRightPanel = () => { + const [selection] = usePanelChannel('automation:selected-capability', null); + + if (!selection) { + return ( +
+

Select a category from the sidebar

+
+ ); + } + + if (selection.creating) { + const NewComponent = newComponentMap[selection.kind]; + if (NewComponent) return ; + } + + if (!selection.dirName) { + return ; + } + + return ; +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Automation/AutomationSidebar.tsx b/src/apps/officer-web/Screens/Dashboard/Automation/AutomationSidebar.tsx new file mode 100644 index 00000000..75d33723 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Automation/AutomationSidebar.tsx @@ -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('automation:selected-capability', null); + + return ( +
+
+
+ + Automation +
+
+
+ {capabilityItems.map((item) => { + const Icon = item.icon; + const active = selection?.queryKey === item.queryKey; + return ( + + ); + })} +
+
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Automation/CapabilityDetailView.tsx b/src/apps/officer-web/Screens/Dashboard/Automation/CapabilityDetailView.tsx new file mode 100644 index 00000000..88c3ce25 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Automation/CapabilityDetailView.tsx @@ -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('automation:selected-capability', null); + const [deleteConfirm, setDeleteConfirm] = useState(false); + + const { data: detail } = useQuery({ + queryKey: [queryKey, dirName], + queryFn: () => client.get(`${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 ( + <> + +
+ + {detail?.name ?? `Loading...`} + {detail && ( + <> + + + + )} +
+
+ {detail?.rawFrontmatter && } + {detail?.body ? ( +
+ + {detail.body} + +
+ ) : detail ? ( +

Empty file

+ ) : null} +
+
+ + + + + Delete {kind} + + Are you sure you want to delete "{detail?.name}"? This action cannot be undone. + + +
+ + +
+
+
+ + ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Automation/CapabilityList.tsx b/src/apps/officer-web/Screens/Dashboard/Automation/CapabilityList.tsx new file mode 100644 index 00000000..eb23f94b --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Automation/CapabilityList.tsx @@ -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('automation:selected-capability', null); + const [search, setSearch] = useState(''); + + const { data: items = [] } = useQuery({ + queryKey: [queryKey], + queryFn: () => client.get(endpoint), + }); + + const q = search.toLowerCase(); + const filtered = search + ? items.filter((c) => c.name.toLowerCase().includes(q) || c.description?.toLowerCase().includes(q)) + : items; + + return ( +
+
+ {kind}s + +
+
+
+ + 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" + /> +
+
+
+
+ {filtered.map((c) => ( + + ))} + {filtered.length === 0 && ( +

+ {items.length === 0 ? `No ${kind.toLowerCase()}s yet` : 'No matches'} +

+ )} +
+
+
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Automation/NewCron.tsx b/src/apps/officer-web/Screens/Dashboard/Automation/NewCron.tsx new file mode 100644 index 00000000..b71cd194 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Automation/NewCron.tsx @@ -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; +}; + +export const NewCron = ({ selection }: NewCronProps) => { + const client = useClient(); + const qc = useQueryClient(); + const [, setSelection] = usePanelChannel('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 ( +
+
+ New Cron + +
+
+
+ + 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" + /> +
+
+ +