remove the Automation page

Deletes the Automation and NewAutomation screens, their /automation and
/new-automation routes, the Dashboard barrel exports, the dock item (+ default
dock path), and the page-title rule. TaskRunnerModal is kept — the file browser
still uses it to run tasks on files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 14:39:09 +00:00
co-authored by Claude Opus 4.8
parent 496f80dd5c
commit 6f7bf8df42
9 changed files with 2 additions and 581 deletions
-2
View File
@@ -50,8 +50,6 @@ export function App() {
/>
<Route path="/settings/integrations" element={<Dashboard.IntegrationsSettings />} />
<Route path="/settings/apps" element={<Dashboard.AppsSettings />} />
<Route path="/automation" element={<Dashboard.Automation />} />
<Route path="/new-automation" element={<Dashboard.NewAutomation />} />
<Route path="/chat" element={<Dashboard.SessionListPage />} />
<Route path="/chat/new" element={<Dashboard.SessionListPage isNew />} />
<Route path="/chat/saved/:id" element={<Dashboard.SessionListPage />} />
@@ -1,232 +0,0 @@
import { useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { Play, Trash2, Terminal, Bot, Workflow } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Card } from '@/components/Card';
import { TaskRunnerModal } from 'officerdev';
import type { TaskSummary } from 'officerdev';
type TaskDetail = {
dirName: string;
name: string;
description: string;
mode: string;
language: string | null;
body: string | null;
inputs: Record<string, unknown> | null;
config: { steps?: Array<{ task: string; foreach?: string }> } | null;
version: number | null;
};
const modeLabels: Record<string, { label: string; icon: typeof Terminal; color: string }> = {
script: { label: 'Script', icon: Terminal, color: 'bg-emerald-500/10 text-emerald-600' },
agentic: { label: 'Agentic', icon: Bot, color: 'bg-violet-500/10 text-violet-600' },
pipeline: { label: 'Pipeline', icon: Workflow, color: 'bg-amber-500/10 text-amber-600' },
};
export const AutomationDetail = () => {
const client = useClient();
const qc = useQueryClient();
const [selected, setSelected] = usePanelChannel<TaskSummary | null>('automation:selected-task', null);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [runTask, setRunTask] = useState<TaskSummary | null>(null);
// Reset delete confirm when selection changes
useEffect(() => {
setDeleteConfirm(false);
}, [selected?.dirName]);
const { data: detail } = useQuery<TaskDetail>({
queryKey: ['tasks', selected?.dirName],
queryFn: () => client.get<TaskDetail>(`/tasks/${selected!.dirName}`),
enabled: !!selected?.dirName,
});
const handleDelete = async () => {
if (!selected) return;
try {
await client.delete(`/tasks/${selected.dirName}`);
await qc.invalidateQueries({ queryKey: ['tasks'] });
setDeleteConfirm(false);
setSelected(null);
toast.success('Automation deleted');
} catch {
toast.error('Failed to delete automation');
}
};
if (!selected) {
return (
<div className="h-full flex items-center justify-center">
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">Select an automation to view details</p>
</div>
);
}
const mode = modeLabels[selected.mode] ?? modeLabels.agentic!;
const ModeIcon = mode.icon;
const hasSteps = detail?.config?.steps && detail.config.steps.length > 0;
const hasInputs = detail?.inputs && Object.keys(detail.inputs).length > 0;
return (
<>
<Card className="h-full overflow-hidden flex flex-col min-h-0">
{/* Header */}
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1 truncate">
{detail?.name ?? selected.name}
</span>
<span
className={`shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium ${mode.color}`}
>
<ModeIcon className="h-2.5 w-2.5" />
{mode.label}
</span>
<button
onClick={() => setRunTask(selected)}
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
title="Run"
>
<Play className="h-3.5 w-3.5 text-duck-teal" />
</button>
<button
onClick={() => setDeleteConfirm(true)}
className="p-1 rounded hover:bg-red-50 dark:hover:bg-red-500/10 cursor-pointer transition-colors"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50 hover:text-red-500" />
</button>
</div>
{/* Content */}
<div className="overflow-y-auto flex-1 p-6">
{/* Description */}
{detail?.description && (
<p className="text-sm text-duck-dark/60 dark:text-foreground/60 mb-4">{detail.description}</p>
)}
{/* Meta badges */}
<div className="flex flex-wrap gap-2 mb-4">
{detail?.language && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-duck-dark/8 dark:bg-foreground/8 text-duck-dark/50 dark:text-foreground/50 font-medium">
{detail.language}
</span>
)}
{detail?.version && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-duck-dark/8 dark:bg-foreground/8 text-duck-dark/50 dark:text-foreground/50 font-medium">
v{detail.version}
</span>
)}
</div>
{/* Pipeline steps */}
{hasSteps && (
<div className="mb-4">
<h3 className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wider mb-2">
Pipeline Steps
</h3>
<div className="flex flex-col gap-1">
{detail!.config!.steps!.map((step, i) => (
<div
key={i}
className="flex items-center gap-2 text-sm text-duck-dark/70 dark:text-foreground/70 px-2 py-1 rounded bg-duck-dark/3 dark:bg-foreground/3"
>
<span className="w-5 text-center font-mono text-xs text-duck-dark/40 dark:text-foreground/40">
{i + 1}
</span>
<span className="font-medium">{step.task}</span>
{step.foreach && (
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
(foreach: {step.foreach})
</span>
)}
</div>
))}
</div>
</div>
)}
{/* Inputs */}
{hasInputs && (
<div className="mb-4">
<h3 className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wider mb-2">
Inputs
</h3>
<div className="flex flex-col gap-1">
{Object.entries(detail!.inputs!).map(([key, def]) => {
const d = def as { type?: string; description?: string; default?: string };
return (
<div
key={key}
className="flex items-baseline gap-2 text-sm px-2 py-1 rounded bg-duck-dark/3 dark:bg-foreground/3"
>
<span className="font-mono text-xs text-duck-teal">{key}</span>
{d.type && (
<span className="text-[10px] text-duck-dark/40 dark:text-foreground/40">{d.type}</span>
)}
{d.description && (
<span className="text-xs text-duck-dark/50 dark:text-foreground/50 flex-1">
{d.description}
</span>
)}
</div>
);
})}
</div>
</div>
)}
{/* Body (agent instructions) */}
{detail?.body ? (
<article className="skill-md">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{detail.body}
</ReactMarkdown>
</article>
) : detail && !hasSteps ? (
<p className="text-sm text-duck-dark/40 dark:text-foreground/40 text-center mt-12">No instructions</p>
) : null}
</div>
</Card>
{/* Delete confirmation */}
<Dialog open={deleteConfirm} onOpenChange={setDeleteConfirm}>
<DialogContent className="sm:max-w-md z-[700]">
<DialogHeader>
<DialogTitle>Delete Automation</DialogTitle>
<DialogDescription>
Are you sure you want to delete &quot;{selected.name}&quot;? This cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 mt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer">
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete} className="cursor-pointer">
Delete
</Button>
</div>
</DialogContent>
</Dialog>
{/* Run modal */}
{runTask && (
<TaskRunnerModal
open
onOpenChange={(open) => {
if (!open) setRunTask(null);
}}
task={runTask}
/>
)}
</>
);
};
@@ -1,202 +0,0 @@
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Plus, Search, Play, Terminal, Bot, Workflow } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { TaskRunnerModal } from 'officerdev';
import type { TaskSummary } from 'officerdev';
const modeIcons = {
script: Terminal,
agentic: Bot,
pipeline: Workflow,
} as const;
const modeColors = {
script: 'bg-emerald-500/10 text-emerald-600',
agentic: 'bg-violet-500/10 text-violet-600',
pipeline: 'bg-amber-500/10 text-amber-600',
} as const;
export const AutomationList = () => {
const client = useClient();
const qc = useQueryClient();
const [selected, setSelected] = usePanelChannel<TaskSummary | null>('automation:selected-task', null);
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [createName, setCreateName] = useState('');
const [createDesc, setCreateDesc] = useState('');
const [creating, setCreating] = useState(false);
const [runTask, setRunTask] = useState<TaskSummary | null>(null);
const { data: tasks = [] } = useQuery<TaskSummary[]>({
queryKey: ['tasks'],
queryFn: () => client.get('/tasks'),
staleTime: 60_000,
});
const q = search.toLowerCase();
const filtered = search
? tasks.filter((t) => t.name.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q))
: tasks;
const handleCreate = async () => {
const name = createName.trim();
if (!name) return;
setCreating(true);
try {
await client.post('/tasks', { name, description: createDesc.trim() || undefined });
await qc.invalidateQueries({ queryKey: ['tasks'] });
setShowCreate(false);
setCreateName('');
setCreateDesc('');
toast.success('Automation created');
} catch {
toast.error('Failed to create automation');
} finally {
setCreating(false);
}
};
return (
<>
<div className="h-full flex flex-col">
{/* Header */}
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">Automations</span>
<button
onClick={() => setShowCreate(true)}
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
>
<Plus className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
</button>
</div>
{/* Search */}
<div className="px-3 pt-3">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30 dark:text-foreground/30" />
<input
value={search}
onChange={(ev) => setSearch(ev.target.value)}
placeholder="Search..."
className="w-full rounded border border-duck-dark/15 dark:border-foreground/15 bg-background/80 pl-7 pr-2 py-1.5 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
/>
</div>
</div>
{/* Task list */}
<div className="flex-1 overflow-y-auto px-3 pt-2 pb-3">
<div className="flex flex-col gap-0.5">
{filtered.map((task) => {
const ModeIcon = modeIcons[task.mode] ?? Bot;
const modeColor = modeColors[task.mode] ?? modeColors.agentic;
const isSelected = selected?.dirName === task.dirName;
return (
<div
key={task.dirName}
className={`group flex items-center gap-2 w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
isSelected
? 'bg-duck-teal/10 text-duck-dark dark:text-foreground'
: 'text-duck-dark/60 dark:text-foreground/60 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
}`}
>
<button
onClick={() => setSelected(task)}
className="flex-1 min-w-0 text-left cursor-pointer"
>
<div className="flex items-center gap-1.5">
<span className="font-medium truncate">{task.name}</span>
<span className={`shrink-0 inline-flex items-center gap-0.5 px-1 py-0.5 rounded text-[9px] font-medium ${modeColor}`}>
<ModeIcon className="h-2.5 w-2.5" />
</span>
</div>
{task.description && (
<p className="text-xs text-duck-dark/40 dark:text-foreground/40 mt-0.5 line-clamp-1">{task.description}</p>
)}
</button>
<button
onClick={(ev) => { ev.stopPropagation(); setRunTask(task); }}
className="p-1 rounded hover:bg-duck-teal/10 transition-colors cursor-pointer opacity-0 group-hover:opacity-100 shrink-0"
title="Run"
>
<Play className="h-3 w-3 text-duck-teal" />
</button>
</div>
);
})}
{tasks.length === 0 && (
<p className="text-xs text-duck-dark/40 dark:text-foreground/40 px-3 py-4 text-center">
No automations yet
</p>
)}
{tasks.length > 0 && filtered.length === 0 && (
<p className="text-xs text-duck-dark/40 dark:text-foreground/40 px-3 py-4 text-center">
No matches
</p>
)}
</div>
</div>
</div>
{/* Create dialog */}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogContent className="sm:max-w-md z-[700]">
<DialogHeader>
<DialogTitle>Create Automation</DialogTitle>
<DialogDescription>Give your automation a name and optional description.</DialogDescription>
</DialogHeader>
<form
onSubmit={(ev) => { ev.preventDefault(); handleCreate(); }}
className="flex flex-col gap-3 mt-1"
>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
<input
value={createName}
onChange={(ev) => setCreateName(ev.target.value)}
placeholder="e.g. Batch Resize Images"
autoFocus
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
<textarea
value={createDesc}
onChange={(ev) => setCreateDesc(ev.target.value)}
placeholder="What does this automation do?"
rows={3}
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
/>
</div>
<div className="flex justify-end gap-2 mt-1">
<Button type="button" variant="outline" onClick={() => setShowCreate(false)} className="cursor-pointer">
Cancel
</Button>
<Button
type="submit"
disabled={!createName.trim() || creating}
className="bg-duck-teal text-white hover:bg-duck-teal/90 cursor-pointer"
>
{creating ? 'Creating...' : 'Create'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
{/* Run modal */}
{runTask && (
<TaskRunnerModal
open
onOpenChange={(open) => { if (!open) setRunTask(null); }}
task={runTask}
/>
)}
</>
);
};
@@ -1,49 +0,0 @@
# Automation
## Overview
The `/automation` route shows all user automations (tasks) in a standard `WorkspaceView` layout: left panel for the list, right panel for task details.
## Route
```tsx
<Route path="/automation" element={<Dashboard.Automation />} />
```
## Layout
```
+------------------+------------------------------------------+
| List (30%) | Detail (70%) |
| | |
| Search | Task name, mode, description |
| Task items | Pipeline steps / Inputs / Body |
| + Create | Run / Delete actions |
+------------------+------------------------------------------+
```
Uses `useDashboardState('screens/automation')` for persistent layout and `usePanelChannel<TaskSummary>('automation:selected-task')` for cross-panel selection.
## Components
### `index.tsx`
Entry point. Sets up `WorkspaceView` with two panels, manages mobile panel switching.
### `AutomationList.tsx`
Left panel. Fetches tasks from `GET /tasks`, shows searchable list with mode badges (Script/Agentic/Pipeline). Has create dialog and run button per task.
### `AutomationDetail.tsx`
Right panel. Fetches task detail from `GET /tasks/:name`. Shows description, pipeline steps, input definitions, and markdown body. Run and delete actions.
## Task Data
Tasks come from `officerdb` database. Modes:
- **script** (green) — deterministic bash/python/ts
- **agentic** (purple) — AI-powered with MCP tools
- **pipeline** (amber) — multi-step orchestration
## Related
- `TaskRunnerModal` in `officerdev` workspace handles execution for all modes
- `CapabilityPage.tsx` — legacy standalone pages for `/skills`, `/tasks`, `/processes`
- File browser context menu triggers via `useTasks` hook
@@ -1,53 +0,0 @@
import { useCallback, useMemo } from 'react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useIsMobile } from 'hooks/useIsMobile';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useDashboardState } from 'state/useDashboardState';
import type { TaskSummary } from 'officerdev';
import { AutomationList } from './AutomationList';
import { AutomationDetail } from './AutomationDetail';
export type { TaskSummary as SelectedTask };
const defaultLayout: LayoutNode = {
type: 'group',
id: 'automation-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'automation-list', appType: null }, size: 30 },
{ node: { type: 'panel', id: 'automation-detail', appType: null }, size: 70 },
],
};
export const Automation = () => {
const workspace = useDashboardState<LayoutNode>('screens/automation', defaultLayout);
const [selected] = usePanelChannel<TaskSummary | null>('automation:selected-task', null);
const isMobile = useIsMobile();
const panelComponents: PanelComponents = useMemo(
() => ({
'automation-list': AutomationList,
'automation-detail': AutomationDetail,
}),
[],
);
const mobilePanelId = isMobile && selected ? 'automation-detail' : undefined;
const onMobileBack = useCallback(() => {}, []);
if (!workspace.isLoaded) return null;
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
components={panelComponents}
mobilePanelId={mobilePanelId}
onMobilePanelChange={mobilePanelId ? () => onMobileBack() : undefined}
/>
</div>
);
};
@@ -110,7 +110,7 @@ export const Dock = ({ items, className }: DockProps) => {
};
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor, Mail, Globe, MonitorSmartphone, Workflow } from 'lucide-react';
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, ScrollText, FolderKanban, Monitor, Mail, Globe, MonitorSmartphone, Workflow } from 'lucide-react';
export const ALL_DOCK_ITEMS: DockItem[] = [
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
@@ -119,7 +119,6 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
{ label: 'Automation', to: '/automation', icon: Bot, color: '#2dd4bf' },
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
@@ -129,4 +128,4 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
];
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/automation', '/projects', '/dashboards', '/chat'];
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/projects', '/dashboards', '/chat'];
@@ -1,37 +0,0 @@
import { useMemo } from 'react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
const LeftPanel = () => {
return <div className="h-full w-full p-4" />;
};
const RightPanel = () => {
return <div className="h-full w-full p-4" />;
};
const layout: LayoutNode = {
type: 'group',
id: 'new-automation-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'new-automation-left', appType: null }, size: 30 },
{ node: { type: 'panel', id: 'new-automation-right', appType: null }, size: 70 },
],
};
export const NewAutomation = () => {
const panelComponents: PanelComponents = useMemo(
() => ({
'new-automation-left': LeftPanel,
'new-automation-right': RightPanel,
}),
[],
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
);
};
@@ -6,8 +6,6 @@ export * from './Plans';
export * from './Processes';
export * from './CapabilityPage';
export * from './Settings';
export * from './Automation';
export * from './NewAutomation';
export * from './Skills';
export * from './TaskLogs';
export * from './Tasks';
@@ -23,7 +23,6 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/skills'), title: 'Skills' },
{ match: (p) => p.startsWith('/processes'), title: 'Processes' },
{ match: (p) => p.startsWith('/dashboards'), title: 'Dashboards' },
{ match: (p) => p.startsWith('/automation') || p.startsWith('/new-automation'), title: 'Automation' },
{ match: (p) => p.startsWith('/terminal'), title: 'Terminal' },
{ match: (p) => p.startsWith('/browser'), title: 'Browser' },
{ match: (p) => p.startsWith('/desktop'), title: 'Desktop' },