mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
feat(kanban): modal create-task dialog, editable board project directory, comment workflow hint (#66333)
Community feedback (@LSanapalli on X): the inline task-creation form is cramped inside a ~280px column with no way to resize; board-level workspace defaults can't be changed after board creation; and users believe they must block a task, comment, then unblock just to talk to a worker. - Create-task dialog: replace the inline column form with a centered modal (reuses hermes-kanban-dialog chrome, 36rem wide) with labeled fields for title, assignee, priority, skills, workspace kind/path, goal mode, and parent task. Same request shape; Enter/Escape behavior preserved; submit disabled until a title is present. - Board settings dialog: new Settings button in the board switcher opens a modal to edit display name, description, and the board-level default project directory (default_workdir). PATCH /boards/:slug now accepts default_workdir (validated absolute existing dir; empty string clears; omitted leaves unchanged) and returns the recomputed default_workspace_kind so task-creation defaults follow immediately. - Comment workflow hint: the task drawer's comment box now explains that comments land on the thread immediately and reach the worker on its next run/kanban_show() — no block/unblock dance needed — with a fuller tooltip for when blocking IS the right tool. - i18n: new keys optional in the kanban namespace with English fallbacks in the bundle (established pattern; avoids churning 17 locale files). - Docs: dashboard section updated for the dialog + Settings button.
This commit is contained in:
parent
71252f0dcb
commit
e4f87557b9
7 changed files with 483 additions and 163 deletions
455
plugins/kanban/dashboard/dist/index.js
vendored
455
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -509,6 +509,7 @@
|
|||
const [board, setBoard] = useState(() => readSelectedBoard() || null);
|
||||
const [boardList, setBoardList] = useState([]); // [{slug, name, counts, ...}]
|
||||
const [showNewBoard, setShowNewBoard] = useState(false);
|
||||
const [showBoardSettings, setShowBoardSettings] = useState(false);
|
||||
|
||||
const [kanbanBoard, setKanbanBoard] = useState(null); // the grid data
|
||||
// Alias so the rest of the function can keep using `board` semantically
|
||||
|
|
@ -971,6 +972,20 @@
|
|||
});
|
||||
}, [loadBoardList, switchBoard, board]);
|
||||
|
||||
// PATCH board metadata (name / description / default project directory).
|
||||
// Refreshes the board list so InlineCreate's workspace defaults pick up
|
||||
// the new default_workdir immediately.
|
||||
const updateBoard = useCallback(function (slug, payload) {
|
||||
return SDK.fetchJSON(`${API}/boards/${encodeURIComponent(slug)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(function (res) {
|
||||
loadBoardList();
|
||||
return res;
|
||||
});
|
||||
}, [loadBoardList]);
|
||||
|
||||
const deleteBoard = useCallback(function (slug) {
|
||||
if (!slug || slug === "default") return Promise.resolve();
|
||||
return SDK.fetchJSON(`${API}/boards/${encodeURIComponent(slug)}`, {
|
||||
|
|
@ -1034,6 +1049,7 @@
|
|||
boardList: boardList,
|
||||
onSwitch: switchBoard,
|
||||
onNewClick: function () { setShowNewBoard(true); },
|
||||
onSettingsClick: function () { setShowBoardSettings(true); },
|
||||
onDeleteBoard: deleteBoard,
|
||||
}),
|
||||
showNewBoard ? h(NewBoardDialog, {
|
||||
|
|
@ -1042,6 +1058,14 @@
|
|||
return createNewBoard(payload).then(function () { setShowNewBoard(false); });
|
||||
},
|
||||
}) : null,
|
||||
showBoardSettings ? h(BoardSettingsDialog, {
|
||||
board: boardList.find(function (item) { return item.slug === board; })
|
||||
|| { slug: board },
|
||||
onCancel: function () { setShowBoardSettings(false); },
|
||||
onSave: function (payload) {
|
||||
return updateBoard(board, payload).then(function () { setShowBoardSettings(false); });
|
||||
},
|
||||
}) : null,
|
||||
h(OrchestrationPanel, null),
|
||||
h(AttentionStrip, {
|
||||
boardData,
|
||||
|
|
@ -1831,6 +1855,13 @@
|
|||
size: "sm",
|
||||
className: "h-7 text-xs",
|
||||
}, tx(t, "newBoard", "+ New board")),
|
||||
h(Button, {
|
||||
onClick: props.onSettingsClick,
|
||||
size: "sm",
|
||||
className: "h-7 text-xs",
|
||||
title: tx(t, "boardSettingsTitle",
|
||||
"Board settings — name, description, and the default project directory new tasks inherit"),
|
||||
}, tx(t, "boardSettings", "Settings")),
|
||||
h(DocsLink, null),
|
||||
);
|
||||
}
|
||||
|
|
@ -1860,6 +1891,13 @@
|
|||
),
|
||||
h("div", { className: "flex-1" }),
|
||||
h(DocsLink, null),
|
||||
h(Button, {
|
||||
onClick: props.onSettingsClick,
|
||||
size: "sm",
|
||||
className: "h-8",
|
||||
title: tx(t, "boardSettingsTitle",
|
||||
"Board settings — name, description, and the default project directory new tasks inherit"),
|
||||
}, tx(t, "boardSettings", "Settings")),
|
||||
h(Button, {
|
||||
onClick: props.onNewClick,
|
||||
size: "sm",
|
||||
|
|
@ -2028,6 +2066,102 @@
|
|||
);
|
||||
}
|
||||
|
||||
// Board settings dialog — edit display name, description, and the
|
||||
// board-level default project directory (default_workdir). The workdir
|
||||
// is the board-level setting every new task's workspace kind/path is
|
||||
// seeded from; task-level values in the create dialog override it.
|
||||
function BoardSettingsDialog(props) {
|
||||
const { t } = useI18n();
|
||||
const b = props.board || {};
|
||||
const [name, setName] = useState(b.name || "");
|
||||
const [description, setDescription] = useState(b.description || "");
|
||||
const [projectDirectory, setProjectDirectory] = useState(b.default_workdir || "");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
function onSubmit(ev) {
|
||||
if (ev) ev.preventDefault();
|
||||
setSubmitting(true);
|
||||
setErr(null);
|
||||
// Send default_workdir unconditionally: "" clears it on the server,
|
||||
// a path sets it (validated server-side: absolute + existing dir).
|
||||
props.onSave({
|
||||
name: name.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
default_workdir: projectDirectory.trim(),
|
||||
}).catch(function (e) {
|
||||
setErr(parseApiErrorMessage(e));
|
||||
setSubmitting(false);
|
||||
});
|
||||
}
|
||||
|
||||
return h("div", {
|
||||
className: "hermes-kanban-dialog-backdrop",
|
||||
onClick: function (e) { if (e.target === e.currentTarget) props.onCancel(); },
|
||||
onKeyDown: function (e) { if (e.key === "Escape") props.onCancel(); },
|
||||
},
|
||||
h("form", {
|
||||
className: "hermes-kanban-dialog",
|
||||
onSubmit: onSubmit,
|
||||
},
|
||||
h("div", { className: "hermes-kanban-dialog-title" },
|
||||
tx(t, "boardSettingsTitleFor", "Board settings — {name}",
|
||||
{ name: b.name || b.slug || "default" })),
|
||||
h("div", { className: "flex flex-col gap-3" },
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs" }, tx(t, "displayName", "Display name")),
|
||||
h(Input, {
|
||||
value: name,
|
||||
onChange: function (e) { setName(e.target.value); },
|
||||
className: "h-8",
|
||||
}),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs" }, tx(t, "description", "Description")),
|
||||
h(Input, {
|
||||
value: description,
|
||||
onChange: function (e) { setDescription(e.target.value); },
|
||||
className: "h-8",
|
||||
}),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs" },
|
||||
tx(t, "projectDirectory", "Project directory")),
|
||||
h(Input, {
|
||||
value: projectDirectory,
|
||||
onChange: function (e) { setProjectDirectory(e.target.value); },
|
||||
placeholder: tx(t, "projectDirectoryPlaceholder",
|
||||
"Absolute path to the project folder"),
|
||||
title: tx(t, "projectDirectoryHelp",
|
||||
"Git projects use preserved worktrees. Other folders use the directory directly. Leave blank only for temporary work."),
|
||||
className: "h-8",
|
||||
autoCapitalize: "none",
|
||||
autoCorrect: "off",
|
||||
spellCheck: false,
|
||||
}),
|
||||
h("div", { className: "text-xs text-muted-foreground" },
|
||||
tx(t, "projectDirectoryOverrideHint",
|
||||
"New tasks inherit this as their workspace default; each task can still override it in the create dialog.")),
|
||||
),
|
||||
),
|
||||
err ? h("div", { className: "text-xs text-destructive mt-2" }, err) : null,
|
||||
h("div", { className: "hermes-kanban-dialog-actions" },
|
||||
h(Button, {
|
||||
type: "button",
|
||||
onClick: props.onCancel,
|
||||
size: "sm",
|
||||
disabled: submitting,
|
||||
}, tx(t, "cancel", "Cancel")),
|
||||
h(Button, {
|
||||
type: "submit",
|
||||
size: "sm",
|
||||
disabled: submitting,
|
||||
}, submitting ? tx(t, "saving", "Saving…") : tx(t, "save", "Save")),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Toolbar
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -2763,7 +2897,12 @@
|
|||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Inline create (with parent selector)
|
||||
// Create-task dialog (modal, with parent selector)
|
||||
//
|
||||
// Launched from a column's [+] button. Was an inline form squeezed into
|
||||
// the ~280px column (8 fields, unlabeled, no room to breathe); now a
|
||||
// centered modal reusing the hermes-kanban-dialog chrome so the form is
|
||||
// resizable-window friendly and every field has a visible label.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function InlineCreate(props) {
|
||||
|
|
@ -2832,122 +2971,165 @@
|
|||
: tx(t, "workspacePathOptional",
|
||||
"repository path (optional when the board has a workdir)");
|
||||
|
||||
return h("div", { className: "hermes-kanban-inline-create" },
|
||||
h("textarea", {
|
||||
value: title,
|
||||
onChange: function (e) { setTitle(e.target.value); },
|
||||
onKeyDown: function (e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
|
||||
if (e.key === "Escape") props.onCancel();
|
||||
},
|
||||
placeholder: props.columnName === "triage"
|
||||
? tx(t, "triagePlaceholder", "Rough idea — AI will spec it…")
|
||||
: tx(t, "taskTitlePlaceholder", "New task title…"),
|
||||
autoFocus: true,
|
||||
className: "text-sm min-h-[2rem] max-h-32 resize-y w-full border border-input bg-transparent px-2 py-1 rounded-md focus:outline-none focus:ring-2 focus:ring-ring",
|
||||
rows: 2,
|
||||
}),
|
||||
h("div", { className: "flex gap-2" },
|
||||
h(Input, {
|
||||
value: assignee,
|
||||
onChange: function (e) { setAssignee(e.target.value); },
|
||||
placeholder: props.columnName === "triage"
|
||||
? tx(t, "specifier", "specifier")
|
||||
: tx(t, "assigneePlaceholder", "assignee"),
|
||||
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.",
|
||||
style: { textTransform: "none" },
|
||||
autoCapitalize: "none",
|
||||
autoCorrect: "off",
|
||||
spellCheck: false,
|
||||
}),
|
||||
h(Input, {
|
||||
type: "number",
|
||||
value: priority,
|
||||
onChange: function (e) { setPriority(e.target.value); },
|
||||
placeholder: "pri",
|
||||
className: "h-7 text-xs w-16",
|
||||
title: "Priority. Higher-priority tasks are claimed first by the dispatcher. 0 = default.",
|
||||
}),
|
||||
),
|
||||
h(Input, {
|
||||
value: skills,
|
||||
onChange: function (e) { setSkills(e.target.value); },
|
||||
placeholder: tx(t, "skillsPlaceholder",
|
||||
"skills (optional, comma-separated): translation, github-code-review"),
|
||||
title: "Force-load these skills into the worker (in addition to the built-in kanban-worker).",
|
||||
className: "h-7 text-xs",
|
||||
}),
|
||||
h("div", { className: "flex gap-2 items-center" },
|
||||
h("label", {
|
||||
className: "flex items-center gap-1.5 text-xs cursor-pointer select-none",
|
||||
title: "Goal mode: the worker keeps going in the same session until a judge agrees the card is done (or the turn budget runs out, which blocks it for review). Best for open-ended cards one shot rarely finishes.",
|
||||
},
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
checked: goalMode,
|
||||
onChange: function (e) { setGoalMode(!!e.target.checked); },
|
||||
className: "h-3.5 w-3.5 accent-current",
|
||||
}),
|
||||
tx(t, "goalMode", "goal mode"),
|
||||
const fieldLabel = function (text, hint) {
|
||||
return h(Label, { className: "text-xs" }, text,
|
||||
hint ? h("span", { className: "text-muted-foreground" }, " ", hint) : null);
|
||||
};
|
||||
|
||||
return h("div", {
|
||||
className: "hermes-kanban-dialog-backdrop",
|
||||
onClick: function (e) { if (e.target === e.currentTarget) props.onCancel(); },
|
||||
onKeyDown: function (e) { if (e.key === "Escape") props.onCancel(); },
|
||||
},
|
||||
h("form", {
|
||||
className: "hermes-kanban-dialog hermes-kanban-create-dialog",
|
||||
onSubmit: function (e) { e.preventDefault(); submit(); },
|
||||
},
|
||||
h("div", { className: "hermes-kanban-dialog-title" },
|
||||
tx(t, "newTaskTitle", "New task — {column}",
|
||||
{ column: getColumnLabel(t, props.columnName) || props.columnName })),
|
||||
h("div", { className: "flex flex-col gap-3" },
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
fieldLabel(tx(t, "taskTitleLabel", "Title")),
|
||||
h("textarea", {
|
||||
value: title,
|
||||
onChange: function (e) { setTitle(e.target.value); },
|
||||
onKeyDown: function (e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
|
||||
},
|
||||
placeholder: props.columnName === "triage"
|
||||
? tx(t, "triagePlaceholder", "Rough idea — AI will spec it…")
|
||||
: tx(t, "taskTitlePlaceholder", "New task title…"),
|
||||
autoFocus: true,
|
||||
className: "text-sm min-h-[3rem] max-h-48 resize-y w-full border border-input bg-transparent px-2 py-1 rounded-md focus:outline-none focus:ring-2 focus:ring-ring",
|
||||
rows: 3,
|
||||
}),
|
||||
),
|
||||
h("div", { className: "flex gap-2" },
|
||||
h("div", { className: "flex flex-col gap-1 flex-1" },
|
||||
fieldLabel(props.columnName === "triage"
|
||||
? tx(t, "specifier", "specifier")
|
||||
: tx(t, "assigneeLabel", "Assignee"),
|
||||
tx(t, "assigneeLabelHint", "(blank = dispatcher picks)")),
|
||||
h(Input, {
|
||||
value: assignee,
|
||||
onChange: function (e) { setAssignee(e.target.value); },
|
||||
placeholder: props.columnName === "triage"
|
||||
? tx(t, "specifier", "specifier")
|
||||
: tx(t, "assigneePlaceholder", "assignee"),
|
||||
className: "h-8 text-sm",
|
||||
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.",
|
||||
style: { textTransform: "none" },
|
||||
autoCapitalize: "none",
|
||||
autoCorrect: "off",
|
||||
spellCheck: false,
|
||||
}),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1 w-20" },
|
||||
fieldLabel(tx(t, "priority", "Priority")),
|
||||
h(Input, {
|
||||
type: "number",
|
||||
value: priority,
|
||||
onChange: function (e) { setPriority(e.target.value); },
|
||||
placeholder: "pri",
|
||||
className: "h-8 text-sm",
|
||||
title: "Priority. Higher-priority tasks are claimed first by the dispatcher. 0 = default.",
|
||||
}),
|
||||
),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
fieldLabel(tx(t, "skillsLabel", "Skills"),
|
||||
tx(t, "skillsLabelHint", "(optional, comma-separated)")),
|
||||
h(Input, {
|
||||
value: skills,
|
||||
onChange: function (e) { setSkills(e.target.value); },
|
||||
placeholder: tx(t, "skillsPlaceholder",
|
||||
"skills (optional, comma-separated): translation, github-code-review"),
|
||||
title: "Force-load these skills into the worker (in addition to the built-in kanban-worker).",
|
||||
className: "h-8 text-sm",
|
||||
}),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
fieldLabel(tx(t, "workspace", "Workspace")),
|
||||
h("div", { className: "flex gap-2" },
|
||||
h(Select, Object.assign({
|
||||
value: workspaceKind,
|
||||
title: "Choose whether task files are temporary or preserved after completion.",
|
||||
className: "h-8 text-sm flex-1",
|
||||
}, selectChangeHandler(setWorkspaceKind)),
|
||||
h(SelectOption, { value: "scratch" },
|
||||
tx(t, "workspaceScratch", "Temporary — deleted on completion")),
|
||||
h(SelectOption, { value: "worktree" },
|
||||
tx(t, "workspaceWorktree", "Git worktree — preserved")),
|
||||
h(SelectOption, { value: "dir" },
|
||||
tx(t, "workspaceDir", "Directory — preserved")),
|
||||
),
|
||||
showPathInput ? h(Input, {
|
||||
value: workspacePath,
|
||||
onChange: function (e) { setWorkspacePath(e.target.value); },
|
||||
placeholder: pathPlaceholder,
|
||||
className: "h-8 text-sm flex-1",
|
||||
}) : null,
|
||||
),
|
||||
workspaceKind === "scratch" ? h("div", {
|
||||
className: "text-xs text-destructive",
|
||||
role: "alert",
|
||||
}, tx(t, "workspaceScratchWarning",
|
||||
"This workspace and any files left in it are deleted when the task completes.")) : null,
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
fieldLabel(tx(t, "parentLabel", "Parent task"),
|
||||
tx(t, "parentLabelHint", "(child stays blocked until the parent is done)")),
|
||||
h(Select, Object.assign({
|
||||
value: parent,
|
||||
className: "h-8 text-sm",
|
||||
title: "Optional parent task. A child stays blocked in its current column until the parent is marked done.",
|
||||
}, selectChangeHandler(setParent)),
|
||||
h(SelectOption, { value: "" }, tx(t, "noParent", "— no parent —")),
|
||||
(props.allTasks || []).map(function (task) {
|
||||
return h(SelectOption, { key: task.id, value: task.id },
|
||||
`${task.id} — ${(task.title || "").slice(0, 50)}`);
|
||||
}),
|
||||
),
|
||||
),
|
||||
h("div", { className: "flex gap-2 items-center" },
|
||||
h("label", {
|
||||
className: "flex items-center gap-1.5 text-xs cursor-pointer select-none",
|
||||
title: "Goal mode: the worker keeps going in the same session until a judge agrees the card is done (or the turn budget runs out, which blocks it for review). Best for open-ended cards one shot rarely finishes.",
|
||||
},
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
checked: goalMode,
|
||||
onChange: function (e) { setGoalMode(!!e.target.checked); },
|
||||
className: "h-3.5 w-3.5 accent-current",
|
||||
}),
|
||||
tx(t, "goalMode", "goal mode"),
|
||||
),
|
||||
goalMode ? h(Input, {
|
||||
type: "number",
|
||||
value: goalMaxTurns,
|
||||
onChange: function (e) { setGoalMaxTurns(e.target.value); },
|
||||
placeholder: tx(t, "goalMaxTurns", "max turns (default 20)"),
|
||||
className: "h-8 text-sm w-44",
|
||||
title: "Turn budget for the goal loop. Blank = backend default (20).",
|
||||
min: 1,
|
||||
}) : null,
|
||||
),
|
||||
),
|
||||
goalMode ? h(Input, {
|
||||
type: "number",
|
||||
value: goalMaxTurns,
|
||||
onChange: function (e) { setGoalMaxTurns(e.target.value); },
|
||||
placeholder: tx(t, "goalMaxTurns", "max turns (default 20)"),
|
||||
className: "h-7 text-xs w-40",
|
||||
title: "Turn budget for the goal loop. Blank = backend default (20).",
|
||||
min: 1,
|
||||
}) : null,
|
||||
),
|
||||
h("div", { className: "flex gap-2" },
|
||||
h(Select, Object.assign({
|
||||
value: workspaceKind,
|
||||
title: "Choose whether task files are temporary or preserved after completion.",
|
||||
className: "h-7 text-xs flex-1",
|
||||
}, selectChangeHandler(setWorkspaceKind)),
|
||||
h(SelectOption, { value: "scratch" },
|
||||
tx(t, "workspaceScratch", "Temporary — deleted on completion")),
|
||||
h(SelectOption, { value: "worktree" },
|
||||
tx(t, "workspaceWorktree", "Git worktree — preserved")),
|
||||
h(SelectOption, { value: "dir" },
|
||||
tx(t, "workspaceDir", "Directory — preserved")),
|
||||
h("div", { className: "hermes-kanban-dialog-actions" },
|
||||
h(Button, {
|
||||
type: "button",
|
||||
onClick: props.onCancel,
|
||||
size: "sm",
|
||||
}, tx(t, "cancel", "Cancel")),
|
||||
h(Button, {
|
||||
type: "submit",
|
||||
size: "sm",
|
||||
disabled: !title.trim(),
|
||||
}, tx(t, "create", "Create")),
|
||||
),
|
||||
showPathInput ? h(Input, {
|
||||
value: workspacePath,
|
||||
onChange: function (e) { setWorkspacePath(e.target.value); },
|
||||
placeholder: pathPlaceholder,
|
||||
className: "h-7 text-xs flex-1",
|
||||
}) : null,
|
||||
),
|
||||
workspaceKind === "scratch" ? h("div", {
|
||||
className: "text-xs text-destructive",
|
||||
role: "alert",
|
||||
}, tx(t, "workspaceScratchWarning",
|
||||
"This workspace and any files left in it are deleted when the task completes.")) : null,
|
||||
h(Select, Object.assign({
|
||||
value: parent,
|
||||
className: "h-7 text-xs",
|
||||
title: "Optional parent task. A child stays blocked in its current column until the parent is marked done.",
|
||||
}, selectChangeHandler(setParent)),
|
||||
h(SelectOption, { value: "" }, tx(t, "noParent", "— no parent —")),
|
||||
(props.allTasks || []).map(function (task) {
|
||||
return h(SelectOption, { key: task.id, value: task.id },
|
||||
`${task.id} — ${(task.title || "").slice(0, 50)}`);
|
||||
}),
|
||||
),
|
||||
h("div", { className: "flex gap-2" },
|
||||
h(Button, {
|
||||
onClick: submit,
|
||||
size: "sm",
|
||||
}, "Create"),
|
||||
h(Button, {
|
||||
onClick: props.onCancel,
|
||||
size: "sm",
|
||||
}, tx(t, "cancel", "Cancel")),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -3228,22 +3410,33 @@
|
|||
if (props.onOpenTask) props.onOpenTask(taskId);
|
||||
},
|
||||
}) : null,
|
||||
data ? h("div", { className: "hermes-kanban-drawer-comment-row" },
|
||||
h(Input, {
|
||||
value: newComment,
|
||||
onChange: function (e) { setNewComment(e.target.value); },
|
||||
onKeyDown: function (e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault(); handleComment();
|
||||
}
|
||||
},
|
||||
placeholder: tx(t, "addComment", "Add a comment… (Enter to submit)"),
|
||||
className: "h-8 text-sm flex-1",
|
||||
}),
|
||||
h(Button, {
|
||||
onClick: handleComment,
|
||||
size: "sm",
|
||||
}, tx(t, "comment", "Comment")),
|
||||
data ? h("div", { className: "hermes-kanban-drawer-comment-foot" },
|
||||
h("div", {
|
||||
className: "hermes-kanban-comment-hint text-xs text-muted-foreground",
|
||||
title: tx(t, "commentHintTitle",
|
||||
"Comments are the channel for talking to a task's worker. They land on the thread immediately — no need to block the task first. A running worker picks the thread up on its next kanban_show() or respawn; blocking is only for when you want the worker to STOP and wait for your input."),
|
||||
},
|
||||
"ⓘ ",
|
||||
tx(t, "commentHint",
|
||||
"Comments reach the worker on its next run or kanban_show() — no need to block the task first."),
|
||||
),
|
||||
h("div", { className: "hermes-kanban-drawer-comment-row" },
|
||||
h(Input, {
|
||||
value: newComment,
|
||||
onChange: function (e) { setNewComment(e.target.value); },
|
||||
onKeyDown: function (e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault(); handleComment();
|
||||
}
|
||||
},
|
||||
placeholder: tx(t, "addComment", "Add a comment… (Enter to submit)"),
|
||||
className: "h-8 text-sm flex-1",
|
||||
}),
|
||||
h(Button, {
|
||||
onClick: handleComment,
|
||||
size: "sm",
|
||||
}, tx(t, "comment", "Comment")),
|
||||
),
|
||||
) : null,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
34
plugins/kanban/dashboard/dist/style.css
vendored
34
plugins/kanban/dashboard/dist/style.css
vendored
|
|
@ -311,22 +311,12 @@
|
|||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ---- Inline create --------------------------------------------------- */
|
||||
/* ---- Create-task dialog ----------------------------------------------- */
|
||||
|
||||
.hermes-kanban-inline-create {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: color-mix(in srgb, var(--color-card) 70%, transparent);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
}
|
||||
|
||||
.hermes-kanban-inline-create > .flex.gap-2:last-child > button:first-of-type {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* Wider than the base dialog so the workspace/parent selectors and the
|
||||
labeled two-column rows breathe; still clamped to the viewport. */
|
||||
.hermes-kanban-create-dialog {
|
||||
width: 36rem;
|
||||
}
|
||||
|
||||
/* ---- Drawer (task detail side panel) --------------------------------- */
|
||||
|
|
@ -576,6 +566,18 @@
|
|||
max-width: 280px;
|
||||
}
|
||||
|
||||
/* Comment footer = workflow hint + input row. The border-top lives on the
|
||||
wrapper so the hint sits inside the same visual block as the input. */
|
||||
.hermes-kanban-drawer-comment-foot {
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-card) 90%, transparent);
|
||||
}
|
||||
|
||||
.hermes-kanban-comment-hint {
|
||||
padding: 0.45rem 0.75rem 0;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.hermes-kanban-drawer-comment-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
|
|
@ -583,8 +585,6 @@
|
|||
/* Keep the comment input clear of the mobile browser nav bar / home
|
||||
indicator by extending the bottom padding with the safe-area inset. */
|
||||
padding-bottom: max(0.55rem, calc(0.55rem + env(safe-area-inset-bottom)));
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-card) 90%, transparent);
|
||||
}
|
||||
|
||||
.hermes-kanban-count {
|
||||
|
|
|
|||
|
|
@ -1990,6 +1990,9 @@ class RenameBoardBody(BaseModel):
|
|||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
# Board-level default project directory for new tasks. ``None`` =
|
||||
# leave unchanged; empty string = clear; a path = validate + set.
|
||||
default_workdir: Optional[str] = None
|
||||
|
||||
|
||||
def _board_counts(slug: str) -> dict[str, int]:
|
||||
|
|
@ -2034,23 +2037,32 @@ def list_boards(include_archived: bool = Query(False)):
|
|||
return {"boards": boards, "current": current}
|
||||
|
||||
|
||||
def _validate_workdir(raw: str) -> str:
|
||||
"""Validate a board default_workdir value; return the resolved path.
|
||||
|
||||
Raises :class:`HTTPException` (400) for relative or non-directory
|
||||
paths — mirroring the create-board contract.
|
||||
"""
|
||||
requested = Path(raw).expanduser()
|
||||
if not requested.is_absolute():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Project directory must be an absolute path.",
|
||||
)
|
||||
if not requested.is_dir():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Project directory must be an existing directory.",
|
||||
)
|
||||
return str(requested.resolve())
|
||||
|
||||
|
||||
@router.post("/boards")
|
||||
def create_board_endpoint(payload: CreateBoardBody):
|
||||
"""Create a new board. Idempotent — ``slug`` collision returns existing."""
|
||||
default_workdir = None
|
||||
if payload.default_workdir:
|
||||
requested = Path(payload.default_workdir).expanduser()
|
||||
if not requested.is_absolute():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Project directory must be an absolute path.",
|
||||
)
|
||||
if not requested.is_dir():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Project directory must be an existing directory.",
|
||||
)
|
||||
default_workdir = str(requested.resolve())
|
||||
default_workdir = _validate_workdir(payload.default_workdir)
|
||||
try:
|
||||
meta = kanban_db.create_board(
|
||||
payload.slug,
|
||||
|
|
@ -2073,20 +2085,28 @@ def create_board_endpoint(payload: CreateBoardBody):
|
|||
|
||||
@router.patch("/boards/{slug}")
|
||||
def rename_board(slug: str, payload: RenameBoardBody):
|
||||
"""Update a board's display metadata (slug is immutable — create a new one to rename the directory)."""
|
||||
"""Update a board's display metadata + default project directory (slug is immutable — create a new one to rename the directory)."""
|
||||
try:
|
||||
normed = kanban_db._normalize_board_slug(slug)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
if not normed or not kanban_db.board_exists(normed):
|
||||
raise HTTPException(status_code=404, detail=f"board {slug!r} does not exist")
|
||||
# default_workdir: None = leave unchanged; "" = clear; path = validate + set.
|
||||
# write_board_metadata treats a falsy value as "clear", so pass "" through.
|
||||
default_workdir: Optional[str] = None
|
||||
if payload.default_workdir is not None:
|
||||
raw = payload.default_workdir.strip()
|
||||
default_workdir = _validate_workdir(raw) if raw else ""
|
||||
meta = kanban_db.write_board_metadata(
|
||||
normed,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
icon=payload.icon,
|
||||
color=payload.color,
|
||||
default_workdir=default_workdir,
|
||||
)
|
||||
meta["default_workspace_kind"] = _default_workspace_kind(meta)
|
||||
return {"board": meta}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -173,6 +173,67 @@ def test_create_board_rejects_invalid_project_directory(client, path):
|
|||
assert "project directory" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_patch_board_sets_project_directory(client, tmp_path):
|
||||
"""Board-level default_workdir must be editable after creation."""
|
||||
kb.create_board("late-config")
|
||||
project_dir = tmp_path / "late-project"
|
||||
project_dir.mkdir()
|
||||
|
||||
response = client.patch(
|
||||
"/api/plugins/kanban/boards/late-config",
|
||||
json={"default_workdir": str(project_dir)},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
board = response.json()["board"]
|
||||
assert board["default_workdir"] == str(project_dir.resolve())
|
||||
# The recommendation flips from scratch to a persistent kind so the
|
||||
# create-task dialog's workspace default follows the board setting.
|
||||
assert board["default_workspace_kind"] == "dir"
|
||||
assert kb.read_board_metadata("late-config")["default_workdir"] == str(
|
||||
project_dir.resolve()
|
||||
)
|
||||
|
||||
|
||||
def test_patch_board_clears_project_directory(client, tmp_path):
|
||||
"""Empty string clears default_workdir; omitting it leaves it unchanged."""
|
||||
project_dir = tmp_path / "was-configured"
|
||||
project_dir.mkdir()
|
||||
kb.create_board("clearable", default_workdir=str(project_dir))
|
||||
|
||||
# Omitted key → unchanged.
|
||||
r = client.patch(
|
||||
"/api/plugins/kanban/boards/clearable",
|
||||
json={"name": "Renamed Only"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["board"]["default_workdir"] == str(project_dir.resolve())
|
||||
|
||||
# Empty string → cleared, recommendation falls back to scratch.
|
||||
r = client.patch(
|
||||
"/api/plugins/kanban/boards/clearable",
|
||||
json={"default_workdir": ""},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
board = r.json()["board"]
|
||||
assert not board.get("default_workdir")
|
||||
assert board["default_workspace_kind"] == "scratch"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["relative/project", "~/missing-project"])
|
||||
def test_patch_board_rejects_invalid_project_directory(client, path):
|
||||
"""PATCH must validate default_workdir like board creation does."""
|
||||
kb.create_board("strict")
|
||||
|
||||
response = client.patch(
|
||||
"/api/plugins/kanban/boards/strict",
|
||||
json={"default_workdir": path},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "project directory" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_new_board_dialog_collects_project_directory():
|
||||
"""Board creation should expose the setting that controls safe task defaults."""
|
||||
bundle = (
|
||||
|
|
|
|||
|
|
@ -828,5 +828,25 @@ export const en: Translations = {
|
|||
"workspace path (optional, derived from assignee if blank)",
|
||||
logTruncated: "(showing last 100 KB — full log at ",
|
||||
logAt: ")",
|
||||
newTaskTitle: "New task — {column}",
|
||||
taskTitleLabel: "Title",
|
||||
assigneeLabel: "Assignee",
|
||||
assigneeLabelHint: "(blank = dispatcher picks)",
|
||||
skillsLabel: "Skills",
|
||||
skillsLabelHint: "(optional, comma-separated)",
|
||||
parentLabel: "Parent task",
|
||||
parentLabelHint: "(child stays blocked until the parent is done)",
|
||||
create: "Create",
|
||||
boardSettings: "Settings",
|
||||
boardSettingsTitle:
|
||||
"Board settings — name, description, and the default project directory new tasks inherit",
|
||||
boardSettingsTitleFor: "Board settings — {name}",
|
||||
projectDirectoryOverrideHint:
|
||||
"New tasks inherit this as their workspace default; each task can still override it in the create dialog.",
|
||||
saving: "Saving…",
|
||||
commentHint:
|
||||
"Comments reach the worker on its next run or kanban_show() — no need to block the task first.",
|
||||
commentHintTitle:
|
||||
"Comments are the channel for talking to a task's worker. They land on the thread immediately — no need to block the task first. A running worker picks the thread up on its next kanban_show() or respawn; blocking is only for when you want the worker to STOP and wait for your input.",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -826,5 +826,25 @@ export interface Translations {
|
|||
workspacePathOptional: string;
|
||||
logTruncated: string;
|
||||
logAt: string;
|
||||
// Optional keys added with the modal create-task dialog, board-settings
|
||||
// dialog, and comment workflow hint. Non-English locales fall back to
|
||||
// the English literal in the plugin bundle until translated, so these
|
||||
// are optional to avoid churning every locale file.
|
||||
newTaskTitle?: string;
|
||||
taskTitleLabel?: string;
|
||||
assigneeLabel?: string;
|
||||
assigneeLabelHint?: string;
|
||||
skillsLabel?: string;
|
||||
skillsLabelHint?: string;
|
||||
parentLabel?: string;
|
||||
parentLabelHint?: string;
|
||||
create?: string;
|
||||
boardSettings?: string;
|
||||
boardSettingsTitle?: string;
|
||||
boardSettingsTitleFor?: string;
|
||||
projectDirectoryOverrideHint?: string;
|
||||
saving?: string;
|
||||
commentHint?: string;
|
||||
commentHintTitle?: string;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,6 +147,12 @@ matters.
|
|||
open.
|
||||
- **+ New board** — opens a modal asking for slug, display name,
|
||||
description, and icon. Option to auto-switch to the new board.
|
||||
- **Settings** — opens a modal for editing the current board's display
|
||||
name, description, and **project directory** (`default_workdir`). The
|
||||
project directory is the board-level workspace default every new task
|
||||
inherits (git repo → preserved worktree, plain dir → preserved
|
||||
directory); each task can still override it at creation time. Clearing
|
||||
the field reverts new tasks to disposable scratch workspaces.
|
||||
- **Archive** — only shown on non-`default` boards. Confirms, then moves
|
||||
the board dir to `boards/_archived/`.
|
||||
|
||||
|
|
@ -425,7 +431,7 @@ hermes kanban create "audit auth flow" \
|
|||
--skill github-code-review
|
||||
```
|
||||
|
||||
**From the dashboard**, type the skills comma-separated into the **skills** field of the inline create form.
|
||||
**From the dashboard**, type the skills comma-separated into the **skills** field of the create-task dialog.
|
||||
|
||||
The dispatcher emits one `--skills <name>` flag per skill listed, so the worker spawns with all of them loaded on top of the auto-injected kanban guidance. The skill names must match skills that are actually installed on the assignee's profile (run `hermes skills list` to see what's available); there's no runtime install.
|
||||
|
||||
|
|
@ -489,7 +495,7 @@ hermes dashboard # "Kanban" tab appears in the nav, after "Skills"
|
|||
- **Per-profile lanes inside Running** — toolbar checkbox toggles sub-grouping of the Running column by assignee.
|
||||
- **Live updates via WebSocket** — the plugin tails the append-only `task_events` table on a short poll interval; the board reflects changes the instant any profile (CLI, gateway, or another dashboard tab) acts. Reloads are debounced so a burst of events triggers a single refetch.
|
||||
- **Drag-drop** cards between columns to change status. The drop sends `PATCH /api/plugins/kanban/tasks/:id` which routes through the same `kanban_db` code the CLI uses — the three surfaces can never drift. Moves into destructive statuses (`done`, `archived`, `blocked`) prompt for confirmation. Touch devices use a pointer-based fallback so the board is usable from a tablet.
|
||||
- **Inline create** — click `+` on any column header to type a title, assignee, priority, and (optionally) a parent task from a dropdown over every existing task. Press Enter to create the task, Shift+Enter to insert a newline in the title field, or Escape to cancel. Creating from the Triage column automatically parks the new task in triage.
|
||||
- **Create-task dialog** — click `+` on any column header to open a modal with labeled fields: title, assignee, priority, skills, workspace kind/path (seeded from the board's project directory; per-task override), goal mode, and (optionally) a parent task from a dropdown over every existing task. Press Enter to create the task, Shift+Enter to insert a newline in the title field, or Escape to cancel. Creating from the Triage column automatically parks the new task in triage.
|
||||
- **Multi-select with bulk actions** — shift/ctrl-click a card or tick its checkbox to add it to the selection. A bulk action bar appears at the top with batch status transitions, archive, and reassign (by profile dropdown, or "(unassign)"). Destructive batches confirm first. Per-id partial failures are reported without aborting the rest.
|
||||
- **Click a card** (without shift/ctrl) to open a side drawer (Escape or click-outside closes) with:
|
||||
- **Editable title** — click the heading to rename.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue