Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions plugins/kanban/dashboard/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3356,6 +3356,166 @@
);
}

function ArtifactList(props) {
const { t: i18n } = useI18n();
const artifacts = props.artifacts || [];
if (!artifacts.length) {
return h("div", { className: "text-xs text-muted-foreground" },
tx(i18n, "noArtifactMetadata", "No artifact metadata"));
}
return h("div", { className: "hermes-kanban-artifact-list" },
artifacts.map(function (a, idx) {
const label = a.path || a.label || a.kind || "artifact";
return h("div", { key: `${a.source || "artifact"}-${idx}`, className: "hermes-kanban-artifact-row" },
h("span", { className: "hermes-kanban-artifact-kind" }, a.kind || "artifact"),
h("code", { className: "hermes-kanban-artifact-path", title: a.resolved_path || label }, label),
a.size != null ? h("span", { className: "text-xs text-muted-foreground whitespace-nowrap" }, _fmtBytes(a.size)) : null,
h("span", { className: `hermes-kanban-artifact-state hermes-kanban-artifact-state--${a.availability || "unknown"}` }, a.availability || "unknown"),
);
}),
);
}

function BlockedSummaryPanel(props) {
const { t: i18n } = useI18n();
const block = props.block;
if (!block || !block.is_blocked) return null;
return h("div", { className: "hermes-kanban-blocked-summary" },
h("div", { className: "hermes-kanban-blocked-summary-title" },
tx(i18n, "blockedMissingInput", "Blocked — missing input needed")),
block.reason ? h("div", { className: "text-xs" }, block.reason) : null,
block.latest_relevant_comment ? h("div", { className: "text-xs text-muted-foreground" },
`${tx(i18n, "latestComment", "Latest comment")}: ${block.latest_relevant_comment.body || ""}`) : null,
h("div", { className: "text-xs text-muted-foreground" },
block.comment_prompt || tx(i18n, "blockedCommentPrompt", "Add the missing info as a comment, then unblock when ready.")),
);
}

// Latest-run line for a summary node: profile · outcome · age. The run's
// raw metadata sits behind a <details> so operators can inspect the
// handoff without leaving the tree. Read-only — no run controls here.
// props.shownResult is the text the node body already renders; the run
// summary only repeats here when an explicit task result shadows it.
function SummaryNodeRun(props) {
const { t: i18n } = useI18n();
const run = props.run;
if (!run) return null;
const parts = [run.profile, run.outcome || run.status].filter(Boolean);
const when = run.ended_at || run.started_at;
if (when && timeAgo) parts.push(timeAgo(when));
if ((props.runCount || 0) > 1) parts.push(`${props.runCount} ${tx(i18n, "runs", "runs")}`);
return h("div", { className: "hermes-kanban-summary-run" },
h("span", { className: "hermes-kanban-summary-label" }, tx(i18n, "latestRun", "Latest run")),
h("span", { className: "text-xs text-muted-foreground" }, parts.join(" · ")),
run.summary && run.summary !== props.shownResult
? h("div", { className: "hermes-kanban-summary-run-summary" }, run.summary)
: null,
run.metadata ? h("details", { className: "hermes-kanban-summary-run-meta" },
h("summary", null, tx(i18n, "runMetadata", "metadata")),
h("pre", null, JSON.stringify(run.metadata, null, 2)),
) : null,
);
}

// Important comments win over the recent tail; both arrive pre-sliced from
// the API (comment_limit). comment_count is the task's full total so the
// header can show "2 of 7" when the tree only carries a slice.
function SummaryNodeComments(props) {
const { t: i18n } = useI18n();
const important = props.important || [];
const shown = important.length ? important : (props.recent || []);
if (!shown.length) return null;
const total = props.total != null ? props.total : shown.length;
const label = important.length
? tx(i18n, "importantComments", "Important comments")
: tx(i18n, "recentComments", "Recent comments");
return h("div", { className: "hermes-kanban-summary-comments" },
h("div", { className: "hermes-kanban-summary-label" },
total > shown.length ? `${label} (${shown.length} of ${total})` : `${label} (${total})`),
shown.map(function (c) {
return h("div", { key: c.id, className: "hermes-kanban-summary-comment" },
h("span", { className: "hermes-kanban-comment-author" }, c.author || "anon"),
h("span", { className: "hermes-kanban-summary-comment-body" }, c.body || ""),
);
}),
);
}

// Parent/child ids as plain text. Indentation already shows the traversal
// structure; this surfaces the payload's links contract, including extra
// parents that indentation can't express.
function SummaryNodeLinks(props) {
const { t: i18n } = useI18n();
const parents = props.parentIds || [];
const children = props.childIds || [];
if (!parents.length && !children.length) return null;
const parts = [];
if (parents.length) parts.push(`${tx(i18n, "parents", "parents")}: ${parents.join(", ")}`);
if (children.length) parts.push(`${tx(i18n, "children", "children")}: ${children.join(", ")}`);
return h("div", { className: "hermes-kanban-summary-links" }, parts.join(" · "));
}

function TaskSummaryTreeSection(props) {
const { t: i18n } = useI18n();
const [tree, setTree] = useState(null);
const [err, setErr] = useState(null);
const [expanded, setExpanded] = useState(true);
useEffect(function () {
let cancelled = false;
// Reset first: the drawer swaps tasks without remounting, so stale
// tree/err state from the previous task would render under the new
// task's detail view until this fetch resolves.
setTree(null);
setErr(null);
SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/summary-tree?comment_limit=3`, props.boardSlug))
.then(function (d) { if (!cancelled) { setTree(d); setErr(null); } })
.catch(function (e) { if (!cancelled) setErr(String(e.message || e)); });
return function () { cancelled = true; };
}, [props.taskId, props.boardSlug]);

const head = tx(i18n, "summaryTree", "Summary tree");
if (err) return h("div", { className: "hermes-kanban-section hermes-kanban-summary-tree" },
h("div", { className: "hermes-kanban-section-head" }, head),
h("div", { className: "text-xs text-destructive" }, err),
);
if (!tree) return h("div", { className: "hermes-kanban-section hermes-kanban-summary-tree" },
h("div", { className: "hermes-kanban-section-head" }, head),
h("div", { className: "text-xs text-muted-foreground" }, tx(i18n, "loadingSummaryTree", "Loading summary tree…")),
);
const nodes = (tree.order || []).map(function (id) { return tree.tasks && tree.tasks[id]; }).filter(Boolean);
return h("div", { className: "hermes-kanban-section hermes-kanban-summary-tree" },
h("button", {
type: "button",
className: "hermes-kanban-section-head hermes-kanban-summary-tree-toggle",
onClick: function () { setExpanded(!expanded); },
}, `${head} (${tree.stats ? tree.stats.total : nodes.length}) ${expanded ? "▾" : "▸"}`),
expanded ? h("div", { className: "hermes-kanban-summary-tree-list" },
nodes.map(function (node) {
const result = node.display_result || node.latest_summary || "";
return h("div", { key: node.id, className: "hermes-kanban-summary-node", style: { marginLeft: `${Math.min(node.depth || 0, 8) * 14}px` } },
h("div", { className: "hermes-kanban-summary-node-head" },
h("span", { className: cn("hermes-kanban-dot", COLUMN_DOT[node.status]) }),
h("span", { className: "font-medium" }, node.title || node.id),
h("span", { className: "text-xs text-muted-foreground" }, node.status),
),
h(SummaryNodeRun, { run: node.latest_run, runCount: node.run_count, shownResult: result }),
result ? h(MarkdownBlock, { source: result, enabled: props.renderMarkdown }) : null,
h(BlockedSummaryPanel, { block: node.block }),
h(SummaryNodeComments, {
important: node.important_comments,
recent: node.comments,
total: node.comment_count,
}),
h(SummaryNodeLinks, { parentIds: node.parents, childIds: node.children }),
h(ArtifactList, { artifacts: node.artifacts || [] }),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The payload includes comments, important_comments, links, and latest_run.metadata, but this renderer only consumes result, block, and artifacts. Please render the fields this summary tree claims to surface (or narrow that contract) and add a behavioral UI/bundle test.

);
}),
tree.stats && tree.stats.truncated ? h("div", { className: "text-xs text-muted-foreground" },
tx(i18n, "summaryTreeTruncated", "Tree truncated — showing the first slice of descendants.")) : null,
) : null,
);
}

function TaskDetail(props) {
const { t: i18n } = useI18n();
const t = props.data.task;
Expand Down Expand Up @@ -3434,6 +3594,11 @@
onAddChild: props.onAddChild,
onRemoveChild: props.onRemoveChild,
}),
h(TaskSummaryTreeSection, {
taskId: t.id,
boardSlug: props.boardSlug,
renderMarkdown: props.renderMarkdown,
}),
(function () {
var finalResult = t.result || t.latest_summary || null;
var isDone = t.status === "done";
Expand Down
127 changes: 127 additions & 0 deletions plugins/kanban/dashboard/dist/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1590,3 +1590,130 @@
.hermes-kanban-trash-label {
font-weight: 500;
}

/* ---- Summary tree ---------------------------------------------------- */
.hermes-kanban-summary-tree-toggle {
width: 100%;
justify-content: space-between;
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
color: inherit;
}
.hermes-kanban-summary-tree-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.hermes-kanban-summary-node {
border-left: 1px solid var(--color-border);
padding-left: 0.6rem;
}
.hermes-kanban-summary-node-head {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.25rem;
}
.hermes-kanban-artifact-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.35rem;
}
.hermes-kanban-artifact-row {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
font-size: 0.72rem;
}
.hermes-kanban-artifact-kind,
.hermes-kanban-artifact-state {
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0.05rem 0.35rem;
color: var(--color-muted-foreground);
white-space: nowrap;
}
.hermes-kanban-artifact-path {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: color-mix(in srgb, var(--color-muted) 45%, transparent);
border-radius: 0.25rem;
padding: 0.05rem 0.25rem;
}
.hermes-kanban-artifact-state--available {
color: var(--color-success, #2fa36b);
border-color: color-mix(in srgb, var(--color-success, #2fa36b) 60%, var(--color-border));
}
.hermes-kanban-blocked-summary {
margin: 0.35rem 0;
padding: 0.45rem 0.55rem;
border: 1px solid color-mix(in srgb, var(--color-warning, #d99a22) 55%, var(--color-border));
border-radius: var(--radius);
background: color-mix(in srgb, var(--color-warning, #d99a22) 8%, transparent);
}
.hermes-kanban-blocked-summary-title {
font-size: 0.74rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.hermes-kanban-summary-label {
font-size: 0.72rem;
font-weight: 600;
color: var(--color-muted-foreground);
white-space: nowrap;
}
.hermes-kanban-summary-run {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.25rem;
font-size: 0.72rem;
}
.hermes-kanban-summary-run-summary {
flex-basis: 100%;
font-size: 0.72rem;
color: var(--color-muted-foreground);
overflow-wrap: anywhere;
}
.hermes-kanban-summary-run-meta summary {
cursor: pointer;
font-size: 0.72rem;
color: var(--color-muted-foreground);
}
.hermes-kanban-summary-run-meta pre {
max-height: 12rem;
overflow: auto;
font-size: 0.68rem;
background: color-mix(in srgb, var(--color-muted) 45%, transparent);
border-radius: 0.25rem;
padding: 0.35rem 0.45rem;
}
.hermes-kanban-summary-comments {
margin-top: 0.35rem;
}
.hermes-kanban-summary-comment {
display: flex;
align-items: baseline;
gap: 0.35rem;
font-size: 0.72rem;
min-width: 0;
}
.hermes-kanban-summary-comment-body {
color: var(--color-muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hermes-kanban-summary-links {
margin-top: 0.25rem;
font-size: 0.72rem;
color: var(--color-muted-foreground);
overflow-wrap: anywhere;
}
Loading