From e4f87557b9cd3e33a67182bd61ef4949d9660d1e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 17 Jul 2026 07:23:54 -0700 Subject: [PATCH] feat(kanban): modal create-task dialog, editable board project directory, comment workflow hint (#66333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- plugins/kanban/dashboard/dist/index.js | 455 +++++++++++++----- plugins/kanban/dashboard/dist/style.css | 34 +- plugins/kanban/dashboard/plugin_api.py | 46 +- tests/plugins/test_kanban_dashboard_plugin.py | 61 +++ web/src/i18n/en.ts | 20 + web/src/i18n/types.ts | 20 + website/docs/user-guide/features/kanban.md | 10 +- 7 files changed, 483 insertions(+), 163 deletions(-) diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 4dd7564f7d43..1ae794831447 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -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, ), ); diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css index 8f2a43c295ff..352f55525b2a 100644 --- a/plugins/kanban/dashboard/dist/style.css +++ b/plugins/kanban/dashboard/dist/style.css @@ -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 { diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 1b85b6fea470..aaf3a981b089 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -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} diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 2cf207d7d91c..717bf660ee9f 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -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 = ( diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index a8be9310835a..38c61a15ca3c 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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.", }, }; diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 6a888226adbf..52fd86acf6a4 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -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; }; } diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index c8841e9172dd..b47ba730715c 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -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 ` 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.