mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(kanban): modal create-task dialog, editable board project directory, comment workflow hint (#66333)
Community feedback (@LSanapalli on X): the inline task-creation form is cramped inside a ~280px column with no way to resize; board-level workspace defaults can't be changed after board creation; and users believe they must block a task, comment, then unblock just to talk to a worker. - Create-task dialog: replace the inline column form with a centered modal (reuses hermes-kanban-dialog chrome, 36rem wide) with labeled fields for title, assignee, priority, skills, workspace kind/path, goal mode, and parent task. Same request shape; Enter/Escape behavior preserved; submit disabled until a title is present. - Board settings dialog: new Settings button in the board switcher opens a modal to edit display name, description, and the board-level default project directory (default_workdir). PATCH /boards/:slug now accepts default_workdir (validated absolute existing dir; empty string clears; omitted leaves unchanged) and returns the recomputed default_workspace_kind so task-creation defaults follow immediately. - Comment workflow hint: the task drawer's comment box now explains that comments land on the thread immediately and reach the worker on its next run/kanban_show() — no block/unblock dance needed — with a fuller tooltip for when blocking IS the right tool. - i18n: new keys optional in the kanban namespace with English fallbacks in the bundle (established pattern; avoids churning 17 locale files). - Docs: dashboard section updated for the dialog + Settings button.
This commit is contained in:
parent
71252f0dcb
commit
e4f87557b9
7 changed files with 483 additions and 163 deletions
455
plugins/kanban/dashboard/dist/index.js
vendored
455
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -509,6 +509,7 @@
|
|||
const [board, setBoard] = useState(() => readSelectedBoard() || null);
|
||||
const [boardList, setBoardList] = useState([]); // [{slug, name, counts, ...}]
|
||||
const [showNewBoard, setShowNewBoard] = useState(false);
|
||||
const [showBoardSettings, setShowBoardSettings] = useState(false);
|
||||
|
||||
const [kanbanBoard, setKanbanBoard] = useState(null); // the grid data
|
||||
// Alias so the rest of the function can keep using `board` semantically
|
||||
|
|
@ -971,6 +972,20 @@
|
|||
});
|
||||
}, [loadBoardList, switchBoard, board]);
|
||||
|
||||
// PATCH board metadata (name / description / default project directory).
|
||||
// Refreshes the board list so InlineCreate's workspace defaults pick up
|
||||
// the new default_workdir immediately.
|
||||
const updateBoard = useCallback(function (slug, payload) {
|
||||
return SDK.fetchJSON(`${API}/boards/${encodeURIComponent(slug)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(function (res) {
|
||||
loadBoardList();
|
||||
return res;
|
||||
});
|
||||
}, [loadBoardList]);
|
||||
|
||||
const deleteBoard = useCallback(function (slug) {
|
||||
if (!slug || slug === "default") return Promise.resolve();
|
||||
return SDK.fetchJSON(`${API}/boards/${encodeURIComponent(slug)}`, {
|
||||
|
|
@ -1034,6 +1049,7 @@
|
|||
boardList: boardList,
|
||||
onSwitch: switchBoard,
|
||||
onNewClick: function () { setShowNewBoard(true); },
|
||||
onSettingsClick: function () { setShowBoardSettings(true); },
|
||||
onDeleteBoard: deleteBoard,
|
||||
}),
|
||||
showNewBoard ? h(NewBoardDialog, {
|
||||
|
|
@ -1042,6 +1058,14 @@
|
|||
return createNewBoard(payload).then(function () { setShowNewBoard(false); });
|
||||
},
|
||||
}) : null,
|
||||
showBoardSettings ? h(BoardSettingsDialog, {
|
||||
board: boardList.find(function (item) { return item.slug === board; })
|
||||
|| { slug: board },
|
||||
onCancel: function () { setShowBoardSettings(false); },
|
||||
onSave: function (payload) {
|
||||
return updateBoard(board, payload).then(function () { setShowBoardSettings(false); });
|
||||
},
|
||||
}) : null,
|
||||
h(OrchestrationPanel, null),
|
||||
h(AttentionStrip, {
|
||||
boardData,
|
||||
|
|
@ -1831,6 +1855,13 @@
|
|||
size: "sm",
|
||||
className: "h-7 text-xs",
|
||||
}, tx(t, "newBoard", "+ New board")),
|
||||
h(Button, {
|
||||
onClick: props.onSettingsClick,
|
||||
size: "sm",
|
||||
className: "h-7 text-xs",
|
||||
title: tx(t, "boardSettingsTitle",
|
||||
"Board settings — name, description, and the default project directory new tasks inherit"),
|
||||
}, tx(t, "boardSettings", "Settings")),
|
||||
h(DocsLink, null),
|
||||
);
|
||||
}
|
||||
|
|
@ -1860,6 +1891,13 @@
|
|||
),
|
||||
h("div", { className: "flex-1" }),
|
||||
h(DocsLink, null),
|
||||
h(Button, {
|
||||
onClick: props.onSettingsClick,
|
||||
size: "sm",
|
||||
className: "h-8",
|
||||
title: tx(t, "boardSettingsTitle",
|
||||
"Board settings — name, description, and the default project directory new tasks inherit"),
|
||||
}, tx(t, "boardSettings", "Settings")),
|
||||
h(Button, {
|
||||
onClick: props.onNewClick,
|
||||
size: "sm",
|
||||
|
|
@ -2028,6 +2066,102 @@
|
|||
);
|
||||
}
|
||||
|
||||
// Board settings dialog — edit display name, description, and the
|
||||
// board-level default project directory (default_workdir). The workdir
|
||||
// is the board-level setting every new task's workspace kind/path is
|
||||
// seeded from; task-level values in the create dialog override it.
|
||||
function BoardSettingsDialog(props) {
|
||||
const { t } = useI18n();
|
||||
const b = props.board || {};
|
||||
const [name, setName] = useState(b.name || "");
|
||||
const [description, setDescription] = useState(b.description || "");
|
||||
const [projectDirectory, setProjectDirectory] = useState(b.default_workdir || "");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
function onSubmit(ev) {
|
||||
if (ev) ev.preventDefault();
|
||||
setSubmitting(true);
|
||||
setErr(null);
|
||||
// Send default_workdir unconditionally: "" clears it on the server,
|
||||
// a path sets it (validated server-side: absolute + existing dir).
|
||||
props.onSave({
|
||||
name: name.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
default_workdir: projectDirectory.trim(),
|
||||
}).catch(function (e) {
|
||||
setErr(parseApiErrorMessage(e));
|
||||
setSubmitting(false);
|
||||
});
|
||||
}
|
||||
|
||||
return h("div", {
|
||||
className: "hermes-kanban-dialog-backdrop",
|
||||
onClick: function (e) { if (e.target === e.currentTarget) props.onCancel(); },
|
||||
onKeyDown: function (e) { if (e.key === "Escape") props.onCancel(); },
|
||||
},
|
||||
h("form", {
|
||||
className: "hermes-kanban-dialog",
|
||||
onSubmit: onSubmit,
|
||||
},
|
||||
h("div", { className: "hermes-kanban-dialog-title" },
|
||||
tx(t, "boardSettingsTitleFor", "Board settings — {name}",
|
||||
{ name: b.name || b.slug || "default" })),
|
||||
h("div", { className: "flex flex-col gap-3" },
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs" }, tx(t, "displayName", "Display name")),
|
||||
h(Input, {
|
||||
value: name,
|
||||
onChange: function (e) { setName(e.target.value); },
|
||||
className: "h-8",
|
||||
}),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs" }, tx(t, "description", "Description")),
|
||||
h(Input, {
|
||||
value: description,
|
||||
onChange: function (e) { setDescription(e.target.value); },
|
||||
className: "h-8",
|
||||
}),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs" },
|
||||
tx(t, "projectDirectory", "Project directory")),
|
||||
h(Input, {
|
||||
value: projectDirectory,
|
||||
onChange: function (e) { setProjectDirectory(e.target.value); },
|
||||
placeholder: tx(t, "projectDirectoryPlaceholder",
|
||||
"Absolute path to the project folder"),
|
||||
title: tx(t, "projectDirectoryHelp",
|
||||
"Git projects use preserved worktrees. Other folders use the directory directly. Leave blank only for temporary work."),
|
||||
className: "h-8",
|
||||
autoCapitalize: "none",
|
||||
autoCorrect: "off",
|
||||
spellCheck: false,
|
||||
}),
|
||||
h("div", { className: "text-xs text-muted-foreground" },
|
||||
tx(t, "projectDirectoryOverrideHint",
|
||||
"New tasks inherit this as their workspace default; each task can still override it in the create dialog.")),
|
||||
),
|
||||
),
|
||||
err ? h("div", { className: "text-xs text-destructive mt-2" }, err) : null,
|
||||
h("div", { className: "hermes-kanban-dialog-actions" },
|
||||
h(Button, {
|
||||
type: "button",
|
||||
onClick: props.onCancel,
|
||||
size: "sm",
|
||||
disabled: submitting,
|
||||
}, tx(t, "cancel", "Cancel")),
|
||||
h(Button, {
|
||||
type: "submit",
|
||||
size: "sm",
|
||||
disabled: submitting,
|
||||
}, submitting ? tx(t, "saving", "Saving…") : tx(t, "save", "Save")),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Toolbar
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -2763,7 +2897,12 @@
|
|||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Inline create (with parent selector)
|
||||
// Create-task dialog (modal, with parent selector)
|
||||
//
|
||||
// Launched from a column's [+] button. Was an inline form squeezed into
|
||||
// the ~280px column (8 fields, unlabeled, no room to breathe); now a
|
||||
// centered modal reusing the hermes-kanban-dialog chrome so the form is
|
||||
// resizable-window friendly and every field has a visible label.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function InlineCreate(props) {
|
||||
|
|
@ -2832,122 +2971,165 @@
|
|||
: tx(t, "workspacePathOptional",
|
||||
"repository path (optional when the board has a workdir)");
|
||||
|
||||
return h("div", { className: "hermes-kanban-inline-create" },
|
||||
h("textarea", {
|
||||
value: title,
|
||||
onChange: function (e) { setTitle(e.target.value); },
|
||||
onKeyDown: function (e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
|
||||
if (e.key === "Escape") props.onCancel();
|
||||
},
|
||||
placeholder: props.columnName === "triage"
|
||||
? tx(t, "triagePlaceholder", "Rough idea — AI will spec it…")
|
||||
: tx(t, "taskTitlePlaceholder", "New task title…"),
|
||||
autoFocus: true,
|
||||
className: "text-sm min-h-[2rem] max-h-32 resize-y w-full border border-input bg-transparent px-2 py-1 rounded-md focus:outline-none focus:ring-2 focus:ring-ring",
|
||||
rows: 2,
|
||||
}),
|
||||
h("div", { className: "flex gap-2" },
|
||||
h(Input, {
|
||||
value: assignee,
|
||||
onChange: function (e) { setAssignee(e.target.value); },
|
||||
placeholder: props.columnName === "triage"
|
||||
? tx(t, "specifier", "specifier")
|
||||
: tx(t, "assigneePlaceholder", "assignee"),
|
||||
className: "h-7 text-xs flex-1",
|
||||
title: props.columnName === "triage"
|
||||
? "Hermes profile that will spec this task (default: the dispatcher's configured specifier). Leave blank to let the dispatcher pick."
|
||||
: "Hermes profile to assign. Leave blank and the dispatcher will pick from available profiles when the task is Ready.",
|
||||
style: { textTransform: "none" },
|
||||
autoCapitalize: "none",
|
||||
autoCorrect: "off",
|
||||
spellCheck: false,
|
||||
}),
|
||||
h(Input, {
|
||||
type: "number",
|
||||
value: priority,
|
||||
onChange: function (e) { setPriority(e.target.value); },
|
||||
placeholder: "pri",
|
||||
className: "h-7 text-xs w-16",
|
||||
title: "Priority. Higher-priority tasks are claimed first by the dispatcher. 0 = default.",
|
||||
}),
|
||||
),
|
||||
h(Input, {
|
||||
value: skills,
|
||||
onChange: function (e) { setSkills(e.target.value); },
|
||||
placeholder: tx(t, "skillsPlaceholder",
|
||||
"skills (optional, comma-separated): translation, github-code-review"),
|
||||
title: "Force-load these skills into the worker (in addition to the built-in kanban-worker).",
|
||||
className: "h-7 text-xs",
|
||||
}),
|
||||
h("div", { className: "flex gap-2 items-center" },
|
||||
h("label", {
|
||||
className: "flex items-center gap-1.5 text-xs cursor-pointer select-none",
|
||||
title: "Goal mode: the worker keeps going in the same session until a judge agrees the card is done (or the turn budget runs out, which blocks it for review). Best for open-ended cards one shot rarely finishes.",
|
||||
},
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
checked: goalMode,
|
||||
onChange: function (e) { setGoalMode(!!e.target.checked); },
|
||||
className: "h-3.5 w-3.5 accent-current",
|
||||
}),
|
||||
tx(t, "goalMode", "goal mode"),
|
||||
const fieldLabel = function (text, hint) {
|
||||
return h(Label, { className: "text-xs" }, text,
|
||||
hint ? h("span", { className: "text-muted-foreground" }, " ", hint) : null);
|
||||
};
|
||||
|
||||
return h("div", {
|
||||
className: "hermes-kanban-dialog-backdrop",
|
||||
onClick: function (e) { if (e.target === e.currentTarget) props.onCancel(); },
|
||||
onKeyDown: function (e) { if (e.key === "Escape") props.onCancel(); },
|
||||
},
|
||||
h("form", {
|
||||
className: "hermes-kanban-dialog hermes-kanban-create-dialog",
|
||||
onSubmit: function (e) { e.preventDefault(); submit(); },
|
||||
},
|
||||
h("div", { className: "hermes-kanban-dialog-title" },
|
||||
tx(t, "newTaskTitle", "New task — {column}",
|
||||
{ column: getColumnLabel(t, props.columnName) || props.columnName })),
|
||||
h("div", { className: "flex flex-col gap-3" },
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
fieldLabel(tx(t, "taskTitleLabel", "Title")),
|
||||
h("textarea", {
|
||||
value: title,
|
||||
onChange: function (e) { setTitle(e.target.value); },
|
||||
onKeyDown: function (e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
|
||||
},
|
||||
placeholder: props.columnName === "triage"
|
||||
? tx(t, "triagePlaceholder", "Rough idea — AI will spec it…")
|
||||
: tx(t, "taskTitlePlaceholder", "New task title…"),
|
||||
autoFocus: true,
|
||||
className: "text-sm min-h-[3rem] max-h-48 resize-y w-full border border-input bg-transparent px-2 py-1 rounded-md focus:outline-none focus:ring-2 focus:ring-ring",
|
||||
rows: 3,
|
||||
}),
|
||||
),
|
||||
h("div", { className: "flex gap-2" },
|
||||
h("div", { className: "flex flex-col gap-1 flex-1" },
|
||||
fieldLabel(props.columnName === "triage"
|
||||
? tx(t, "specifier", "specifier")
|
||||
: tx(t, "assigneeLabel", "Assignee"),
|
||||
tx(t, "assigneeLabelHint", "(blank = dispatcher picks)")),
|
||||
h(Input, {
|
||||
value: assignee,
|
||||
onChange: function (e) { setAssignee(e.target.value); },
|
||||
placeholder: props.columnName === "triage"
|
||||
? tx(t, "specifier", "specifier")
|
||||
: tx(t, "assigneePlaceholder", "assignee"),
|
||||
className: "h-8 text-sm",
|
||||
title: props.columnName === "triage"
|
||||
? "Hermes profile that will spec this task (default: the dispatcher's configured specifier). Leave blank to let the dispatcher pick."
|
||||
: "Hermes profile to assign. Leave blank and the dispatcher will pick from available profiles when the task is Ready.",
|
||||
style: { textTransform: "none" },
|
||||
autoCapitalize: "none",
|
||||
autoCorrect: "off",
|
||||
spellCheck: false,
|
||||
}),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1 w-20" },
|
||||
fieldLabel(tx(t, "priority", "Priority")),
|
||||
h(Input, {
|
||||
type: "number",
|
||||
value: priority,
|
||||
onChange: function (e) { setPriority(e.target.value); },
|
||||
placeholder: "pri",
|
||||
className: "h-8 text-sm",
|
||||
title: "Priority. Higher-priority tasks are claimed first by the dispatcher. 0 = default.",
|
||||
}),
|
||||
),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
fieldLabel(tx(t, "skillsLabel", "Skills"),
|
||||
tx(t, "skillsLabelHint", "(optional, comma-separated)")),
|
||||
h(Input, {
|
||||
value: skills,
|
||||
onChange: function (e) { setSkills(e.target.value); },
|
||||
placeholder: tx(t, "skillsPlaceholder",
|
||||
"skills (optional, comma-separated): translation, github-code-review"),
|
||||
title: "Force-load these skills into the worker (in addition to the built-in kanban-worker).",
|
||||
className: "h-8 text-sm",
|
||||
}),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
fieldLabel(tx(t, "workspace", "Workspace")),
|
||||
h("div", { className: "flex gap-2" },
|
||||
h(Select, Object.assign({
|
||||
value: workspaceKind,
|
||||
title: "Choose whether task files are temporary or preserved after completion.",
|
||||
className: "h-8 text-sm flex-1",
|
||||
}, selectChangeHandler(setWorkspaceKind)),
|
||||
h(SelectOption, { value: "scratch" },
|
||||
tx(t, "workspaceScratch", "Temporary — deleted on completion")),
|
||||
h(SelectOption, { value: "worktree" },
|
||||
tx(t, "workspaceWorktree", "Git worktree — preserved")),
|
||||
h(SelectOption, { value: "dir" },
|
||||
tx(t, "workspaceDir", "Directory — preserved")),
|
||||
),
|
||||
showPathInput ? h(Input, {
|
||||
value: workspacePath,
|
||||
onChange: function (e) { setWorkspacePath(e.target.value); },
|
||||
placeholder: pathPlaceholder,
|
||||
className: "h-8 text-sm flex-1",
|
||||
}) : null,
|
||||
),
|
||||
workspaceKind === "scratch" ? h("div", {
|
||||
className: "text-xs text-destructive",
|
||||
role: "alert",
|
||||
}, tx(t, "workspaceScratchWarning",
|
||||
"This workspace and any files left in it are deleted when the task completes.")) : null,
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
fieldLabel(tx(t, "parentLabel", "Parent task"),
|
||||
tx(t, "parentLabelHint", "(child stays blocked until the parent is done)")),
|
||||
h(Select, Object.assign({
|
||||
value: parent,
|
||||
className: "h-8 text-sm",
|
||||
title: "Optional parent task. A child stays blocked in its current column until the parent is marked done.",
|
||||
}, selectChangeHandler(setParent)),
|
||||
h(SelectOption, { value: "" }, tx(t, "noParent", "— no parent —")),
|
||||
(props.allTasks || []).map(function (task) {
|
||||
return h(SelectOption, { key: task.id, value: task.id },
|
||||
`${task.id} — ${(task.title || "").slice(0, 50)}`);
|
||||
}),
|
||||
),
|
||||
),
|
||||
h("div", { className: "flex gap-2 items-center" },
|
||||
h("label", {
|
||||
className: "flex items-center gap-1.5 text-xs cursor-pointer select-none",
|
||||
title: "Goal mode: the worker keeps going in the same session until a judge agrees the card is done (or the turn budget runs out, which blocks it for review). Best for open-ended cards one shot rarely finishes.",
|
||||
},
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
checked: goalMode,
|
||||
onChange: function (e) { setGoalMode(!!e.target.checked); },
|
||||
className: "h-3.5 w-3.5 accent-current",
|
||||
}),
|
||||
tx(t, "goalMode", "goal mode"),
|
||||
),
|
||||
goalMode ? h(Input, {
|
||||
type: "number",
|
||||
value: goalMaxTurns,
|
||||
onChange: function (e) { setGoalMaxTurns(e.target.value); },
|
||||
placeholder: tx(t, "goalMaxTurns", "max turns (default 20)"),
|
||||
className: "h-8 text-sm w-44",
|
||||
title: "Turn budget for the goal loop. Blank = backend default (20).",
|
||||
min: 1,
|
||||
}) : null,
|
||||
),
|
||||
),
|
||||
goalMode ? h(Input, {
|
||||
type: "number",
|
||||
value: goalMaxTurns,
|
||||
onChange: function (e) { setGoalMaxTurns(e.target.value); },
|
||||
placeholder: tx(t, "goalMaxTurns", "max turns (default 20)"),
|
||||
className: "h-7 text-xs w-40",
|
||||
title: "Turn budget for the goal loop. Blank = backend default (20).",
|
||||
min: 1,
|
||||
}) : null,
|
||||
),
|
||||
h("div", { className: "flex gap-2" },
|
||||
h(Select, Object.assign({
|
||||
value: workspaceKind,
|
||||
title: "Choose whether task files are temporary or preserved after completion.",
|
||||
className: "h-7 text-xs flex-1",
|
||||
}, selectChangeHandler(setWorkspaceKind)),
|
||||
h(SelectOption, { value: "scratch" },
|
||||
tx(t, "workspaceScratch", "Temporary — deleted on completion")),
|
||||
h(SelectOption, { value: "worktree" },
|
||||
tx(t, "workspaceWorktree", "Git worktree — preserved")),
|
||||
h(SelectOption, { value: "dir" },
|
||||
tx(t, "workspaceDir", "Directory — preserved")),
|
||||
h("div", { className: "hermes-kanban-dialog-actions" },
|
||||
h(Button, {
|
||||
type: "button",
|
||||
onClick: props.onCancel,
|
||||
size: "sm",
|
||||
}, tx(t, "cancel", "Cancel")),
|
||||
h(Button, {
|
||||
type: "submit",
|
||||
size: "sm",
|
||||
disabled: !title.trim(),
|
||||
}, tx(t, "create", "Create")),
|
||||
),
|
||||
showPathInput ? h(Input, {
|
||||
value: workspacePath,
|
||||
onChange: function (e) { setWorkspacePath(e.target.value); },
|
||||
placeholder: pathPlaceholder,
|
||||
className: "h-7 text-xs flex-1",
|
||||
}) : null,
|
||||
),
|
||||
workspaceKind === "scratch" ? h("div", {
|
||||
className: "text-xs text-destructive",
|
||||
role: "alert",
|
||||
}, tx(t, "workspaceScratchWarning",
|
||||
"This workspace and any files left in it are deleted when the task completes.")) : null,
|
||||
h(Select, Object.assign({
|
||||
value: parent,
|
||||
className: "h-7 text-xs",
|
||||
title: "Optional parent task. A child stays blocked in its current column until the parent is marked done.",
|
||||
}, selectChangeHandler(setParent)),
|
||||
h(SelectOption, { value: "" }, tx(t, "noParent", "— no parent —")),
|
||||
(props.allTasks || []).map(function (task) {
|
||||
return h(SelectOption, { key: task.id, value: task.id },
|
||||
`${task.id} — ${(task.title || "").slice(0, 50)}`);
|
||||
}),
|
||||
),
|
||||
h("div", { className: "flex gap-2" },
|
||||
h(Button, {
|
||||
onClick: submit,
|
||||
size: "sm",
|
||||
}, "Create"),
|
||||
h(Button, {
|
||||
onClick: props.onCancel,
|
||||
size: "sm",
|
||||
}, tx(t, "cancel", "Cancel")),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -3228,22 +3410,33 @@
|
|||
if (props.onOpenTask) props.onOpenTask(taskId);
|
||||
},
|
||||
}) : null,
|
||||
data ? h("div", { className: "hermes-kanban-drawer-comment-row" },
|
||||
h(Input, {
|
||||
value: newComment,
|
||||
onChange: function (e) { setNewComment(e.target.value); },
|
||||
onKeyDown: function (e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault(); handleComment();
|
||||
}
|
||||
},
|
||||
placeholder: tx(t, "addComment", "Add a comment… (Enter to submit)"),
|
||||
className: "h-8 text-sm flex-1",
|
||||
}),
|
||||
h(Button, {
|
||||
onClick: handleComment,
|
||||
size: "sm",
|
||||
}, tx(t, "comment", "Comment")),
|
||||
data ? h("div", { className: "hermes-kanban-drawer-comment-foot" },
|
||||
h("div", {
|
||||
className: "hermes-kanban-comment-hint text-xs text-muted-foreground",
|
||||
title: tx(t, "commentHintTitle",
|
||||
"Comments are the channel for talking to a task's worker. They land on the thread immediately — no need to block the task first. A running worker picks the thread up on its next kanban_show() or respawn; blocking is only for when you want the worker to STOP and wait for your input."),
|
||||
},
|
||||
"ⓘ ",
|
||||
tx(t, "commentHint",
|
||||
"Comments reach the worker on its next run or kanban_show() — no need to block the task first."),
|
||||
),
|
||||
h("div", { className: "hermes-kanban-drawer-comment-row" },
|
||||
h(Input, {
|
||||
value: newComment,
|
||||
onChange: function (e) { setNewComment(e.target.value); },
|
||||
onKeyDown: function (e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault(); handleComment();
|
||||
}
|
||||
},
|
||||
placeholder: tx(t, "addComment", "Add a comment… (Enter to submit)"),
|
||||
className: "h-8 text-sm flex-1",
|
||||
}),
|
||||
h(Button, {
|
||||
onClick: handleComment,
|
||||
size: "sm",
|
||||
}, tx(t, "comment", "Comment")),
|
||||
),
|
||||
) : null,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
34
plugins/kanban/dashboard/dist/style.css
vendored
34
plugins/kanban/dashboard/dist/style.css
vendored
|
|
@ -311,22 +311,12 @@
|
|||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ---- Inline create --------------------------------------------------- */
|
||||
/* ---- Create-task dialog ----------------------------------------------- */
|
||||
|
||||
.hermes-kanban-inline-create {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: color-mix(in srgb, var(--color-card) 70%, transparent);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
}
|
||||
|
||||
.hermes-kanban-inline-create > .flex.gap-2:last-child > button:first-of-type {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* Wider than the base dialog so the workspace/parent selectors and the
|
||||
labeled two-column rows breathe; still clamped to the viewport. */
|
||||
.hermes-kanban-create-dialog {
|
||||
width: 36rem;
|
||||
}
|
||||
|
||||
/* ---- Drawer (task detail side panel) --------------------------------- */
|
||||
|
|
@ -576,6 +566,18 @@
|
|||
max-width: 280px;
|
||||
}
|
||||
|
||||
/* Comment footer = workflow hint + input row. The border-top lives on the
|
||||
wrapper so the hint sits inside the same visual block as the input. */
|
||||
.hermes-kanban-drawer-comment-foot {
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-card) 90%, transparent);
|
||||
}
|
||||
|
||||
.hermes-kanban-comment-hint {
|
||||
padding: 0.45rem 0.75rem 0;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.hermes-kanban-drawer-comment-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
|
|
@ -583,8 +585,6 @@
|
|||
/* Keep the comment input clear of the mobile browser nav bar / home
|
||||
indicator by extending the bottom padding with the safe-area inset. */
|
||||
padding-bottom: max(0.55rem, calc(0.55rem + env(safe-area-inset-bottom)));
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-card) 90%, transparent);
|
||||
}
|
||||
|
||||
.hermes-kanban-count {
|
||||
|
|
|
|||
|
|
@ -1990,6 +1990,9 @@ class RenameBoardBody(BaseModel):
|
|||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
# Board-level default project directory for new tasks. ``None`` =
|
||||
# leave unchanged; empty string = clear; a path = validate + set.
|
||||
default_workdir: Optional[str] = None
|
||||
|
||||
|
||||
def _board_counts(slug: str) -> dict[str, int]:
|
||||
|
|
@ -2034,23 +2037,32 @@ def list_boards(include_archived: bool = Query(False)):
|
|||
return {"boards": boards, "current": current}
|
||||
|
||||
|
||||
def _validate_workdir(raw: str) -> str:
|
||||
"""Validate a board default_workdir value; return the resolved path.
|
||||
|
||||
Raises :class:`HTTPException` (400) for relative or non-directory
|
||||
paths — mirroring the create-board contract.
|
||||
"""
|
||||
requested = Path(raw).expanduser()
|
||||
if not requested.is_absolute():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Project directory must be an absolute path.",
|
||||
)
|
||||
if not requested.is_dir():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Project directory must be an existing directory.",
|
||||
)
|
||||
return str(requested.resolve())
|
||||
|
||||
|
||||
@router.post("/boards")
|
||||
def create_board_endpoint(payload: CreateBoardBody):
|
||||
"""Create a new board. Idempotent — ``slug`` collision returns existing."""
|
||||
default_workdir = None
|
||||
if payload.default_workdir:
|
||||
requested = Path(payload.default_workdir).expanduser()
|
||||
if not requested.is_absolute():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Project directory must be an absolute path.",
|
||||
)
|
||||
if not requested.is_dir():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Project directory must be an existing directory.",
|
||||
)
|
||||
default_workdir = str(requested.resolve())
|
||||
default_workdir = _validate_workdir(payload.default_workdir)
|
||||
try:
|
||||
meta = kanban_db.create_board(
|
||||
payload.slug,
|
||||
|
|
@ -2073,20 +2085,28 @@ def create_board_endpoint(payload: CreateBoardBody):
|
|||
|
||||
@router.patch("/boards/{slug}")
|
||||
def rename_board(slug: str, payload: RenameBoardBody):
|
||||
"""Update a board's display metadata (slug is immutable — create a new one to rename the directory)."""
|
||||
"""Update a board's display metadata + default project directory (slug is immutable — create a new one to rename the directory)."""
|
||||
try:
|
||||
normed = kanban_db._normalize_board_slug(slug)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
if not normed or not kanban_db.board_exists(normed):
|
||||
raise HTTPException(status_code=404, detail=f"board {slug!r} does not exist")
|
||||
# default_workdir: None = leave unchanged; "" = clear; path = validate + set.
|
||||
# write_board_metadata treats a falsy value as "clear", so pass "" through.
|
||||
default_workdir: Optional[str] = None
|
||||
if payload.default_workdir is not None:
|
||||
raw = payload.default_workdir.strip()
|
||||
default_workdir = _validate_workdir(raw) if raw else ""
|
||||
meta = kanban_db.write_board_metadata(
|
||||
normed,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
icon=payload.icon,
|
||||
color=payload.color,
|
||||
default_workdir=default_workdir,
|
||||
)
|
||||
meta["default_workspace_kind"] = _default_workspace_kind(meta)
|
||||
return {"board": meta}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue