From 98b4562947eb0c3ffc534b16edb7be0cf81b3f47 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:43:34 -0700 Subject: [PATCH] fix(kanban): make Done-card results actionable --- plugins/kanban/dashboard/dist/index.js | 35 ++++++++- plugins/kanban/dashboard/plugin_api.py | 19 ++++- tests/plugins/test_kanban_dashboard_plugin.py | 77 +++++++++++++++---- 3 files changed, 110 insertions(+), 21 deletions(-) diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 2fedaba8b68e..4dd7564f7d43 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -1093,6 +1093,7 @@ taskId: selectedTaskId, boardSlug: board, onClose: function () { setSelectedTaskId(null); }, + onOpenTask: setSelectedTaskId, onRefresh: loadBoard, renderMarkdown: renderMd, allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []), @@ -3222,6 +3223,10 @@ onDeleteAttachment: handleDeleteAttachment, uploadBusy: uploadBusy, uploadErr: uploadErr, + onOpenTask: function (taskId) { + props.onClose(); + if (props.onOpenTask) props.onOpenTask(taskId); + }, }) : null, data ? h("div", { className: "hermes-kanban-drawer-comment-row" }, h(Input, { @@ -3358,6 +3363,7 @@ const events = props.data.events || []; const attachments = props.data.attachments || []; const links = props.data.links || { parents: [], children: [] }; + const childResults = props.data.child_results || []; return h("div", { className: "hermes-kanban-drawer-body" }, h("div", { className: "hermes-kanban-drawer-title" }, @@ -3429,9 +3435,9 @@ onRemoveChild: props.onRemoveChild, }), (function () { - var finalResult = t.final_result || t.result || t.latest_summary || null; + var finalResult = t.result || t.latest_summary || null; var isDone = t.status === "done"; - var isParent = (t.link_counts && t.link_counts.children > 0); + var isParent = links.children.length > 0; if (finalResult) { var label = t.result ? tx(i18n, "result", "Result") @@ -3446,7 +3452,7 @@ h("div", { className: "hermes-kanban-section-head" }, tx(i18n, "result", "Result")), h("div", { className: "hermes-kanban-done-no-result hermes-kanban-done-parent-note" }, tx(i18n, "doneParentNote", - "This card is an orchestrator / parent task. The substantive results are in the child cards listed in Dependencies below."), + "This card is an orchestrator / parent task. Review the child results section for the substantive work."), ), ); } @@ -3461,6 +3467,29 @@ } return null; })(), + childResults.length > 0 ? h("div", { className: "hermes-kanban-section" }, + h("div", { className: "hermes-kanban-section-head" }, + `${tx(i18n, "childResults", "Child Results")} (${childResults.length})`), + childResults.map(function (child) { + var childResult = child.result || child.latest_summary || null; + return h("div", { key: child.id, className: "hermes-kanban-comment" }, + h("div", { className: "hermes-kanban-comment-head" }, + h("span", { className: "hermes-kanban-comment-author" }, + `${child.id} ยท ${child.title || tx(i18n, "untitled", "(untitled)")}`), + h(Badge, { variant: "outline" }, child.status), + h("button", { + type: "button", + className: "hermes-kanban-diag-action-btn", + onClick: function () { if (props.onOpenTask) props.onOpenTask(child.id); }, + }, tx(i18n, "open", "Open")), + ), + childResult + ? h(MarkdownBlock, { source: childResult, enabled: props.renderMarkdown }) + : h("div", { className: "text-xs text-muted-foreground" }, + tx(i18n, "noChildResult", "No result recorded yet.")), + ); + }), + ) : null, h(AttachmentsSection, { attachments: attachments, boardSlug: props.boardSlug, diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 5164ff67dcac..e280e54c8052 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -172,7 +172,6 @@ def _task_dict( # ``task_runs.summary`` (the kanban-worker pattern) instead of # ``tasks.result``. ``None`` when no run has produced a summary yet. d["latest_summary"] = latest_summary - d["final_result"] = task.result or latest_summary or None # Keep body short on list endpoints; full body comes from /tasks/:id. return d @@ -547,6 +546,21 @@ def get_task( # a second round-trip. Cards on /board carry a 200-char preview. full_summary = kanban_db.latest_summary(conn, task_id) task_d = _task_dict(task, latest_summary=full_summary) + links = _links_for(conn, task_id) + child_ids = links["children"] + child_summaries = kanban_db.latest_summaries(conn, child_ids) + child_results = [] + for child_id in child_ids: + child = kanban_db.get_task(conn, child_id) + if child is None: + continue + child_results.append({ + "id": child.id, + "title": child.title, + "status": child.status, + "latest_summary": child_summaries.get(child.id), + "result": child.result, + }) # Attach diagnostics so the drawer's Diagnostics section can # render recovery actions without a second round-trip. diags = _compute_task_diagnostics(conn, task_ids=[task_id]) @@ -559,7 +573,8 @@ def get_task( "comments": [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)], "events": [_event_dict(e) for e in kanban_db.list_events(conn, task_id)], "attachments": [_attachment_dict(a) for a in kanban_db.list_attachments(conn, task_id)], - "links": _links_for(conn, task_id), + "links": links, + "child_results": child_results, "runs": [ _run_dict(r) for r in kanban_db.list_runs( diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index cc12875d20d1..d0a696f91c64 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -2368,8 +2368,8 @@ def test_dashboard_failed_card_highlight_class_exists(): # --------------------------------------------------------------------------- -def test_task_dict_final_result_uses_tasks_result_first(client): - """When tasks.result is set, final_result equals it (top priority).""" +def test_task_detail_exposes_result_and_latest_summary_separately(client): + """The drawer receives both source fields without a duplicate alias.""" r = client.post( "/api/plugins/kanban/tasks", json={"title": "Task with explicit result"}, @@ -2383,11 +2383,12 @@ def test_task_dict_final_result_uses_tasks_result_first(client): assert r.status_code == 200 data = r.json()["task"] assert data["result"] == "The final answer is 42." - assert data["final_result"] == "The final answer is 42." + assert data["latest_summary"] == "short handoff" + assert "final_result" not in data -def test_task_dict_final_result_falls_back_to_latest_summary(client): - """When tasks.result is None but a run summary exists, final_result returns it.""" +def test_task_detail_exposes_latest_summary_when_result_is_empty(client): + """Summary-only completions remain available to the drawer fallback.""" conn = kb.connect() task_id = kb.create_task(conn, title="Task with only run summary") kb.claim_task(conn, task_id) @@ -2399,11 +2400,11 @@ def test_task_dict_final_result_falls_back_to_latest_summary(client): data = r.json()["task"] assert data["status"] == "done" assert not data["result"] - assert data["final_result"] == "Report written to /output/report.md" + assert data["latest_summary"] == "Report written to /output/report.md" -def test_task_dict_final_result_none_when_nothing_recorded(client): - """When neither tasks.result nor any run summary exists, final_result is None.""" +def test_task_detail_latest_summary_none_when_nothing_recorded(client): + """When no run summary exists, the existing field remains None.""" r = client.post( "/api/plugins/kanban/tasks", json={"title": "Task with no result at all"}, @@ -2411,11 +2412,11 @@ def test_task_dict_final_result_none_when_nothing_recorded(client): task_id = r.json()["task"]["id"] r = client.get(f"/api/plugins/kanban/tasks/{task_id}") assert r.status_code == 200 - assert r.json()["task"]["final_result"] is None + assert r.json()["task"]["latest_summary"] is None -def test_board_tasks_include_final_result_field(client): - """final_result must appear on board-level task cards too.""" +def test_board_tasks_include_latest_summary(client): + """Board cards already expose the summary used by the drawer fallback.""" conn = kb.connect() task_id = kb.create_task(conn, title="Board card with summary only") kb.claim_task(conn, task_id) @@ -2427,19 +2428,63 @@ def test_board_tasks_include_final_result_field(client): done_col = next(c for c in r.json()["columns"] if c["name"] == "done") card = next((t for t in done_col["tasks"] if t["id"] == task_id), None) assert card is not None - assert "Done: see attachment" in card["final_result"] + assert "Done: see attachment" in card["latest_summary"] def test_dashboard_done_final_result_section_rendered_from_summary(): """Frontend must render Final Result section from run summary when task.result is empty.""" repo_root = Path(__file__).resolve().parents[2] dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() - assert "final_result" in dist + assert "t.result || t.latest_summary" in dist assert "Final Result (run summary)" in dist assert "No final result was recorded" in dist assert "orchestrator" in dist or "parent task" in dist -# --------------------------------------------------------------------------- -# Final result visibility for Done cards -# --------------------------------------------------------------------------- + + +def test_task_detail_includes_child_result_summaries(client): + """Parent drawers should receive the child results they need to render.""" + with kb.connect() as conn: + parent = kb.create_task(conn, title="Research topic") + child = kb.create_task(conn, title="Collect sources") + kb.link_tasks(conn, parent, child) + kb.complete_task(conn, parent, summary="Delegated research to child tasks.") + kb.recompute_ready(conn) + kb.complete_task(conn, child, summary="Collected five primary sources.") + + response = client.get(f"/api/plugins/kanban/tasks/{parent}") + + assert response.status_code == 200 + assert response.json()["child_results"] == [ + { + "id": child, + "title": "Collect sources", + "status": "done", + "latest_summary": "Collected five primary sources.", + "result": None, + } + ] + + +def test_dashboard_final_result_uses_existing_fields_without_alias(): + """The drawer should not duplicate result/summary into another API field.""" + repo_root = Path(__file__).resolve().parents[2] + dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() + api = (repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py").read_text() + + assert "var finalResult = t.result || t.latest_summary || null;" in dist + assert "t.final_result" not in dist + assert 'd["final_result"]' not in api + + +def test_dashboard_parent_notice_and_child_results_use_detail_links(): + """Parent detection must use links.children, which exists in task detail.""" + repo_root = Path(__file__).resolve().parents[2] + dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() + detail = dist[dist.index("function TaskDetail"):] + + assert "links.children.length > 0" in detail + assert "t.link_counts" not in detail + assert "Child Results" in detail + assert "props.data.child_results" in detail