diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 714e6b2a..fb7cf25d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -462,6 +462,7 @@ import { createBoardTask, githubPullRequestIdentity, loadBoardSnapshot, + nextTaskNumber, saveBoardSnapshot, taskForPullRequest, taskForSession, @@ -4068,6 +4069,7 @@ export default function App() { title: summary.slice(0, 72) || "未命名任务", status: "todo", priority: "none", + number: nextTaskNumber(board.tasks), order: board.tasks.filter((candidate) => candidate.status === "todo") .length, }); @@ -4708,6 +4710,7 @@ export default function App() { status: "in_progress", priority: "none", labels: ["GitHub", "PR"], + number: nextTaskNumber(current), order: current.filter((task) => task.status === "in_progress").length, }); tasks = [...tasks, target]; diff --git a/apps/desktop/src/components/ui/drag-drop.tsx b/apps/desktop/src/components/ui/drag-drop.tsx index d3699983..347caad8 100644 --- a/apps/desktop/src/components/ui/drag-drop.tsx +++ b/apps/desktop/src/components/ui/drag-drop.tsx @@ -1,4 +1,5 @@ import { PointerActivationConstraints } from "@dnd-kit/dom"; +import { OptimisticSortingPlugin } from "@dnd-kit/dom/sortable"; import { DragDropProvider as DndKitProvider, KeyboardSensor, @@ -7,6 +8,7 @@ import { } from "@dnd-kit/react"; import type { DragEndEvent, + DragMoveEvent, DragOverEvent, DragStartEvent, UseDroppableInput, @@ -23,6 +25,7 @@ function DragDropRoot(props: ComponentProps) { export { DragDropRoot, KeyboardSensor, + OptimisticSortingPlugin, PointerActivationConstraints, PointerSensor, useDroppable as useDragDropZone, @@ -30,6 +33,7 @@ export { }; export type { DragEndEvent, + DragMoveEvent, DragOverEvent, DragStartEvent, UseDroppableInput, diff --git a/apps/desktop/src/i18n/strings.ts b/apps/desktop/src/i18n/strings.ts index cdbf8ddb..f11c9c5f 100644 --- a/apps/desktop/src/i18n/strings.ts +++ b/apps/desktop/src/i18n/strings.ts @@ -2243,6 +2243,8 @@ export const en = { "taskboard.view.board": "Board", "taskboard.selectTaskCard": "Select task: {title}", "taskboard.cardPullRequests": "PR {count}", + "taskboard.taskNumber": "TASK-{number}", + "taskboard.labelOverflow": "+{count}", "taskboard.emptyList": "No tasks yet", "taskboard.expandTask": "Expand task: {title}", "taskboard.collapseTask": "Collapse task: {title}", @@ -5054,6 +5056,8 @@ export const zhCN: Record = { "taskboard.view.board": "看板", "taskboard.selectTaskCard": "选择任务:{title}", "taskboard.cardPullRequests": "PR {count}", + "taskboard.taskNumber": "TASK-{number}", + "taskboard.labelOverflow": "+{count}", "taskboard.emptyList": "还没有任务", "taskboard.expandTask": "展开任务:{title}", "taskboard.collapseTask": "收起任务:{title}", diff --git a/apps/desktop/src/taskboard/TaskBoardCard.tsx b/apps/desktop/src/taskboard/TaskBoardCard.tsx new file mode 100644 index 00000000..599161fb --- /dev/null +++ b/apps/desktop/src/taskboard/TaskBoardCard.tsx @@ -0,0 +1,209 @@ +import { StatusBadge } from "@/components/business/status-badge"; +import type { StatusTone } from "@/components/business/status-badge"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + OptimisticSortingPlugin, + useDragDropSortable, +} from "@/components/ui/drag-drop"; +import type { UseSortableInput } from "@/components/ui/drag-drop"; +import { Flag, GripVertical, MessageSquare } from "@/components/ui/icons"; +import type { Locale, StringKey, Translate } from "@/i18n"; +import { cn } from "@/lib/utils"; +import type { SidebarPullRequestStatus } from "@/sidebar/sidebarGitStatus"; + +import { TaskActionsMenu } from "./TaskActionsMenu"; +import type { BoardTask, TaskBoardLane, TaskStatus } from "./taskBoard"; +import { taskPriorityLabel } from "./TaskEditorDialog"; +import { + formatUpdatedAt, + openPullRequestCount, + sessionActivityKind, +} from "./workspaceModel"; +import type { ProjectedTask } from "./workspaceTypes"; + +export interface BoardCardDragData { + taskId: string; + lane: TaskBoardLane; +} + +/** + * The board resolves drops itself (`boardDnd.ts`) and React stays the only writer of the card + * tree, so the library's optimistic DOM reordering is disabled: it moves the dragged card into + * another lane's DOM, which React later tries to unmount from its original parent. + */ +export const boardCardPlugins: NonNullable< + UseSortableInput["plugins"] +> = (defaults) => + defaults.filter((plugin) => plugin !== OptimisticSortingPlugin); + +const MAX_LABEL_CHIPS = 2; + +const CARD_ACTIVITY: Record< + "running" | "awaiting_input" | "failed", + { tone: StatusTone; label: StringKey } +> = { + running: { tone: "success", label: "session.running" }, + awaiting_input: { tone: "warning", label: "session.awaitingInput" }, + failed: { tone: "destructive", label: "session.failed" }, +}; + +function cardActivity( + t: Translate, + projected: ProjectedTask +): { tone: StatusTone; label: string } | null { + const kind = sessionActivityKind(projected.currentSession); + if (kind === "idle") return null; + const activity = CARD_ACTIVITY[kind]; + return { tone: activity.tone, label: t(activity.label) }; +} + +interface TaskBoardCardProps { + t: Translate; + locale: Locale; + lane: TaskBoardLane; + index: number; + projected: ProjectedTask; + /** Insertion indicator: the drop lands before or after this card. */ + dropEdge: "before" | "after" | null; + selected: boolean; + pullRequestsByPath: ReadonlyMap; + onSelect: () => void; + onEdit: () => void; + onDelete: () => void; + onMove: (status: TaskStatus) => void; + onStartTask?: (task: BoardTask) => void; +} + +export function TaskBoardCard(props: TaskBoardCardProps) { + const { t, locale, lane, projected, selected } = props; + const { task, sessions } = projected; + const sortable = useDragDropSortable({ + id: task.id, + index: props.index, + group: lane, + type: "task", + accept: "task", + plugins: boardCardPlugins, + data: { taskId: task.id, lane }, + }); + const openPullRequests = openPullRequestCount( + sessions, + props.pullRequestsByPath + ); + const activity = cardActivity(t, projected); + const labels = task.labels.slice(0, MAX_LABEL_CHIPS); + const hiddenLabels = task.labels.length - labels.length; + const description = task.description.trim(); + + return ( +
+
+ + + {t("taskboard.taskNumber", { number: task.number })} + +
+ {activity ? ( + {activity.label} + ) : null} + +
+
+ + + + {description === "" ? null : ( +

+ {description} +

+ )} + +
+ {task.priority === "none" ? null : ( + + + + {taskPriorityLabel(t, task.priority)} + + + )} + {labels.map((label) => ( + + {label} + + ))} + {hiddenLabels > 0 ? ( + + {t("taskboard.labelOverflow", { count: hiddenLabels })} + + ) : null} + {sessions.length > 0 ? ( + + + + {t("taskboard.sessionCount", { count: sessions.length })} + + + ) : null} + {openPullRequests !== null && openPullRequests > 0 ? ( + + {t("taskboard.cardPullRequests", { count: openPullRequests })} + + ) : null} + + {formatUpdatedAt(task.updatedAt, locale, t)} + +
+
+ ); +} diff --git a/apps/desktop/src/taskboard/TaskBoardCollection.tsx b/apps/desktop/src/taskboard/TaskBoardCollection.tsx index 1f4d2d85..7b628421 100644 --- a/apps/desktop/src/taskboard/TaskBoardCollection.tsx +++ b/apps/desktop/src/taskboard/TaskBoardCollection.tsx @@ -1,7 +1,7 @@ import type { Locale, Translate } from "@/i18n"; import type { SidebarPullRequestStatus } from "@/sidebar/sidebarGitStatus"; -import type { BoardTask, TaskStatus } from "./taskBoard"; +import type { BoardTask, TaskBoardLane, TaskStatus } from "./taskBoard"; import { TaskBoardKanban } from "./TaskBoardKanban"; import { TaskBoardList } from "./TaskBoardList"; import type { ProjectedTask, TaskBoardView } from "./workspaceTypes"; @@ -23,7 +23,8 @@ interface TaskBoardCollectionProps { onSelectSession: (taskId: string, sessionId: string) => void; onEditTask: (task: BoardTask) => void; onDeleteTask: (task: BoardTask) => void; - onMoveTask: (task: BoardTask, status: TaskStatus) => void; + onMoveTask: (task: BoardTask, status: TaskStatus, beforeId?: string) => void; + onAddTask: (lane: TaskBoardLane) => void; onStartTask?: (task: BoardTask) => void; onShowMore: () => void; } @@ -42,6 +43,7 @@ export function TaskBoardCollection(props: TaskBoardCollectionProps) { onEditTask={props.onEditTask} onDeleteTask={props.onDeleteTask} onMoveTask={props.onMoveTask} + onAddTask={props.onAddTask} onStartTask={props.onStartTask} /> ); diff --git a/apps/desktop/src/taskboard/TaskBoardColumn.tsx b/apps/desktop/src/taskboard/TaskBoardColumn.tsx new file mode 100644 index 00000000..fd3e78a8 --- /dev/null +++ b/apps/desktop/src/taskboard/TaskBoardColumn.tsx @@ -0,0 +1,107 @@ +import { StatusIndicator } from "@/components/business/status-indicator"; +import { Button } from "@/components/ui/button"; +import { useDragDropZone } from "@/components/ui/drag-drop"; +import { Plus } from "@/components/ui/icons"; +import type { Locale, Translate } from "@/i18n"; +import type { SidebarPullRequestStatus } from "@/sidebar/sidebarGitStatus"; + +import type { BoardTask, TaskBoardLane, TaskStatus } from "./taskBoard"; +import { TaskBoardCard } from "./TaskBoardCard"; +import { laneLabel, LANE_TONES } from "./workspaceModel"; +import type { ProjectedTask } from "./workspaceTypes"; + +interface TaskBoardColumnProps { + t: Translate; + locale: Locale; + lane: TaskBoardLane; + tasks: readonly ProjectedTask[]; + activeFilterCount: number; + dropTarget: boolean; + dropEdgeTaskId: string | null; + dropEdgeAfter: boolean; + selectedTaskId: string | null; + pullRequestsByPath: ReadonlyMap; + onAddTask: () => void; + onSelectTask: (task: ProjectedTask) => void; + onEditTask: (task: BoardTask) => void; + onDeleteTask: (task: BoardTask) => void; + onMoveTask: (task: BoardTask, status: TaskStatus) => void; + onStartTask?: (task: BoardTask) => void; +} + +export function TaskBoardColumn(props: TaskBoardColumnProps) { + const { t, lane, tasks } = props; + const zone = useDragDropZone({ + id: `taskboard-lane:${lane}`, + accept: "task", + collisionPriority: 0, + data: { lane }, + }); + const label = laneLabel(t, lane); + const dropTarget = props.dropTarget || zone.isDropTarget; + + return ( +
+
+

+ +

+ + {tasks.length} + + +
+
+ {tasks.map((projected, index) => ( + props.onSelectTask(projected)} + onEdit={() => props.onEditTask(projected.task)} + onDelete={() => props.onDeleteTask(projected.task)} + onMove={(status) => props.onMoveTask(projected.task, status)} + onStartTask={props.onStartTask} + /> + ))} + {tasks.length === 0 ? ( +

+ {props.activeFilterCount > 0 + ? t("taskboard.emptyFiltered") + : t("taskboard.emptyColumn")} +

+ ) : null} +
+
+ ); +} diff --git a/apps/desktop/src/taskboard/TaskBoardKanban.tsx b/apps/desktop/src/taskboard/TaskBoardKanban.tsx index 1e5656cc..a9c4a72e 100644 --- a/apps/desktop/src/taskboard/TaskBoardKanban.tsx +++ b/apps/desktop/src/taskboard/TaskBoardKanban.tsx @@ -1,19 +1,25 @@ -import { StatusIndicator } from "@/components/business/status-indicator"; -import { Button } from "@/components/ui/button"; +import { useRef, useState } from "react"; + +import { DragDropRoot } from "@/components/ui/drag-drop"; +import type { + DragEndEvent, + DragMoveEvent, + DragOverEvent, + DragStartEvent, +} from "@/components/ui/drag-drop"; import type { Locale, Translate } from "@/i18n"; -import { cn } from "@/lib/utils"; import type { SidebarPullRequestStatus } from "@/sidebar/sidebarGitStatus"; -import { TaskActionsMenu } from "./TaskActionsMenu"; -import { TASK_BOARD_LANES } from "./taskBoard"; -import type { BoardTask, TaskBoardLane, TaskStatus } from "./taskBoard"; -import { taskPriorityLabel } from "./TaskEditorDialog"; import { - formatUpdatedAt, - LANE_TONES, - laneLabel, - openPullRequestCount, -} from "./workspaceModel"; + boardDragData, + boardDropAfter, + boardDropAfterKeyboard, + boardDropPreview, +} from "./boardDnd"; +import type { BoardTask, TaskBoardLane, TaskStatus } from "./taskBoard"; +import { TASK_BOARD_LANES } from "./taskBoard"; +import { TaskBoardColumn } from "./TaskBoardColumn"; +import { useBoardDragPan } from "./useBoardDragPan"; import type { ProjectedTask } from "./workspaceTypes"; interface TaskBoardKanbanProps { @@ -26,10 +32,31 @@ interface TaskBoardKanbanProps { onSelectTask: (task: ProjectedTask) => void; onEditTask: (task: BoardTask) => void; onDeleteTask: (task: BoardTask) => void; - onMoveTask: (task: BoardTask, status: TaskStatus) => void; + onMoveTask: (task: BoardTask, status: TaskStatus, beforeId?: string) => void; + onAddTask: (lane: TaskBoardLane) => void; onStartTask?: (task: BoardTask) => void; } +interface BoardDropState { + lane: TaskBoardLane; + status: TaskStatus; + beforeId?: string; + /** Card carrying the insertion indicator, and whether the drop lands after it. */ + edgeTaskId: string | null; + edgeAfter: boolean; +} + +/** Structural slice of dnd-kit's drag operation that the board resolves drops from. */ +interface BoardDragOperation { + source?: { id: unknown; element?: Element | null } | null; + target?: { data?: unknown; element?: Element | null } | null; + activatorEvent?: Event | null; +} + +function isKeyboardDrag(activatorEvent: Event | null | undefined): boolean { + return activatorEvent != null && "key" in activatorEvent; +} + function groupTasks( tasks: readonly ProjectedTask[] ): Record { @@ -43,155 +70,164 @@ function groupTasks( return grouped; } -function TaskBoardCard({ - t, - locale, - projected, - selected, - pullRequestsByPath, - onSelect, - onEdit, - onDelete, - onMove, - onStartTask, -}: { - t: Translate; - locale: Locale; - projected: ProjectedTask; - selected: boolean; - pullRequestsByPath: ReadonlyMap; - onSelect: () => void; - onEdit: () => void; - onDelete: () => void; - onMove: (status: TaskStatus) => void; - onStartTask?: (task: BoardTask) => void; -}) { - const { task, lane, sessions } = projected; - const openPullRequests = openPullRequestCount(sessions, pullRequestsByPath); - +function sameDropState( + left: BoardDropState | null, + right: BoardDropState | null +): boolean { + if (left === right) return true; + if (left == null || right == null) return false; return ( -
-
- - -
- -
- {task.priority === "none" ? null : ( - {taskPriorityLabel(t, task.priority)} - )} - {sessions.length > 0 ? ( - {t("taskboard.sessionCount", { count: sessions.length })} - ) : null} - {openPullRequests !== null && openPullRequests > 0 ? ( - - {t("taskboard.cardPullRequests", { count: openPullRequests })} - - ) : null} - - {formatUpdatedAt(task.updatedAt, locale, t)} - -
-
+ left.lane === right.lane && + left.beforeId === right.beforeId && + left.edgeTaskId === right.edgeTaskId && + left.edgeAfter === right.edgeAfter ); } export function TaskBoardKanban(props: TaskBoardKanbanProps) { const groupedTasks = groupTasks(props.projectedTasks); + const [dropState, setDropState] = useState(null); + const activeTaskIdRef = useRef(null); + // Last hovered keyboard target and the insertion side computed for it. Repeated drag-over and + // drag-move events for the same target must not recompute (and flip) the side. + const keyboardTargetRef = useRef<{ taskId: string; after: boolean } | null>( + null + ); + const pan = useBoardDragPan(); + const tasksById = new Map( + props.projectedTasks.map((projected) => [projected.task.id, projected.task]) + ); + + // The board owns its drag resolution instead of the library's optimistic DOM reordering: React + // stays the only writer of the card tree, so a lane change can never leave React removing a node + // that the drag layer already moved. + const resolveDropState = ( + operation: BoardDragOperation, + activeTaskId: string | null + ): BoardDropState | null => { + if (activeTaskId == null) return null; + const target = boardDragData(operation.target?.data); + if (!target) return null; + const laneTasks = groupedTasks[target.lane]; + const keyboard = isKeyboardDrag(operation.activatorEvent); + let after = false; + if (keyboard) { + const previous = keyboardTargetRef.current; + if (target.taskId != null) { + if (previous != null && previous.taskId === target.taskId) { + after = previous.after; + } else { + after = boardDropAfterKeyboard( + laneTasks, + target.taskId, + previous?.taskId ?? activeTaskId + ); + keyboardTargetRef.current = { taskId: target.taskId, after }; + } + } + } else { + after = boardDropAfter( + operation.source?.element, + operation.target?.element + ); + } + const preview = boardDropPreview( + laneTasks, + activeTaskId, + target.lane, + target.taskId ?? null, + after + ); + return { + lane: target.lane, + status: preview.status, + beforeId: preview.beforeId, + edgeTaskId: preview.edge?.taskId ?? null, + edgeAfter: preview.edge?.after ?? false, + }; + }; + + const updateDropState = (operation: BoardDragOperation) => { + const next = resolveDropState(operation, activeTaskIdRef.current); + setDropState((previous) => + sameDropState(previous, next) ? previous : next + ); + }; + + const handleDragStart = (event: DragStartEvent) => { + const id = event.operation.source?.id; + const activeTaskId = typeof id === "string" ? id : null; + activeTaskIdRef.current = activeTaskId; + keyboardTargetRef.current = null; + setDropState(resolveDropState(event.operation, activeTaskId)); + }; + + const handleDragOver = (event: DragOverEvent) => { + updateDropState(event.operation); + }; + + const handleDragMove = (event: DragMoveEvent) => { + updateDropState(event.operation); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const activeTaskId = activeTaskIdRef.current; + activeTaskIdRef.current = null; + keyboardTargetRef.current = null; + const drop = resolveDropState(event.operation, activeTaskId); + setDropState(null); + if (event.canceled || activeTaskId == null) return; + const task = tasksById.get(activeTaskId); + if (!drop || !task) return; + props.onMoveTask(task, drop.status, drop.beforeId); + }; return (
-
- {TASK_BOARD_LANES.map((lane) => { - const tasks = groupedTasks[lane]; - return ( -
+ {TASK_BOARD_LANES.map((lane) => ( + -
-

- -

- - {tasks.length} - -
-
- {tasks.map((projected) => ( - props.onSelectTask(projected)} - onEdit={() => props.onEditTask(projected.task)} - onDelete={() => props.onDeleteTask(projected.task)} - onMove={(status) => - props.onMoveTask(projected.task, status) - } - onStartTask={props.onStartTask} - /> - ))} - {tasks.length === 0 ? ( -

- {props.activeFilterCount > 0 - ? props.t("taskboard.emptyFiltered") - : props.t("taskboard.emptyColumn")} -

- ) : null} -
-
- ); - })} -
+ t={props.t} + locale={props.locale} + lane={lane} + tasks={groupedTasks[lane]} + activeFilterCount={props.activeFilterCount} + dropTarget={dropState?.lane === lane} + dropEdgeTaskId={ + dropState?.lane === lane ? dropState.edgeTaskId : null + } + dropEdgeAfter={dropState?.edgeAfter ?? false} + selectedTaskId={props.selectedTaskId} + pullRequestsByPath={props.pullRequestsByPath} + onAddTask={() => props.onAddTask(lane)} + onSelectTask={props.onSelectTask} + onEditTask={props.onEditTask} + onDeleteTask={props.onDeleteTask} + onMoveTask={props.onMoveTask} + onStartTask={props.onStartTask} + /> + ))} +
+ ); } diff --git a/apps/desktop/src/taskboard/TaskBoardPage.tsx b/apps/desktop/src/taskboard/TaskBoardPage.tsx index 75bd2094..c6982788 100644 --- a/apps/desktop/src/taskboard/TaskBoardPage.tsx +++ b/apps/desktop/src/taskboard/TaskBoardPage.tsx @@ -7,6 +7,7 @@ import { Separator } from "@/components/ui/separator"; import { useLanguage } from "@/i18n"; import type { BoardTask, TaskPriority } from "./taskBoard"; +import { laneStatus } from "./taskBoard"; import { TaskBoardCollection } from "./TaskBoardCollection"; import { TaskBoardHeader } from "./TaskBoardHeader"; import { TaskEditorDialog } from "./TaskEditorDialog"; @@ -144,8 +145,18 @@ export function TaskBoardPage({ clearFilters, keepInspectorInPlace: isNarrow, }); - const moveTask = (task: BoardTask, status: BoardTask["status"]): void => - data.dispatch({ type: "move", id: task.id, status, now: Date.now() }); + const moveTask = ( + task: BoardTask, + status: BoardTask["status"], + beforeId?: string + ): void => + data.dispatch({ + type: "move", + id: task.id, + status, + beforeId, + now: Date.now(), + }); const changeInspectorOpen = (open: boolean): void => { if (!open && isNarrow) restoreInspectorFocus.current = true; setInspectorOpen(open); @@ -245,7 +256,7 @@ export function TaskBoardPage({ remainingTaskCount={remainingTaskCount} activeFilterCount={activeFilterCount} expandedTaskIds={selection.expandedTaskIds} - selectedTaskId={selection.selectedTask?.id ?? null} + selectedTaskId={selection.selectedTaskId} selectedSessionId={selection.selectedSession?.id ?? null} pullRequestsByPath={pullRequestsByPath} onToggleTask={actions.toggleTask} @@ -254,6 +265,7 @@ export function TaskBoardPage({ onEditTask={(task) => actions.openEditor(task, task.status)} onDeleteTask={(task) => void actions.deleteTask(task)} onMoveTask={moveTask} + onAddTask={(lane) => actions.openEditor(null, laneStatus(lane))} onStartTask={onStartTask} onShowMore={() => setVisibleTaskLimit((limit) => limit + INITIAL_TASK_LIMIT) diff --git a/apps/desktop/src/taskboard/boardDnd.ts b/apps/desktop/src/taskboard/boardDnd.ts new file mode 100644 index 00000000..8e22e6d9 --- /dev/null +++ b/apps/desktop/src/taskboard/boardDnd.ts @@ -0,0 +1,162 @@ +import { laneStatus, TASK_BOARD_LANES } from "./taskBoard"; +import type { TaskBoardLane, TaskStatus } from "./taskBoard"; +import type { ProjectedTask } from "./workspaceTypes"; +/** Durable outcome of one board drop. `beforeId` is the exact anchor inside `status`. */ +export interface BoardDrop { + status: TaskStatus; + beforeId?: string; +} + +/** Durable outcome plus the card the insertion indicator attaches to. */ +export interface BoardDropPreview extends BoardDrop { + edge: { taskId: string; after: boolean } | null; +} + +export interface BoardDragData { + lane: TaskBoardLane; + taskId?: string; +} + +export function isTaskBoardLane(value: unknown): value is TaskBoardLane { + return ( + typeof value === "string" && + (TASK_BOARD_LANES as readonly string[]).includes(value) + ); +} + +/** + * Reads the drag target's data. Cards carry `{taskId, lane}` and lane scrollers carry `{lane}`, so + * both a card hover and a background hover resolve to the same lane. + */ +export function boardDragData(value: unknown): BoardDragData | null { + if (value == null || typeof value !== "object") return null; + const candidate = value as { lane?: unknown; taskId?: unknown }; + if (!isTaskBoardLane(candidate.lane)) return null; + return { + lane: candidate.lane, + taskId: typeof candidate.taskId === "string" ? candidate.taskId : undefined, + }; +} + +/** + * Resolves a drop into one reducer `move`. `laneTasks` is the lane's projected order including the + * dragged card; `index` is the insertion index in that list *without* the dragged card. The anchor + * is the first task at or after the insertion point whose durable status matches the lane's status + * — a lane can only mix statuses in `needs_you` (`in_review` plus attention-carrying + * `in_progress`), and the reducer orders by status before `order`, so the nearest achievable + * anchor wins over the literal pixel. + */ +export function boardDropTarget( + laneTasks: readonly ProjectedTask[], + activeTaskId: string, + lane: TaskBoardLane, + index: number +): BoardDrop { + const status = laneStatus(lane); + const remaining = laneTasks.filter( + (projected) => projected.task.id !== activeTaskId + ); + const bounded = Math.min(Math.max(0, Math.trunc(index)), remaining.length); + const anchor = remaining + .slice(bounded) + .find((projected) => projected.task.status === status); + return anchor ? { status, beforeId: anchor.task.id } : { status }; +} + +/** + * Insertion side for a keyboard drag: the hovered card is the one the pressed arrow moved toward, + * so the dragged card lands after it only when that card sits further along the lane than the + * previously hovered card (or the dragged card at drag start). Moving one card down therefore + * lands after it, and moving back up lands before it again. + */ +export function boardDropAfterKeyboard( + laneTasks: readonly ProjectedTask[], + targetTaskId: string, + previousTaskId: string | null +): boolean { + if (previousTaskId == null || previousTaskId === targetTaskId) return false; + const targetIndex = laneTasks.findIndex( + (projected) => projected.task.id === targetTaskId + ); + const previousIndex = laneTasks.findIndex( + (projected) => projected.task.id === previousTaskId + ); + return ( + previousIndex !== -1 && targetIndex !== -1 && targetIndex > previousIndex + ); +} + +/** + * Drop resolution for a hovered card (or lane background) plus the pointer's position relative to + * the target. Dropping on the dragged card itself keeps the current position, so a self-hover is a + * no-op instead of an accidental append. + */ +export function boardDropPreview( + laneTasks: readonly ProjectedTask[], + activeTaskId: string, + lane: TaskBoardLane, + targetTaskId: string | null, + after: boolean +): BoardDropPreview { + const remaining = laneTasks.filter( + (projected) => projected.task.id !== activeTaskId + ); + if (targetTaskId === activeTaskId) { + const activeIndex = laneTasks.findIndex( + (projected) => projected.task.id === activeTaskId + ); + return { + ...boardDropTarget( + laneTasks, + activeTaskId, + lane, + activeIndex === -1 ? remaining.length : activeIndex + ), + edge: null, + }; + } + if (targetTaskId == null) { + return { + ...boardDropTarget(laneTasks, activeTaskId, lane, remaining.length), + edge: null, + }; + } + const targetIndex = remaining.findIndex( + (projected) => projected.task.id === targetTaskId + ); + if (targetIndex === -1) { + return { + ...boardDropTarget(laneTasks, activeTaskId, lane, remaining.length), + edge: null, + }; + } + return { + ...boardDropTarget( + laneTasks, + activeTaskId, + lane, + targetIndex + (after ? 1 : 0) + ), + edge: { taskId: targetTaskId, after }, + }; +} + +/** + * Whether a pointer drag currently floats below the hovered card's midpoint. Board cards are + * draggable without the library's optimistic DOM reordering (React owns the card tree), so the + * insertion side is read from the two real element rectangles rather than from a sortable index. + * Keyboard drags do not have a meaningful midpoint (the shape is snapped onto the hovered card), + * so they resolve their side from the arrow history instead — see `boardDropAfterKeyboard`. + */ +export function boardDropAfter( + source: Element | null | undefined, + target: Element | null | undefined +): boolean { + if (!source || !target) return false; + const sourceRect = source.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + return ( + sourceRect.top + sourceRect.height / 2 > + targetRect.top + targetRect.height / 2 + ); +} diff --git a/apps/desktop/src/taskboard/task-board.css b/apps/desktop/src/taskboard/task-board.css index 18f9df05..3d648262 100644 --- a/apps/desktop/src/taskboard/task-board.css +++ b/apps/desktop/src/taskboard/task-board.css @@ -35,13 +35,88 @@ } .task-board-kanban { - display: grid; - grid-template-columns: repeat(4, minmax(14rem, 1fr)); - min-width: calc(56rem + 1.5rem); + --task-board-column-width: 17rem; + display: flex; + min-height: 100%; + align-items: stretch; + gap: 0.5rem; } .task-board-kanban-column { - align-self: stretch; + display: flex; + flex: 0 0 var(--task-board-column-width); + flex-direction: column; + min-height: 0; + min-width: 0; + padding: 0.5rem; + border-radius: var(--ds-radius-module); + background: var(--ds-color-fill-quiet); +} + +.task-board-kanban-column[data-task-column="running"] { + background: color-mix( + in oklch, + var(--ds-color-success) 8%, + var(--ds-color-fill-quiet) + ); +} + +.task-board-kanban-column[data-task-column="needs_you"] { + background: color-mix( + in oklch, + var(--ds-color-warning) 7%, + var(--ds-color-fill-quiet) + ); +} + +.task-board-kanban-column[data-task-column="done"] { + background: color-mix( + in oklch, + var(--ds-color-success) 4%, + var(--ds-color-fill-quiet) + ); +} + +.task-board-kanban-cards { + display: grid; + flex: 1; + grid-auto-rows: max-content; + align-content: start; + gap: 0.5rem; + min-height: 0; + padding: 0.25rem; + overflow-y: auto; + overscroll-behavior-block: contain; + border-radius: var(--ds-radius-control); +} + +/* Drop feedback strengthens the lane's own status tint instead of boxing the card area. */ +.task-board-kanban-column[data-drop-target] { + background: var(--ds-color-fill-rest); +} + +.task-board-kanban-column[data-task-column="running"][data-drop-target] { + background: color-mix( + in oklch, + var(--ds-color-success) 16%, + var(--ds-color-fill-quiet) + ); +} + +.task-board-kanban-column[data-task-column="needs_you"][data-drop-target] { + background: color-mix( + in oklch, + var(--ds-color-warning) 14%, + var(--ds-color-fill-quiet) + ); +} + +.task-board-kanban-column[data-task-column="done"][data-drop-target] { + background: color-mix( + in oklch, + var(--ds-color-success) 9%, + var(--ds-color-fill-quiet) + ); } .task-board-card { @@ -50,6 +125,34 @@ overflow-wrap: anywhere; } +.task-board-card-grip { + opacity: 0; +} + +.task-board-card:hover .task-board-card-grip, +.task-board-card:focus-within .task-board-card-grip { + opacity: 1; +} + +.task-board-card[data-dragging] { + cursor: grabbing; + box-shadow: var(--ds-elevation-raised); +} + +/* The library's insert-anchor keeps the dragged card's slot; show it faintly instead of hiding it. */ +.task-board-card[data-dnd-placeholder] { + visibility: visible; + opacity: 0.4; +} + +.task-board-card[data-drop-edge="before"] { + box-shadow: 0 -2px 0 0 var(--ds-color-focus); +} + +.task-board-card[data-drop-edge="after"] { + box-shadow: 0 2px 0 0 var(--ds-color-focus); +} + .task-board-kanban-column [data-slot="status-indicator"] { color: var(--foreground); font-size: inherit; @@ -173,7 +276,8 @@ } @media (prefers-reduced-motion: reduce) { - .task-board-row-action { + .task-board-row-action, + .task-board-card-grip { transition-duration: 0.01ms; } } diff --git a/apps/desktop/src/taskboard/taskBoard.ts b/apps/desktop/src/taskboard/taskBoard.ts index 344ed18d..fc372037 100644 --- a/apps/desktop/src/taskboard/taskBoard.ts +++ b/apps/desktop/src/taskboard/taskBoard.ts @@ -43,6 +43,8 @@ export interface GitHubPullRequestReference { export interface BoardTask { id: string; + /** Stable display identity, rendered as `TASK-{number}`. Unique per board, assigned by the reducer. */ + number: number; title: string; description: string; status: TaskStatus; @@ -61,7 +63,9 @@ export interface BoardTask { /** * Board lanes are a projection, not another persisted workflow field. A Task keeps its durable - * stage while the latest Session supplies live execution and attention state. + * stage while the latest Session supplies live attention state. `running` is the durable + * `in_progress` stage (idle work stays visible where it is), while `awaiting_input` and `failed` + * route to `needs_you` so attention survives the drop that put the Task in progress. */ export function taskBoardLane( task: Pick, @@ -72,8 +76,51 @@ export function taskBoardLane( if (task.status === "todo") return "queue"; if (activity === "awaiting_input" || activity === "failed") return "needs_you"; - if (activity === "running") return "running"; - return "queue"; + return "running"; +} + +/** The durable stage a lane's drop writes. The inverse of `taskBoardLane` for drag targets. */ +export function laneStatus(lane: TaskBoardLane): TaskStatus { + if (lane === "queue") return "todo"; + if (lane === "running") return "in_progress"; + if (lane === "needs_you") return "in_review"; + return "done"; +} + +/** Next free display number; unnumbered or repaired tasks can never collide with it. */ +export function nextTaskNumber(tasks: readonly BoardTask[]): number { + let next = 1; + for (const task of tasks) { + if (Number.isSafeInteger(task.number) && task.number >= next) + next = task.number + 1; + } + return next; +} + +/** + * Repairs task numbers in place order: a positive, unused number is kept, and anything else gets + * the next free number. Used by snapshot migration and the save boundary so a numbering glitch + * never discards a board (unlike identity fields such as `id`, which stay strict). + */ +export function assignTaskNumbers(tasks: readonly BoardTask[]): BoardTask[] { + const used = new Set(); + for (const task of tasks) { + if (Number.isSafeInteger(task.number) && task.number > 0) + used.add(task.number); + } + const claimed = new Set(); + let next = 1; + return tasks.map((task) => { + const number = task.number; + if (Number.isSafeInteger(number) && number > 0 && !claimed.has(number)) { + claimed.add(number); + return task; + } + while (used.has(next)) next += 1; + used.add(next); + claimed.add(next); + return { ...task, number: next }; + }); } export interface BoardFilters { @@ -93,7 +140,7 @@ export interface StorageLike { } export const TASKBOARD_STORAGE_KEY = "codetwo.taskboard.v1"; -export const TASKBOARD_SNAPSHOT_VERSION = 3 as const; +export const TASKBOARD_SNAPSHOT_VERSION = 4 as const; export const CORRUPT_BOARD_WARNING = "无法读取已保存的任务看板,已恢复为示例任务。"; @@ -122,6 +169,7 @@ const DEFAULT_TASK_DATA: readonly Omit< >[] = [ { id: "seed-define-workflow", + number: 1, title: "确认任务流转规则", description: "和团队确认待处理、进行中、待审阅与已完成四个阶段的进入条件。", status: "done", @@ -134,6 +182,7 @@ const DEFAULT_TASK_DATA: readonly Omit< }, { id: "seed-local-persistence", + number: 2, title: "接入任务本地持久化", description: "保存看板快照,并在数据损坏或浏览器存储不可用时提供清晰反馈。", status: "in_progress", @@ -146,6 +195,7 @@ const DEFAULT_TASK_DATA: readonly Omit< }, { id: "seed-review-mobile-layout", + number: 3, title: "审阅移动端看板布局", description: "验证窄屏下的横向浏览、任务操作菜单与筛选体验。", status: "in_review", @@ -158,6 +208,7 @@ const DEFAULT_TASK_DATA: readonly Omit< }, { id: "seed-empty-state-copy", + number: 4, title: "完善空状态与操作提示", description: "为第一次使用看板的成员准备简洁、可行动的中文引导。", status: "todo", @@ -170,6 +221,7 @@ const DEFAULT_TASK_DATA: readonly Omit< }, { id: "seed-session-link", + number: 5, title: "设计会话关联入口", description: "让任务可以跳转到相关编码会话,同时保持任务状态由看板独立管理。", @@ -183,6 +235,7 @@ const DEFAULT_TASK_DATA: readonly Omit< }, { id: "seed-accessibility-notes", + number: 6, title: "补充键盘操作与无障碍说明", description: "覆盖焦点顺序、按钮名称以及不用拖拽也能移动任务的操作路径。", status: "todo", @@ -195,6 +248,7 @@ const DEFAULT_TASK_DATA: readonly Omit< }, { id: "seed-filter-search", + number: 7, title: "实现看板筛选与搜索", description: "支持按关键词、优先级和标签缩小任务范围,并保持原有排序。", status: "in_progress", @@ -207,6 +261,7 @@ const DEFAULT_TASK_DATA: readonly Omit< }, { id: "seed-review-drag-order", + number: 8, title: "验证跨列拖拽顺序", description: "检查同列重排、跨列移动和筛选状态下的任务顺序是否稳定。", status: "in_review", @@ -219,6 +274,7 @@ const DEFAULT_TASK_DATA: readonly Omit< }, { id: "seed-priority-guidelines", + number: 9, title: "整理任务优先级规范", description: "明确无、低、中、高和紧急五档优先级的使用场景。", status: "done", @@ -344,6 +400,8 @@ function generatedTaskId(): string { } export interface CreateBoardTaskInput { + /** Display number; callers normally omit it and let the reducer assign `max+1`. */ + number?: number; title: string; description?: string; status?: TaskStatus; @@ -364,8 +422,15 @@ export function createBoardTask( options: CreateBoardTaskOptions = {} ): BoardTask { const now = options.now ?? Date.now(); + const providedNumber = input.number; return { id: options.id?.trim() ?? generatedTaskId(), + number: + typeof providedNumber === "number" && + Number.isSafeInteger(providedNumber) && + providedNumber > 0 + ? providedNumber + : 0, title: input.title.trim() || "未命名任务", description: input.description?.trim() ?? "", status: input.status ?? "todo", @@ -730,7 +795,11 @@ function parseGitHubPullRequestReference( }; } -type SupportedBoardSnapshotVersion = 1 | 2 | typeof TASKBOARD_SNAPSHOT_VERSION; +type SupportedBoardSnapshotVersion = + | 1 + | 2 + | 3 + | typeof TASKBOARD_SNAPSHOT_VERSION; function parseTask( value: unknown, @@ -739,6 +808,7 @@ function parseTask( if (!isRecord(value)) return null; const { id, + number, title, description, status, @@ -762,6 +832,13 @@ function parseTask( version < 3 ? null : parseGitHubPullRequestReference(pullRequest); const persistedPullRequestRevision = version < 3 ? 0 : pullRequestLinkRevision; + const persistedNumber = + version >= 4 && + typeof number === "number" && + Number.isSafeInteger(number) && + number > 0 + ? number + : 0; if ( typeof id !== "string" || !id.trim() || @@ -795,6 +872,8 @@ function parseTask( } return { id: id.trim(), + // v4 numbers are repaired in `parseBoardSnapshot`, so a malformed value never drops the board. + number: persistedNumber, title: title.trim(), description, status, @@ -824,6 +903,7 @@ export function parseBoardSnapshot( !isRecord(value) || (value.version !== 1 && value.version !== 2 && + value.version !== 3 && value.version !== TASKBOARD_SNAPSHOT_VERSION) || !Array.isArray(value.tasks) ) { @@ -854,7 +934,7 @@ export function parseBoardSnapshot( } tasks.push(task); } - return { tasks, warning: null }; + return { tasks: assignTaskNumbers(tasks), warning: null }; } catch { return corruptBoardState(locale); } @@ -896,7 +976,7 @@ export function saveBoardSnapshot( try { const snapshot: BoardSnapshot = { version: TASKBOARD_SNAPSHOT_VERSION, - tasks: tasks.map(cloneTask), + tasks: assignTaskNumbers(tasks).map(cloneTask), }; resolvedStorage.setItem(TASKBOARD_STORAGE_KEY, JSON.stringify(snapshot)); return { ok: true }; @@ -953,6 +1033,7 @@ function reindexStatuses( function sameTask(left: BoardTask, right: BoardTask): boolean { return ( left.id === right.id && + left.number === right.number && left.title === right.title && left.description === right.description && left.status === right.status && @@ -981,6 +1062,7 @@ export function boardReducer( if (state.tasks.some((task) => task.id === action.task.id)) return state; const current = tasksInStatus(state.tasks, action.task.status); const task = cloneTask(action.task); + task.number = nextTaskNumber(state.tasks); task.order = current.length; return { ...state, diff --git a/apps/desktop/src/taskboard/useBoardDragPan.ts b/apps/desktop/src/taskboard/useBoardDragPan.ts new file mode 100644 index 00000000..7f4c82a9 --- /dev/null +++ b/apps/desktop/src/taskboard/useBoardDragPan.ts @@ -0,0 +1,162 @@ +import { useCallback, useEffect, useRef } from "react"; + +/** Distance that separates a click from a pan, mirroring the card drag activation threshold. */ +const PAN_ACTIVATION_DISTANCE = 5; + +/** + * Elements whose press must not start a board pan: they own their own pointer semantics (drag + * cards, links, form controls, menus). Kept broad on purpose; `data-board-pan-exempt` is the + * explicit opt-out. + */ +const PAN_BLOCKING_SELECTOR = [ + "[data-task-card]", + "[data-no-board-pan]", + "a", + "button", + "input", + "textarea", + "select", + "option", + "label", + "summary", + "[role='button']", + "[role='link']", + "[role='menuitem']", + "[role='option']", + "[role='checkbox']", + "[role='radio']", + "[role='tab']", + "[role='switch']", + "[contenteditable='true']", +].join(", "); + +/** True when a press starting on `target` belongs to a card or control instead of the board pan. */ +export function boardPanBlocked(target: Element | null): boolean { + return target != null && target.closest(PAN_BLOCKING_SELECTOR) !== null; +} + +/** + * Blank-area left-drag panning for the board's horizontal scroller (Trello/Linear pattern). The + * gesture is mouse-only and starts only on empty board background; card drags stay with dnd-kit + * because every card root carries `data-task-card`. Touch and pen scrolling remain browser-owned. + * + * The pointer is captured on `pointerdown` so the gesture survives leaving the board, text + * selection is suppressed for its duration, and cleanup runs on release, cancel, capture loss and + * window blur — a lost release can never leave the board stuck in a panning state. + */ +export function useBoardDragPan() { + const ref = useRef(null); + const pointerIdRef = useRef(null); + const activeRef = useRef(false); + const startXRef = useRef(0); + const lastXRef = useRef(0); + + const reset = useCallback(() => { + const element = ref.current; + if (element && pointerIdRef.current !== null) { + try { + element.releasePointerCapture(pointerIdRef.current); + } catch { + /* capture already released */ + } + } + pointerIdRef.current = null; + activeRef.current = false; + if (element) { + element.style.removeProperty("cursor"); + element.style.removeProperty("user-select"); + element.style.removeProperty("-webkit-user-select"); + } + }, []); + + const onPointerDown = useCallback((event: React.PointerEvent) => { + if (event.pointerType !== "mouse" || event.button !== 0) return; + const element = ref.current; + if (!element) return; + const target = event.target instanceof Element ? event.target : null; + if (boardPanBlocked(target)) return; + + pointerIdRef.current = event.pointerId; + activeRef.current = false; + startXRef.current = event.clientX; + lastXRef.current = event.clientX; + element.style.userSelect = "none"; + element.style.setProperty("-webkit-user-select", "none"); + try { + element.setPointerCapture(event.pointerId); + } catch { + /* capture unsupported; per-move button checks and window blur still end the gesture */ + } + event.preventDefault(); + }, []); + + const onPointerMove = useCallback( + (event: React.PointerEvent) => { + if ( + pointerIdRef.current === null || + event.pointerId !== pointerIdRef.current + ) + return; + const element = ref.current; + if (!element) return; + if ((event.buttons & 1) === 0) { + reset(); + return; + } + if (!activeRef.current) { + if ( + Math.abs(event.clientX - startXRef.current) < PAN_ACTIVATION_DISTANCE + ) + return; + activeRef.current = true; + element.style.cursor = "grabbing"; + } + const delta = event.clientX - lastXRef.current; + lastXRef.current = event.clientX; + const maxScroll = element.scrollWidth - element.clientWidth; + element.scrollLeft = Math.min( + Math.max(element.scrollLeft - delta, 0), + Math.max(maxScroll, 0) + ); + event.preventDefault(); + }, + [reset] + ); + + const onPointerUp = useCallback( + (event: React.PointerEvent) => { + if (event.pointerId !== pointerIdRef.current) return; + reset(); + }, + [reset] + ); + + // Safari/Firefox can still start a selection or native drag while the gesture is pending. + useEffect(() => { + const element = ref.current; + if (!element) return; + const veto = (event: Event) => { + if (pointerIdRef.current !== null) event.preventDefault(); + }; + element.addEventListener("selectstart", veto); + element.addEventListener("dragstart", veto); + return () => { + element.removeEventListener("selectstart", veto); + element.removeEventListener("dragstart", veto); + }; + }, []); + + useEffect(() => { + window.addEventListener("blur", reset); + return () => window.removeEventListener("blur", reset); + }, [reset]); + + return { + ref, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel: onPointerUp, + onLostPointerCapture: onPointerUp, + }; +} diff --git a/apps/desktop/src/taskboard/useTaskBoardActions.ts b/apps/desktop/src/taskboard/useTaskBoardActions.ts index 36d114a3..b97f00e4 100644 --- a/apps/desktop/src/taskboard/useTaskBoardActions.ts +++ b/apps/desktop/src/taskboard/useTaskBoardActions.ts @@ -4,7 +4,7 @@ import { confirmNative } from "@/bridge"; import type { Translate } from "@/i18n"; import type { useToast } from "@/ui/toast"; -import { createBoardTask, filterBoardTasks } from "./taskBoard"; +import { createBoardTask, filterBoardTasks, nextTaskNumber } from "./taskBoard"; import type { BoardAction, BoardFilters, @@ -74,6 +74,7 @@ export function useTaskBoardActions(options: TaskBoardActionsOptions) { } else { const task = createBoardTask({ ...value, + number: nextTaskNumber(options.tasks), order: nextColumnOrder(options.tasks, value.status), }); options.dispatch({ type: "create", task }); diff --git a/apps/desktop/src/taskboard/useTaskBoardSelection.ts b/apps/desktop/src/taskboard/useTaskBoardSelection.ts index 3f228f78..23c716f1 100644 --- a/apps/desktop/src/taskboard/useTaskBoardSelection.ts +++ b/apps/desktop/src/taskboard/useTaskBoardSelection.ts @@ -33,13 +33,20 @@ export function useTaskBoardSelection( if (selectedSessionId !== null) setSelectedSessionId(null); return; } - if (selectedTaskId !== selectedProjectedTask.task.id) { - setSelectedTaskId(selectedProjectedTask.task.id); + // The Inspector falls back to the first visible Task, but the board and list only highlight a + // Task the user actually picked: the fallback is never written into the selection, and a + // deleted selection is dropped instead of jumping to another row. + if ( + selectedTaskId !== null && + !allTasks.some(({ task }) => task.id === selectedTaskId) + ) { + setSelectedTaskId(null); } const nextSessionId = selectedSession?.id ?? null; if (selectedSessionId !== nextSessionId) setSelectedSessionId(nextSessionId); }, [ + allTasks, selectedProjectedTask, selectedSession, selectedSessionId, diff --git a/apps/desktop/tests/taskBoard.test.ts b/apps/desktop/tests/taskBoard.test.ts index 689147aa..3c44b6ba 100644 --- a/apps/desktop/tests/taskBoard.test.ts +++ b/apps/desktop/tests/taskBoard.test.ts @@ -11,6 +11,7 @@ import { TASK_PRIORITIES, TASK_BOARD_LANES, TASK_STATUSES, + assignTaskNumbers, associateTaskPullRequest, associateTaskSession, boardLabels, @@ -19,8 +20,10 @@ import { createBoardTask, createInitialTaskBoardState, filterBoardTasks, + laneStatus, loadBoardSnapshot, githubPullRequestIdentity, + nextTaskNumber, parseBoardSnapshot, saveBoardSnapshot, seedTasks, @@ -117,6 +120,7 @@ function task( ): BoardTask { return { id, + number: 0, title: `Task ${id}`, description: `Description ${id}`, status, @@ -171,6 +175,7 @@ describe("task board model constants and creation", () => { ]); expect(PRIORITIES).toBe(TASK_PRIORITIES); expect(TASKBOARD_STORAGE_KEY).toBe("codetwo.taskboard.v1"); + expect(TASKBOARD_SNAPSHOT_VERSION).toBe(4); }); test("returns deterministic, realistic Chinese seed tasks as fresh objects", () => { @@ -196,6 +201,9 @@ describe("task board model constants and creation", () => { done: 2, }); expect(first.every((item) => item.sessionIds.length === 0)).toBe(true); + expect(first.map((item) => item.number)).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, + ]); first[0].title = "mutated"; first[0].labels.push("mutated"); @@ -205,6 +213,7 @@ describe("task board model constants and creation", () => { test("creates a complete normalized task with injectable identity and time", () => { const created = createBoardTask( { + number: 12, title: " 修复筛选交互 ", description: " 保留任务顺序 ", status: "in_progress", @@ -217,6 +226,7 @@ describe("task board model constants and creation", () => { expect(created).toEqual({ id: "task-7", + number: 12, title: "修复筛选交互", description: "保留任务顺序", status: "in_progress", @@ -229,6 +239,38 @@ describe("task board model constants and creation", () => { pullRequest: null, pullRequestLinkRevision: 0, }); + + const unnumbered = createBoardTask({ title: "未编号" }, { now: BASE_TIME }); + expect(unnumbered.number).toBe(0); + expect( + createBoardTask({ title: "非法编号", number: -3 }, { now: BASE_TIME }) + .number + ).toBe(0); + }); + + test("assigns, advances, and repairs display numbers", () => { + expect(nextTaskNumber([])).toBe(1); + expect(nextTaskNumber([task("a", "todo", 0), task("b", "todo", 0)])).toBe( + 1 + ); + expect( + nextTaskNumber([ + task("a", "todo", 0, { number: 4 }), + task("b", "todo", 0, { number: 2 }), + ]) + ).toBe(5); + + const repaired = assignTaskNumbers([ + task("zero", "todo", 0, { number: 0 }), + task("kept", "todo", 0, { number: 5 }), + task("missing-number", "todo", 0, { number: Number.NaN }), + task("duplicate", "todo", 0, { number: 5 }), + task("negative", "todo", 0, { number: -2 }), + ]); + expect(repaired.map((item) => item.number)).toEqual([1, 5, 2, 3, 4]); + expect(assignTaskNumbers(repaired).map((item) => item.number)).toEqual([ + 1, 5, 2, 3, 4, + ]); }); test("adds durable sessions to a task history and starts todo work", () => { @@ -342,11 +384,16 @@ describe("task board projection helpers", () => { expect(taskBoardLane(task("failed", "in_progress"), "failed")).toBe( "needs_you" ); - expect(taskBoardLane(task("paused", "in_progress"), "idle")).toBe("queue"); + expect(taskBoardLane(task("idle", "in_progress"), "idle")).toBe("running"); expect(taskBoardLane(task("review", "in_review"), "running")).toBe( "needs_you" ); expect(taskBoardLane(task("done", "done"), "failed")).toBe("done"); + + expect(laneStatus("queue")).toBe("todo"); + expect(laneStatus("running")).toBe("in_progress"); + expect(laneStatus("needs_you")).toBe("in_review"); + expect(laneStatus("done")).toBe("done"); }); test("sorts by column and order without mutating, with stable equal-order tasks", () => { @@ -449,6 +496,7 @@ describe("task board persistence", () => { const savedTasks = [ task("saved", "in_progress", 3, { + number: 7, labels: ["本地"], pullRequest: pullRequest(), pullRequestLinkRevision: 2, @@ -524,6 +572,7 @@ describe("task board persistence", () => { { ...rawTask, id: "normalized", + number: 1, title: "Normalized title", labels: ["UI"], sessionIds: ["session-1", "session-2"], @@ -543,6 +592,7 @@ describe("task board persistence", () => { ); expect(loaded.warning).toBeNull(); + expect(loaded.tasks[0]?.number).toBe(1); expect(loaded.tasks[0]?.sessionIds).toEqual(["session-old"]); expect(loaded.tasks[0]?.pullRequest).toBeNull(); expect(loaded.tasks[0]?.pullRequestLinkRevision).toBe(0); @@ -627,6 +677,15 @@ describe("task board reducer", () => { expect(idsForStatus(next, "todo")).toEqual(["a", "b", "c"]); expect(next.tasks.find((item) => item.id === "c")?.order).toBe(2); + expect(next.tasks.find((item) => item.id === "c")?.number).toBe(1); + const numbered = boardReducer( + state([ + task("existing", "todo", 0, { number: 9 }), + task("adjacent", "done", 0, { number: 3 }), + ]), + { type: "create", task: task("fresh", "todo", 0, { number: 1_000 }) } + ); + expect(numbered.tasks.find((item) => item.id === "fresh")?.number).toBe(10); expect(next.tasks.find((item) => item.id === "c")?.labels).not.toBe( created.labels ); diff --git a/apps/desktop/tests/taskBoardDnd.test.ts b/apps/desktop/tests/taskBoardDnd.test.ts new file mode 100644 index 00000000..c6e91ed0 --- /dev/null +++ b/apps/desktop/tests/taskBoardDnd.test.ts @@ -0,0 +1,229 @@ +// @ts-nocheck +import { describe, expect, test } from "bun:test"; + +import { activateDom, dom } from "./domTestHarness"; + +activateDom(); + +const { + boardDragData, + boardDropAfter, + boardDropAfterKeyboard, + boardDropPreview, + boardDropTarget, + isTaskBoardLane, +} = await import("../src/taskboard/boardDnd"); +const { boardPanBlocked } = await import("../src/taskboard/useBoardDragPan"); + +function projected(id, status, lane) { + return { + task: { + id, + number: 0, + title: id, + description: "", + status, + priority: "none", + labels: [], + order: 0, + createdAt: 0, + updatedAt: 0, + sessionIds: [], + pullRequest: null, + pullRequestLinkRevision: 0, + }, + lane, + sessions: [], + }; +} + +describe("board drag data", () => { + test("recognizes exactly the four lanes", () => { + expect(isTaskBoardLane("queue")).toBe(true); + expect(isTaskBoardLane("running")).toBe(true); + expect(isTaskBoardLane("needs_you")).toBe(true); + expect(isTaskBoardLane("done")).toBe(true); + expect(isTaskBoardLane("todo")).toBe(false); + expect(isTaskBoardLane(1)).toBe(false); + expect(isTaskBoardLane(null)).toBe(false); + }); + + test("reads lane zones and card payloads, rejecting foreign data", () => { + expect(boardDragData({ lane: "running" })).toEqual({ + lane: "running", + taskId: undefined, + }); + expect(boardDragData({ lane: "done", taskId: "t1" })).toEqual({ + lane: "done", + taskId: "t1", + }); + expect(boardDragData({ lane: "backlog", taskId: "t1" })).toBeNull(); + expect(boardDragData({ taskId: "t1" })).toBeNull(); + expect(boardDragData(null)).toBeNull(); + expect(boardDragData("running")).toBeNull(); + }); +}); + +describe("board drop targets", () => { + const runningLane = [ + projected("a", "in_progress", "running"), + projected("b", "in_progress", "running"), + projected("c", "in_progress", "running"), + ]; + + test("reorders within a lane at the exact anchor", () => { + expect(boardDropTarget(runningLane, "c", "running", 0)).toEqual({ + status: "in_progress", + beforeId: "a", + }); + expect(boardDropTarget(runningLane, "a", "running", 1)).toEqual({ + status: "in_progress", + beforeId: "c", + }); + expect(boardDropTarget(runningLane, "a", "running", 2)).toEqual({ + status: "in_progress", + }); + }); + + test("clamps indexes and appends past the end", () => { + expect(boardDropTarget(runningLane, "c", "running", -5)).toEqual({ + status: "in_progress", + beforeId: "a", + }); + expect(boardDropTarget(runningLane, "a", "running", 99)).toEqual({ + status: "in_progress", + }); + }); + + test("maps cross-lane drops to the lane's durable status", () => { + expect(boardDropTarget(runningLane, "new", "running", 1)).toEqual({ + status: "in_progress", + beforeId: "b", + }); + expect(boardDropTarget([], "new", "queue", 0)).toEqual({ status: "todo" }); + expect(boardDropTarget([], "new", "done", 0)).toEqual({ status: "done" }); + }); + + test("skips attention cards that share a mixed needs_you lane", () => { + const needsYou = [ + projected("attention", "in_progress", "needs_you"), + projected("review", "in_review", "needs_you"), + ]; + expect(boardDropTarget(needsYou, "new", "needs_you", 0)).toEqual({ + status: "in_review", + beforeId: "review", + }); + expect(boardDropTarget(needsYou, "new", "needs_you", 1)).toEqual({ + status: "in_review", + beforeId: "review", + }); + expect(boardDropTarget(needsYou, "new", "needs_you", 2)).toEqual({ + status: "in_review", + }); + }); +}); + +describe("board drop previews", () => { + const laneTasks = [ + projected("first", "todo", "queue"), + projected("second", "todo", "queue"), + projected("third", "todo", "queue"), + ]; + + test("anchors before or after the hovered card", () => { + expect( + boardDropPreview(laneTasks, "third", "queue", "first", false) + ).toEqual({ + status: "todo", + beforeId: "first", + edge: { taskId: "first", after: false }, + }); + expect( + boardDropPreview(laneTasks, "third", "queue", "first", true) + ).toEqual({ + status: "todo", + beforeId: "second", + edge: { taskId: "first", after: true }, + }); + expect( + boardDropPreview(laneTasks, "third", "queue", "second", true) + ).toEqual({ status: "todo", edge: { taskId: "second", after: true } }); + }); + + test("appends on lane background with no edge indicator", () => { + expect(boardDropPreview(laneTasks, "first", "queue", null, false)).toEqual({ + status: "todo", + edge: null, + }); + expect( + boardDropPreview(laneTasks, "first", "queue", "missing", false) + ).toEqual({ status: "todo", edge: null }); + }); + + test("keeps the current position when the hovered card is the dragged card", () => { + expect( + boardDropPreview(laneTasks, "second", "queue", "second", true) + ).toEqual({ status: "todo", beforeId: "third", edge: null }); + expect( + boardDropPreview(laneTasks, "third", "queue", "third", false) + ).toEqual({ status: "todo", edge: null }); + }); + + test("maps a hovered card in another lane to that lane's status", () => { + const running = [ + projected("r1", "in_progress", "running"), + projected("r2", "in_progress", "running"), + ]; + expect(boardDropPreview(running, "first", "running", "r2", false)).toEqual({ + status: "in_progress", + beforeId: "r2", + edge: { taskId: "r2", after: false }, + }); + }); +}); + +describe("board drop side", () => { + test("compares the dragged card's center with the hovered card's center", () => { + const source = dom.document.createElement("div"); + const target = dom.document.createElement("div"); + source.getBoundingClientRect = () => ({ top: 0, height: 100 }); + target.getBoundingClientRect = () => ({ top: 0, height: 100 }); + expect(boardDropAfter(source, target)).toBe(false); + source.getBoundingClientRect = () => ({ top: 10, height: 100 }); + expect(boardDropAfter(source, target)).toBe(true); + expect(boardDropAfter(null, target)).toBe(false); + expect(boardDropAfter(source, null)).toBe(false); + }); + + test("resolves the keyboard side from the arrow history", () => { + const laneTasks = [ + projected("a", "todo", "queue"), + projected("b", "todo", "queue"), + projected("c", "todo", "queue"), + ]; + expect(boardDropAfterKeyboard(laneTasks, "b", "a")).toBe(true); + expect(boardDropAfterKeyboard(laneTasks, "b", "c")).toBe(false); + expect(boardDropAfterKeyboard(laneTasks, "b", "b")).toBe(false); + expect(boardDropAfterKeyboard(laneTasks, "b", null)).toBe(false); + expect(boardDropAfterKeyboard(laneTasks, "b", "missing")).toBe(false); + expect(boardDropAfterKeyboard(laneTasks, "missing", "a")).toBe(false); + }); +}); + +describe("board pan gesture boundary", () => { + test("starts only on non-interactive board background", () => { + const card = dom.document.createElement("article"); + card.setAttribute("data-task-card", "t1"); + const title = dom.document.createElement("span"); + card.append(title); + const button = dom.document.createElement("button"); + const plain = dom.document.createElement("div"); + + expect(boardPanBlocked(title)).toBe(true); + expect(boardPanBlocked(card)).toBe(true); + expect(boardPanBlocked(button)).toBe(true); + expect(boardPanBlocked(plain)).toBe(false); + expect(boardPanBlocked(null)).toBe(false); + expect(boardPanBlocked(dom.document.documentElement)).toBe(false); + }); +}); diff --git a/apps/desktop/tests/taskBoardRendered.test.tsx b/apps/desktop/tests/taskBoardRendered.test.tsx index 1fb7f4e3..e874bb6e 100644 --- a/apps/desktop/tests/taskBoardRendered.test.tsx +++ b/apps/desktop/tests/taskBoardRendered.test.tsx @@ -221,8 +221,11 @@ describe("TaskBoardPage rendered", () => { ); expect(boardScroll?.className).toContain("overflow-x-auto"); expect(boardScroll?.className).toContain("max-w-full"); - expect(taskBoardStyles).toContain("repeat(4, minmax(14rem, 1fr))"); - expect(taskBoardStyles).toContain("min-width: calc(56rem + 1.5rem)"); + expect(taskBoardStyles).toContain("--task-board-column-width: 17rem"); + expect(taskBoardStyles).toContain( + "flex: 0 0 var(--task-board-column-width)" + ); + expect(taskBoardStyles).toContain("overflow-y: auto"); const card = view.container.querySelector("[data-task-card]"); expect(card?.className).toContain("overflow-hidden"); expect(card?.querySelector("[data-task-card-meta]")?.className).toContain( @@ -230,7 +233,29 @@ describe("TaskBoardPage rendered", () => { ); expect(card?.textContent).not.toContain("0 个会话"); expect(card?.textContent).not.toContain("PR 0"); + expect(card?.textContent).toContain("TASK-2"); + expect(card?.textContent).toContain("保存看板快照"); + expect(card?.textContent).toContain("紧急"); + expect(card?.textContent).toContain("工程"); + expect(card?.querySelector('[data-slot="status-badge"]')).toBeNull(); expect(view.container.textContent).toContain("接入任务本地持久化"); + const runningColumn = view.container.querySelector( + '[data-task-column="running"]' + ); + const queueColumn = view.container.querySelector( + '[data-task-column="queue"]' + ); + expect(runningColumn?.querySelectorAll("[data-task-card]")).toHaveLength(1); + expect(queueColumn?.querySelector("[data-task-card]")).toBeNull(); + expect( + view.container.querySelectorAll("[data-task-column-cards]") + ).toHaveLength(4); + expect( + view.container.querySelectorAll('[aria-label="在队列中新建任务"]') + ).toHaveLength(1); + expect( + view.container.querySelectorAll('[aria-label="在已完成中新建任务"]') + ).toHaveLength(1); expect(dom.window.localStorage.getItem(TASKBOARD_VIEW_STORAGE_KEY)).toBe( "board" ); @@ -244,6 +269,122 @@ describe("TaskBoardPage rendered", () => { ); }); + test("renders the dense board card contract with activity, labels, and overflow", async () => { + const task = createBoardTask( + { + title: "丰富的卡片", + description: "描述预览第一行", + status: "todo", + priority: "urgent", + labels: ["前端", "体验", "回归"], + sessionIds: ["session-running"], + }, + { id: "TASK-CARD", now: 1_700_000_000_000 } + ); + storeTasks([task]); + const view = await renderBoard({ + sessions: [ + { + id: "session-running", + title: "执行中的会话", + running: true, + activity: { + revision: 1, + state: { kind: "running", turn_id: "turn-1" }, + }, + }, + ], + }); + await click(button(view.container, "看板")); + + const card = view.container.querySelector('[data-task-card="TASK-CARD"]'); + expect(card?.textContent).toContain("TASK-1"); + expect(card?.textContent).toContain("丰富的卡片"); + expect(card?.textContent).toContain("描述预览第一行"); + expect(card?.textContent).toContain("紧急"); + expect( + [...(card?.querySelectorAll("[data-task-label]") ?? [])].map( + (label) => label.textContent + ) + ).toEqual(["前端", "体验"]); + expect(card?.textContent).toContain("+1"); + const sessionCount = card?.querySelector("[data-session-count]"); + expect(sessionCount?.textContent).toContain("1 个会话"); + expect(sessionCount?.querySelector("svg")).not.toBeNull(); + expect(card?.querySelector('[data-slot="status-badge"]')?.textContent).toBe( + "处理中" + ); + }); + + test("projects idle work into running and attention into needs you", async () => { + const idle = createBoardTask( + { title: "空闲进行中", status: "in_progress" }, + { id: "TASK-IDLE", now: 1_700_000_000_000 } + ); + const waiting = createBoardTask( + { + title: "等待输入的进行中", + status: "in_progress", + sessionIds: ["session-waiting"], + }, + { id: "TASK-WAITING", now: 1_700_000_000_001 } + ); + storeTasks([idle, waiting]); + const view = await renderBoard({ + sessions: [ + { + id: "session-waiting", + title: "等待", + activity: { + revision: 1, + state: { kind: "awaiting_input", turn_id: "turn-1", pending: [] }, + }, + }, + ], + }); + await click(button(view.container, "看板")); + + const running = view.container.querySelector( + '[data-task-column="running"]' + ); + const needsYou = view.container.querySelector( + '[data-task-column="needs_you"]' + ); + expect(running?.textContent).toContain("空闲进行中"); + expect(running?.textContent).not.toContain("等待输入的进行中"); + expect(needsYou?.textContent).toContain("等待输入的进行中"); + expect( + needsYou?.querySelector('[data-slot="status-badge"]')?.textContent + ).toBe("等待输入"); + }); + + test("creates a Task from a lane header in that lane's stage", async () => { + const view = await renderBoard(); + await click(button(view.container, "看板")); + await click(button(view.container, "在队列中新建任务")); + + const title = dom.document.body.querySelector( + 'input[placeholder="例如:完善任务筛选体验"]' + ); + await setValue(title, "从列头新建"); + await click(button(dom.document.body, "创建任务")); + + await waitFor(() => + expect(view.container.textContent).toContain("从列头新建") + ); + const snapshot = JSON.parse( + dom.window.localStorage.getItem(TASKBOARD_STORAGE_KEY) + ); + expect( + snapshot.tasks.find((task) => task.title === "从列头新建").status + ).toBe("todo"); + expect( + view.container + .querySelector('[data-task-column="queue"]') + ?.querySelectorAll("[data-task-card]").length + ).toBe(4); + }); + test("restores a valid view preference and falls back from an invalid value", async () => { installStorage(); dom.window.localStorage.setItem(TASKBOARD_VIEW_STORAGE_KEY, "board"); @@ -287,6 +428,24 @@ describe("TaskBoardPage rendered", () => { ).toContain("为第一次使用看板的成员准备简洁、可行动的中文引导。"); }); + test("highlights a board card only after the user picks it", async () => { + const view = await renderBoard(); + await click(button(view.container, "看板")); + + expect( + view.container.querySelector('[data-task-card][data-selected="true"]') + ).toBeNull(); + expect( + view.container.querySelector('[aria-label="任务检查器"]')?.textContent + ).toContain("完善空状态与操作提示"); + + await click(button(view.container, "选择任务:完善空状态与操作提示")); + expect( + view.container.querySelector('[data-task-card][data-selected="true"]') + ?.dataset.taskCard + ).toBe("seed-empty-state-copy"); + }); + test("keeps a large persisted list progressive on first render", async () => { const statuses = ["todo", "in_progress", "in_review", "done"]; const tasks = Array.from({ length: 160 }, (_, index) => diff --git a/apps/desktop/tests/taskBoardWorkspaceModel.test.ts b/apps/desktop/tests/taskBoardWorkspaceModel.test.ts index d1b62b98..3dfefc97 100644 --- a/apps/desktop/tests/taskBoardWorkspaceModel.test.ts +++ b/apps/desktop/tests/taskBoardWorkspaceModel.test.ts @@ -28,6 +28,7 @@ const t: Translate = (key, values) => function task(overrides: Partial = {}): BoardTask { return { id: "task-1", + number: 1, title: "Task", description: "", status: "in_progress", @@ -257,7 +258,7 @@ describe("TaskBoard workspace model", () => { { id: "s1", number: 1, current: true }, ]); expect(projected.currentSession?.id).toBe("s1"); - expect(projected.lane).toBe("queue"); + expect(projected.lane).toBe("running"); const [allArchived] = projectTasks( [task({ sessionIds: ["s2"] })], sessions diff --git a/docs/sdlc/changes/2026-09-16-multica-kanban-design/evidence/board-dark.png b/docs/sdlc/changes/2026-09-16-multica-kanban-design/evidence/board-dark.png new file mode 100644 index 00000000..8028db05 Binary files /dev/null and b/docs/sdlc/changes/2026-09-16-multica-kanban-design/evidence/board-dark.png differ diff --git a/docs/sdlc/changes/2026-09-16-multica-kanban-design/evidence/board-drag.png b/docs/sdlc/changes/2026-09-16-multica-kanban-design/evidence/board-drag.png new file mode 100644 index 00000000..0e9eed01 Binary files /dev/null and b/docs/sdlc/changes/2026-09-16-multica-kanban-design/evidence/board-drag.png differ diff --git a/docs/sdlc/changes/2026-09-16-multica-kanban-design/evidence/board-filtered.png b/docs/sdlc/changes/2026-09-16-multica-kanban-design/evidence/board-filtered.png new file mode 100644 index 00000000..a29efa06 Binary files /dev/null and b/docs/sdlc/changes/2026-09-16-multica-kanban-design/evidence/board-filtered.png differ diff --git a/docs/sdlc/changes/2026-09-16-multica-kanban-design/evidence/board-light.png b/docs/sdlc/changes/2026-09-16-multica-kanban-design/evidence/board-light.png new file mode 100644 index 00000000..33bc58b5 Binary files /dev/null and b/docs/sdlc/changes/2026-09-16-multica-kanban-design/evidence/board-light.png differ diff --git a/docs/sdlc/changes/2026-09-16-multica-kanban-design/evidence/board-narrow.png b/docs/sdlc/changes/2026-09-16-multica-kanban-design/evidence/board-narrow.png new file mode 100644 index 00000000..dfaca22e Binary files /dev/null and b/docs/sdlc/changes/2026-09-16-multica-kanban-design/evidence/board-narrow.png differ diff --git a/docs/sdlc/changes/2026-09-16-multica-kanban-design/evidence/board-selected.png b/docs/sdlc/changes/2026-09-16-multica-kanban-design/evidence/board-selected.png new file mode 100644 index 00000000..f9de3309 Binary files /dev/null and b/docs/sdlc/changes/2026-09-16-multica-kanban-design/evidence/board-selected.png differ diff --git a/docs/sdlc/changes/2026-09-16-multica-kanban-design/intent.md b/docs/sdlc/changes/2026-09-16-multica-kanban-design/intent.md new file mode 100644 index 00000000..16c4b999 --- /dev/null +++ b/docs/sdlc/changes/2026-09-16-multica-kanban-design/intent.md @@ -0,0 +1,55 @@ +--- +id: 2026-09-16-multica-kanban-design +schema: 5 +stage: intent +status: accepted +owner: chenli +created: 2026-09-16 +source: user +risk: medium +approved_by: chenli +approved_at: 2026-09-16 +approval_source: "Direct request: https://multica.ai/ 借鉴一下这个产品的看板设计. Scope confirmed in the same session: 完整借鉴(卡片信息密度 + 列布局与列内新建 + 拖拽)with 对齐投影 for cross-lane drag semantics. Follow-up in the same session: 不要这种聚集边框,另外最底下 session 前面是不是有个图标会比较好 — the drag-time outline was replaced by a stronger lane tint and the session count gained a chat mark." +next_trigger: Implement, verify, and hand off; merge and release need their own authorization. +--- + +# Intent: Borrow Multica's Kanban board design + +## Intent + +The user asked to borrow the kanban design of [multica.ai](https://multica.ai/) for the desktop Task +Board (`apps/desktop/src/taskboard/`). The reference product's board (open source at +`github.com/multica-ai/multica`, `packages/views/issues/components/board-*.tsx`) was read before +planning. Its board differs from ours in three user-visible ways: + +1. Cards are information-dense: identifier, title, description preview, priority, labels, agent + activity badge, dates, child progress — while our `TaskBoardKanban` renders title, a priority + word, a session count, a PR count and a timestamp. +2. Lanes are fixed-width columns with their own vertical scroll, a tinted column plane, a + header (status + count) and a per-column add button; our board uses four equal-width columns that + share one scroll area and have no column actions. +3. Cards are draggable between lanes (changing status) and within a lane (changing order) with drag + feedback and blank-area drag-to-pan; our board has no drag path at all — moves go through the + card menu only, and an unused drag affordance (`taskboard.dragTask*`, `taskboard.addInColumn`, + the reducer's exact `beforeId` anchor, the `@dnd-kit/react` wrapper) is already in the tree. + +Outcome: the board view adopts the reference board's density, column geometry and direct +manipulation while keeping CodeTwo's own design standard, data model, and non-drag paths. + +Constraints: the desktop design standard (`Design.md`, `docs/design/system.md`) still owns shape, +color, typography, motion and shared components; no new dependency is added (the bundled +`@dnd-kit/react` + `useBoardDragPan`-style pointer handling is enough); task data stays local +(`localStorage` snapshot) and no server/team board is introduced; the existing list view, editor, +inspector and "Move to" menu remain the accessible, non-drag path; the shared 100%-mutation gate on +`workspaceModel.ts` must stay green. + +Non-goals: swimlane/grouping views, assignees/collaborators, start/due dates, sub-task progress, +saved-view filters, hiding or reordering lanes, live agent streaming on cards, and any change to the +list view's information architecture. + +## Non-goals + +- No swimlane, grouping, table, or gantt view; no lane show/hide/reorder. +- No assignee, date, or sub-task fields on `BoardTask`. +- No server-side or shared task board; the desktop board stays `localStorage`-backed. +- No list-view redesign and no change to the editor, inspector, or PR linking. diff --git a/docs/sdlc/changes/2026-09-16-multica-kanban-design/plan.md b/docs/sdlc/changes/2026-09-16-multica-kanban-design/plan.md new file mode 100644 index 00000000..3d03c4fa --- /dev/null +++ b/docs/sdlc/changes/2026-09-16-multica-kanban-design/plan.md @@ -0,0 +1,60 @@ +--- +id: 2026-09-16-multica-kanban-design +schema: 5 +stage: plan +status: accepted +owner: chenli +created: 2026-09-16 +based_on: spec.md +scope: apps/desktop/src/App.tsx, apps/desktop/src/components/ui/drag-drop.tsx, apps/desktop/src/taskboard, apps/desktop/src/i18n/strings.ts, apps/desktop/tests/taskBoard.test.ts, apps/desktop/tests/taskBoardDnd.test.ts, apps/desktop/tests/taskBoardRendered.test.tsx, apps/desktop/tests/taskBoardWorkspaceModel.test.ts, docs/sdlc/changes/2026-09-16-multica-kanban-design +--- + +# Plan: Borrow Multica's Kanban board design + +## Plan + +1. `apps/desktop/src/taskboard/taskBoard.ts` — add `number` to `BoardTask`, bump the snapshot to v4 + with deterministic migration/repair, add `nextTaskNumber`, `laneStatus`, and the aligned + `taskBoardLane`; `createBoardTask` accepts an optional `number`. +2. `apps/desktop/src/taskboard/boardDnd.ts` (new) — the pure drag contract: lane data/group parsing, + sortable snapshot narrowing, and `boardDropTarget` (lane → durable status + exact `beforeId` + anchor), so drop math is unit-testable without a DOM. +3. `apps/desktop/src/taskboard/useBoardDragPan.ts` (new) — blank-area horizontal pan for the board + scroller, with the interactive-element exemption as a pure exported predicate. +4. `apps/desktop/src/taskboard/TaskBoardCard.tsx` (new) — the four-row card from the Spec + (identity/activity row, title, description preview, meta row). +5. `apps/desktop/src/taskboard/TaskBoardColumn.tsx` (new) — fixed-width lane, header with count and + add button, vertical scroller doubling as the drop zone, empty state, drop highlight. +6. `apps/desktop/src/taskboard/TaskBoardKanban.tsx` — replace the current grouping-only board with + the `DragDropRoot` board built from the two new components; resolve drops through `boardDnd.ts` + and report them as `onMoveTask(task, status, beforeId?)`. +7. `apps/desktop/src/taskboard/{TaskBoardCollection,TaskBoardPage,TaskBoardList}.tsx` — widen the + move callback with the optional anchor and keep every existing menu/editor call site working. +8. `apps/desktop/src/taskboard/task-board.css` — fixed-width lane geometry, per-lane tone tint, + column scroller, drag/drop feedback, and the reduced-motion guard. +9. `apps/desktop/src/i18n/strings.ts` — the new card/column/drag keys in `en` and `zhCN`. +10. Tests: update `tests/taskBoard.test.ts` (numbers, migration, lane mapping, reducer anchors), + `tests/taskBoardWorkspaceModel.test.ts` (aligned projection), `tests/taskBoardRendered.test.tsx` + (new card/column contracts), and add `tests/taskBoardDnd.test.ts` for the pure drop contract. + +Checks by risk and affected behavior: + +- Desktop renderer: `bun run lint`, `bunx tsc --noEmit`, `bun test` from `apps/desktop`. +- Mutation gate: `bun run mutation:taskboard` must stay at 100% for `workspaceModel.ts`. +- Rendered board (AC-1…AC-4, AC-7, AC-8): the DOM-rendered suite covers structure, counts, add + button, empty states and the drag source markup; the visual/interactive acceptance runs the Vite + renderer (`bun run dev:renderer`, port 1420) in the session browser for light/dark/narrow + screenshots and a real pointer drag, because a passing DOM test cannot prove layout or drag + feedback. If the collaborative browser cannot reach the local port, the attempt is recorded as + residual risk instead of being claimed. +- Repository: `bun script/verify/sdlc.ts --worktree` and `bun script/verify/docs.ts` before handoff. + +Temporary resources: `.codex/run/instances/multica-kanban-design/` holds the Vite dev-server log and +any screenshots produced during rendered acceptance; the Vite process on port 1420 is stopped before +handoff, and the retained screenshots (when produced) are linked as evidence. `apps/desktop/node_modules` +stays in place as the shared package install; the checker's `reports/` output from the mutation run is +disposable. + +Rollback: revert the taskboard, i18n, and test edits (the board returns to the previous +grouping-only view); existing `localStorage` snapshots keep loading because v4 parsing is additive +and no persisted field is renamed or removed. diff --git a/docs/sdlc/changes/2026-09-16-multica-kanban-design/spec.md b/docs/sdlc/changes/2026-09-16-multica-kanban-design/spec.md new file mode 100644 index 00000000..8890cb00 --- /dev/null +++ b/docs/sdlc/changes/2026-09-16-multica-kanban-design/spec.md @@ -0,0 +1,133 @@ +--- +id: 2026-09-16-multica-kanban-design +schema: 5 +stage: spec +status: accepted +owner: chenli +created: 2026-09-16 +based_on: intent.md +--- + +# Spec: Borrow Multica's Kanban board design + +## Design + +### Selection + +A Task only looks selected after the user picks it: the fallback that gives the inspector its +content (the first visible Task) is never written into the selection state, and a selection whose +Task was deleted is cleared. The selected card and list row share one treatment — the list's soft +accent tint (`bg-accent/35`) — instead of the neutral `fill-selected` fill that read as a boxed +card on the board. + +### Card content (borrowed row structure) + +`TaskBoardCard` renders four rows inside the existing flat `surface` module (16px radius, 12px +inset, no border, no shadow change): + +1. identity row — a decorative grip mark (visible on hover/focus, the whole card is the drag + source), the stable task number `TASK-{number}` (metadata, muted), then the kebab `TaskActionsMenu`; + when the latest Session is running, awaiting input, or failed, a `StatusBadge` chip + (`session.running` / `session.awaitingInput` / `session.failed`, tones success/warning/destructive) + sits before the kebab. +2. title — 2-line clamp, the existing selection button. +3. description preview — one muted line, only when the description is non-empty. +4. meta row — flag icon + `taskPriorityLabel` for any priority except `none` (icon carries the + `aria-label`), label chips (first two + `+N`), the session count behind a small chat mark when + `> 0`, the open-PR count when `> 0` and resolved, and the updated-at text pushed to the trailing + edge. Zero counts stay hidden (unchanged contract). + +Priority icons use the shared `Flag` glyph; the icon tone stays on the neutral text hierarchy +(medium/high/urgent are words, not colors) so no new color semantics are introduced. + +### Column geometry and chrome + +The board becomes four fixed-width lanes (`--task-board-column-width: 17rem`) in a flex row; the +board area is the only horizontal scroller (`overflow-x-auto`, `overflow-y-hidden`), and each lane +owns its vertical scroller (`overflow-y-auto`, `min-h-0`). Lane header: `StatusIndicator` (tone from +`LANE_TONES`) + lane label + count, and a hover/focus `Button` with the `Plus` icon that opens the +editor in that lane's durable status (`Add task to {status}` — reuses `taskboard.addInColumn`). Lane +background is the neutral `fill-quiet` plane mixed with the lane's status tone at low strength +through `color-mix` in `task-board.css`, so the four stages stay distinguishable without a new +token; the empty-lane copy and `data-task-column`/`data-task-card` hooks are preserved. + +### Drag and drop + +`TaskBoardKanban` wraps the board in the shared `DragDropRoot` boundary. Each card is a +`useDragDropSortable` item (`type`/`accept: "task"`, `group: lane`, `index`, data `{taskId, lane}`); +each lane's card scroller is a `useDragDropZone` (`accept: "task"`, lowest collision priority, data +`{lane}`) so empty lanes and lane background accept drops. + +- Drag feedback: the source card dims while dragging (`isDragging`), the lane under the pointer + strengthens its own status tint (no outline box around the card area), the hovered card carries a + 2px insertion edge, and the shared feedback plugin moves the card with the pointer and keeps a + faint placeholder in the column. +- Drop resolution is pure and unit-tested (`boardDnd.ts`): + `boardDropTarget(laneTasks, activeTaskId, index)` returns the durable status for the lane plus the + optional exact `beforeId` anchor. The anchor is the first task at or after the insertion point + whose durable status matches the lane's status; a drop on lane background appends + (`beforeId` absent). The reducer's existing `move` already treats a supplied anchor as exact, so + stale anchors no-op instead of appending. +- Cross-lane semantics: dropping on a lane sets `laneStatus(lane)` — `queue` writes To do, + `running` In progress, `needs_you` In review, and `done` Done — and the drop position is applied + inside that status group. Because a lane can mix durable statuses only in `needs_you` + (`in_review` plus attention-carrying `in_progress`), the achievable position inside the group + wins over the literal pixel position; this is deterministic and documented in the module. +- Blank-area left-drag on the board background pans horizontally (Trello/Linear pattern) through a + small pointer hook; it never activates on cards, buttons, links, or form controls, and it leaves + touch/pen to the browser. +- Keyboard, menu and editor paths are untouched: `TaskActionsMenu`'s "Move to {status}" and the + editor's status field remain the non-drag path, and Escape or a canceled drag leaves all state + unchanged. + +### Lane projection alignment + +`taskBoardLane` keeps its order of checks but no longer sends an idle `in_progress` task back to +`queue`: `done → done`, `in_review → needs_you`, To do → `queue`, `awaiting_input|failed → needs_you`, +otherwise `running`. Lanes therefore read as durable stages (`queue` ≈ To do, `running` ≈ In progress, +`needs_you ≈ in_review + attention`, `done`) and a drop's result stays visible instead of bouncing +back. `needs_you` remains the attention lane for `in_progress` sessions that await input or failed. + +### Task number and snapshot v4 + +`BoardTask` gains `number: number`, a stable per-board display identity rendered as `TASK-{n}`. +`TASKBOARD_SNAPSHOT_VERSION` becomes `4`: + +- `parseBoardSnapshot` still accepts versions 1–4. Tasks from v1–v3 (and v4 entries whose number is + missing, non-positive, or duplicated) receive the next free number in stored order, so legacy + boards migrate deterministically and a numbering glitch never discards user data. +- `nextTaskNumber(tasks)` is `max(number) + 1`; the reducer's `create` assigns it so two creations + cannot collide, `useTaskBoardActions.saveEditor` and App's direct board writes pass it explicitly, + and `seedTasks` numbers the starter board 1…9 in creation order. + +## Acceptance criteria + +- [x] AC-1: A board card shows the stable `TASK-{number}`, its title, a one-line description preview + when the description exists, the priority flag plus label for every priority except `none`, + up to two label chips with a `+N` overflow, the session count (with its mark) when non-zero, + the open-PR count when non-zero and resolved, the updated-at text, and a `StatusBadge` only + when the current Session is running, awaiting input, or failed. +- [x] AC-2: The four lanes are fixed-width columns with independent vertical scrolling; the board is + the sole horizontal scroller; each lane header shows the lane status, count, and an add button + that opens the editor in that lane's durable status; empty lanes keep their empty copy; the + lane plane carries a subtle per-lane status tint in light and dark. +- [x] AC-3: Dragging a card within a lane reorders it and dropping it on another lane changes its + durable stage (Queue writes To do, Running In progress, Needs you In review, Done Done) at + the resolved anchor; a drop on lane background appends; dropping outside any lane or canceling + the drag changes nothing. +- [x] AC-4: During a drag the source card is visibly dimmed, the lane under the pointer strengthens + its tint (no outline box around the card area) while the hovered card shows the insertion + edge, and releasing over a lane applies exactly one move (no duplicate dispatches). +- [x] AC-5: `taskBoardLane` maps To do to `queue`, `in_progress→running` (idle and running), + `in_progress` with `awaiting_input`/`failed` → `needs_you`, `in_review→needs_you`, + `done→done`. +- [x] AC-6: Snapshot v4 round-trips task numbers; v1/v2/v3 snapshots load with deterministic numbers + in stored order; invalid or duplicate numbers are repaired without dropping the board; new + tasks receive `max+1` and never collide. +- [x] AC-7: The list view, its 40-row progressive window, the kebab "Move to" path, the editor's + status field and the inspector keep working; the board and list highlight a Task only after an + explicit pick (soft accent tint, matching the list's existing selected row), while the + inspector keeps its first-visible fallback content and a deleted selection is dropped instead + of jumping to another row. +- [x] AC-8: Light, dark, and narrow renderings of the board were produced and inspected, including a + drag-in-progress frame and a filtered/empty column state. diff --git a/docs/sdlc/changes/2026-09-16-multica-kanban-design/verification.md b/docs/sdlc/changes/2026-09-16-multica-kanban-design/verification.md new file mode 100644 index 00000000..9f14f875 --- /dev/null +++ b/docs/sdlc/changes/2026-09-16-multica-kanban-design/verification.md @@ -0,0 +1,132 @@ +--- +id: 2026-09-16-multica-kanban-design +schema: 5 +stage: verification +status: passed +owner: chenli +created: 2026-09-16 +based_on: plan.md +revision: 7178600a (branch t3code/multica-kanban-design), based on 8d1f32d7 +verification_mode: owner +verified_by: chenli +verified_at: 2026-09-16 +release_target: none +cleanup_status: complete +--- + +# Verification: Borrow Multica's Kanban board design + +## Verification + +- AC-1: PASS — `bun test tests/taskBoardRendered.test.tsx` ("renders the dense board card + contract with activity, labels, and overflow") asserts `TASK-1`, title, one-line description + preview, `紧急`, the two visible label chips plus `+1`, `1 个会话`, and the `处理中` status badge + in one card; the same case now also asserts the session count sits behind a chat mark + (`[data-session-count]` contains an `svg`), and the earlier board test asserts the priority flag + label, a label chip, `TASK-2`, and that a card with no Session renders no status badge and never + prints zero counts. The board screenshots come from the session-less seed board (this standalone + renderer has no Core session), so they show flag and label chips but no session mark. Evidence: + [light board](evidence/board-light.png), [dark board](evidence/board-dark.png), + [selected card](evidence/board-selected.png). +- AC-2: PASS — the same rendered suite asserts the new geometry contract in `task-board.css` + (`--task-board-column-width: 17rem`, `flex: 0 0 var(--task-board-column-width)`, column + `overflow-y: auto`), four `[data-task-column-cards]` scrollers, a per-lane `在队列中新建任务` / + `在已完成中新建任务` button, and that creating from the Queue header stores the To do stage and + renders in that lane. Live rendering at 1000×700 measured four 272px lanes, a board scroller of + `scrollWidth 1128` inside `clientWidth 712`, and no document overflow; the filtered state shows + `No tasks match these filters` in the three empty lanes. Evidence: [light board](evidence/board-light.png), + [dark board](evidence/board-dark.png), [compact board](evidence/board-narrow.png), + [filtered board](evidence/board-filtered.png). +- AC-3: PASS — `bun test tests/taskBoardDnd.test.ts` covers `boardDropTarget` (exact anchors, + clamping, cross-lane status mapping, mixed `needs_you` skipping), `boardDropPreview` + (before/after, background append, self-hover no-op, cross-lane) and `boardDropAfterKeyboard`; + `bun test tests/taskBoard.test.ts` keeps the reducer's exact-`beforeId`/append/cross-column + cases. Live in the running renderer, a keyboard drag moved a Queue card one position down + (`seed-empty-state-copy` after `seed-session-link`), a second drag moved it into Running + (`status: "in_progress"`, order 0, before `seed-local-persistence`), and Escape left storage + byte-identical (`unchanged: true`). +- AC-4: PASS — live drag frames show the dimmed source slot (`data-dnd-placeholder` at 40% + opacity), the dragged card lifted at `position: fixed`, the target lane's strengthened tint + (measured `oklch(0.901 0.024 149)` against `oklch(0.981 0 0)` for an untargeted lane, with no + outline rule left in `task-board.css`) and the insertion edge on the hovered card; after each + drop the board persisted exactly one move, `data-dnd-dragging` and `data-dnd-placeholder` counts + returned to zero, and the error log stayed empty (only the host's benign `ResizeObserver` + notices). Evidence: [drag in progress](evidence/board-drag.png). +- AC-5: PASS — `taskBoard.test.ts` maps To do to `queue`, `in_progress→running` for both idle and + running, `awaiting_input`/`failed → needs_you`, `in_review→needs_you`, `done→done`, plus + `laneStatus` for all four lanes; `taskBoardWorkspaceModel.test.ts` expects the idle + `in_progress` projection in `running`; the rendered suite shows the same split for a live + Session awaiting input. +- AC-6: PASS — `taskBoard.test.ts` asserts `TASKBOARD_SNAPSHOT_VERSION === 4`, seed numbers 1–9, + `nextTaskNumber` (empty, unnumbered, `max+1`), `assignTaskNumbers` repair of zero/NaN/duplicate/ + negative numbers (idempotent), the reducer creating `max+1`, v1 migration numbering, and a v4 + round trip that preserves an explicit number; the rendered suite persists created tasks and + reloads them. +- AC-7: PASS — `bun test` passes 947 tests (3 skipped) across 167 files, including every existing + list-view, editor, selection, inspector, filter and menu case. The new rendered case "highlights a + board card only after the user picks it" asserts that a freshly rendered board has no + `[data-task-card][data-selected]` while the inspector still names the first Task, and that picking + the card highlights exactly it; the selected card and list row use the list's soft accent tint + (`bg-accent/35`). [Selected card](evidence/board-selected.png) shows the resulting treatment. +- AC-8: PASS — six rendered states were produced in the Vite renderer at `localhost:1420` and + inspected: [light board with all four lanes](evidence/board-light.png) (rail collapsed so every + lane is visible, no card highlighted by default), [selected card](evidence/board-selected.png), + [dark scheme](evidence/board-dark.png), [1000×700 compact layout](evidence/board-narrow.png) with + the board as the only horizontal scroller, [a drag in progress](evidence/board-drag.png), and + [a filtered board with three empty lanes](evidence/board-filtered.png). All were re-captured + after the follow-up that removed the drag-time outline, added the session mark, and replaced the + card's neutral selected fill with the list's accent tint, so the record holds no stale visual. + +Checks: + +- `bun test` from `apps/desktop`: 947 passed, 3 skipped, 0 failed (re-run after the follow-up). +- `bun run lint` and `bunx tsc --noEmit` from `apps/desktop`: clean (re-run after the follow-up). +- `bun run mutation:taskboard`: 100.00% mutation score for `src/taskboard/workspaceModel.ts` + (thresholds 100/100/100), re-run after the final follow-up on the worktree. +- `bun run build:renderer` from `apps/desktop`: passed (lint, types, Vite production build), + re-run after the follow-up. +- `bun script/verify/sdlc.ts --worktree` and `bun script/verify/docs.ts`: passed. + +Verdict: verified. +Residual risk: the selection change drops the "first row looks current" cue the list had before +this pass; the inspector still names the fallback Task, and the list/board highlight now follows an +explicit pick only. Pointer drags could not be driven end-to-end in this session — synthetic +`PointerEvent`s cannot claim pointer capture, so the library cancels the gesture, and the +collaborative browser exposes no native drag input. The shared drop-resolution, reducer and +reconciliation paths were exercised through the keyboard sensor, which is the same code minus the +pointer sensor; the pointer-only part (the card following the cursor, auto-scroll) is library +behavior that the light/dark screenshots cannot prove. Board cards deliberately disable the +library's optimistic sorting plugin, so cards do not reflow live during a drag; the lane ring and +the insertion edge are the feedback instead. `needs_you` can still mix attention-carrying +`in_progress` cards with `in_review` ones, and reordering there clamps to the nearest same-status +position, which the drop-preview tests document. + +## Cleanup + +Removed: `.codex/run/instances/multica-kanban-design/` (Vite dev-server logs, the three mutation +logs, and the working copies of the screenshots) and its now-empty `instances/`/`run/` parents after +the six evidence PNGs were consolidated under +`docs/sdlc/changes/2026-09-16-multica-kanban-design/evidence/`; each render pass removed its +`apps/desktop/dist` (renderer build) and `apps/desktop/reports/taskboard-mutation.json` (4.5 MB +mutation report); every Stryker run removed its own `.stryker-tmp` sandbox; the scratch `multica` +clone and landing-page images under the session's temporary directory. +Retained: none. The committed evidence screenshots live in this change record and +`apps/desktop/node_modules` stays in place as the shared package install. +Processes: the task-owned Vite renderer dev server on port 1420 was stopped +(`lsof -nP -iTCP:1420 -sTCP:LISTEN` empty afterwards); no Core, desktop app, or user-owned +process was started, stopped, or connected. +Evidence: `ls` of the removed instance directory before removal, `lsof`/`pgrep` for port 1420 and +`vite`, and `git status` showing only the intended taskboard, i18n, test, and record paths. + +## Review and release + +Approval: implementation was requested directly by the user on 2026-09-16 (https://multica.ai/ +借鉴一下这个产品的看板设计), with the scope and drag semantics confirmed in the same session. +Merge, release, and external actions are not authorized. +Rollback: revert the taskboard, i18n, and test edits plus the new modules; existing `localStorage` +snapshots keep loading because v4 parsing is additive and no persisted field was renamed. +Release: No release requested; merge and external actions require their own authorization. +Review: [PR #237](https://github.com/IchenDEV/codeTwo/pull/237) carries this change on branch +t3code/multica-kanban-design; the hosted Validate job passed on the code revision 7178600a +(run 35071350449) and on the record revision 788dd43a (run 35073185753). +Feedback: Link an Incident and regression Eval when a real failure occurs.