diff --git a/apps/desktop/src/plugins/kanban/api.ts b/apps/desktop/src/plugins/kanban/api.ts index bc46657277f..0f1010b5674 100644 --- a/apps/desktop/src/plugins/kanban/api.ts +++ b/apps/desktop/src/plugins/kanban/api.ts @@ -12,9 +12,11 @@ import { atom, type PluginRestOptions, type PluginStorage, queryClient } from '@hermes/plugin-sdk' import type { + BoardMeta, BoardsResponse, KanbanBoard, KanbanProfile, + KanbanProject, KanbanTask, KanbanTaskDetail, OrchestrationSettings, @@ -113,6 +115,7 @@ export const taskKey = (slug: string, id: string) => ['kanban', 'task', slug, id export const logKey = (slug: string, id: string) => ['kanban', 'log', slug, id] as const export const BOARDS_KEY = ['kanban', 'boards'] as const export const PROFILES_KEY = ['kanban', 'profiles'] as const +export const PROJECTS_KEY = ['kanban', 'projects'] as const export const ORCHESTRATION_KEY = ['kanban', 'orchestration'] as const // ── reads ───────────────────────────────────────────────────────────────────── @@ -129,6 +132,9 @@ export const fetchBoards = () => call('/boards') export const fetchProfiles = () => call<{ profiles: KanbanProfile[] }>('/profiles') +/** First-class Hermes projects, for scoping a board's default workspace. */ +export const fetchProjects = () => call<{ projects: KanbanProject[] }>('/projects') + export const fetchOrchestration = () => call('/orchestration') // ── writes ──────────────────────────────────────────────────────────────────── @@ -191,8 +197,16 @@ export const reclaimTask = (id: string) => nudged(call(withBoard(`/tasks/${id}/r export const uploadAttachment = (id: string, upload: { filename: string; contentType?: string; bytes: ArrayBuffer }) => call(withBoard(`/tasks/${id}/attachments`), { method: 'POST', upload }) -export const createBoard = (slug: string, name: string) => - call<{ board: { slug: string } }>('/boards', { method: 'POST', body: { slug, name } }) +export const createBoard = (slug: string, name: string, projectId?: string) => + call<{ board: { slug: string } }>('/boards', { + method: 'POST', + body: { slug, name, ...(projectId ? { project_id: projectId } : {}) } + }) + +/** Edit a board's display metadata + default project directory. Pass + * `default_workdir: ''` to clear it. Slug is immutable. */ +export const updateBoard = (slug: string, patch: Record) => + call<{ board: BoardMeta }>(`/boards/${encodeURIComponent(slug)}`, { method: 'PATCH', body: patch }) export const nudgeDispatcher = () => call<{ spawned?: unknown[] }>(withBoard('/dispatch'), { method: 'POST', body: {} }) diff --git a/apps/desktop/src/plugins/kanban/board-switcher.tsx b/apps/desktop/src/plugins/kanban/board-switcher.tsx index c76625c1129..f9c1bb295bd 100644 --- a/apps/desktop/src/plugins/kanban/board-switcher.tsx +++ b/apps/desktop/src/plugins/kanban/board-switcher.tsx @@ -20,6 +20,11 @@ import { DropdownMenuTrigger, host, Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, useMutation, useQuery, useQueryClient, @@ -27,12 +32,47 @@ import { } from '@hermes/plugin-sdk' import { useEffect, useState } from 'react' -import { $boardSlug, BOARDS_KEY, createBoard, fetchBoards } from './api' -import { errText } from './ui' +import { $boardSlug, BOARDS_KEY, createBoard, fetchBoards, fetchProjects, PROJECTS_KEY, updateBoard } from './api' +import type { BoardMeta } from './types' +import { errText, FIELD_LABEL } from './ui' + +const NO_PROJECT = '__none__' + +/** Board scope = a first-class Hermes project. Its primary repo becomes the + * board's default workspace root; new tasks inherit it as a worktree with a + * deterministic branch. "No project" falls back to scratch sandboxes. */ +function ProjectPicker({ onChange, value }: { onChange: (id: string) => void; value: string }) { + const { data } = useQuery({ queryKey: PROJECTS_KEY, queryFn: fetchProjects, staleTime: 30_000 }) + const projects = data?.projects ?? [] + + return ( + + ) +} function NewBoardDialog({ onClose, open }: { onClose: () => void; open: boolean }) { const qc = useQueryClient() const [name, setName] = useState('') + const [project, setProject] = useState('') const slug = name .trim() @@ -43,11 +83,12 @@ function NewBoardDialog({ onClose, open }: { onClose: () => void; open: boolean useEffect(() => { if (open) { setName('') + setProject('') } }, [open]) const create = useMutation({ - mutationFn: () => createBoard(slug, name.trim()), + mutationFn: () => createBoard(slug, name.trim(), project || undefined), onError: err => host.notify({ kind: 'error', message: errText(err) }), onSuccess: result => { $boardSlug.set(result.board.slug) @@ -58,19 +99,23 @@ function NewBoardDialog({ onClose, open }: { onClose: () => void; open: boolean return ( !o && onClose()} open={open}> - + New board -
- setName(event.target.value)} - onKeyDown={event => event.key === 'Enter' && slug && create.mutate()} - placeholder="Board name" - value={name} - /> - {slug && slug: {slug}} +
+ +
+ + + +
+ ) +} + export function BoardSwitcher() { const slug = useValue($boardSlug) const { data: boards } = useQuery({ queryFn: fetchBoards, queryKey: BOARDS_KEY, staleTime: 30_000 }) const [adding, setAdding] = useState(false) + const [settingsFor, setSettingsFor] = useState(null) if (!boards) { return null @@ -124,6 +220,12 @@ export function BoardSwitcher() { ))} + {current && ( + setSettingsFor(current)}> + + Board settings… + + )} setAdding(true)}> New board… @@ -131,6 +233,7 @@ export function BoardSwitcher() { setAdding(false)} open={adding} /> + setSettingsFor(null)} /> ) } diff --git a/apps/desktop/src/plugins/kanban/board.tsx b/apps/desktop/src/plugins/kanban/board.tsx index c40cea3cba7..b3ba686a472 100644 --- a/apps/desktop/src/plugins/kanban/board.tsx +++ b/apps/desktop/src/plugins/kanban/board.tsx @@ -64,10 +64,12 @@ import { $introDismissed, $lanesByProfile, boardKey, + BOARDS_KEY, bulkTasks, createTask, deleteTask, fetchBoard, + fetchBoards, fetchProfiles, patchTask, PROFILES_KEY @@ -536,20 +538,34 @@ function NewTaskDialog({ // (ultimately the active profile), applied at create time. Never silently // unassigned — parking a card is the explicit choice, not the default. const resolvedDefault = useOrchestration()?.resolved_default_assignee || 'default' + + // Board-level workspace default: a task inherits the current board's project + // workspace (scratch when unscoped, worktree in a git repo, else dir) unless + // overridden below. Set the board's project in the switcher's "Board settings…". + const selectedSlug = useValue($boardSlug) + const { data: boards } = useQuery({ queryKey: BOARDS_KEY, queryFn: fetchBoards, staleTime: 30_000 }) + const currentBoard = boards?.boards.find(b => b.slug === (selectedSlug || boards.current)) + const boardDefaultKind = currentBoard?.default_workspace_kind || 'scratch' + const boardDefaultDir = currentBoard?.default_workdir || '' + const isTriage = target === 'triage' const [title, setTitle] = useState('') const [bodyText, setBodyText] = useState('') const [assignee, setAssignee] = useState('') const [priority, setPriority] = useState('0') const [skills, setSkills] = useState('') - const [workspaceKind, setWorkspaceKind] = useState('scratch') + const [workspaceKind, setWorkspaceKind] = useState(boardDefaultKind) + // Empty = inherit the board's project dir (backend resolves it); a path here + // overrides just this task. Only meaningful for dir/worktree. + const [workspacePath, setWorkspacePath] = useState('') const [parent, setParent] = useState('') const [goalMode, setGoalMode] = useState(false) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) // Reset per open — the dialog is externally controlled (open = target set), - // so onOpenChange(true) never fires; key the reset off `target` instead. + // so onOpenChange(true) never fires; key the reset off `target` (and the + // resolved board default, which may arrive after the first open). useEffect(() => { if (target) { setTitle('') @@ -557,13 +573,14 @@ function NewTaskDialog({ setAssignee('') setPriority('0') setSkills('') - setWorkspaceKind('scratch') + setWorkspaceKind(boardDefaultKind) + setWorkspacePath('') setParent('') setGoalMode(false) setError(null) setBusy(false) } - }, [target]) + }, [target, boardDefaultKind]) const submit = async () => { const trimmed = title.trim() @@ -592,7 +609,9 @@ function NewTaskDialog({ skills: skillList.length ? skillList : undefined, title: trimmed, triage: isTriage, - workspace_kind: workspaceKind + workspace_kind: workspaceKind, + // Empty → backend inherits the board's project dir. + workspace_path: workspaceKind !== 'scratch' && workspacePath.trim() ? workspacePath.trim() : undefined }) if (task && task.status !== target) { @@ -652,6 +671,7 @@ function NewTaskDialog({ {WORKSPACE_KINDS.map(kind => ( {kind} + {kind === boardDefaultKind ? ' · board default' : ''} ))} @@ -659,6 +679,21 @@ function NewTaskDialog({ + {workspaceKind !== 'scratch' && ( + + setWorkspacePath(event.target.value)} + placeholder={boardDefaultDir || 'Inherits the board’s project directory'} + value={workspacePath} + /> + + {boardDefaultDir + ? `Leave empty to inherit ${boardDefaultDir}` + : 'Leave empty to inherit the board’s project directory.'} + + + )} +