WORKFLOW-APP/app/tasks/TaskDetail.tsx

184 lines
7.2 KiB
TypeScript
Raw Permalink Normal View History

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 16:57:02 +00:00
// 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();
}