From 5da29e4004d250872006794bd3f43d4678073bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 23 Jul 2026 16:25:45 +0000 Subject: [PATCH] jobs: split left column into Active + History resizable panels (email-style) Co-Authored-By: Claude Opus 4.8 --- .../Screens/Dashboard/Jobs/JobsPage.tsx | 208 +++++++++--------- 1 file changed, 110 insertions(+), 98 deletions(-) diff --git a/src/apps/officer-web/Screens/Dashboard/Jobs/JobsPage.tsx b/src/apps/officer-web/Screens/Dashboard/Jobs/JobsPage.tsx index 6e261bd9..9b63bce4 100644 --- a/src/apps/officer-web/Screens/Dashboard/Jobs/JobsPage.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Jobs/JobsPage.tsx @@ -34,90 +34,105 @@ const StatusIcon = ({ status }: { status: JobSummary['status'] }) => { } }; -const SectionLabel = ({ children }: { children: ReactNode }) => ( -
- {children} -
-); - -// ── Left panel: the jobs list (polls so running/queued statuses stay fresh) ── -const JobsListPanel = () => { +// Jobs data — each list panel polls independently (cheap for a single user). +const useJobsData = () => { 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); - const load = useCallback( - () => client.get('/jobs').then((data) => { setJobs(data); setIsLoading(false); }).catch(() => setIsLoading(false)), + () => client.get('/jobs').then((d) => { setJobs(d); setIsLoading(false); }).catch(() => setIsLoading(false)), [client], ); - useEffect(() => { load(); const timer = setInterval(load, 2500); return () => clearInterval(timer); }, [load]); + const cancel = useCallback( + (ev: MouseEvent, id: string) => { ev.stopPropagation(); client.post(`/jobs/${id}/stop`, {}).then(() => load()).catch(() => {}); }, + [client, load], + ); + return { jobs, isLoading, cancel }; +}; - // Stop a running job / remove a queued one, then refresh immediately. - const cancel = (ev: MouseEvent, id: string) => { - ev.stopPropagation(); - client.post(`/jobs/${id}/stop`, {}).then(() => load()).catch(() => {}); - }; - - 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; - - // Active = running first, then the FIFO queue (oldest pending on top — next to run). History = the - // rest (already newest-first from the API). - const active = [ - ...filtered.filter((j) => j.status === 'running'), - ...filtered.filter((j) => j.status === 'pending').sort((a, b) => +new Date(a.createdAt) - +new Date(b.createdAt)), - ]; - const history = filtered.filter((j) => j.status !== 'running' && j.status !== 'pending'); - - const renderRow = (job: JobSummary) => { - const cancellable = job.status === 'running' || job.status === 'pending'; - return ( -
- + {cancellable && ( + - {cancellable && ( - - )} -
- ); - }; + )} + + ); +}; +const PanelHeader = ({ children }: { children: ReactNode }) => ( +
{children}
+); + +// Top-left panel: running first, then the FIFO queue (oldest pending on top — next to run). +const ActiveJobsPanel = () => { + const { jobs, isLoading, cancel } = useJobsData(); + const active = [ + ...jobs.filter((j) => j.status === 'running'), + ...jobs.filter((j) => j.status === 'pending').sort((a, b) => +new Date(a.createdAt) - +new Date(b.createdAt)), + ]; return (
-
-

Jobs

+ +

Running & Queued

+ {active.length > 0 && {active.length}} +
+
+ {isLoading ? ( +
Loading…
+ ) : active.length === 0 ? ( +
Nothing running
+ ) : ( + active.map((job) => ) + )} +
+ +
+ ); +}; + +// Bottom-left panel: finished / failed / stopped, newest first, searchable. +const HistoryJobsPanel = () => { + const { jobs, isLoading, cancel } = useJobsData(); + const [search, setSearch] = useState(''); + const history = jobs + .filter((j) => j.status !== 'running' && j.status !== 'pending') + .filter((j) => { + if (!search) return true; + const q = search.toLowerCase(); + return j.taskName.toLowerCase().includes(q) || j.taskDirName.toLowerCase().includes(q) || j.status.includes(q); + }); + return ( +
+ + +

History

{ placeholder="Search…" value={search} onChange={(ev) => 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" + className="w-full pl-8 pr-3 py-1 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…
+ ) : history.length === 0 ? ( +
{search ? 'No matches' : 'No finished jobs'}
+ ) : ( + history.map((job) => ) + )}
- - {isLoading ? ( -
Loading…
- ) : ( -
- {/* Active (running + queued) — top 70% */} -
- Running & Queued{active.length ? ` · ${active.length}` : ''} -
- {active.length === 0 ? ( -
Nothing running
- ) : ( - active.map(renderRow) - )} -
-
- {/* History — bottom 30% */} -
- History -
- {history.length === 0 ? ( -
{search ? 'No matches' : 'No finished jobs'}
- ) : ( - history.map(renderRow) - )} -
-
-
- )}
); @@ -210,17 +205,34 @@ const JobDetailPanel = () => { return ; }; +// Left column = two stacked panels (Active over History) with a resizable divider, like /email's +// reader/chat split. Right column = the detail. const JOBS_LAYOUT: LayoutNode = { type: 'group', id: 'jobs-root', direction: 'horizontal', children: [ - { node: { type: 'panel', id: 'jobs-list', appType: null }, size: 32 }, + { + node: { + type: 'group', + id: 'jobs-left', + direction: 'vertical', + children: [ + { node: { type: 'panel', id: 'jobs-active', appType: null }, size: 68 }, + { node: { type: 'panel', id: 'jobs-history', appType: null }, size: 32 }, + ], + }, + size: 32, + }, { node: { type: 'panel', id: 'job-detail', appType: null }, size: 68 }, ], }; -const PANEL_COMPONENTS: PanelComponents = { 'jobs-list': JobsListPanel, 'job-detail': JobDetailPanel }; +const PANEL_COMPONENTS: PanelComponents = { + 'jobs-active': ActiveJobsPanel, + 'jobs-history': HistoryJobsPanel, + 'job-detail': JobDetailPanel, +}; // One page for /jobs and /jobs/:id — master (list) + detail, resizable like /chat. export const JobsPage = () => {