mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
feat(kanban): add tooltips and docs link across dashboard (#21541)
Makes first-time use of the kanban view self-explanatory. Every control that wasn't already labelled now has a `title` tooltip describing what it does, and a `?` icon next to the board switcher opens the kanban docs page in a new tab. Coverage: - BoardSwitcher: board select, + New board button, docs-link icon (both compact and full variants) - BoardToolbar: Search, Tenant, Assignee, Show archived, Nudge dispatcher, Refresh - BulkActionBar: → ready, Complete, Archive, reassign group, Apply, Clear - Column header: hovering the header now surfaces COLUMN_HELP as a tooltip in addition to the visible sub-text; column count also labelled - Card: task id, priority badge, tenant badge, assignee/unassigned, comment count, link count, age timestamp - InlineCreate: assignee, priority, parent-task selectors Closes the community feedback from @CharlieDePew asking for tooltips and a docs link in the kanban view. Relevant docs page: https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban
This commit is contained in:
parent
7f92e5506e
commit
7d66d30d77
2 changed files with 92 additions and 15 deletions
81
plugins/kanban/dashboard/dist/index.js
vendored
81
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -97,6 +97,12 @@
|
||||||
const API = "/api/plugins/kanban";
|
const API = "/api/plugins/kanban";
|
||||||
const MIME_TASK = "text/x-hermes-task";
|
const MIME_TASK = "text/x-hermes-task";
|
||||||
|
|
||||||
|
// Docs link — surfaced as a `?` icon next to the board switcher and as
|
||||||
|
// `title=` hints on unlabelled controls. Kept in one place so rebrands or
|
||||||
|
// path changes are a single edit.
|
||||||
|
const DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban";
|
||||||
|
const DOCS_TUTORIAL_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban-tutorial";
|
||||||
|
|
||||||
// localStorage key for the user's selected board. Independent of the
|
// localStorage key for the user's selected board. Independent of the
|
||||||
// CLI's on-disk ``<root>/kanban/current`` pointer so browser users
|
// CLI's on-disk ``<root>/kanban/current`` pointer so browser users
|
||||||
// can inspect any board without shifting the CLI's active board out
|
// can inspect any board without shifting the CLI's active board out
|
||||||
|
|
@ -1128,6 +1134,20 @@
|
||||||
// Board switcher (multi-project)
|
// Board switcher (multi-project)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Small `?` affordance next to the board controls. Opens the kanban docs
|
||||||
|
// page in a new tab so users can look up what any of the widgets mean
|
||||||
|
// without losing the current board view.
|
||||||
|
function DocsLink() {
|
||||||
|
return h("a", {
|
||||||
|
href: DOCS_URL,
|
||||||
|
target: "_blank",
|
||||||
|
rel: "noopener noreferrer",
|
||||||
|
className: "hermes-kanban-docs-link",
|
||||||
|
title: "Open Hermes Kanban docs in a new tab",
|
||||||
|
"aria-label": "Hermes Kanban documentation",
|
||||||
|
}, "?");
|
||||||
|
}
|
||||||
|
|
||||||
function BoardSwitcher(props) {
|
function BoardSwitcher(props) {
|
||||||
const list = props.boardList || [];
|
const list = props.boardList || [];
|
||||||
const current = list.find(function (b) { return b.slug === props.board; });
|
const current = list.find(function (b) { return b.slug === props.board; });
|
||||||
|
|
@ -1152,6 +1172,7 @@
|
||||||
size: "sm",
|
size: "sm",
|
||||||
className: "h-7 text-xs",
|
className: "h-7 text-xs",
|
||||||
}, "+ New board"),
|
}, "+ New board"),
|
||||||
|
h(DocsLink, null),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1165,6 +1186,7 @@
|
||||||
value: props.board,
|
value: props.board,
|
||||||
className: "h-8 min-w-[220px]",
|
className: "h-8 min-w-[220px]",
|
||||||
"aria-label": "Switch kanban board",
|
"aria-label": "Switch kanban board",
|
||||||
|
title: "Boards are independent work streams. Each board has its own tasks, tenants, and assignees.",
|
||||||
}, selectChangeHandler(function (v) { if (v) props.onSwitch(v); })),
|
}, selectChangeHandler(function (v) { if (v) props.onSwitch(v); })),
|
||||||
list.map(function (b) {
|
list.map(function (b) {
|
||||||
const label = b.total > 0
|
const label = b.total > 0
|
||||||
|
|
@ -1178,10 +1200,12 @@
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
h("div", { className: "flex-1" }),
|
h("div", { className: "flex-1" }),
|
||||||
|
h(DocsLink, null),
|
||||||
h(Button, {
|
h(Button, {
|
||||||
onClick: props.onNewClick,
|
onClick: props.onNewClick,
|
||||||
size: "sm",
|
size: "sm",
|
||||||
className: "h-8",
|
className: "h-8",
|
||||||
|
title: "Create a new board. Useful when you want an unrelated work stream (different project, different team, isolated scratch area).",
|
||||||
}, "+ New board"),
|
}, "+ New board"),
|
||||||
props.board !== "default"
|
props.board !== "default"
|
||||||
? h(Button, {
|
? h(Button, {
|
||||||
|
|
@ -1326,7 +1350,8 @@
|
||||||
const tenants = (props.board && props.board.tenants) || [];
|
const tenants = (props.board && props.board.tenants) || [];
|
||||||
const assignees = (props.board && props.board.assignees) || [];
|
const assignees = (props.board && props.board.assignees) || [];
|
||||||
return h("div", { className: "flex flex-wrap items-end gap-3" },
|
return h("div", { className: "flex flex-wrap items-end gap-3" },
|
||||||
h("div", { className: "flex flex-col gap-1" },
|
h("div", { className: "flex flex-col gap-1",
|
||||||
|
title: "Fuzzy-match tasks by id, title, or description. Matches across all columns." },
|
||||||
h(Label, { className: "text-xs text-muted-foreground" }, "Search"),
|
h(Label, { className: "text-xs text-muted-foreground" }, "Search"),
|
||||||
h(Input, {
|
h(Input, {
|
||||||
placeholder: "Filter cards…",
|
placeholder: "Filter cards…",
|
||||||
|
|
@ -1335,7 +1360,8 @@
|
||||||
className: "w-56 h-8",
|
className: "w-56 h-8",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
h("div", { className: "flex flex-col gap-1" },
|
h("div", { className: "flex flex-col gap-1",
|
||||||
|
title: "Tenants are free-form tags on a task (e.g. customer, project, team). Set them via the task drawer or kanban_create." },
|
||||||
h(Label, { className: "text-xs text-muted-foreground" }, "Tenant"),
|
h(Label, { className: "text-xs text-muted-foreground" }, "Tenant"),
|
||||||
h(Select, Object.assign({
|
h(Select, Object.assign({
|
||||||
value: props.tenantFilter,
|
value: props.tenantFilter,
|
||||||
|
|
@ -1347,7 +1373,8 @@
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
h("div", { className: "flex flex-col gap-1" },
|
h("div", { className: "flex flex-col gap-1",
|
||||||
|
title: "Filter by assigned Hermes profile. Profiles are the named agent identities that claim and work on tasks." },
|
||||||
h(Label, { className: "text-xs text-muted-foreground" }, "Assignee"),
|
h(Label, { className: "text-xs text-muted-foreground" }, "Assignee"),
|
||||||
h(Select, Object.assign({
|
h(Select, Object.assign({
|
||||||
value: props.assigneeFilter,
|
value: props.assigneeFilter,
|
||||||
|
|
@ -1359,7 +1386,8 @@
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
h("label", { className: "flex items-center gap-2 text-xs" },
|
h("label", { className: "flex items-center gap-2 text-xs",
|
||||||
|
title: "Include archived tasks in the board view. Archived tasks are hidden by default." },
|
||||||
h("input", {
|
h("input", {
|
||||||
type: "checkbox",
|
type: "checkbox",
|
||||||
checked: props.includeArchived,
|
checked: props.includeArchived,
|
||||||
|
|
@ -1380,10 +1408,12 @@
|
||||||
h(Button, {
|
h(Button, {
|
||||||
onClick: props.onNudgeDispatch,
|
onClick: props.onNudgeDispatch,
|
||||||
size: "sm",
|
size: "sm",
|
||||||
|
title: "Wake the dispatcher to claim ready tasks now instead of waiting for the next tick. Use this after adding tasks if you want them picked up immediately.",
|
||||||
}, "Nudge dispatcher"),
|
}, "Nudge dispatcher"),
|
||||||
h(Button, {
|
h(Button, {
|
||||||
onClick: props.onRefresh,
|
onClick: props.onRefresh,
|
||||||
size: "sm",
|
size: "sm",
|
||||||
|
title: "Reload the board from the database. The board auto-refreshes on task events; this is for forcing a re-read.",
|
||||||
}, "Refresh"),
|
}, "Refresh"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1400,6 +1430,7 @@
|
||||||
h(Button, {
|
h(Button, {
|
||||||
onClick: function () { props.onApply({ status: "ready" }); },
|
onClick: function () { props.onApply({ status: "ready" }); },
|
||||||
size: "sm",
|
size: "sm",
|
||||||
|
title: "Move selected tasks to Ready. Ready tasks are picked up by the dispatcher on the next tick.",
|
||||||
}, "→ ready"),
|
}, "→ ready"),
|
||||||
h(Button, {
|
h(Button, {
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
|
|
@ -1407,6 +1438,7 @@
|
||||||
`Mark ${props.count} task(s) as done?`);
|
`Mark ${props.count} task(s) as done?`);
|
||||||
},
|
},
|
||||||
size: "sm",
|
size: "sm",
|
||||||
|
title: "Mark selected tasks as done. Releases any claims and unblocks dependent children. You'll be asked for a completion summary.",
|
||||||
}, "Complete"),
|
}, "Complete"),
|
||||||
h(Button, {
|
h(Button, {
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
|
|
@ -1414,8 +1446,10 @@
|
||||||
`Archive ${props.count} task(s)?`);
|
`Archive ${props.count} task(s)?`);
|
||||||
},
|
},
|
||||||
size: "sm",
|
size: "sm",
|
||||||
|
title: "Archive selected tasks. They disappear from the default board view but remain in the database.",
|
||||||
}, "Archive"),
|
}, "Archive"),
|
||||||
h("div", { className: "hermes-kanban-bulk-reassign" },
|
h("div", { className: "hermes-kanban-bulk-reassign",
|
||||||
|
title: "Reassign selected tasks to a different Hermes profile. Pick a profile (or unassign) and click Apply." },
|
||||||
h(Select, {
|
h(Select, {
|
||||||
value: assignee,
|
value: assignee,
|
||||||
onChange: function (e) { setAssignee(e.target.value); },
|
onChange: function (e) { setAssignee(e.target.value); },
|
||||||
|
|
@ -1435,12 +1469,14 @@
|
||||||
},
|
},
|
||||||
disabled: !assignee,
|
disabled: !assignee,
|
||||||
size: "sm",
|
size: "sm",
|
||||||
|
title: "Apply the selected assignee to all selected tasks.",
|
||||||
}, "Apply"),
|
}, "Apply"),
|
||||||
),
|
),
|
||||||
h("div", { className: "flex-1" }),
|
h("div", { className: "flex-1" }),
|
||||||
h(Button, {
|
h(Button, {
|
||||||
onClick: props.onClear,
|
onClick: props.onClear,
|
||||||
size: "sm",
|
size: "sm",
|
||||||
|
title: "Deselect all tasks and hide this bar.",
|
||||||
}, "Clear"),
|
}, "Clear"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1521,11 +1557,13 @@
|
||||||
onDragLeave: handleDragLeave,
|
onDragLeave: handleDragLeave,
|
||||||
onDrop: handleDrop,
|
onDrop: handleDrop,
|
||||||
},
|
},
|
||||||
h("div", { className: "hermes-kanban-column-header" },
|
h("div", { className: "hermes-kanban-column-header",
|
||||||
|
title: COLUMN_HELP[props.column.name] || "" },
|
||||||
h("span", { className: cn("hermes-kanban-dot", COLUMN_DOT[props.column.name]) }),
|
h("span", { className: cn("hermes-kanban-dot", COLUMN_DOT[props.column.name]) }),
|
||||||
h("span", { className: "hermes-kanban-column-label" },
|
h("span", { className: "hermes-kanban-column-label" },
|
||||||
COLUMN_LABEL[props.column.name] || props.column.name),
|
COLUMN_LABEL[props.column.name] || props.column.name),
|
||||||
h("span", { className: "hermes-kanban-column-count" },
|
h("span", { className: "hermes-kanban-column-count",
|
||||||
|
title: `${props.column.tasks.length} task${props.column.tasks.length === 1 ? "" : "s"} in this column` },
|
||||||
props.column.tasks.length),
|
props.column.tasks.length),
|
||||||
h("button", {
|
h("button", {
|
||||||
type: "button",
|
type: "button",
|
||||||
|
|
@ -1652,7 +1690,8 @@
|
||||||
onClick: function (e) { e.stopPropagation(); },
|
onClick: function (e) { e.stopPropagation(); },
|
||||||
title: "Select for bulk actions",
|
title: "Select for bulk actions",
|
||||||
}),
|
}),
|
||||||
h("span", { className: "hermes-kanban-card-id" }, t.id),
|
h("span", { className: "hermes-kanban-card-id",
|
||||||
|
title: `Task id: ${t.id}. Use this id with kanban_show, /kanban show, or hermes kanban show.` }, t.id),
|
||||||
t.warnings && t.warnings.count > 0
|
t.warnings && t.warnings.count > 0
|
||||||
? h("span", {
|
? h("span", {
|
||||||
className: cn(
|
className: cn(
|
||||||
|
|
@ -1669,10 +1708,12 @@
|
||||||
t.warnings.highest_severity === "error" ? "!!" : "⚠")
|
t.warnings.highest_severity === "error" ? "!!" : "⚠")
|
||||||
: null,
|
: null,
|
||||||
t.priority > 0
|
t.priority > 0
|
||||||
? h(Badge, { className: "hermes-kanban-priority" }, `P${t.priority}`)
|
? h(Badge, { className: "hermes-kanban-priority",
|
||||||
|
title: `Priority ${t.priority}. Higher-priority tasks are claimed first by the dispatcher.` }, `P${t.priority}`)
|
||||||
: null,
|
: null,
|
||||||
t.tenant
|
t.tenant
|
||||||
? h(Badge, { variant: "outline", className: "hermes-kanban-tag" }, t.tenant)
|
? h(Badge, { variant: "outline", className: "hermes-kanban-tag",
|
||||||
|
title: `Tenant: ${t.tenant}. Free-form tag for grouping tasks (customer, project, team).` }, t.tenant)
|
||||||
: null,
|
: null,
|
||||||
progress
|
progress
|
||||||
? h("span", {
|
? h("span", {
|
||||||
|
|
@ -1687,16 +1728,21 @@
|
||||||
h("div", { className: "hermes-kanban-card-title" }, t.title || "(untitled)"),
|
h("div", { className: "hermes-kanban-card-title" }, t.title || "(untitled)"),
|
||||||
h("div", { className: "hermes-kanban-card-row hermes-kanban-card-meta" },
|
h("div", { className: "hermes-kanban-card-row hermes-kanban-card-meta" },
|
||||||
t.assignee
|
t.assignee
|
||||||
? h("span", { className: "hermes-kanban-assignee" }, "@", t.assignee)
|
? h("span", { className: "hermes-kanban-assignee",
|
||||||
: h("span", { className: "hermes-kanban-unassigned" }, "unassigned"),
|
title: `Assigned to Hermes profile @${t.assignee}` }, "@", t.assignee)
|
||||||
|
: h("span", { className: "hermes-kanban-unassigned",
|
||||||
|
title: "No profile assigned. The dispatcher will pick one from available profiles when the task is Ready." }, "unassigned"),
|
||||||
t.comment_count > 0
|
t.comment_count > 0
|
||||||
? h("span", { className: "hermes-kanban-count" }, "💬 ", t.comment_count)
|
? h("span", { className: "hermes-kanban-count",
|
||||||
|
title: `${t.comment_count} comment${t.comment_count === 1 ? "" : "s"} on this task` }, "💬 ", t.comment_count)
|
||||||
: null,
|
: null,
|
||||||
t.link_counts && (t.link_counts.parents + t.link_counts.children) > 0
|
t.link_counts && (t.link_counts.parents + t.link_counts.children) > 0
|
||||||
? h("span", { className: "hermes-kanban-count" },
|
? h("span", { className: "hermes-kanban-count",
|
||||||
|
title: `${t.link_counts.parents} parent${t.link_counts.parents === 1 ? "" : "s"}, ${t.link_counts.children} child${t.link_counts.children === 1 ? "" : "ren"}. Children stay blocked until their parent is done.` },
|
||||||
"↔ ", t.link_counts.parents + t.link_counts.children)
|
"↔ ", t.link_counts.parents + t.link_counts.children)
|
||||||
: null,
|
: null,
|
||||||
h("span", { className: "hermes-kanban-ago" },
|
h("span", { className: "hermes-kanban-ago",
|
||||||
|
title: t.created_at ? `Created ${t.created_at}` : "" },
|
||||||
timeAgo ? timeAgo(t.created_at) : ""),
|
timeAgo ? timeAgo(t.created_at) : ""),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1777,6 +1823,9 @@
|
||||||
onChange: function (e) { setAssignee(e.target.value); },
|
onChange: function (e) { setAssignee(e.target.value); },
|
||||||
placeholder: props.columnName === "triage" ? "specifier" : "assignee",
|
placeholder: props.columnName === "triage" ? "specifier" : "assignee",
|
||||||
className: "h-7 text-xs flex-1",
|
className: "h-7 text-xs flex-1",
|
||||||
|
title: props.columnName === "triage"
|
||||||
|
? "Hermes profile that will spec this task (default: the dispatcher's configured specifier). Leave blank to let the dispatcher pick."
|
||||||
|
: "Hermes profile to assign. Leave blank and the dispatcher will pick from available profiles when the task is Ready.",
|
||||||
}),
|
}),
|
||||||
h(Input, {
|
h(Input, {
|
||||||
type: "number",
|
type: "number",
|
||||||
|
|
@ -1784,6 +1833,7 @@
|
||||||
onChange: function (e) { setPriority(e.target.value); },
|
onChange: function (e) { setPriority(e.target.value); },
|
||||||
placeholder: "pri",
|
placeholder: "pri",
|
||||||
className: "h-7 text-xs w-16",
|
className: "h-7 text-xs w-16",
|
||||||
|
title: "Priority. Higher-priority tasks are claimed first by the dispatcher. 0 = default.",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
h(Input, {
|
h(Input, {
|
||||||
|
|
@ -1815,6 +1865,7 @@
|
||||||
value: parent,
|
value: parent,
|
||||||
onChange: function (e) { setParent(e.target.value); },
|
onChange: function (e) { setParent(e.target.value); },
|
||||||
className: "h-7 text-xs",
|
className: "h-7 text-xs",
|
||||||
|
title: "Optional parent task. A child stays blocked in its current column until the parent is marked done.",
|
||||||
},
|
},
|
||||||
h(SelectOption, { value: "" }, "— no parent —"),
|
h(SelectOption, { value: "" }, "— no parent —"),
|
||||||
(props.allTasks || []).map(function (t) {
|
(props.allTasks || []).map(function (t) {
|
||||||
|
|
|
||||||
26
plugins/kanban/dashboard/dist/style.css
vendored
26
plugins/kanban/dashboard/dist/style.css
vendored
|
|
@ -891,6 +891,32 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.hermes-kanban-docs-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-muted-foreground, rgba(180, 180, 200, 0.8));
|
||||||
|
background: var(--color-card-subtle, rgba(255, 255, 255, 0.04));
|
||||||
|
border: 1px solid var(--color-border, rgba(120, 120, 140, 0.25));
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: help;
|
||||||
|
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.hermes-kanban-docs-link:hover,
|
||||||
|
.hermes-kanban-docs-link:focus-visible {
|
||||||
|
color: var(--color-foreground, #e7e7ee);
|
||||||
|
background: var(--color-card, rgba(255, 255, 255, 0.08));
|
||||||
|
border-color: var(--color-border, rgba(160, 160, 190, 0.45));
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
.hermes-kanban-dialog-backdrop {
|
.hermes-kanban-dialog-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue