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}