diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index af6ba426c5c5..8ecde8612ed2 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -1888,6 +1888,7 @@ const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [icon, setIcon] = useState(""); + const [projectDirectory, setProjectDirectory] = useState(""); const [switchTo, setSwitchTo] = useState(true); const [submitting, setSubmitting] = useState(false); const [err, setErr] = useState(null); @@ -1912,6 +1913,7 @@ name: name.trim() || autoName || undefined, description: description.trim() || undefined, icon: icon.trim() || undefined, + default_workdir: projectDirectory.trim() || undefined, switch: switchTo, }).catch(function (e) { setErr(String(e && e.message ? e.message : e)); @@ -1967,6 +1969,27 @@ className: "h-8", }), ), + h("div", { className: "flex flex-col gap-1" }, + h(Label, { className: "text-xs" }, + tx(t, "projectDirectory", "Project directory"), " ", + h("span", { className: "text-muted-foreground" }, + tx(t, "projectDirectoryHint", "(recommended)"))), + 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, "projectDirectoryExplanation", + "Sets the default location for task files so project output is preserved.")), + ), h("div", { className: "flex flex-col gap-1" }, h(Label, { className: "text-xs" }, tx(t, "icon", "Icon"), " ", h("span", { className: "text-muted-foreground" }, diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index b2fc468de5d0..428f372384d6 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -1982,6 +1982,7 @@ class CreateBoardBody(BaseModel): description: Optional[str] = None icon: Optional[str] = None color: Optional[str] = None + default_workdir: Optional[str] = None switch: bool = False @@ -2037,6 +2038,20 @@ def list_boards(include_archived: bool = Query(False)): @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()) try: meta = kanban_db.create_board( payload.slug, @@ -2044,6 +2059,7 @@ def create_board_endpoint(payload: CreateBoardBody): description=payload.description, icon=payload.icon, color=payload.color, + default_workdir=default_workdir, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) @@ -2052,6 +2068,7 @@ def create_board_endpoint(payload: CreateBoardBody): kanban_db.set_current_board(meta["slug"]) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) + meta["default_workspace_kind"] = _default_workspace_kind(meta) return {"board": meta, "current": kanban_db.get_current_board()} diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 1ec04e8dbc07..b7ab24ec2952 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -138,6 +138,58 @@ def test_board_list_recommends_persistent_workspace_for_configured_workdir( assert boards["disposable"]["default_workspace_kind"] == "scratch" +def test_create_board_persists_project_directory(client, tmp_path): + """The dashboard board form should anchor future tasks to its project.""" + project_dir = tmp_path / "project" + project_dir.mkdir() + + response = client.post( + "/api/plugins/kanban/boards", + json={ + "slug": "project-board", + "name": "Project Board", + "default_workdir": str(project_dir), + }, + ) + + assert response.status_code == 200, response.text + board = response.json()["board"] + assert board["default_workdir"] == str(project_dir.resolve()) + assert board["default_workspace_kind"] == "dir" + assert kb.read_board_metadata("project-board")["default_workdir"] == str( + project_dir.resolve() + ) + + +@pytest.mark.parametrize("path", ["relative/project", "~/missing-project"]) +def test_create_board_rejects_invalid_project_directory(client, path): + """A board must not persist a path that cannot anchor worker output.""" + response = client.post( + "/api/plugins/kanban/boards", + json={"slug": "invalid-project", "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 = ( + Path(__file__).resolve().parents[2] + / "plugins" + / "kanban" + / "dashboard" + / "dist" + / "index.js" + ).read_text(encoding="utf-8") + + assert 'const [projectDirectory, setProjectDirectory] = useState("");' in bundle + assert "Project directory" in bundle + assert "Absolute path to the project folder" in bundle + assert "default_workdir: projectDirectory.trim() || undefined" in bundle + + def test_dashboard_workspace_picker_explains_persistence_contract(): """Task creation must make scratch deletion visible without a hover.""" bundle = (