From 6155cafacfdffccf74103e6397ae4ab03d291ade Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 11 Jul 2026 14:39:09 +0000 Subject: [PATCH 1/4] Optimize task polling and list queries Co-authored-by: DanielWalnut --- backend/src/api.ts | 5 +- backend/src/db.ts | 77 ++++++++ backend/tests/api-handler.test.ts | 69 +++++++ backend/tests/taskdb.test.ts | 23 +++ taskboard-electron/src/renderer/App.tsx | 186 ++++++++++-------- .../src/renderer/viewPolling.test.ts | 45 +++++ .../src/renderer/viewPolling.ts | 44 +++++ 7 files changed, 363 insertions(+), 86 deletions(-) create mode 100644 taskboard-electron/src/renderer/viewPolling.test.ts create mode 100644 taskboard-electron/src/renderer/viewPolling.ts diff --git a/backend/src/api.ts b/backend/src/api.ts index f15e4b0..dd72e4c 100644 --- a/backend/src/api.ts +++ b/backend/src/api.ts @@ -1423,8 +1423,9 @@ async function handleGet( } if (path === "/api/tasks") { + const summary = url.searchParams.get("mode") === "summary"; return jsonResponse( - ctx.db.get_all_tasks().map((t) => attachDependencyMetadata(ctx.db, t)), + ctx.db.get_all_tasks_with_dependencies(summary), 200, origin, ); @@ -1512,7 +1513,7 @@ async function handleGet( return jsonResponse({ csrf_token: CSRF_TOKEN }, 200, origin); if (path === "/api/health") return jsonResponse( - { status: "ok", tasks: ctx.db.get_all_tasks().length }, + { status: "ok", tasks: ctx.db.count_tasks() }, 200, origin, ); diff --git a/backend/src/db.ts b/backend/src/db.ts index 9b5dfcf..532640b 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -106,6 +106,10 @@ export class TaskDB { this._migrate("ALTER TABLE tasks ADD COLUMN image_paths TEXT DEFAULT '[]'"); this._migrate("ALTER TABLE tasks ADD COLUMN notify_slack_channel TEXT"); this._migrate("ALTER TABLE tasks ADD COLUMN notify_telegram_chat_id TEXT"); + this.conn.run(` + CREATE INDEX IF NOT EXISTS idx_tasks_status_next_run + ON tasks(status, next_run_at) + `); this.conn.run(` CREATE TABLE IF NOT EXISTS settings ( @@ -174,6 +178,10 @@ export class TaskDB { ) `); this._migrate("ALTER TABLE task_runs ADD COLUMN raw_output TEXT"); + this.conn.run(` + CREATE INDEX IF NOT EXISTS idx_task_runs_task_started + ON task_runs(task_id, started_at DESC) + `); this.conn.run(` CREATE TABLE IF NOT EXISTS heartbeats ( @@ -265,6 +273,10 @@ export class TaskDB { CREATE INDEX IF NOT EXISTS idx_task_output_events_timestamp ON task_output_events(timestamp) `); + this.conn.run(` + CREATE INDEX IF NOT EXISTS idx_task_output_events_task_timestamp + ON task_output_events(task_id, timestamp DESC) + `); // DAG dependency table this.conn.run(` @@ -1286,6 +1298,71 @@ export class TaskDB { return rows.map((r) => this._deserialize_task(r)); } + /** + * Return the task list with dependency metadata using three fixed queries. + * + * The legacy list keeps the same shape as get_task() plus dependencies and + * dependents. Summary mode omits large detail-only columns and truncates the + * prompt for TaskCard rendering. + */ + get_all_tasks_with_dependencies(summary: boolean = false): Row[] { + const taskSql = summary + ? `SELECT id, title, substr(prompt, 1, 240) AS prompt_preview, + status, schedule_type, cron_expr, delay_seconds, next_run_at, + last_run_at, run_count, max_runs, tags, agent, dag_id + FROM tasks ORDER BY created_at DESC` + : "SELECT * FROM tasks ORDER BY created_at DESC"; + const rows = this.conn.query(taskSql).all() as Row[]; + if (rows.length === 0) return []; + + const taskIds = new Set(rows.map((row) => Number(row["id"]))); + const dependenciesByTask = new Map(); + const dependentsByTask = new Map(); + const dependencies = this.conn + .query( + `SELECT td.*, t.title AS depends_on_title, t.status AS depends_on_status + FROM task_dependencies td + JOIN tasks t ON t.id = td.depends_on_task_id`, + ) + .all() as Row[]; + const dependents = this.conn + .query( + `SELECT td.*, t.title AS task_title, t.status AS task_status + FROM task_dependencies td + JOIN tasks t ON t.id = td.task_id`, + ) + .all() as Row[]; + + for (const dependency of dependencies) { + const taskId = Number(dependency["task_id"]); + if (!taskIds.has(taskId)) continue; + const value = summary + ? { depends_on_task_id: dependency["depends_on_task_id"] } + : { ...dependency }; + const current = dependenciesByTask.get(taskId) ?? []; + current.push(value); + dependenciesByTask.set(taskId, current); + } + for (const dependent of dependents) { + const upstreamId = Number(dependent["depends_on_task_id"]); + if (!taskIds.has(upstreamId)) continue; + const current = dependentsByTask.get(upstreamId) ?? []; + current.push(Number(dependent["task_id"])); + dependentsByTask.set(upstreamId, current); + } + + return rows.map((row) => ({ + ...(summary ? row : this._deserialize_task(row)), + dependencies: dependenciesByTask.get(Number(row["id"])) ?? [], + dependents: dependentsByTask.get(Number(row["id"])) ?? [], + })); + } + + count_tasks(): number { + const row = this.conn.query("SELECT COUNT(*) AS count FROM tasks").get() as Row; + return Number(row["count"]); + } + get_due_tasks(): Row[] { const rows = this.conn .query( diff --git a/backend/tests/api-handler.test.ts b/backend/tests/api-handler.test.ts index 82290b5..1240867 100644 --- a/backend/tests/api-handler.test.ts +++ b/backend/tests/api-handler.test.ts @@ -158,6 +158,75 @@ describe("api handler", () => { expect(tasks[0]["dependents"]).toEqual([]); }); + test("GET /api/tasks batches dependency metadata without per-task queries", async () => { + const upstream = db.add_task( + makeTask({ title: "Upstream", prompt: "prepare", working_dir: "." }), + ); + const downstream = db.add_task( + makeTask({ title: "Downstream", prompt: "finish", working_dir: "." }), + ); + db.add_dependency(downstream, upstream, true); + db.get_dependencies = () => { + throw new Error("per-task dependency query should not run"); + }; + db.get_dependents = () => { + throw new Error("per-task dependent query should not run"); + }; + + const tasks = await json(new Request("http://127.0.0.1:9712/api/tasks")); + + expect(tasks).toHaveLength(2); + expect(tasks.find((task) => task.id === upstream).dependents).toEqual([ + downstream, + ]); + expect( + tasks.find((task) => task.id === downstream).dependencies[0], + ).toMatchObject({ + task_id: downstream, + depends_on_task_id: upstream, + inject_result: 1, + depends_on_title: "Upstream", + }); + expect(tasks.find((task) => task.id === upstream).prompt).toBe("prepare"); + }); + + test("GET /api/tasks summary returns only board fields", async () => { + db.add_task( + makeTask({ + title: "Summary", + prompt: "x".repeat(400), + working_dir: "/private/project", + }), + ); + + const tasks = await json( + new Request("http://127.0.0.1:9712/api/tasks?mode=summary"), + ); + const task = tasks[0]; + + expect(task.prompt_preview).toHaveLength(240); + expect(task.dependencies).toEqual([]); + expect(task.dependents).toEqual([]); + expect(task).not.toHaveProperty("prompt"); + expect(task).not.toHaveProperty("working_dir"); + expect(task).not.toHaveProperty("result"); + expect(task).not.toHaveProperty("error"); + expect(task).not.toHaveProperty("prompt_images"); + }); + + test("GET /api/health counts tasks without loading task rows", async () => { + db.add_task(makeTask({ title: "Count me", prompt: "p" })); + db.get_all_tasks = () => { + throw new Error("health should use COUNT"); + }; + + const health = await json( + new Request("http://127.0.0.1:9712/api/health"), + ); + + expect(health).toEqual({ status: "ok", tasks: 1 }); + }); + test("GET task output falls back to latest persisted raw output", async () => { const created = await json( new Request("http://127.0.0.1:9712/api/tasks", { diff --git a/backend/tests/taskdb.test.ts b/backend/tests/taskdb.test.ts index a64a258..17700b5 100644 --- a/backend/tests/taskdb.test.ts +++ b/backend/tests/taskdb.test.ts @@ -49,6 +49,29 @@ describe("TaskDB", () => { expect(db.get_setting("k")).toBe("v2"); }); + test("test_task_count_and_polling_indexes_are_idempotent", () => { + db.add_task(makeTask({ title: "one", prompt: "p" })); + expect(db.count_tasks()).toBe(1); + + const dbPath = db.db_path; + db.conn.close(); + db = new TaskDB(dbPath); + + const indexNames = (table: string) => + new Set( + ( + db.conn.query(`PRAGMA index_list('${table}')`).all() as Array<{ + name: string; + }> + ).map((row) => row.name), + ); + expect(indexNames("tasks")).toContain("idx_tasks_status_next_run"); + expect(indexNames("task_runs")).toContain("idx_task_runs_task_started"); + expect(indexNames("task_output_events")).toContain( + "idx_task_output_events_task_timestamp", + ); + }); + // ── run history ──────────────────────────────────────────────────────────── test("test_run_lifecycle_and_ordering", () => { const tid = db.add_task( diff --git a/taskboard-electron/src/renderer/App.tsx b/taskboard-electron/src/renderer/App.tsx index 592c827..df4499e 100644 --- a/taskboard-electron/src/renderer/App.tsx +++ b/taskboard-electron/src/renderer/App.tsx @@ -1,4 +1,12 @@ -import { useState, useEffect, useCallback, useRef, type CSSProperties } from "react"; +import { + memo, + useState, + useEffect, + useCallback, + useMemo, + useRef, + type CSSProperties, +} from "react"; import { CheckCircle2, GitFork, @@ -50,6 +58,7 @@ import { type TaskResponseRefreshResult, } from "./operatorUi.ts"; import { buildExecutionSteps } from "./traceSteps.ts"; +import { fetchMainViewData, type MainView } from "./viewPolling.ts"; const API = "http://127.0.0.1:9712/api"; @@ -837,21 +846,8 @@ async function fetchWithTimeout( } // ─── API helpers ─── -async function fetchTasks() { - const res = await fetch(`${API}/tasks`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json(); -} - async function fetchTask(id) { const res = await fetch(`${API}/tasks/${id}`); - const payload = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`); - return payload; -} - -async function fetchHeartbeats() { - const res = await fetch(`${API}/heartbeats`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } @@ -869,12 +865,6 @@ async function createTask(data) { }); } -async function fetchSkillPatterns() { - const res = await fetch(`${API}/skill-patterns`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json(); -} - async function triggerSkillSweep(agent?: string) { return apiMutation("/skills/sweep", { method: "POST", @@ -907,12 +897,6 @@ async function dismissSkillPattern(id) { }); } -async function fetchSkills() { - const res = await fetch(`${API}/skills`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json(); -} - async function setSkillEnabledApi(id, enabled) { return apiMutation(`/skills/${id}`, { method: "PUT", @@ -1603,7 +1587,8 @@ function segmentedButton(active: boolean): CSSProperties { }; } -function TaskCard({ task, onAction, onViewDetail }) { +function TaskCard({ task, onAction, onViewDetail, themeVersion }) { + void themeVersion; const [hovered, setHovered] = useState(false); const tags = task.tags ? task.tags.split(",").filter(Boolean) : []; @@ -1677,7 +1662,7 @@ function TaskCard({ task, onAction, onViewDetail }) { WebkitBoxOrient: "vertical", }} > - {task.prompt || "No prompt saved for this task."} + {task.prompt_preview || task.prompt || "No prompt saved for this task."}
@@ -1784,6 +1769,8 @@ function TaskCard({ task, onAction, onViewDetail }) { ); } +const MemoizedTaskCard = memo(TaskCard); + function ActionBtn({ icon, title, onClick, color }) { const [hovered, setHovered] = useState(false); return ( @@ -1811,7 +1798,7 @@ function ActionBtn({ icon, title, onClick, color }) { ); } -function Column({ col, tasks, onAction, onViewDetail }) { +function Column({ col, tasks, onAction, onViewDetail, themeVersion }) { const iconColor = theme[col.tone] || theme.accent; const iconBackground = theme[`${col.tone}Bg`] || theme.field; @@ -1877,7 +1864,13 @@ function Column({ col, tasks, onAction, onViewDetail }) {
{tasks.map((t) => ( - + ))} {tasks.length === 0 && (
([]); - const [activeView, setActiveView] = useState("tasks"); + const [activeView, setActiveView] = useState("tasks"); const [showNew, setShowNew] = useState(false); const [showNewHeartbeat, setShowNewHeartbeat] = useState(false); const [showSettings, setShowSettings] = useState(false); @@ -6015,34 +6008,36 @@ export default function App() { const poll = useCallback(async () => { const generation = pollGuardRef.current.begin(); try { - const [taskData, heartbeatData, skillRes, skillsRes] = await Promise.all([ - fetchTasks(), - fetchHeartbeats(), - fetchSkillPatterns(), - fetchSkills(), - ]); + const data = await fetchMainViewData(activeView, API); if (!pollGuardRef.current.isCurrent(generation)) return; - const reconciled = reconcileTasksWithSubmittedAnswers( - taskData, - submittedTaskAnswersRef.current, - ); - submittedTaskAnswersRef.current = Object.fromEntries( - reconciled.pendingSubmissionIds.map((id) => [id, submittedTaskAnswersRef.current[id]]), - ); - setTasks(reconciled.tasks); - setHeartbeats(heartbeatData); - setSkillData(skillRes); - setSkills(skillsRes.skills || []); + if (data.tasks !== undefined) { + const reconciled = reconcileTasksWithSubmittedAnswers( + data.tasks, + submittedTaskAnswersRef.current, + ); + submittedTaskAnswersRef.current = Object.fromEntries( + reconciled.pendingSubmissionIds.map((id) => [id, submittedTaskAnswersRef.current[id]]), + ); + setTasks(reconciled.tasks); + setDetail((current) => { + if (!current) return current; + const summary = reconciled.tasks.find((task) => task.id === current.id); + return summary ? { ...current, ...summary } : current; + }); + } + if (data.heartbeats !== undefined) setHeartbeats(data.heartbeats); + if (data.skillData !== undefined) setSkillData(data.skillData); + if (data.skills !== undefined) setSkills(data.skills); setConnected(true); setApiError(null); return true; } catch (err) { if (!pollGuardRef.current.isCurrent(generation)) return; setConnected(false); - setApiError(`Failed to fetch tasks: ${err.message}`); + setApiError(`Failed to refresh ${activeView}: ${err.message}`); return false; } - }, []); + }, [activeView]); useEffect(() => { if (!backendReady) return; @@ -6081,27 +6076,33 @@ export default function App() { fetchChannelsStatus().then((s) => setChannelsStatus(s)); }, [backendReady]); - const handleAction = async (action, id) => { + const openTaskDetail = useCallback(async (task) => { + try { + setDetail(await fetchTask(task.id)); + } catch (e) { + setApiError(`Failed to fetch task details: ${e.message}`); + } + }, []); + + const handleAction = useCallback(async (action, id) => { try { if (action === "cancel") await cancelTask(id); else if (action === "retry") await retryTask(id); else if (action === "delete") { await deleteTask(id); - if (detail?.id === id) setDetail(null); + setDetail((current) => (current?.id === id ? null : current)); } else if (action === "edit") { - const task = tasks.find((t) => t.id === id); - if (task) setEditingTask(task); + setEditingTask(await fetchTask(id)); return; } else if (action === "fork") { - const task = tasks.find((t) => t.id === id); - if (task) setForkingTask(task); + setForkingTask(await fetchTask(id)); return; } poll(); } catch (e) { setApiError(`${action} failed: ${e.message}`); } - }; + }, [poll]); const handleHeartbeatAction = async (action, id) => { try { @@ -6310,21 +6311,44 @@ export default function App() { ? "Search skills" : ""; - const filtered = filter - ? tasks.filter( - (t) => - t.title.toLowerCase().includes(filter.toLowerCase()) || - t.tags?.toLowerCase().includes(filter.toLowerCase()), - ) - : tasks; - - const runningCount = tasks.filter((t) => t.status === "running").length; - const queueCount = tasks.filter((t) => - ["pending", "scheduled", "blocked"].includes(t.status), - ).length; - const doneCount = tasks.filter((t) => - ["completed", "failed", "cancelled"].includes(t.status), - ).length; + const filtered = useMemo(() => { + const query = filters.tasks.trim().toLowerCase(); + if (!query) return tasks; + return tasks.filter( + (task) => + task.title.toLowerCase().includes(query) || task.tags?.toLowerCase().includes(query), + ); + }, [filters.tasks, tasks]); + const tasksByColumn = useMemo( + () => + Object.fromEntries( + COLUMNS.map((column) => [ + column.key, + filtered.filter((task) => column.statuses.includes(task.status)), + ]), + ), + [filtered], + ); + const filteredHeartbeats = useMemo(() => { + const query = filters.heartbeats.trim().toLowerCase(); + if (!query) return heartbeats; + return heartbeats.filter( + (heartbeat) => + heartbeat.name.toLowerCase().includes(query) || + heartbeat.check_prompt.toLowerCase().includes(query), + ); + }, [filters.heartbeats, heartbeats]); + const { runningCount, queueCount, doneCount } = useMemo(() => { + let running = 0; + let queued = 0; + let done = 0; + for (const task of tasks) { + if (task.status === "running") running += 1; + else if (["pending", "scheduled", "blocked"].includes(task.status)) queued += 1; + else if (["completed", "failed", "cancelled"].includes(task.status)) done += 1; + } + return { runningCount: running, queueCount: queued, doneCount: done }; + }, [tasks]); const enabledHeartbeatCount = heartbeats.filter((h) => h.enabled).length; const pausedHeartbeatCount = Math.max(heartbeats.length - enabledHeartbeatCount, 0); const heartbeatIssueCount = heartbeats.filter((h) => h.last_error).length; @@ -6575,7 +6599,7 @@ export default function App() { ].map((tab) => (
@@ -7171,14 +7196,7 @@ export default function App() { gap: 12, }} > - {(filter - ? heartbeats.filter( - (h) => - h.name.toLowerCase().includes(filter.toLowerCase()) || - h.check_prompt.toLowerCase().includes(filter.toLowerCase()), - ) - : heartbeats - ).map((h) => ( + {filteredHeartbeats.map((h) => ( t.id === detail.id) || detail} + task={detail} onClose={() => setDetail(null)} onRespond={handleRespond} onResume={handleResume} diff --git a/taskboard-electron/src/renderer/viewPolling.test.ts b/taskboard-electron/src/renderer/viewPolling.test.ts new file mode 100644 index 0000000..39303d8 --- /dev/null +++ b/taskboard-electron/src/renderer/viewPolling.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { fetchMainViewData, type MainView } from "./viewPolling.ts"; + +function response(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("fetchMainViewData", () => { + test.each([ + ["tasks", ["/tasks?mode=summary"]], + ["heartbeats", ["/heartbeats"]], + ["skills", ["/skill-patterns", "/skills", "/tasks?mode=summary"]], + ] as Array<[MainView, string[]]>)( + "fetches only data needed by the %s view", + async (view, expectedPaths) => { + const requested: string[] = []; + const fetchImpl = async (input: RequestInfo | URL): Promise => { + const url = new URL(String(input)); + requested.push(`${url.pathname}${url.search}`.replace("/api", "")); + if (url.pathname.endsWith("/skills")) return response({ skills: [{ id: 1 }] }); + if (url.pathname.endsWith("/skill-patterns")) return response({ patterns: [] }); + return response([]); + }; + + await fetchMainViewData(view, "http://localhost/api", fetchImpl); + + expect(requested.sort()).toEqual(expectedPaths.sort()); + }, + ); + + test("returns partial data without empty placeholders for unrequested views", async () => { + const data = await fetchMainViewData( + "heartbeats", + "http://localhost/api", + async () => response([{ id: 7 }]), + ); + + expect(data).toEqual({ heartbeats: [{ id: 7 }] }); + expect("tasks" in data).toBe(false); + expect("skills" in data).toBe(false); + }); +}); diff --git a/taskboard-electron/src/renderer/viewPolling.ts b/taskboard-electron/src/renderer/viewPolling.ts new file mode 100644 index 0000000..14ee370 --- /dev/null +++ b/taskboard-electron/src/renderer/viewPolling.ts @@ -0,0 +1,44 @@ +export type MainView = "tasks" | "heartbeats" | "skills"; + +export interface MainViewData { + tasks?: any[]; + heartbeats?: any[]; + skillData?: any; + skills?: any[]; +} + +type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +async function getJson(fetchImpl: FetchLike, url: string): Promise { + const response = await fetchImpl(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); +} + +/** + * Fetch only the data required by the visible top-level view. + * Skills also needs lightweight task summaries to label contributing tasks. + */ +export async function fetchMainViewData( + activeView: MainView, + apiBase: string, + fetchImpl: FetchLike = fetch, +): Promise { + if (activeView === "tasks") { + return { tasks: await getJson(fetchImpl, `${apiBase}/tasks?mode=summary`) }; + } + if (activeView === "heartbeats") { + return { heartbeats: await getJson(fetchImpl, `${apiBase}/heartbeats`) }; + } + + const [skillData, skillsResponse, tasks] = await Promise.all([ + getJson(fetchImpl, `${apiBase}/skill-patterns`), + getJson(fetchImpl, `${apiBase}/skills`), + getJson(fetchImpl, `${apiBase}/tasks?mode=summary`), + ]); + return { + skillData, + skills: skillsResponse.skills || [], + tasks, + }; +} From 040dd6abbb95edd1d8802eab746697d466b989f6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 11 Jul 2026 14:42:49 +0000 Subject: [PATCH 2/4] Harden polling against stale responses Co-authored-by: DanielWalnut --- backend/src/db.ts | 4 +- backend/tests/api-handler.test.ts | 18 ++--- taskboard-electron/src/renderer/App.tsx | 70 +++++++++++-------- .../src/renderer/viewPolling.test.ts | 6 +- 4 files changed, 54 insertions(+), 44 deletions(-) diff --git a/backend/src/db.ts b/backend/src/db.ts index 532640b..afb1588 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -1359,7 +1359,9 @@ export class TaskDB { } count_tasks(): number { - const row = this.conn.query("SELECT COUNT(*) AS count FROM tasks").get() as Row; + const row = this.conn + .query("SELECT COUNT(*) AS count FROM tasks") + .get() as Row; return Number(row["count"]); } diff --git a/backend/tests/api-handler.test.ts b/backend/tests/api-handler.test.ts index 1240867..f924b8c 100644 --- a/backend/tests/api-handler.test.ts +++ b/backend/tests/api-handler.test.ts @@ -176,18 +176,22 @@ describe("api handler", () => { const tasks = await json(new Request("http://127.0.0.1:9712/api/tasks")); expect(tasks).toHaveLength(2); - expect(tasks.find((task) => task.id === upstream).dependents).toEqual([ - downstream, - ]); expect( - tasks.find((task) => task.id === downstream).dependencies[0], + tasks.find((task: Record) => task.id === upstream) + .dependents, + ).toEqual([downstream]); + expect( + tasks.find((task: Record) => task.id === downstream) + .dependencies[0], ).toMatchObject({ task_id: downstream, depends_on_task_id: upstream, inject_result: 1, depends_on_title: "Upstream", }); - expect(tasks.find((task) => task.id === upstream).prompt).toBe("prepare"); + expect( + tasks.find((task: Record) => task.id === upstream).prompt, + ).toBe("prepare"); }); test("GET /api/tasks summary returns only board fields", async () => { @@ -220,9 +224,7 @@ describe("api handler", () => { throw new Error("health should use COUNT"); }; - const health = await json( - new Request("http://127.0.0.1:9712/api/health"), - ); + const health = await json(new Request("http://127.0.0.1:9712/api/health")); expect(health).toEqual({ status: "ok", tasks: 1 }); }); diff --git a/taskboard-electron/src/renderer/App.tsx b/taskboard-electron/src/renderer/App.tsx index df4499e..f2b9651 100644 --- a/taskboard-electron/src/renderer/App.tsx +++ b/taskboard-electron/src/renderer/App.tsx @@ -1,12 +1,4 @@ -import { - memo, - useState, - useEffect, - useCallback, - useMemo, - useRef, - type CSSProperties, -} from "react"; +import { memo, useState, useEffect, useCallback, useMemo, useRef, type CSSProperties } from "react"; import { CheckCircle2, GitFork, @@ -5958,6 +5950,7 @@ export default function App() { const pollGuardRef = useRef(createRequestGenerationGuard()); const heartbeatDetailId = heartbeatDetail?.id; const submittedTaskAnswersRef = useRef>({}); + const detailRequestIdRef = useRef(0); // ─── Color mode ─── const [colorMode, setColorMode] = useState(() => localStorage.getItem("colorMode") || "system"); @@ -6077,32 +6070,47 @@ export default function App() { }, [backendReady]); const openTaskDetail = useCallback(async (task) => { + const requestId = ++detailRequestIdRef.current; try { - setDetail(await fetchTask(task.id)); + const fullTask = await fetchTask(task.id); + if (requestId === detailRequestIdRef.current) setDetail(fullTask); } catch (e) { - setApiError(`Failed to fetch task details: ${e.message}`); + if (requestId === detailRequestIdRef.current) { + setApiError(`Failed to fetch task details: ${e.message}`); + } } }, []); + const closeTaskDetail = useCallback(() => { + detailRequestIdRef.current += 1; + setDetail(null); + }, []); + const switchActiveView = useCallback((view: MainView) => { + pollGuardRef.current.invalidate(); + setActiveView(view); + }, []); - const handleAction = useCallback(async (action, id) => { - try { - if (action === "cancel") await cancelTask(id); - else if (action === "retry") await retryTask(id); - else if (action === "delete") { - await deleteTask(id); - setDetail((current) => (current?.id === id ? null : current)); - } else if (action === "edit") { - setEditingTask(await fetchTask(id)); - return; - } else if (action === "fork") { - setForkingTask(await fetchTask(id)); - return; + const handleAction = useCallback( + async (action, id) => { + try { + if (action === "cancel") await cancelTask(id); + else if (action === "retry") await retryTask(id); + else if (action === "delete") { + await deleteTask(id); + setDetail((current) => (current?.id === id ? null : current)); + } else if (action === "edit") { + setEditingTask(await fetchTask(id)); + return; + } else if (action === "fork") { + setForkingTask(await fetchTask(id)); + return; + } + poll(); + } catch (e) { + setApiError(`${action} failed: ${e.message}`); } - poll(); - } catch (e) { - setApiError(`${action} failed: ${e.message}`); - } - }, [poll]); + }, + [poll], + ); const handleHeartbeatAction = async (action, id) => { try { @@ -6599,7 +6607,7 @@ export default function App() { ].map((tab) => (