fix(kanban): make Done-card results actionable

This commit is contained in:
Teknium 2026-07-12 23:43:34 -07:00
parent deae8e3b4d
commit 98b4562947
3 changed files with 110 additions and 21 deletions

View file

@ -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,

View file

@ -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(

View file

@ -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