diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx
index c99c9b7c..0b094674 100644
--- a/ui/src/components/IssuesList.tsx
+++ b/ui/src/components/IssuesList.tsx
@@ -1,6 +1,10 @@
import { useMemo, useState, useCallback } from "react";
import { Link } from "react-router-dom";
+import { useQuery } from "@tanstack/react-query";
import { useDialog } from "../context/DialogContext";
+import { useCompany } from "../context/CompanyContext";
+import { issuesApi } from "../api/issues";
+import { queryKeys } from "../lib/queryKeys";
import { groupBy } from "../lib/groupBy";
import { formatDate } from "../lib/utils";
import { StatusIcon } from "./StatusIcon";
@@ -11,7 +15,8 @@ import { Button } from "@/components/ui/button";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
-import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight } from "lucide-react";
+import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3 } from "lucide-react";
+import { KanbanBoard } from "./KanbanBoard";
import type { Issue } from "@paperclip/shared";
/* ── Helpers ── */
@@ -32,6 +37,7 @@ export type IssueViewState = {
sortField: "status" | "priority" | "title" | "created" | "updated";
sortDir: "asc" | "desc";
groupBy: "status" | "priority" | "assignee" | "none";
+ viewMode: "list" | "board";
};
const defaultViewState: IssueViewState = {
@@ -41,6 +47,7 @@ const defaultViewState: IssueViewState = {
sortField: "status",
sortDir: "asc",
groupBy: "status",
+ viewMode: "list",
};
const quickFilterPresets = [
@@ -215,6 +222,24 @@ export function IssuesList({
+ {/* View mode toggle */}
+
+
+
+
+
{/* Filter */}
@@ -335,85 +360,89 @@ export function IssuesList({
- {/* Sort */}
-
-
-
-
-
-
- {([
- ["status", "Status"],
- ["priority", "Priority"],
- ["title", "Title"],
- ["created", "Created"],
- ["updated", "Updated"],
- ] as const).map(([field, label]) => (
-
- ))}
-
-
-
+ {/* Sort (list view only) */}
+ {viewState.viewMode === "list" && (
+
+
+
+
+
+
+ {([
+ ["status", "Status"],
+ ["priority", "Priority"],
+ ["title", "Title"],
+ ["created", "Created"],
+ ["updated", "Updated"],
+ ] as const).map(([field, label]) => (
+
+ ))}
+
+
+
+ )}
- {/* Group */}
-
-
-
-
-
-
- {([
- ["status", "Status"],
- ["priority", "Priority"],
- ["assignee", "Assignee"],
- ["none", "None"],
- ] as const).map(([value, label]) => (
-
- ))}
-
-
-
+ {/* Group (list view only) */}
+ {viewState.viewMode === "list" && (
+
+
+
+
+
+
+ {([
+ ["status", "Status"],
+ ["priority", "Priority"],
+ ["assignee", "Assignee"],
+ ["none", "None"],
+ ] as const).map(([value, label]) => (
+
+ ))}
+
+
+
+ )}
{isLoading && Loading...
}
{error && {error.message}
}
- {!isLoading && filtered.length === 0 && (
+ {!isLoading && filtered.length === 0 && viewState.viewMode === "list" && (
)}
- {groupedContent.map((group) => (
-
- {group.label && (
-
-
-
-
- {group.label}
-
-
-
-
- )}
-
- {group.items.map((issue) => (
-
- {/* Spacer matching caret width so status icon aligns with group title */}
-
- { e.preventDefault(); e.stopPropagation(); }}>
- onUpdateIssue(issue.id, { status: s })}
- />
-
-
- {issue.identifier ?? issue.id.slice(0, 8)}
-
- {issue.title}
-
- {liveIssueIds?.has(issue.id) && (
-
-
-
-
-
- Live
-
- )}
- {issue.assigneeAgentId && (() => {
- const name = agentName(issue.assigneeAgentId);
- return name
- ?
- :
{issue.assigneeAgentId.slice(0, 8)};
- })()}
-
- {formatDate(issue.createdAt)}
+ {viewState.viewMode === "board" ? (
+
+ ) : (
+ groupedContent.map((group) => (
+
+ {group.label && (
+
+
+
+
+ {group.label}
-
-
- ))}
-
-
- ))}
+
+
+
+ )}
+
+ {group.items.map((issue) => (
+
+ {/* Spacer matching caret width so status icon aligns with group title */}
+
+ { e.preventDefault(); e.stopPropagation(); }}>
+ onUpdateIssue(issue.id, { status: s })}
+ />
+
+
+ {issue.identifier ?? issue.id.slice(0, 8)}
+
+ {issue.title}
+
+ {liveIssueIds?.has(issue.id) && (
+
+
+
+
+
+ Live
+
+ )}
+ {issue.assigneeAgentId && (() => {
+ const name = agentName(issue.assigneeAgentId);
+ return name
+ ?
+ : {issue.assigneeAgentId.slice(0, 8)};
+ })()}
+
+ {formatDate(issue.createdAt)}
+
+
+
+ ))}
+
+
+ ))
+ )}
);
}
diff --git a/ui/src/components/KanbanBoard.tsx b/ui/src/components/KanbanBoard.tsx
new file mode 100644
index 00000000..5b47ed83
--- /dev/null
+++ b/ui/src/components/KanbanBoard.tsx
@@ -0,0 +1,274 @@
+import { useMemo, useState } from "react";
+import { Link } from "react-router-dom";
+import {
+ DndContext,
+ DragOverlay,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragStartEvent,
+ type DragEndEvent,
+ type DragOverEvent,
+} from "@dnd-kit/core";
+import { useDroppable } from "@dnd-kit/core";
+import { CSS } from "@dnd-kit/utilities";
+import {
+ SortableContext,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { StatusIcon } from "./StatusIcon";
+import { PriorityIcon } from "./PriorityIcon";
+import { Identity } from "./Identity";
+import type { Issue } from "@paperclip/shared";
+
+const boardStatuses = [
+ "backlog",
+ "todo",
+ "in_progress",
+ "in_review",
+ "blocked",
+ "done",
+ "cancelled",
+];
+
+function statusLabel(status: string): string {
+ return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+interface Agent {
+ id: string;
+ name: string;
+}
+
+interface KanbanBoardProps {
+ issues: Issue[];
+ agents?: Agent[];
+ liveIssueIds?: Set;
+ onUpdateIssue: (id: string, data: Record) => void;
+}
+
+/* ── Droppable Column ── */
+
+function KanbanColumn({
+ status,
+ issues,
+ agents,
+ liveIssueIds,
+}: {
+ status: string;
+ issues: Issue[];
+ agents?: Agent[];
+ liveIssueIds?: Set;
+}) {
+ const { setNodeRef, isOver } = useDroppable({ id: status });
+
+ return (
+
+
+
+
+ {statusLabel(status)}
+
+
+ {issues.length}
+
+
+
+ i.id)}
+ strategy={verticalListSortingStrategy}
+ >
+ {issues.map((issue) => (
+
+ ))}
+
+
+
+ );
+}
+
+/* ── Draggable Card ── */
+
+function KanbanCard({
+ issue,
+ agents,
+ isLive,
+ isOverlay,
+}: {
+ issue: Issue;
+ agents?: Agent[];
+ isLive?: boolean;
+ isOverlay?: boolean;
+}) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: issue.id, data: { issue } });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ };
+
+ const agentName = (id: string | null) => {
+ if (!id || !agents) return null;
+ return agents.find((a) => a.id === id)?.name ?? null;
+ };
+
+ return (
+
+
{
+ // Prevent navigation during drag
+ if (isDragging) e.preventDefault();
+ }}
+ >
+
+
+ {issue.identifier ?? issue.id.slice(0, 8)}
+
+ {isLive && (
+
+
+
+
+ )}
+
+
{issue.title}
+
+
+ {issue.assigneeAgentId && (() => {
+ const name = agentName(issue.assigneeAgentId);
+ return name ? (
+
+ ) : (
+
+ {issue.assigneeAgentId.slice(0, 8)}
+
+ );
+ })()}
+
+
+
+ );
+}
+
+/* ── Main Board ── */
+
+export function KanbanBoard({
+ issues,
+ agents,
+ liveIssueIds,
+ onUpdateIssue,
+}: KanbanBoardProps) {
+ const [activeId, setActiveId] = useState(null);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
+ );
+
+ const columnIssues = useMemo(() => {
+ const grouped: Record = {};
+ for (const status of boardStatuses) {
+ grouped[status] = [];
+ }
+ for (const issue of issues) {
+ if (grouped[issue.status]) {
+ grouped[issue.status].push(issue);
+ }
+ }
+ return grouped;
+ }, [issues]);
+
+ const activeIssue = useMemo(
+ () => (activeId ? issues.find((i) => i.id === activeId) : null),
+ [activeId, issues]
+ );
+
+ function handleDragStart(event: DragStartEvent) {
+ setActiveId(event.active.id as string);
+ }
+
+ function handleDragEnd(event: DragEndEvent) {
+ setActiveId(null);
+ const { active, over } = event;
+ if (!over) return;
+
+ const issueId = active.id as string;
+ const issue = issues.find((i) => i.id === issueId);
+ if (!issue) return;
+
+ // Determine target status: the "over" could be a column id (status string)
+ // or another card's id. Find which column the "over" belongs to.
+ let targetStatus: string | null = null;
+
+ if (boardStatuses.includes(over.id as string)) {
+ targetStatus = over.id as string;
+ } else {
+ // It's a card - find which column it's in
+ const targetIssue = issues.find((i) => i.id === over.id);
+ if (targetIssue) {
+ targetStatus = targetIssue.status;
+ }
+ }
+
+ if (targetStatus && targetStatus !== issue.status) {
+ onUpdateIssue(issueId, { status: targetStatus });
+ }
+ }
+
+ function handleDragOver(_event: DragOverEvent) {
+ // Could be used for visual feedback; keeping simple for now
+ }
+
+ return (
+
+
+ {boardStatuses.map((status) => (
+
+ ))}
+
+
+ {activeIssue ? (
+
+ ) : null}
+
+
+ );
+}