Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 27 additions & 14 deletions plugins/kanban/dashboard/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,7 @@
error ? h("div", { className: "text-xs text-destructive px-2" }, error) : null,
h(BoardColumns, {
board: filteredBoard,
boardMeta: boardList.find(function (item) { return item.slug === board; }) || null,
laneByProfile,
selectedIds,
failedIds,
Expand Down Expand Up @@ -2384,6 +2385,7 @@
return h(Column, {
key: col.name,
column: col,
boardMeta: props.boardMeta,
laneByProfile: props.laneByProfile,
selectedIds: props.selectedIds,
failedIds: props.failedIds,
Expand Down Expand Up @@ -2504,6 +2506,8 @@
showCreate ? h(InlineCreate, {
columnName: props.column.name,
allTasks: props.allTasks,
defaultWorkspaceKind: (props.boardMeta && props.boardMeta.default_workspace_kind) || "scratch",
defaultWorkspacePath: (props.boardMeta && props.boardMeta.default_workdir) || "",
onSubmit: function (body) {
props.onCreate(body).then(function () { setShowCreate(false); });
},
Expand Down Expand Up @@ -2745,12 +2749,13 @@
const [priority, setPriority] = useState(0);
const [parent, setParent] = useState("");
const [skills, setSkills] = useState("");
// Workspace controls. `scratch` (default) ignores path; `worktree` optionally
// takes a path (dispatcher derives one from the assignee profile otherwise);
// `dir` requires a path. Backend enforces the rule — we only hide/show the
// input here to save vertical space in the common `scratch` case.
const [workspaceKind, setWorkspaceKind] = useState("scratch");
const [workspacePath, setWorkspacePath] = useState("");
// A board with a configured workdir defaults to a persistent workspace:
// worktree for git repositories, dir for ordinary directories. Boards
// without one keep scratch for disposable research and ops tasks.
const defaultWorkspaceKind = props.defaultWorkspaceKind || "scratch";
const defaultWorkspacePath = props.defaultWorkspacePath || "";
const [workspaceKind, setWorkspaceKind] = useState(defaultWorkspaceKind);
const [workspacePath, setWorkspacePath] = useState(defaultWorkspacePath);
// Goal-mode: when on, the dispatched worker runs the Ralph-style /goal
// loop — a judge re-checks the card after each turn and the worker keeps
// going in the same session until done, or the turn budget runs out
Expand Down Expand Up @@ -2793,15 +2798,15 @@
}
props.onSubmit(body);
setTitle(""); setAssignee(""); setPriority(0); setParent(""); setSkills("");
setWorkspaceKind("scratch"); setWorkspacePath("");
setWorkspaceKind(defaultWorkspaceKind); setWorkspacePath(defaultWorkspacePath);
setGoalMode(false); setGoalMaxTurns("");
};

const showPathInput = workspaceKind !== "scratch";
const pathPlaceholder = workspaceKind === "dir"
? tx(t, "workspacePathDir", "workspace path (required, e.g. ~/projects/my-app)")
? tx(t, "workspacePathDir", "workspace path (required without a board workdir)")
: tx(t, "workspacePathOptional",
"workspace path (optional, derived from assignee if blank)");
"repository path (optional when the board has a workdir)");

return h("div", { className: "hermes-kanban-inline-create" },
h("textarea", {
Expand Down Expand Up @@ -2877,12 +2882,15 @@
h("div", { className: "flex gap-2" },
h(Select, Object.assign({
value: workspaceKind,
title: "scratch: isolated temp dir (default). worktree: git worktree on the assignee profile. dir: exact path (required below).",
className: "h-7 text-xs w-28",
title: "Choose whether task files are temporary or preserved after completion.",
className: "h-7 text-xs flex-1",
}, selectChangeHandler(setWorkspaceKind)),
h(SelectOption, { value: "scratch" }, "scratch"),
h(SelectOption, { value: "worktree" }, "worktree"),
h(SelectOption, { value: "dir" }, "dir"),
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,
Expand All @@ -2891,6 +2899,11 @@
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",
Expand Down
12 changes: 12 additions & 0 deletions plugins/kanban/dashboard/plugin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2010,6 +2010,17 @@ def _board_counts(slug: str) -> dict[str, int]:
return {}


def _default_workspace_kind(board: dict[str, Any]) -> str:
"""Recommend a non-destructive task workspace from board metadata."""
workdir = str(board.get("default_workdir") or "").strip()
if not workdir:
return "scratch"
try:
return "worktree" if kanban_db._git_toplevel(Path(workdir)) else "dir"
except (OSError, ValueError):
return "dir"


@router.get("/boards")
def list_boards(include_archived: bool = Query(False)):
"""Return every board on disk with task counts and the active slug."""
Expand All @@ -2019,6 +2030,7 @@ def list_boards(include_archived: bool = Query(False)):
b["is_current"] = (b["slug"] == current)
b["counts"] = _board_counts(b["slug"])
b["total"] = sum(b["counts"].values())
b["default_workspace_kind"] = _default_workspace_kind(b)
return {"boards": boards, "current": current}


Expand Down
45 changes: 45 additions & 0 deletions tests/plugins/test_kanban_dashboard_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import importlib.util
import os
import subprocess
import sys
import time
from pathlib import Path
Expand Down Expand Up @@ -114,6 +115,50 @@ def test_create_task_appears_on_board(client):
assert "researcher" in data["assignees"]


def test_board_list_recommends_persistent_workspace_for_configured_workdir(
client, tmp_path
):
"""Board metadata should tell the UI which safe task default to use."""
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q", str(repo)], check=True)
kb.write_board_metadata("default", default_workdir=str(repo))

plain_dir = tmp_path / "notes"
plain_dir.mkdir()
kb.create_board("notes", default_workdir=str(plain_dir))
kb.create_board("disposable")

response = client.get("/api/plugins/kanban/boards")

assert response.status_code == 200
boards = {board["slug"]: board for board in response.json()["boards"]}
assert boards["default"]["default_workspace_kind"] == "worktree"
assert boards["notes"]["default_workspace_kind"] == "dir"
assert boards["disposable"]["default_workspace_kind"] == "scratch"


def test_dashboard_workspace_picker_explains_persistence_contract():
"""Task creation must make scratch deletion visible without a hover."""
bundle = (
Path(__file__).resolve().parents[2]
/ "plugins"
/ "kanban"
/ "dashboard"
/ "dist"
/ "index.js"
).read_text(encoding="utf-8")

assert "Temporary — deleted on completion" in bundle
assert "Git worktree — preserved" in bundle
assert "Directory — preserved" in bundle
assert "defaultWorkspacePath: (props.boardMeta && props.boardMeta.default_workdir) || \"\"" in bundle
assert (
"This workspace and any files left in it are deleted when the task completes."
in bundle
)


def test_scheduled_tasks_have_their_own_column_not_todo(client):
"""Scheduled/time-delay tasks must not be silently bucketed into todo."""

Expand Down
Loading