Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
31237ad
feat: add anchored range selection to multi-select reducer
zeval Jun 28, 2026
78b2a1f
feat: cmd+click and shift+click multi-select on kanban board
zeval Jun 28, 2026
eb2e382
feat: add cmd/shift multi-select to sidebar task list
zeval Jun 28, 2026
60df474
test: e2e coverage for cmd/shift multi-select
zeval Jun 28, 2026
bf3b1e6
fix: harden multi-select selection lifecycle from review feedback
zeval Jun 28, 2026
cdfbe76
test: scope multi-select e2e helpers to active containers
zeval Jun 28, 2026
6aa36c8
fix: add sidebar multi-select hook tests and tighten bulk-action life…
zeval Jun 28, 2026
e6f5044
fix: prune hidden sidebar selections and order bulk moves by view
zeval Jun 28, 2026
c2a8928
test: cover visible-order bulk move and anchor realignment
zeval Jun 28, 2026
b7a0d1a
fix: harden sidebar bulk-move scope and keyboard selection parity
zeval Jun 29, 2026
72a1a1c
fix: order kanban bulk moves by board position
zeval Jun 29, 2026
5d74b8d
test: cover multi-select helpers and fix stale-id sort fallback
zeval Jun 29, 2026
fc8b362
fix: order kanban card context-menu moves by board position
zeval Jun 29, 2026
f938825
feat: sidebar multi-select shows only bulk-valid actions with bulk pi…
zeval Jul 1, 2026
4b615fa
fix: harden multi-select bulk ordering, selection clearing, and a11y
zeval Jul 1, 2026
db6c18e
fix: gate kanban card move-to-step for mixed-workflow selections
zeval Jul 1, 2026
5f09827
fix: unpin fully pinned sidebar selections
zeval Jul 6, 2026
7532efe
refactor: keep sidebar task components under limits
zeval Jul 6, 2026
06869aa
test: restore sidebar selection console spy safely
zeval Jul 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/components/kanban-board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, onBeforeEdit }: KanbanB
selectedRepositoryIds={s.userSettings.repositoryIds}
selectedIds={s.multiSelect.selectedIds}
Comment thread
zeval marked this conversation as resolved.
onToggleSelect={s.multiSelect.toggleSelect}
onSelectRange={s.multiSelect.selectRange}
isMultiSelectMode={s.multiSelect.isMultiSelectMode}
onToggleMultiSelect={s.multiSelect.toggleMultiSelect}
/>
Expand Down
64 changes: 64 additions & 0 deletions apps/web/components/kanban-card-click.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, it, expect, vi } from "vitest";
import { dispatchKanbanCardClick, type Task } from "./kanban-card";

function fakeEvent(mods: Partial<MouseEvent> = {}) {
return {
metaKey: false,
ctrlKey: false,
shiftKey: false,
preventDefault: vi.fn(),
...mods,
} as unknown as React.MouseEvent;
}

const task = { id: "t1", title: "T", workflowStepId: "s1" } as Task;

function handlers() {
return {
onToggleSelect: vi.fn(),
onRangeSelect: vi.fn(),
onClick: vi.fn(),
isMultiSelectMode: false,
};
}

describe("dispatchKanbanCardClick", () => {
it("cmd/meta-click toggles and prevents default", () => {
const h = handlers();
const e = fakeEvent({ metaKey: true });
dispatchKanbanCardClick(e, task.id, task, h);
expect(e.preventDefault).toHaveBeenCalled();
expect(h.onToggleSelect).toHaveBeenCalledWith("t1");
expect(h.onClick).not.toHaveBeenCalled();
expect(h.onRangeSelect).not.toHaveBeenCalled();
});

it("ctrl-click toggles (Windows/Linux)", () => {
const h = handlers();
dispatchKanbanCardClick(fakeEvent({ ctrlKey: true }), task.id, task, h);
expect(h.onToggleSelect).toHaveBeenCalledWith("t1");
});

it("shift-click range-selects and prevents default", () => {
const h = handlers();
const e = fakeEvent({ shiftKey: true });
dispatchKanbanCardClick(e, task.id, task, h);
expect(e.preventDefault).toHaveBeenCalled();
expect(h.onRangeSelect).toHaveBeenCalledWith("t1");
expect(h.onToggleSelect).not.toHaveBeenCalled();
});

it("plain click while in multi-select mode toggles", () => {
const h = { ...handlers(), isMultiSelectMode: true };
dispatchKanbanCardClick(fakeEvent(), task.id, task, h);
expect(h.onToggleSelect).toHaveBeenCalledWith("t1");
expect(h.onClick).not.toHaveBeenCalled();
});

it("plain click outside multi-select mode previews/opens", () => {
const h = handlers();
dispatchKanbanCardClick(fakeEvent(), task.id, task, h);
expect(h.onClick).toHaveBeenCalledWith(task);
expect(h.onToggleSelect).not.toHaveBeenCalled();
});
});
2 changes: 1 addition & 1 deletion apps/web/components/kanban-card-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export type KanbanCardShellProps = KanbanCardActionProps &
isSelected?: boolean;
isMultiSelectMode?: boolean;
isPreviewed: boolean;
onClick: () => void;
onClick: (e: React.MouseEvent) => void;
onCheckboxClick: (e: React.MouseEvent) => void;
};

Expand Down
82 changes: 66 additions & 16 deletions apps/web/components/kanban-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-d
import { TaskGitHubIssueDialog } from "@/components/task/task-github-issue-dialog";
import { TaskGitHubPRDialog } from "@/components/task/task-github-pr-dialog";
import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move";
import { useTaskMultiSelectStore } from "@/hooks/use-task-multi-select";
import { repositorySlug } from "@/lib/repository-slug";
import { formatUserHomePath } from "@/lib/utils";
import { repositoryId as toRepositoryId, type Repository, type TaskState } from "@/lib/types/http";
Expand Down Expand Up @@ -82,6 +83,8 @@ interface KanbanCardProps {
isSelected?: boolean;
selectedIds?: Set<string>;
onToggleSelect?: (taskId: string) => void;
/** Shift-click range select within this card's column. */
onRangeSelect?: (taskId: string) => void;
isMultiSelectMode?: boolean;
}

Expand Down Expand Up @@ -111,6 +114,7 @@ function useKanbanCardMenus({
>) {
const moveTargets = useKanbanCardMoveTargets(task.id, steps);
const moveTasks = useTaskWorkflowMove();
const { sortByDisplayOrder, getWorkflowIdForTask } = useTaskMultiSelectStore();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
const [showPRDialog, setShowPRDialog] = useState(false);
Expand All @@ -134,13 +138,22 @@ function useKanbanCardMenus({
};

const selectedTaskIds = isSelected && selectedIds?.size ? [...selectedIds] : [task.id];
// Sort into board order lazily (only when a move actually fires) so a backward
// range selection isn't scrambled — and we don't pay it on every card render.
const orderedSelectedIds = () => sortByDisplayOrder(selectedTaskIds);
// A selection spanning workflows can't safely use same-workflow "Move to" (it
// would drag other-workflow cards into this card's workflow), so gate it — only
// computed for a genuine multi-selection to avoid per-render snapshot scans.
const isMixedWorkflowSelection =
selectedTaskIds.length > 1 &&
new Set(selectedTaskIds.map((id) => getWorkflowIdForTask(id))).size > 1;
const moveSelectedToStep = (stepId: string) => {
if (selectedTaskIds.length === 1 && selectedTaskIds[0] === task.id && onMove) {
onMove(task, stepId);
return;
}
if (!moveTargets.currentWorkflowId) return;
runMoveTasks(selectedTaskIds, moveTargets.currentWorkflowId, stepId);
runMoveTasks(orderedSelectedIds(), moveTargets.currentWorkflowId, stepId);
Comment thread
zeval marked this conversation as resolved.
};

const menuBase = {
Expand Down Expand Up @@ -168,9 +181,11 @@ function useKanbanCardMenus({
}),
contextMenuEntries: buildKanbanCardMenuEntries({
...menuBase,
onMoveToStep: moveSelectedToStep,
// Hide same-workflow "Move to" for a mixed-workflow selection; only the
// explicit "Send to workflow" path remains (matches the toolbar guard).
onMoveToStep: isMixedWorkflowSelection ? undefined : moveSelectedToStep,
onSendToWorkflow: (workflowId, stepId) => {
runMoveTasks(selectedTaskIds, workflowId, stepId);
runMoveTasks(orderedSelectedIds(), workflowId, stepId);
},
}),
showDeleteConfirm,
Expand Down Expand Up @@ -251,6 +266,42 @@ function KanbanCardDialogs({
);
}

/**
* Cmd/Ctrl-click toggles a single card; Shift-click range-selects within the
* column; either modifier enters multi-select mode without the toggle button.
* A plain click toggles while in multi-select mode, otherwise previews/opens.
*/
/** @internal Exported for unit testing the four-branch click dispatch. */
export function dispatchKanbanCardClick(
e: React.MouseEvent,
taskId: string,
task: Task,
handlers: {
onToggleSelect?: (taskId: string) => void;
onRangeSelect?: (taskId: string) => void;
onClick?: (task: Task) => void;
isMultiSelectMode?: boolean;
},
): void {
// Only intercept a modifier click when the matching handler is wired, so a
// card rendered without selection handlers still opens on Cmd/Shift click.
if ((e.metaKey || e.ctrlKey) && handlers.onToggleSelect) {
e.preventDefault();
handlers.onToggleSelect(taskId);
return;
}
if (e.shiftKey && handlers.onRangeSelect) {
e.preventDefault();
handlers.onRangeSelect(taskId);
return;
}
if (handlers.isMultiSelectMode && handlers.onToggleSelect) {
handlers.onToggleSelect(taskId);
return;
}
handlers.onClick?.(task);
}

function useActiveWorkspaceRepositories() {
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
return useAppStore((state) =>
Expand All @@ -274,6 +325,7 @@ export function KanbanCard({
isSelected,
selectedIds,
onToggleSelect,
onRangeSelect,
isMultiSelectMode,
}: KanbanCardProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
Expand Down Expand Up @@ -306,18 +358,13 @@ export function KanbanCard({
onMove,
});

const handleClick = () => {
if (isMultiSelectMode) {
onToggleSelect?.(task.id);
return;
}
onClick?.(task);
};

const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
onToggleSelect?.(task.id);
};
const handleClick = (e: React.MouseEvent) =>
dispatchKanbanCardClick(e, task.id, task, {
onToggleSelect,
onRangeSelect,
onClick,
isMultiSelectMode,
});

return (
<>
Expand All @@ -338,7 +385,10 @@ export function KanbanCard({
isArchiving={isArchiving}
menuEntries={dropdownMenuEntries}
onClick={handleClick}
onCheckboxClick={handleCheckboxClick}
onCheckboxClick={(e) => {
e.stopPropagation();
onToggleSelect?.(task.id);
}}
onOpenFullPage={onOpenFullPage}
/>
</KanbanCardContextMenu>
Expand Down
10 changes: 10 additions & 0 deletions apps/web/components/kanban-column.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ interface KanbanColumnProps {
hideHeader?: boolean;
selectedIds?: Set<string>;
onToggleSelect?: (taskId: string) => void;
/** Shift-click range select; receives the clicked id + this column's ordered ids. */
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
isMultiSelectMode?: boolean;
}

Expand All @@ -53,6 +55,7 @@ export function KanbanColumn({
hideHeader = false,
selectedIds,
onToggleSelect,
onSelectRange,
isMultiSelectMode,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({
Expand All @@ -66,6 +69,10 @@ export function KanbanColumn({
[repositoriesByWorkspace],
);

// Ordered ids of the cards rendered in this column — the source of truth for
// shift-click range selection (matches exactly what the user sees).
const columnTaskIds = useMemo(() => tasks.map((t) => t.id), [tasks]);

return (
<div
ref={setNodeRef}
Expand Down Expand Up @@ -109,6 +116,9 @@ export function KanbanColumn({
isSelected={selectedIds?.has(task.id)}
selectedIds={selectedIds}
onToggleSelect={onToggleSelect}
onRangeSelect={
onSelectRange ? (taskId) => onSelectRange(taskId, columnTaskIds) : undefined
}
isMultiSelectMode={isMultiSelectMode}
/>
))}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/components/kanban/swimlane-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export type SwimlaneContainerProps = {
selectedRepositoryIds?: string[];
selectedIds?: Set<string>;
onToggleSelect?: (taskId: string) => void;
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
isMultiSelectMode?: boolean;
onToggleMultiSelect?: () => void;
};
Expand Down Expand Up @@ -119,6 +120,7 @@ type WorkflowItemProps = {
showMaximizeButton?: boolean;
selectedIds?: Set<string>;
onToggleSelect?: (taskId: string) => void;
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
isMultiSelectMode?: boolean;
onToggleMultiSelect?: () => void;
};
Expand Down Expand Up @@ -263,6 +265,7 @@ export function SwimlaneContainer({
selectedRepositoryIds = [],
selectedIds,
onToggleSelect,
onSelectRange,
isMultiSelectMode,
onToggleMultiSelect,
}: SwimlaneContainerProps) {
Expand Down Expand Up @@ -332,6 +335,7 @@ export function SwimlaneContainer({
showMaximizeButton={showMaximizeButton}
selectedIds={selectedIds}
onToggleSelect={onToggleSelect}
onSelectRange={onSelectRange}
isMultiSelectMode={isMultiSelectMode}
onToggleMultiSelect={index === 0 ? onToggleMultiSelect : undefined}
/>
Expand Down
Loading
Loading