diff --git a/docs/jobs-unification.md b/docs/jobs-unification.md index 61092002..d1c6d84e 100644 --- a/docs/jobs-unification.md +++ b/docs/jobs-unification.md @@ -76,9 +76,10 @@ Favor power-user affordances over guardrails. See memory `sole-user-assume-compe Router mounted at `/jobs` and `/pipeline-jobs`. *Needs a restart to deploy; then curl/phone-testable.* WS consolidation still pending (old `/api/tasks/run/ws` + `/api/tasks/pipeline/ws` still live). - [~] 3 frontend - - [x] 3a JobDetail script branch — `ScriptJobDetail` (terminal, polls `GET /jobs/:id/log` + status, - Stop button) + `JobDetailView` wrapper routing by `mode` (pipeline detail renamed - `PipelineJobDetail`). Script jobs are now viewable at `/jobs/:id`. + - [x] 3a jobs UI — **master-detail** `JobsPage` (like `/chat`): `WorkspaceLayout` with a list panel + (left, polls `GET /jobs`, highlights active) + a detail panel (right) that branches by `mode` + — `ScriptJobDetail` terminal (polls log + status, Stop) or `PipelineJobDetail`. One page serves + both `/jobs` and `/jobs/:id`; list click navigates. Replaced the old separate list/detail pages. - [ ] 3b `/jobs/new` page — extract the input UI (TaskInputForm + per-group config + folder probing) from `TaskRunnerModal` into a shared component; Run/Queue → `POST /jobs` → navigate. - [x] `inline` flag plumbed (parser + list/detail endpoints); 3 quick tasks flagged. diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 614e027f..2528980f 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -64,8 +64,8 @@ export function App() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetailView.tsx b/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetailView.tsx deleted file mode 100644 index 1799666d..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetailView.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useState, useEffect } from 'react'; -import { useParams } from 'react-router'; -import { Loader2 } from 'lucide-react'; -import { useClient } from 'hooks/useClient'; -import { PipelineJobDetail } from './JobDetail'; -import { ScriptJobDetail } from './ScriptJobDetail'; - -// Route entry for /jobs/:id — picks the right detail view by job mode (script terminal vs pipeline steps). -export const JobDetail = () => { - const { id } = useParams<{ id: string }>(); - const client = useClient(); - const [mode, setMode] = useState(null); - const [notFound, setNotFound] = useState(false); - - useEffect(() => { - if (!id) return; - client - .get<{ mode?: string }>(`/jobs/${id}`) - .then((j) => setMode(j.mode ?? 'pipeline')) - .catch(() => setNotFound(true)); - }, [id]); - - if (notFound) { - return
Job not found.
; - } - if (mode === null) { - return ( -
- -
- ); - } - return mode === 'script' ? : ; -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Jobs/JobsPage.tsx b/src/apps/officer-web/Screens/Dashboard/Jobs/JobsPage.tsx new file mode 100644 index 00000000..c215b8eb --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Jobs/JobsPage.tsx @@ -0,0 +1,177 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router'; +import { Search, CheckCircle2, AlertCircle, Clock, Loader2, StopCircle, AlertTriangle, Inbox } from 'lucide-react'; +import { WorkspaceLayout } from 'officerdev'; +import type { LayoutNode, PanelComponents } from 'officerdev'; +import { useClient } from 'hooks/useClient'; +import { Card } from '@/components/Card'; +import { ScriptJobDetail } from './ScriptJobDetail'; +import { PipelineJobDetail } from './JobDetail'; + +type JobSummary = { + id: string; + mode: string; + taskDirName: string; + taskName: string; + status: 'pending' | 'running' | 'completed' | 'failed' | 'stopped' | 'interrupted'; + exitCode: number | null; + totalCost: { inputTokens: number; outputTokens: number; totalUSD: number } | null; + createdAt: string; + error: string | null; +}; + +const formatDate = (iso: string) => + new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + +const StatusIcon = ({ status }: { status: JobSummary['status'] }) => { + switch (status) { + case 'completed': return ; + case 'failed': return ; + case 'running': return ; + case 'stopped': return ; + case 'interrupted': return ; + case 'pending': return ; + } +}; + +// ── Left panel: the jobs list (polls so running/queued statuses stay fresh) ── +const JobsListPanel = () => { + const client = useClient(); + const navigate = useNavigate(); + const { id: activeId } = useParams<{ id: string }>(); + const [jobs, setJobs] = useState([]); + const [search, setSearch] = useState(''); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let alive = true; + const load = () => + client.get('/jobs').then((data) => { if (alive) { setJobs(data); setIsLoading(false); } }).catch(() => alive && setIsLoading(false)); + load(); + const timer = setInterval(load, 2500); + return () => { alive = false; clearInterval(timer); }; + }, []); + + const filtered = search + ? jobs.filter((j) => { + const q = search.toLowerCase(); + return j.taskName.toLowerCase().includes(q) || j.taskDirName.toLowerCase().includes(q) || j.status.includes(q); + }) + : jobs; + + return ( +
+ +
+

Jobs

+
+ + setSearch(ev.target.value)} + className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-duck-dark/15 bg-background/60 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/40" + /> +
+
+ +
+ {isLoading &&
Loading…
} + {!isLoading && filtered.length === 0 && ( +
+ {search ? 'No jobs match' : 'No jobs yet'} +
+ )} + {filtered.map((job) => ( + + ))} +
+
+
+ ); +}; + +// ── Right panel: the selected job's detail, by mode (script terminal / pipeline steps) ── +const JobDetailPanel = () => { + const { id } = useParams<{ id: string }>(); + const client = useClient(); + const [mode, setMode] = useState(null); + + useEffect(() => { + if (!id) { setMode(null); return; } + setMode(null); + client.get<{ mode?: string }>(`/jobs/${id}`).then((j) => setMode(j.mode ?? 'pipeline')).catch(() => setMode('notfound')); + }, [id]); + + if (!id) { + return ( +
+ + + Select a job + +
+ ); + } + if (mode === null) { + return ( +
+ +
+ ); + } + if (mode === 'notfound') { + return ( +
+ Job not found. +
+ ); + } + // Pipeline detail brings its own full chrome; the script terminal gets a card background here. + if (mode === 'script') { + return ( +
+ +
+ ); + } + return ; +}; + +const JOBS_LAYOUT: LayoutNode = { + type: 'group', + id: 'jobs-root', + direction: 'horizontal', + children: [ + { node: { type: 'panel', id: 'jobs-list', appType: null }, size: 32 }, + { node: { type: 'panel', id: 'job-detail', appType: null }, size: 68 }, + ], +}; + +const PANEL_COMPONENTS: PanelComponents = { 'jobs-list': JobsListPanel, 'job-detail': JobDetailPanel }; + +// One page for /jobs and /jobs/:id — master (list) + detail, resizable like /chat. +export const JobsPage = () => { + const [layout, setLayout] = useState(JOBS_LAYOUT); + return ( +
+ +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Jobs/JobsScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Jobs/JobsScreen.tsx deleted file mode 100644 index 1365baee..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Jobs/JobsScreen.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router'; -import { Search, CheckCircle2, AlertCircle, Clock, Loader2, StopCircle, AlertTriangle } from 'lucide-react'; -import { useClient } from 'hooks/useClient'; -import { Card } from '@/components/Card'; - -type JobSummary = { - id: string; - taskDirName: string; - taskName: string; - status: 'pending' | 'running' | 'completed' | 'failed' | 'stopped' | 'interrupted'; - totalCost: { inputTokens: number; outputTokens: number; totalUSD: number } | null; - createdAt: string; - startedAt: string | null; - completedAt: string | null; - error: string | null; -}; - -const formatDate = (iso: string) => { - const d = new Date(iso); - return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); -}; - -const formatCost = (cost: number) => `$${cost.toFixed(4)}`; - -const StatusIcon = ({ status }: { status: JobSummary['status'] }) => { - switch (status) { - case 'completed': - return ; - case 'failed': - return ; - case 'running': - return ; - case 'stopped': - return ; - case 'interrupted': - return ; - case 'pending': - return ; - } -}; - -const StatusBadge = ({ status }: { status: JobSummary['status'] }) => { - const colors: Record = { - completed: 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300', - failed: 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300', - running: 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300', - stopped: 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300', - interrupted: 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300', - pending: 'bg-gray-100 dark:bg-gray-800/40 text-gray-600 dark:text-gray-400', - }; - - return ( - - {status} - - ); -}; - -export const JobsScreen = () => { - const client = useClient(); - const navigate = useNavigate(); - const [jobs, setJobs] = useState([]); - const [search, setSearch] = useState(''); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - client - .get('/pipeline-jobs') - .then((data) => { - setJobs(data); - setIsLoading(false); - }) - .catch(() => setIsLoading(false)); - }, []); - - const filtered = search - ? jobs.filter((j) => { - const q = search.toLowerCase(); - return j.taskName.toLowerCase().includes(q) || j.taskDirName.toLowerCase().includes(q) || j.status.includes(q); - }) - : jobs; - - return ( -
- -
-

Pipeline Jobs

-
- - setSearch(ev.target.value)} - className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-duck-dark/15 bg-background/60 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/40" - /> -
-
- -
- {isLoading && ( -
Loading...
- )} - {!isLoading && filtered.length === 0 && ( -
- {search ? 'No jobs match your search' : 'No pipeline jobs yet'} -
- )} - {filtered.map((job) => ( - - ))} -
-
-
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Jobs/index.tsx b/src/apps/officer-web/Screens/Dashboard/Jobs/index.tsx index 861b6e81..567e5503 100644 --- a/src/apps/officer-web/Screens/Dashboard/Jobs/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Jobs/index.tsx @@ -1,2 +1 @@ -export { JobsScreen } from './JobsScreen'; -export { JobDetail } from './JobDetailView'; +export { JobsPage } from './JobsPage';