);
// Top-left panel: running first, then the FIFO queue (oldest pending on top — next to run).
// TEMPORARY. The first producer for officer-notify, so the pipe can be exercised from the UI before any
// real event is wired to it. Sends { type: 'test' }, which reaches every configured channel — Discord
// today, APNs and FCM once their credentials exist. Delete this once a real producer replaces it.
const TestNotificationButton = () => {
const { t } = useTranslation();
const client = useClient();
const [sending, setSending] = useState(false);
const send = async () => {
setSending(true);
try {
// userId is omitted deliberately: the sidecar takes it from the header the proxy injects.
const res = await client.post<{ results?: { channel: string; sent: number; failed: number }[] }>(
'/notify/_officer/notify',
{ type: 'test' },
);
const results = res?.results ?? [];
if (results.length === 0) {
toast.warning(t('jobs.noChannels'));
} else {
toast.success(results.map((r) => `${r.channel}: ${r.sent} sent`).join(', '));
}
} catch {
toast.error(t('jobs.notifyFailed'));
} finally {
setSending(false);
}
};
return (
);
};
const ActiveJobsPanel = () => {
const { t } = useTranslation();
const { jobs, isLoading, act } = 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 (
);
}
// Pipeline detail brings its own full chrome; the script terminal + download progress get a card here.
if (mode === 'script') {
return (
);
}
if (mode === 'download') {
return (
);
}
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: '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-active': ActiveJobsPanel,
'jobs-history': HistoryJobsPanel,
'job-detail': JobDetailPanel,
};
// Guard the persisted layout against a stale shape (e.g. panel ids changed in a later build): the panels
// render from PANEL_COMPONENTS by id, so a layout whose panel ids don't match ours would render blanks.
// If it doesn't line up exactly, fall back to the default rather than trust the saved node.
const collectPanelIds = (node: LayoutNode, acc: Set): Set => {
if (node.type === 'panel') acc.add(node.id);
else for (const child of node.children) collectPanelIds(child.node, acc);
return acc;
};
const matchesPanelSet = (node: LayoutNode): boolean => {
const ids = collectPanelIds(node, new Set());
const expected = Object.keys(PANEL_COMPONENTS);
return ids.size === expected.length && expected.every((id) => ids.has(id));
};
// One page for /jobs and /jobs/:id — master (list) + detail, resizable like /chat. The layout is
// persisted per-user via useDashboardState (screens/ namespace), so pane sizes survive reloads.
// Wait for isLoaded before mounting: ResizablePanel reads defaultSize only at mount, so mounting
// during the query's loading window would freeze the fallback sizes and ignore the persisted layout
// that arrives after (the "survives nav but not refresh" bug — on nav the query is already cached).
export const JobsPage = () => {
const { value, setValue, isLoaded } = useDashboardState('screens/jobs', JOBS_LAYOUT);
if (!isLoaded) return ;
const layout = matchesPanelSet(value) ? value : JOBS_LAYOUT;
return (