diff --git a/apps/web/components/kanban/graph2-task-pipeline.tsx b/apps/web/components/kanban/graph2-task-pipeline.tsx
index 7c0e31ab0b..d9be6ed4b6 100644
--- a/apps/web/components/kanban/graph2-task-pipeline.tsx
+++ b/apps/web/components/kanban/graph2-task-pipeline.tsx
@@ -8,10 +8,12 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
+import { Checkbox } from "@kandev/ui/checkbox";
import { cn } from "@kandev/ui/lib/utils";
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog";
import { needsAction } from "@/lib/utils/needs-action";
+import { useAppStore } from "@/components/state-provider";
import { Graph2StepNode } from "./graph2-step-node";
import { Graph2Connector } from "./graph2-connector";
import type { Task } from "@/components/kanban-card";
@@ -46,6 +48,9 @@ export type Graph2TaskPipelineProps = {
isMoving?: boolean;
isDeleting?: boolean;
isArchiving?: boolean;
+ isSelected?: boolean;
+ onToggleSelect?: (taskId: string) => void;
+ isMultiSelectMode?: boolean;
};
function getStepPhase(index: number, currentStepIndex: number): "past" | "current" | "future" {
@@ -173,6 +178,69 @@ function TaskActions({
);
}
+function TaskButton({
+ task,
+ repoName,
+ isSelected,
+ onClick,
+}: {
+ task: Task;
+ repoName: string | undefined;
+ isSelected?: boolean;
+ onClick: () => void;
+}) {
+ const hasAction = needsAction(task);
+ const sessionCount = task.sessionCount ?? 0;
+ return (
+
+ );
+}
+
+function useTaskRepoName(task: Task): string | undefined {
+ const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
+ return useMemo(() => {
+ const primaryRepoId = task.repositories?.[0]?.repository_id;
+ if (!primaryRepoId) return undefined;
+ for (const repos of Object.values(repositoriesByWorkspace)) {
+ const repo = repos.find((r) => r.id === primaryRepoId);
+ if (repo) return repo.name;
+ }
+ return undefined;
+ }, [repositoriesByWorkspace, task.repositories]);
+}
+
export function Graph2TaskPipeline({
task,
steps,
@@ -184,43 +252,55 @@ export function Graph2TaskPipeline({
isMoving,
isDeleting,
isArchiving,
+ isSelected,
+ onToggleSelect,
+ isMultiSelectMode,
}: Graph2TaskPipelineProps) {
const currentStepIndex = useMemo(
() => steps.findIndex((s) => s.id === task.workflowStepId),
[steps, task.workflowStepId],
);
- const hasAction = needsAction(task);
- const sessionCount = task.sessionCount ?? 0;
+ const repoName = useTaskRepoName(task);
+ const showCheckbox = isMultiSelectMode || !!isSelected;
+
+ const handleTaskClick = () => {
+ if (isMultiSelectMode || isSelected) {
+ onToggleSelect?.(task.id);
+ return;
+ }
+ onOpenTask(task);
+ };
+
+ const handleCheckboxClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onToggleSelect?.(task.id);
+ };
return (
-
+
-
);
diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx
index 08302ecfbd..eff8fab1c9 100644
--- a/apps/web/components/kanban/kanban-header.tsx
+++ b/apps/web/components/kanban/kanban-header.tsx
@@ -181,6 +181,7 @@ function ViewToggleGroup({
diff --git a/apps/web/components/kanban/swimlane-graph2-content.tsx b/apps/web/components/kanban/swimlane-graph2-content.tsx
index 4a669a5199..8514526907 100644
--- a/apps/web/components/kanban/swimlane-graph2-content.tsx
+++ b/apps/web/components/kanban/swimlane-graph2-content.tsx
@@ -16,6 +16,9 @@ export function SwimlaneGraph2Content({
onMoveError,
deletingTaskId,
archivingTaskId,
+ selectedIds,
+ onToggleSelect,
+ isMultiSelectMode,
}: ViewContentProps) {
const { moveTask } = useSwimlaneMove(workflowId, {
onMoveError,
@@ -66,6 +69,9 @@ export function SwimlaneGraph2Content({
isMoving={movingTaskId === task.id}
isDeleting={deletingTaskId === task.id}
isArchiving={archivingTaskId === task.id}
+ isSelected={selectedIds?.has(task.id)}
+ onToggleSelect={onToggleSelect}
+ isMultiSelectMode={isMultiSelectMode}
/>
))}
diff --git a/apps/web/e2e/pages/kanban-page.ts b/apps/web/e2e/pages/kanban-page.ts
index 58e702804d..c79ac63872 100644
--- a/apps/web/e2e/pages/kanban-page.ts
+++ b/apps/web/e2e/pages/kanban-page.ts
@@ -13,6 +13,9 @@ export class KanbanPage {
readonly multiSelectToggle: Locator;
+ readonly viewTogglePipeline: Locator;
+ readonly viewToggleKanban: Locator;
+
constructor(private page: Page) {
this.board = page.getByTestId("kanban-board");
this.createTaskButton = page.getByTestId("create-task-button");
@@ -24,6 +27,8 @@ export class KanbanPage {
this.bulkClearButton = page.getByTestId("bulk-clear-selection");
this.bulkDeleteConfirm = page.getByTestId("bulk-delete-confirm");
this.bulkArchiveConfirm = page.getByTestId("bulk-archive-confirm");
+ this.viewTogglePipeline = page.getByTestId("view-toggle-pipeline");
+ this.viewToggleKanban = page.getByTestId("view-toggle-kanban");
}
async goto() {
@@ -128,4 +133,25 @@ export class KanbanPage {
await this.enableMultiSelect();
await this.taskSelectCheckbox(taskId).click();
}
+
+ pipelineTask(taskId: string): Locator {
+ return this.page.getByTestId(`pipeline-task-${taskId}`);
+ }
+
+ pipelineTaskRepoName(taskId: string): Locator {
+ return this.page.getByTestId(`pipeline-task-repo-${taskId}`);
+ }
+
+ async switchToPipelineView() {
+ await this.viewTogglePipeline.first().click();
+ // Wait for a pipeline-specific element — swimlane-container is shared with the kanban view.
+ await this.page.locator('[data-testid^="pipeline-task-"]').first().waitFor();
+ }
+
+ async selectPipelineTask(taskId: string) {
+ const row = this.pipelineTask(taskId);
+ await row.waitFor({ state: "visible" });
+ await this.enableMultiSelect();
+ await this.taskSelectCheckbox(taskId).click();
+ }
}
diff --git a/apps/web/e2e/tests/kanban/pipeline-view.spec.ts b/apps/web/e2e/tests/kanban/pipeline-view.spec.ts
new file mode 100644
index 0000000000..b467595d9e
--- /dev/null
+++ b/apps/web/e2e/tests/kanban/pipeline-view.spec.ts
@@ -0,0 +1,111 @@
+import { test, expect } from "../../fixtures/test-base";
+import { KanbanPage } from "../../pages/kanban-page";
+
+test.describe("Pipeline view", () => {
+ test.beforeEach(async ({ testPage }) => {
+ // The view toggle and multi-select toggle are only rendered on desktop layouts.
+ await testPage.setViewportSize({ width: 1280, height: 800 });
+ });
+
+ test("shows the linked repository name beneath the task title", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const task = await apiClient.createTask(seedData.workspaceId, "Pipeline Repo Task", {
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ });
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+ await kanban.switchToPipelineView();
+
+ const repoName = kanban.pipelineTaskRepoName(task.id);
+ await expect(repoName).toBeVisible();
+ // seedData seeds the repository with the default name from createRepository().
+ await expect(repoName).toHaveText("E2E Repo");
+ });
+
+ test("does not show a repo name when the task has no linked repository", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const task = await apiClient.createTask(seedData.workspaceId, "Pipeline No Repo Task", {
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ });
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+ await kanban.switchToPipelineView();
+
+ await expect(kanban.pipelineTask(task.id)).toBeVisible();
+ await expect(kanban.pipelineTaskRepoName(task.id)).toHaveCount(0);
+ });
+
+ test("multi-select checkbox appears and toolbar reflects the selection", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const [t1, t2] = await Promise.all([
+ apiClient.createTask(seedData.workspaceId, "Pipeline MS 1", {
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ }),
+ apiClient.createTask(seedData.workspaceId, "Pipeline MS 2", {
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ }),
+ ]);
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+ await kanban.switchToPipelineView();
+
+ // Checkbox is hidden until multi-select mode is enabled.
+ await expect(kanban.taskSelectCheckbox(t1.id)).toHaveCount(0);
+ await expect(kanban.multiSelectToolbar).not.toBeVisible();
+
+ await kanban.selectPipelineTask(t1.id);
+ await expect(kanban.taskSelectCheckbox(t1.id)).toBeVisible();
+ await expect(kanban.multiSelectToolbar).toBeVisible();
+ await expect(kanban.multiSelectToolbar).toContainText("1 selected");
+
+ // Once multi-select mode is on, every pipeline task exposes a checkbox.
+ await kanban.taskSelectCheckbox(t2.id).click();
+ await expect(kanban.multiSelectToolbar).toContainText("2 selected");
+ });
+
+ test("bulk delete from pipeline view removes the selected tasks", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const [t1, t2] = await Promise.all([
+ apiClient.createTask(seedData.workspaceId, "Pipeline Delete 1", {
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ }),
+ apiClient.createTask(seedData.workspaceId, "Pipeline Delete 2", {
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ }),
+ ]);
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+ await kanban.switchToPipelineView();
+
+ await kanban.selectPipelineTask(t1.id);
+ await kanban.taskSelectCheckbox(t2.id).click();
+ await expect(kanban.multiSelectToolbar).toContainText("2 selected");
+
+ await kanban.bulkDeleteButton.click();
+ await expect(kanban.bulkDeleteConfirm).toBeVisible();
+ await kanban.bulkDeleteConfirm.click();
+
+ await expect(kanban.pipelineTask(t1.id)).toHaveCount(0, { timeout: 10000 });
+ await expect(kanban.pipelineTask(t2.id)).toHaveCount(0);
+ await expect(kanban.multiSelectToolbar).not.toBeVisible();
+ });
+});