WORKFLOW-APP/app/tasks/TaskDetail.tsx
Dave c349998fa1 feat: Task Kanban UI + Python task worker
Adds the visible front-end and the worker back-end for the agent
task system. The PocketBase schema, API routes, and ToastProvider
were already in the previous commit; this commit completes the
picture.

UI
- app/tasks/{page,KanbanBoard,TaskCard,CardModal,TaskDetail,api,columns}
  Full Kanban with six columns (Backlog, Ready, In Progress,
  Blocked, Done, Failed), drag-and-drop between columns, quick-add
  chips, type/priority filters, task detail panel with retry/cancel/
  delete, 5s polling, transition toasts.
- components/NavBar.tsx: 'Tasks' link in primary nav.
- app/bizportal-2/BizPortal2Explorer.tsx: 'Send to Kanban' button
  on the result view — dispatches the current search to the agent
  queue as a 'ready' task.

Back-end
- scripts/task_worker.py: PocketBase polling worker with two
  lanes. Deterministic handles bizportal_search/reserve, pdf_generate,
  email_send. Custom lane writes to /tmp/agent_inbox/ for the
  LLM agent to pick up. Atomic claim via PB filter, auto-retry up
  to 3x, structured log on each task.
- scripts/bizportal_service.py: bugfix — _is_logged_in was using
  a non-CSS selector so session-recovery was a no-op. Also made
  the login flow re-runnable mid-session (handles BizPortal's
  ~20-minute session timeout transparently).
- scripts/init_pb_schema.py: duration_ms no longer capped at 0.

Verified end-to-end:
- CK search via worker: 1 company, 3 directors, ~10s
- ID search via worker: 4 companies, 7 total directors, ~34s
- Auto-retry + status transitions + toast fires
2026-08-17 18:57:02 +02:00

183 lines
7.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// app/tasks/TaskDetail.tsx
// Expanded panel for a single task. Shows log, result, error. Has
// retry, cancel, and delete buttons.
"use client";
import { useState } from "react";
import { TASK_TYPE_META, STATUS_LABEL, PRIORITY_LABEL } from "./columns";
import { type Task, retryTask, cancelTask, deleteTask } from "./api";
interface TaskDetailProps {
task: Task;
onClose: () => void;
onMutated: () => void;
onToast: (kind: "info" | "success" | "error" | "warn", title: string, body?: string) => void;
}
export default function TaskDetail({ task, onClose, onMutated, onToast }: TaskDetailProps) {
const [busy, setBusy] = useState<string | null>(null);
const meta = TASK_TYPE_META[task.task_type];
async function act(label: string, fn: () => Promise<any>) {
setBusy(label);
try {
await fn();
onMutated();
onToast("success", `${label} done`);
} catch (err: any) {
onToast("error", `${label} failed`, err.message);
} finally {
setBusy(null);
}
}
return (
<div
className="fixed inset-0 z-40 bg-black/40 flex items-start justify-center pt-12 px-4 overflow-y-auto"
onClick={onClose}
role="dialog"
aria-modal="true"
>
<div
className="bg-surface rounded-lg border border-border shadow-xl w-full max-w-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="p-5 space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-2">
<span className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border ${meta.tone}`}>
{meta.icon} {meta.label}
</span>
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border bg-blue-50 text-blue-700 border-blue-200">
{STATUS_LABEL[task.status]}
</span>
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border bg-amber-50 text-amber-800 border-amber-200">
{PRIORITY_LABEL[task.priority]}
</span>
{task.retry_count > 0 ? (
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border bg-orange-50 text-orange-800 border-orange-300">
retry ×{task.retry_count}
</span>
) : null}
</div>
<h2 className="text-xl font-bold text-fg leading-tight">{task.title}</h2>
{task.description ? (
<p className="text-sm text-muted mt-2 whitespace-pre-wrap">{task.description}</p>
) : null}
</div>
<button
type="button"
onClick={onClose}
className="text-muted hover:text-fg text-sm"
aria-label="Close"
>
✕
</button>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<Meta label="Assignee" value={task.assignee ?? "—"} />
<Meta label="Claimed by" value={task.claimed_by ?? "—"} />
<Meta label="Created" value={formatTime(task.created)} />
<Meta label="Claimed at" value={formatTime(task.claimed_at)} />
<Meta label="Started" value={formatTime(task.started_at)} />
<Meta label="Completed" value={formatTime(task.completed_at)} />
<Meta label="Duration" value={task.duration_ms != null ? `${(task.duration_ms / 1000).toFixed(2)}s` : "—"} />
<Meta label="Created by" value={task.created_by ?? "—"} />
</div>
{task.payload && Object.keys(task.payload as object).length > 0 ? (
<Section title="Payload">
<pre className="text-xs font-mono bg-bg p-3 rounded border border-border overflow-x-auto">
{JSON.stringify(task.payload, null, 2)}
</pre>
</Section>
) : null}
{task.error ? (
<Section title="Error">
<p className="text-xs text-red-700 bg-red-50 border border-red-300 p-3 rounded">
{task.error}
</p>
</Section>
) : null}
{task.log && task.log.length > 0 ? (
<Section title={`Log (${task.log.length} entries)`}>
<pre className="text-[11px] font-mono bg-bg p-3 rounded border border-border overflow-x-auto whitespace-pre-wrap max-h-60 overflow-y-auto">
{task.log.join("\n")}
</pre>
</Section>
) : null}
{task.result ? (
<Section title="Result">
<pre className="text-xs font-mono bg-bg p-3 rounded border border-border overflow-x-auto max-h-60 overflow-y-auto">
{JSON.stringify(task.result, null, 2)}
</pre>
</Section>
) : null}
<div className="flex flex-wrap gap-2 pt-2 border-t border-border">
{(task.status === "failed" || task.status === "done") && (
<button
onClick={() => act("Retry", () => retryTask(task.id))}
disabled={busy !== null}
className="px-3 py-1.5 rounded-md bg-blue-600 text-white text-xs font-semibold hover:bg-blue-700 disabled:opacity-50"
>
{busy === "Retry" ? "Retrying…" : "↻ Retry"}
</button>
)}
{task.status === "in_progress" && (
<button
onClick={() => act("Cancel", () => cancelTask(task.id))}
disabled={busy !== null}
className="px-3 py-1.5 rounded-md bg-amber-500 text-white text-xs font-semibold hover:bg-amber-600 disabled:opacity-50"
>
{busy === "Cancel" ? "Cancelling…" : "✕ Cancel"}
</button>
)}
<button
onClick={() => {
if (confirm(`Delete "${task.title}"? This cannot be undone.`)) {
act("Delete", () => deleteTask(task.id).then(onClose));
}
}}
disabled={busy !== null}
className="px-3 py-1.5 rounded-md border border-red-300 text-red-700 text-xs font-semibold hover:bg-red-50 disabled:opacity-50"
>
{busy === "Delete" ? "Deleting…" : "🗑 Delete"}
</button>
</div>
</div>
</div>
</div>
);
}
function Meta({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-[10px] font-semibold text-muted uppercase tracking-wider mb-0.5">{label}</p>
<p className="text-fg">{value}</p>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<h3 className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5">{title}</h3>
{children}
</div>
);
}
function formatTime(iso: string | null): string {
if (!iso) return "—";
const t = new Date(iso.includes("T") ? iso : iso.replace(" ", "T") + "Z");
if (isNaN(t.getTime())) return "—";
return t.toLocaleString();
}