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:
Teknium 2026-07-17 07:23:54 -07:00 committed by GitHub
parent 71252f0dcb
commit e4f87557b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 483 additions and 163 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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.",
},
};

View file

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

View file

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