|
| 1 | +import { useEffect, useRef, useState } from "react"; |
| 2 | +import { z } from "zod"; |
| 3 | +import type { WorkspaceAgentTodo, WorkspaceDrawerSelection, WorkspaceGoal } from "./personal-workspace-model"; |
| 4 | +import { useWorkspaceI18n } from "./i18n"; |
| 5 | + |
| 6 | +const pageSchema = z.object({ |
| 7 | + ok: z.literal(true), total: z.number().int().nonnegative(), next_cursor: z.string().nullable(), |
| 8 | + items: z.array(z.object({ todo_id: z.string(), text: z.string(), claimed_by: z.string().nullable(), evidence: z.string().nullable(), priority: z.string().nullable(), task_class: z.string().nullable() })).max(40), |
| 9 | +}); |
| 10 | +const rowHeight = 148; |
| 11 | + |
| 12 | +/** Fixed-height previews keep layout/DOM cost bounded; the drawer retains full text. */ |
| 13 | +export function CompletedTaskLane({ goal, agentId, seed, enabled, onSelect }: { |
| 14 | + goal: WorkspaceGoal; agentId: string; seed: WorkspaceAgentTodo[]; enabled: boolean; |
| 15 | + onSelect: (selection: WorkspaceDrawerSelection) => void; |
| 16 | +}) { |
| 17 | + const { t } = useWorkspaceI18n(); |
| 18 | + const [rows, setRows] = useState(seed); |
| 19 | + const [total, setTotal] = useState(agentId === "all" ? goal.doneTodoCount ?? seed.length : seed.length); |
| 20 | + const [cursor, setCursor] = useState<string | null | undefined>(undefined); |
| 21 | + const [busy, setBusy] = useState(false); |
| 22 | + const [error, setError] = useState(false); |
| 23 | + const [expired, setExpired] = useState(false); |
| 24 | + const [viewport, setViewport] = useState({ top: 0, height: 600 }); |
| 25 | + const [focused, setFocused] = useState<number | null>(null); |
| 26 | + const scroll = useRef<HTMLDivElement>(null); |
| 27 | + const request = useRef<AbortController | null>(null); |
| 28 | + const [retry, setRetry] = useState(0); |
| 29 | + useEffect(() => { |
| 30 | + const element = scroll.current; |
| 31 | + if (!element) return; |
| 32 | + const observer = new ResizeObserver(() => setViewport({ top: element.scrollTop, height: element.clientHeight })); |
| 33 | + observer.observe(element); |
| 34 | + return () => { observer.disconnect(); request.current?.abort(); }; |
| 35 | + }, []); |
| 36 | + const needsPage = cursor === undefined || viewport.top + viewport.height >= rows.length * rowHeight - rowHeight * 2; |
| 37 | + useEffect(() => { |
| 38 | + if (!enabled || !needsPage || cursor === null || error || request.current) return; |
| 39 | + const controller = new AbortController(); |
| 40 | + request.current = controller; |
| 41 | + setBusy(true); |
| 42 | + const query = new URLSearchParams({ goal_id: goal.goalId }); |
| 43 | + if (agentId !== "all") query.set("agent_id", agentId); |
| 44 | + if (cursor) query.set("cursor", cursor); |
| 45 | + void fetch(`/api/chat/completed-todos?${query}`, { signal: controller.signal }).then(async (response) => { |
| 46 | + if (response.status === 409) setExpired(true); |
| 47 | + if (!response.ok) throw new Error("history unavailable"); |
| 48 | + const page = pageSchema.parse(await response.json()); |
| 49 | + if (controller.signal.aborted) return; |
| 50 | + const next = page.items.map((todo): WorkspaceAgentTodo => ({ todoId: todo.todo_id, text: todo.text, claimedBy: todo.claimed_by, evidence: todo.evidence, priority: todo.priority, taskClass: todo.task_class, done: true, status: "done" })); |
| 51 | + setRows((current) => cursor === undefined ? next : [...current, ...next.filter((todo) => !current.some((existing) => existing.todoId === todo.todoId))]); |
| 52 | + setTotal(page.total); |
| 53 | + setCursor(page.next_cursor); |
| 54 | + }).catch(() => { if (!controller.signal.aborted) setError(true); }).finally(() => { |
| 55 | + if (!controller.signal.aborted) { request.current = null; setBusy(false); } |
| 56 | + }); |
| 57 | + }, [enabled, needsPage, cursor, error, retry, goal.goalId, agentId, busy]); |
| 58 | + const first = Math.max(0, Math.floor(viewport.top / rowHeight) - 3); |
| 59 | + const last = Math.min(rows.length, Math.ceil((viewport.top + viewport.height) / rowHeight) + 3); |
| 60 | + const indices = Array.from({ length: Math.max(0, last - first) }, (_, index) => first + index); |
| 61 | + if (focused !== null && focused < rows.length && !indices.includes(focused)) indices.push(focused); |
| 62 | + return <section className="personal-object-list personal-task-lane" data-testid="completed-task-lane"> |
| 63 | + <header><strong><i aria-hidden="true" className="personal-kanban-dot tone-done" />{t("tasks.completed")}</strong><span>{total}</span></header> |
| 64 | + <div aria-label={t("tasks.completed")} className="personal-task-lane-scroll" ref={scroll} role="region" tabIndex={0} onScroll={(event) => setViewport({ top: event.currentTarget.scrollTop, height: event.currentTarget.clientHeight })}> |
| 65 | + <div className="personal-completed-window" style={{ height: rows.length * rowHeight }}> |
| 66 | + {indices.map((index) => { |
| 67 | + const todo = rows[index]!; |
| 68 | + return <div className="personal-task-card personal-completed-row" key={todo.todoId} style={{ top: index * rowHeight, height: rowHeight }}> |
| 69 | + <button type="button" onFocus={() => setFocused(index)} onBlur={() => setFocused(null)} onClick={() => onSelect({ kind: "todo", item: { ...todo, goalId: goal.goalId, goalTitle: goal.title, ownerLabel: todo.claimedBy ?? goal.agentLabel ?? goal.agentId } })}> |
| 70 | + <span className="is-done">✓</span><strong>{todo.text}</strong><small>{todo.claimedBy ?? goal.agentLabel ?? goal.agentId}</small> |
| 71 | + </button> |
| 72 | + </div>; |
| 73 | + })} |
| 74 | + </div> |
| 75 | + <div className="personal-completed-footer" role="status"> |
| 76 | + {busy ? t("tasks.historyLoading") : error ? <><span>{t(expired ? "tasks.historyExpired" : "tasks.historyError")}</span><button type="button" onClick={() => { if (expired) { setCursor(undefined); if (scroll.current) scroll.current.scrollTop = 0; } setExpired(false); setError(false); setRetry((value) => value + 1); }}>{t("tasks.historyRetry")}</button></> : !enabled ? t("tasks.historyLocalOnly") : cursor === null ? t("tasks.historyEnd") : <button type="button" onClick={() => { if (scroll.current) scroll.current.scrollTop = rows.length * rowHeight; }}>{t("tasks.historyMore")}</button>} |
| 77 | + </div> |
| 78 | + </div> |
| 79 | + </section>; |
| 80 | +} |
0 commit comments