beads-kanban-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/.designs/beads-kanban-ui-bj0.md +73 -0
  2. package/.designs/beads-kanban-ui-qxq.md +144 -0
  3. package/.designs/epic-support.md +282 -0
  4. package/.env.local.example +2 -0
  5. package/.eslintrc.json +3 -0
  6. package/.gitattributes +3 -0
  7. package/.github/workflows/release.yml +123 -0
  8. package/.history/README_20260121193710.md +227 -0
  9. package/.history/README_20260121193918.md +227 -0
  10. package/.history/README_20260121193921.md +227 -0
  11. package/.history/README_20260121193933.md +227 -0
  12. package/.history/README_20260121193934.md +227 -0
  13. package/.history/README_20260121193944.md +227 -0
  14. package/.history/README_20260121193953.md +227 -0
  15. package/.history/src/app/page_20260121133429.tsx +134 -0
  16. package/.history/src/app/page_20260121133928.tsx +134 -0
  17. package/.history/src/app/page_20260121144850.tsx +138 -0
  18. package/.history/src/app/page_20260121144854.tsx +138 -0
  19. package/.history/src/app/page_20260121144858.tsx +138 -0
  20. package/.history/src/app/page_20260121144902.tsx +138 -0
  21. package/.history/src/app/page_20260121144906.tsx +138 -0
  22. package/.history/src/app/page_20260121144911.tsx +138 -0
  23. package/.history/src/app/page_20260121144928.tsx +138 -0
  24. package/.playwright-mcp/.playwright-mcp/morphing-dialog-wheel-scroll-fix.png +0 -0
  25. package/.playwright-mcp/beams-test.png +0 -0
  26. package/.playwright-mcp/card-verification.png +0 -0
  27. package/.playwright-mcp/design-doc-dialog-fix-verification.png +0 -0
  28. package/.playwright-mcp/dialog-width-test.png +0 -0
  29. package/.playwright-mcp/homepage.png +0 -0
  30. package/.playwright-mcp/morphing-dialog-expanded.png +0 -0
  31. package/.playwright-mcp/morphing-dialog-fixes-final.png +0 -0
  32. package/.playwright-mcp/morphing-dialog-open.png +0 -0
  33. package/.playwright-mcp/page-2026-01-21T14-08-31-529Z.png +0 -0
  34. package/.playwright-mcp/page-2026-01-21T14-09-23-431Z.png +0 -0
  35. package/.playwright-mcp/page-2026-01-21T14-10-28-773Z.png +0 -0
  36. package/.playwright-mcp/page-2026-01-21T14-10-47-432Z.png +0 -0
  37. package/.playwright-mcp/page-2026-01-21T14-11-12-350Z.png +0 -0
  38. package/.playwright-mcp/screenshot-after-click.png +0 -0
  39. package/.playwright-mcp/screenshot-after-dialog-click.png +0 -0
  40. package/.playwright-mcp/sheet-restored-after-dialog-close.png +0 -0
  41. package/.playwright-mcp/test-1-sheet-open-with-overlay.png +0 -0
  42. package/.playwright-mcp/test-2-morphing-dialog-with-overlay.png +0 -0
  43. package/.playwright-mcp/test-3-sheet-open-dark-overlay.png +0 -0
  44. package/.playwright-mcp/test-4-morphing-dialog-with-dark-overlay.png +0 -0
  45. package/.playwright-mcp/test-5-morphing-dialog-scrolled.png +0 -0
  46. package/.playwright-mcp/test-6-sheet-restored-after-dialog-close.png +0 -0
  47. package/.playwright-mcp/wheel-scroll-fixed.png +0 -0
  48. package/README.md +243 -0
  49. package/Screenshots/bead-detail.png +0 -0
  50. package/Screenshots/dashboard.png +0 -0
  51. package/Screenshots/kanban-board.png +0 -0
  52. package/components.json +27 -0
  53. package/logo/logo.svg +1 -0
  54. package/next.config.js +9 -0
  55. package/npm/README.md +37 -0
  56. package/npm/bin/cli.js +107 -0
  57. package/npm/package.json +20 -0
  58. package/npm/scripts/postinstall.js +132 -0
  59. package/package.json +62 -0
  60. package/postcss.config.js +6 -0
  61. package/public/logo.svg +1 -0
  62. package/restart.sh +5 -0
  63. package/server/Cargo.lock +1685 -0
  64. package/server/Cargo.toml +24 -0
  65. package/server/src/db.rs +570 -0
  66. package/server/src/main.rs +141 -0
  67. package/server/src/routes/beads.rs +413 -0
  68. package/server/src/routes/cli.rs +150 -0
  69. package/server/src/routes/fs.rs +360 -0
  70. package/server/src/routes/git.rs +169 -0
  71. package/server/src/routes/mod.rs +107 -0
  72. package/server/src/routes/projects.rs +177 -0
  73. package/server/src/routes/watch.rs +211 -0
  74. package/src/app/globals.css +101 -0
  75. package/src/app/layout.tsx +36 -0
  76. package/src/app/page.tsx +348 -0
  77. package/src/app/project/kanban-board.tsx +356 -0
  78. package/src/app/project/page.tsx +18 -0
  79. package/src/app/settings/page.tsx +224 -0
  80. package/src/components/Beams.css +5 -0
  81. package/src/components/Beams.jsx +307 -0
  82. package/src/components/Galaxy.css +5 -0
  83. package/src/components/Galaxy.jsx +333 -0
  84. package/src/components/activity-timeline.tsx +172 -0
  85. package/src/components/add-project-dialog.tsx +219 -0
  86. package/src/components/bead-card.tsx +196 -0
  87. package/src/components/bead-detail.tsx +306 -0
  88. package/src/components/color-picker.tsx +101 -0
  89. package/src/components/comment-input.tsx +155 -0
  90. package/src/components/comment-list.tsx +147 -0
  91. package/src/components/dependency-badge.tsx +106 -0
  92. package/src/components/design-doc-dialog.tsx +58 -0
  93. package/src/components/design-doc-preview.tsx +97 -0
  94. package/src/components/design-doc-viewer.tsx +199 -0
  95. package/src/components/editable-project-name.tsx +178 -0
  96. package/src/components/epic-card.tsx +263 -0
  97. package/src/components/folder-browser.tsx +273 -0
  98. package/src/components/footer.tsx +27 -0
  99. package/src/components/kanban/default.tsx +184 -0
  100. package/src/components/kanban-column.tsx +167 -0
  101. package/src/components/project-card.tsx +191 -0
  102. package/src/components/quick-filter-bar.tsx +279 -0
  103. package/src/components/scan-directory-dialog.tsx +368 -0
  104. package/src/components/status-donut.tsx +197 -0
  105. package/src/components/subtask-list.tsx +128 -0
  106. package/src/components/tag-picker.tsx +252 -0
  107. package/src/components/ui/.gitkeep +0 -0
  108. package/src/components/ui/alert-dialog.tsx +141 -0
  109. package/src/components/ui/avatar.tsx +67 -0
  110. package/src/components/ui/badge.tsx +230 -0
  111. package/src/components/ui/button.tsx +433 -0
  112. package/src/components/ui/card/index.tsx +24 -0
  113. package/src/components/ui/card/roiui-card.module.css +197 -0
  114. package/src/components/ui/card/roiui-card.tsx +154 -0
  115. package/src/components/ui/card/shadcn-card.tsx +76 -0
  116. package/src/components/ui/chart.tsx +369 -0
  117. package/src/components/ui/dialog.tsx +122 -0
  118. package/src/components/ui/dropdown-menu.tsx +201 -0
  119. package/src/components/ui/input.tsx +22 -0
  120. package/src/components/ui/kanban.tsx +522 -0
  121. package/src/components/ui/morphing-dialog.tsx +457 -0
  122. package/src/components/ui/popover.tsx +33 -0
  123. package/src/components/ui/progress.tsx +28 -0
  124. package/src/components/ui/scroll-area.tsx +48 -0
  125. package/src/components/ui/select.tsx +159 -0
  126. package/src/components/ui/separator.tsx +31 -0
  127. package/src/components/ui/sheet.tsx +142 -0
  128. package/src/components/ui/skeleton.tsx +15 -0
  129. package/src/components/ui/toast.tsx +129 -0
  130. package/src/components/ui/toaster.tsx +35 -0
  131. package/src/components/ui/tooltip.tsx +30 -0
  132. package/src/hooks/.gitkeep +0 -0
  133. package/src/hooks/use-bead-filters.ts +261 -0
  134. package/src/hooks/use-beads.ts +162 -0
  135. package/src/hooks/use-branch-statuses.ts +161 -0
  136. package/src/hooks/use-epics.ts +173 -0
  137. package/src/hooks/use-file-watcher.ts +111 -0
  138. package/src/hooks/use-keyboard-navigation.ts +282 -0
  139. package/src/hooks/use-project.ts +61 -0
  140. package/src/hooks/use-projects.ts +93 -0
  141. package/src/hooks/use-toast.ts +194 -0
  142. package/src/hooks/useClickOutside.tsx +26 -0
  143. package/src/lib/.gitkeep +0 -0
  144. package/src/lib/api.ts +186 -0
  145. package/src/lib/beads-parser.ts +252 -0
  146. package/src/lib/cli.ts +193 -0
  147. package/src/lib/db.ts +145 -0
  148. package/src/lib/design-doc.ts +74 -0
  149. package/src/lib/epic-parser.ts +242 -0
  150. package/src/lib/git.ts +102 -0
  151. package/src/lib/utils.ts +12 -0
  152. package/src/types/index.ts +107 -0
  153. package/tailwind.config.ts +85 -0
  154. package/tsconfig.json +26 -0
@@ -0,0 +1,273 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback, useRef } from "react";
4
+ import * as api from "@/lib/api";
5
+ import { ScrollArea } from "@/components/ui/scroll-area";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Badge } from "@/components/ui/badge";
8
+ import { cn } from "@/lib/utils";
9
+ import { Folder, FolderOpen, ChevronRight, Home } from "lucide-react";
10
+ import type { FsEntry } from "@/lib/api";
11
+
12
+ interface FolderBrowserProps {
13
+ currentPath: string;
14
+ onPathChange: (path: string) => void;
15
+ onSelectPath: (path: string, hasBeads: boolean) => void;
16
+ className?: string;
17
+ }
18
+
19
+ interface DirectoryEntry extends FsEntry {
20
+ hasBeads: boolean;
21
+ }
22
+
23
+ export function FolderBrowser({
24
+ currentPath,
25
+ onPathChange,
26
+ onSelectPath,
27
+ className,
28
+ }: FolderBrowserProps) {
29
+ const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
30
+ const [loading, setLoading] = useState(false);
31
+ const [error, setError] = useState<string | null>(null);
32
+ const [selectedIndex, setSelectedIndex] = useState<number>(-1);
33
+ const [currentPathHasBeads, setCurrentPathHasBeads] = useState(false);
34
+ const listRef = useRef<HTMLDivElement>(null);
35
+
36
+ // Load directories when path changes
37
+ useEffect(() => {
38
+ const loadDirectories = async () => {
39
+ if (!currentPath) return;
40
+
41
+ setLoading(true);
42
+ setError(null);
43
+ setSelectedIndex(-1);
44
+
45
+ try {
46
+ // Fetch directory contents and check if current path has beads in parallel
47
+ const [listResult, currentBeadsResult] = await Promise.all([
48
+ api.fs.list(currentPath),
49
+ api.fs.exists(`${currentPath.replace(/\/+$/, "")}/.beads`),
50
+ ]);
51
+
52
+ // Filter to only directories
53
+ const dirs = listResult.entries.filter((entry) => entry.isDirectory);
54
+
55
+ // Check which directories have .beads folders in parallel
56
+ const dirsWithBeadsStatus = await Promise.all(
57
+ dirs.map(async (dir) => {
58
+ const beadsPath = `${dir.path}/.beads`;
59
+ const result = await api.fs.exists(beadsPath);
60
+ return {
61
+ ...dir,
62
+ hasBeads: result.exists,
63
+ };
64
+ })
65
+ );
66
+
67
+ // Sort: directories with .beads first, then alphabetically
68
+ dirsWithBeadsStatus.sort((a, b) => {
69
+ if (a.hasBeads && !b.hasBeads) return -1;
70
+ if (!a.hasBeads && b.hasBeads) return 1;
71
+ return a.name.localeCompare(b.name);
72
+ });
73
+
74
+ setDirectories(dirsWithBeadsStatus);
75
+ setCurrentPathHasBeads(currentBeadsResult.exists);
76
+ } catch (err) {
77
+ console.error("Error loading directories:", err);
78
+ setError(err instanceof Error ? err.message : "Failed to load directories");
79
+ setDirectories([]);
80
+ } finally {
81
+ setLoading(false);
82
+ }
83
+ };
84
+
85
+ loadDirectories();
86
+ }, [currentPath]);
87
+
88
+ const navigateToDirectory = useCallback(
89
+ (path: string) => {
90
+ onPathChange(path);
91
+ },
92
+ [onPathChange]
93
+ );
94
+
95
+ const navigateUp = useCallback(() => {
96
+ const parentPath = currentPath.replace(/\/[^/]+\/?$/, "") || "/";
97
+ onPathChange(parentPath);
98
+ }, [currentPath, onPathChange]);
99
+
100
+ const navigateToHome = useCallback(() => {
101
+ // Get home directory - on macOS/Linux it's typically /Users/username or /home/username
102
+ const homePath =
103
+ typeof window !== "undefined"
104
+ ? "/Users" // Start at /Users on macOS for navigation
105
+ : "/";
106
+ onPathChange(homePath);
107
+ }, [onPathChange]);
108
+
109
+ // Build breadcrumb segments from current path
110
+ const pathSegments = currentPath.split("/").filter(Boolean);
111
+
112
+ const handleKeyDown = useCallback(
113
+ (e: React.KeyboardEvent) => {
114
+ if (directories.length === 0) return;
115
+
116
+ switch (e.key) {
117
+ case "ArrowDown":
118
+ e.preventDefault();
119
+ setSelectedIndex((prev) =>
120
+ prev < directories.length - 1 ? prev + 1 : prev
121
+ );
122
+ break;
123
+ case "ArrowUp":
124
+ e.preventDefault();
125
+ setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
126
+ break;
127
+ case "Enter":
128
+ e.preventDefault();
129
+ if (selectedIndex >= 0 && selectedIndex < directories.length) {
130
+ navigateToDirectory(directories[selectedIndex].path);
131
+ }
132
+ break;
133
+ case "Backspace":
134
+ if (currentPath !== "/") {
135
+ e.preventDefault();
136
+ navigateUp();
137
+ }
138
+ break;
139
+ }
140
+ },
141
+ [directories, selectedIndex, navigateToDirectory, navigateUp, currentPath]
142
+ );
143
+
144
+ const handleSelect = useCallback(() => {
145
+ onSelectPath(currentPath, currentPathHasBeads);
146
+ }, [currentPath, currentPathHasBeads, onSelectPath]);
147
+
148
+ return (
149
+ <div
150
+ className={cn("flex flex-col gap-3", className)}
151
+ onKeyDown={handleKeyDown}
152
+ tabIndex={0}
153
+ role="application"
154
+ aria-label="Folder browser"
155
+ >
156
+ {/* Breadcrumb navigation */}
157
+ <div className="flex items-center gap-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-2 py-1.5 text-sm">
158
+ <Button
159
+ variant="ghost"
160
+ size="xs"
161
+ mode="icon"
162
+ onClick={navigateToHome}
163
+ aria-label="Go to home directory"
164
+ className="shrink-0"
165
+ >
166
+ <Home />
167
+ </Button>
168
+ <ChevronRight className="size-3 shrink-0 text-zinc-500" />
169
+ {pathSegments.map((segment, index) => {
170
+ const segmentPath = "/" + pathSegments.slice(0, index + 1).join("/");
171
+ const isLast = index === pathSegments.length - 1;
172
+
173
+ return (
174
+ <div key={segmentPath} className="flex items-center gap-1">
175
+ <button
176
+ type="button"
177
+ onClick={() => navigateToDirectory(segmentPath)}
178
+ className={cn(
179
+ "rounded px-1 py-0.5 text-sm transition-colors hover:bg-zinc-700",
180
+ isLast ? "text-zinc-100" : "text-zinc-400"
181
+ )}
182
+ >
183
+ {segment}
184
+ </button>
185
+ {!isLast && (
186
+ <ChevronRight className="size-3 shrink-0 text-zinc-500" />
187
+ )}
188
+ </div>
189
+ );
190
+ })}
191
+ </div>
192
+
193
+ {/* Current path beads indicator */}
194
+ {currentPathHasBeads && (
195
+ <div className="flex items-center gap-2 rounded-md border border-purple-500/30 bg-purple-500/10 px-3 py-2">
196
+ <Badge variant="info" size="sm">
197
+ .beads found
198
+ </Badge>
199
+ <span className="text-xs text-zinc-400">
200
+ This folder contains a beads project
201
+ </span>
202
+ </div>
203
+ )}
204
+
205
+ {/* Directory list */}
206
+ <ScrollArea className="h-[300px] rounded-md border border-zinc-700 bg-zinc-800/50">
207
+ <div ref={listRef} className="p-2" role="listbox" aria-label="Directories">
208
+ {loading ? (
209
+ <div className="flex items-center justify-center py-8 text-zinc-500">
210
+ <div className="size-4 animate-spin rounded-full border-2 border-zinc-500 border-t-transparent" />
211
+ <span className="ml-2 text-sm">Loading...</span>
212
+ </div>
213
+ ) : error ? (
214
+ <div className="py-8 text-center text-sm text-red-400">{error}</div>
215
+ ) : directories.length === 0 ? (
216
+ <div className="py-8 text-center text-sm text-zinc-500">
217
+ No subdirectories found
218
+ </div>
219
+ ) : (
220
+ directories.map((dir, index) => (
221
+ <button
222
+ key={dir.path}
223
+ type="button"
224
+ role="option"
225
+ aria-selected={selectedIndex === index}
226
+ onClick={() => setSelectedIndex(index)}
227
+ onDoubleClick={() => navigateToDirectory(dir.path)}
228
+ className={cn(
229
+ "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
230
+ selectedIndex === index
231
+ ? "bg-zinc-700 text-zinc-100"
232
+ : "text-zinc-300 hover:bg-zinc-700/50",
233
+ dir.hasBeads && "border-l-2 border-purple-500"
234
+ )}
235
+ >
236
+ {selectedIndex === index ? (
237
+ <FolderOpen className="size-4 shrink-0 text-zinc-400" />
238
+ ) : (
239
+ <Folder
240
+ className={cn(
241
+ "size-4 shrink-0",
242
+ dir.hasBeads ? "text-purple-400" : "text-zinc-400"
243
+ )}
244
+ />
245
+ )}
246
+ <span className="truncate">{dir.name}</span>
247
+ {dir.hasBeads && (
248
+ <Badge variant="info" size="xs" className="ml-auto shrink-0">
249
+ .beads
250
+ </Badge>
251
+ )}
252
+ </button>
253
+ ))
254
+ )}
255
+ </div>
256
+ </ScrollArea>
257
+
258
+ {/* Keyboard hints */}
259
+ <div className="text-xs text-zinc-500">
260
+ Double-click or press Enter to open. Backspace to go up.
261
+ </div>
262
+
263
+ {/* Select button */}
264
+ <Button
265
+ onClick={handleSelect}
266
+ disabled={!currentPath || loading}
267
+ className="w-full"
268
+ >
269
+ Select This Folder
270
+ </Button>
271
+ </div>
272
+ );
273
+ }
@@ -0,0 +1,27 @@
1
+ export function Footer() {
2
+ return (
3
+ <footer className="border-t border-zinc-200 px-6 py-4">
4
+ <div className="flex flex-col items-center justify-center gap-2 text-sm text-zinc-500 sm:flex-row sm:gap-4">
5
+ <span>Beads Kanban UI</span>
6
+ <span className="hidden sm:inline">·</span>
7
+ <a
8
+ href="https://github.com/AvivK5498/beads-kanban-ui"
9
+ target="_blank"
10
+ rel="noopener noreferrer"
11
+ className="flex items-center gap-1.5 hover:text-zinc-900 transition-colors"
12
+ >
13
+ <svg
14
+ xmlns="http://www.w3.org/2000/svg"
15
+ width="16"
16
+ height="16"
17
+ viewBox="0 0 24 24"
18
+ fill="currentColor"
19
+ >
20
+ <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
21
+ </svg>
22
+ View on GitHub
23
+ </a>
24
+ </div>
25
+ </footer>
26
+ );
27
+ }
@@ -0,0 +1,184 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
5
+ import { Badge } from '@/components/ui/badge';
6
+ import { Button } from '@/components/ui/button';
7
+ import {
8
+ Kanban,
9
+ KanbanBoard,
10
+ KanbanColumn,
11
+ KanbanColumnContent,
12
+ KanbanColumnHandle,
13
+ KanbanItem,
14
+ KanbanItemHandle,
15
+ KanbanOverlay,
16
+ } from '@/components/ui/kanban';
17
+ import { GripVertical } from 'lucide-react';
18
+
19
+ interface Task {
20
+ id: string;
21
+ title: string;
22
+ priority: 'low' | 'medium' | 'high';
23
+ description?: string;
24
+ assignee?: string;
25
+ assigneeAvatar?: string;
26
+ dueDate?: string;
27
+ }
28
+
29
+ const COLUMN_TITLES: Record<string, string> = {
30
+ backlog: 'Backlog',
31
+ inProgress: 'In Progress',
32
+ review: 'Review',
33
+ done: 'Done',
34
+ };
35
+
36
+ interface TaskCardProps extends Omit<React.ComponentProps<typeof KanbanItem>, 'value' | 'children'> {
37
+ task: Task;
38
+ asHandle?: boolean;
39
+ }
40
+
41
+ function TaskCard({ task, asHandle, ...props }: TaskCardProps) {
42
+ const cardContent = (
43
+ <div className="rounded-md border bg-card p-3 shadow-xs">
44
+ <div className="flex flex-col gap-2.5">
45
+ <div className="flex items-center justify-between gap-2">
46
+ <span className="line-clamp-1 font-medium text-sm">{task.title}</span>
47
+ <Badge
48
+ variant={task.priority === 'high' ? 'destructive' : task.priority === 'medium' ? 'primary' : 'warning'}
49
+ appearance="outline"
50
+ className="pointer-events-none h-5 rounded-sm px-1.5 text-[11px] capitalize shrink-0"
51
+ >
52
+ {task.priority}
53
+ </Badge>
54
+ </div>
55
+ <div className="flex items-center justify-between text-muted-foreground text-xs">
56
+ {task.assignee && (
57
+ <div className="flex items-center gap-1">
58
+ <Avatar className="size-4">
59
+ <AvatarImage src={task.assigneeAvatar} />
60
+ <AvatarFallback>{task.assignee.charAt(0)}</AvatarFallback>
61
+ </Avatar>
62
+ <span className="line-clamp-1">{task.assignee}</span>
63
+ </div>
64
+ )}
65
+ {task.dueDate && <time className="text-[10px] tabular-nums whitespace-nowrap">{task.dueDate}</time>}
66
+ </div>
67
+ </div>
68
+ </div>
69
+ );
70
+
71
+ return (
72
+ <KanbanItem value={task.id} {...props}>
73
+ {asHandle ? <KanbanItemHandle>{cardContent}</KanbanItemHandle> : cardContent}
74
+ </KanbanItem>
75
+ );
76
+ }
77
+
78
+ interface TaskColumnProps extends Omit<React.ComponentProps<typeof KanbanColumn>, 'children'> {
79
+ tasks: Task[];
80
+ isOverlay?: boolean;
81
+ }
82
+
83
+ function TaskColumn({ value, tasks, isOverlay, ...props }: TaskColumnProps) {
84
+ return (
85
+ <KanbanColumn value={value} {...props} className="rounded-md border bg-card p-2.5 shadow-xs">
86
+ <div className="flex items-center justify-between mb-2.5">
87
+ <div className="flex items-center gap-2.5">
88
+ <span className="font-semibold text-sm">{COLUMN_TITLES[value]}</span>
89
+ <Badge variant="secondary">{tasks.length}</Badge>
90
+ </div>
91
+ <KanbanColumnHandle asChild>
92
+ <Button variant="dim" size="sm" mode="icon">
93
+ <GripVertical />
94
+ </Button>
95
+ </KanbanColumnHandle>
96
+ </div>
97
+ <KanbanColumnContent value={value} className="flex flex-col gap-2.5 p-0.5">
98
+ {tasks.map((task) => (
99
+ <TaskCard key={task.id} task={task} asHandle={!isOverlay} />
100
+ ))}
101
+ </KanbanColumnContent>
102
+ </KanbanColumn>
103
+ );
104
+ }
105
+
106
+ export default function Component() {
107
+ const [columns, setColumns] = React.useState<Record<string, Task[]>>({
108
+ backlog: [
109
+ {
110
+ id: '1',
111
+ title: 'Add authentication',
112
+ priority: 'high',
113
+ assignee: 'John Doe',
114
+ assigneeAvatar: 'https://randomuser.me/api/portraits/men/1.jpg',
115
+ dueDate: 'Jan 10, 2025',
116
+ },
117
+ {
118
+ id: '2',
119
+ title: 'Create API endpoints',
120
+ priority: 'medium',
121
+ assignee: 'Jane Smith',
122
+ assigneeAvatar: 'https://randomuser.me/api/portraits/women/2.jpg',
123
+ dueDate: 'Jan 15, 2025',
124
+ },
125
+ {
126
+ id: '3',
127
+ title: 'Write documentation',
128
+ priority: 'low',
129
+ assignee: 'Bob Johnson',
130
+ assigneeAvatar: 'https://randomuser.me/api/portraits/men/3.jpg',
131
+ dueDate: 'Jan 20, 2025',
132
+ },
133
+ ],
134
+ inProgress: [
135
+ {
136
+ id: '4',
137
+ title: 'Design system updates',
138
+ priority: 'high',
139
+ assignee: 'Alice Brown',
140
+ assigneeAvatar: 'https://randomuser.me/api/portraits/women/4.jpg',
141
+ dueDate: 'Aug 25, 2025',
142
+ },
143
+ {
144
+ id: '5',
145
+ title: 'Implement dark mode',
146
+ priority: 'medium',
147
+ assignee: 'Charlie Wilson',
148
+ assigneeAvatar: 'https://randomuser.me/api/portraits/men/5.jpg',
149
+ dueDate: 'Aug 25, 2025',
150
+ },
151
+ ],
152
+ done: [
153
+ {
154
+ id: '7',
155
+ title: 'Setup project',
156
+ priority: 'high',
157
+ assignee: 'Eve Davis',
158
+ assigneeAvatar: 'https://randomuser.me/api/portraits/women/6.jpg',
159
+ dueDate: 'Sep 25, 2025',
160
+ },
161
+ {
162
+ id: '8',
163
+ title: 'Initial commit',
164
+ priority: 'low',
165
+ assignee: 'Frank White',
166
+ assigneeAvatar: 'https://randomuser.me/api/portraits/men/7.jpg',
167
+ dueDate: 'Sep 20, 2025',
168
+ },
169
+ ],
170
+ });
171
+
172
+ return (
173
+ <Kanban value={columns} onValueChange={setColumns} getItemValue={(item) => item.id}>
174
+ <KanbanBoard className="grid auto-rows-fr grid-cols-3">
175
+ {Object.entries(columns).map(([columnValue, tasks]) => (
176
+ <TaskColumn key={columnValue} value={columnValue} tasks={tasks} />
177
+ ))}
178
+ </KanbanBoard>
179
+ <KanbanOverlay>
180
+ <div className="rounded-md bg-muted/60 size-full" />
181
+ </KanbanOverlay>
182
+ </Kanban>
183
+ );
184
+ }
@@ -0,0 +1,167 @@
1
+ "use client";
2
+
3
+ import { ScrollArea } from "@/components/ui/scroll-area";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { BeadCard } from "@/components/bead-card";
6
+ import { EpicCard } from "@/components/epic-card";
7
+ import { cn } from "@/lib/utils";
8
+ import type { Bead, BeadStatus, Epic } from "@/types";
9
+ import type { BranchStatus } from "@/lib/git";
10
+
11
+ export interface KanbanColumnProps {
12
+ status: BeadStatus;
13
+ title: string;
14
+ beads: Bead[];
15
+ /** All beads for resolving epic children */
16
+ allBeads: Bead[];
17
+ selectedBeadId?: string | null;
18
+ ticketNumbers?: Map<string, number>;
19
+ branchStatuses?: Record<string, BranchStatus>;
20
+ onSelectBead: (bead: Bead) => void;
21
+ onChildClick?: (child: Bead) => void;
22
+ onNavigateToDependency?: (beadId: string) => void;
23
+ /** Project root path for fetching design docs */
24
+ projectPath?: string;
25
+ }
26
+
27
+ /**
28
+ * Get accent border class for column header based on status
29
+ */
30
+ function getColumnAccentBorder(status: BeadStatus): string {
31
+ switch (status) {
32
+ case "open":
33
+ return "border-t-2 border-t-blue-500/60";
34
+ case "in_progress":
35
+ return "border-t-2 border-t-amber-500/60";
36
+ case "inreview":
37
+ return "border-t-2 border-t-purple-500/60";
38
+ case "closed":
39
+ return "border-t-2 border-t-green-500/60";
40
+ default:
41
+ return "border-t-2 border-t-zinc-500/60";
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Get header text color based on status
47
+ */
48
+ function getHeaderTextColor(status: BeadStatus): string {
49
+ switch (status) {
50
+ case "open":
51
+ return "text-blue-400";
52
+ case "in_progress":
53
+ return "text-amber-400";
54
+ case "inreview":
55
+ return "text-purple-400";
56
+ case "closed":
57
+ return "text-green-400";
58
+ default:
59
+ return "text-zinc-400";
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Get badge color class for count badge based on status (dark theme)
65
+ */
66
+ function getBadgeVariant(status: BeadStatus): string {
67
+ switch (status) {
68
+ case "open":
69
+ return "bg-blue-500/20 text-blue-400 border-blue-500/30 hover:bg-blue-500/20";
70
+ case "in_progress":
71
+ return "bg-amber-500/20 text-amber-400 border-amber-500/30 hover:bg-amber-500/20";
72
+ case "inreview":
73
+ return "bg-purple-500/20 text-purple-400 border-purple-500/30 hover:bg-purple-500/20";
74
+ case "closed":
75
+ return "bg-green-500/20 text-green-400 border-green-500/30 hover:bg-green-500/20";
76
+ default:
77
+ return "bg-zinc-500/20 text-zinc-400 border-zinc-500/30 hover:bg-zinc-500/20";
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Type guard to check if a bead is an epic
83
+ */
84
+ function isEpic(bead: Bead): bead is Epic {
85
+ return bead.issue_type === 'epic';
86
+ }
87
+
88
+ /**
89
+ * Reusable Kanban column component with header, count badge, and scrollable bead list
90
+ * Renders EpicCard for epics and BeadCard for standalone tasks
91
+ */
92
+ export function KanbanColumn({
93
+ status,
94
+ title,
95
+ beads,
96
+ allBeads,
97
+ selectedBeadId,
98
+ ticketNumbers,
99
+ branchStatuses = {},
100
+ onSelectBead,
101
+ onChildClick,
102
+ onNavigateToDependency,
103
+ projectPath,
104
+ }: KanbanColumnProps) {
105
+ return (
106
+ <div
107
+ className={cn(
108
+ "flex flex-col h-full min-h-0 rounded-lg",
109
+ "bg-zinc-900/30 border border-zinc-800/50"
110
+ )}
111
+ >
112
+ {/* Column Header - fixed height with colored accent border */}
113
+ <div className={cn(
114
+ "flex-shrink-0 flex items-center justify-between px-4 py-3 border-b border-zinc-800/50",
115
+ getColumnAccentBorder(status)
116
+ )}>
117
+ <h2 className={cn("font-semibold text-sm", getHeaderTextColor(status))}>{title}</h2>
118
+ <Badge
119
+ variant="secondary"
120
+ className={cn("text-xs px-2 py-0.5", getBadgeVariant(status))}
121
+ >
122
+ {beads.length}
123
+ </Badge>
124
+ </div>
125
+
126
+ {/* Scrollable Bead List */}
127
+ <ScrollArea className="flex-1 min-h-0 p-3">
128
+ <div className="space-y-3">
129
+ {beads.map((bead) => {
130
+ // Render EpicCard for epics, BeadCard for standalone tasks
131
+ if (isEpic(bead)) {
132
+ return (
133
+ <EpicCard
134
+ key={bead.id}
135
+ epic={bead}
136
+ allBeads={allBeads}
137
+ ticketNumber={ticketNumbers?.get(bead.id)}
138
+ isSelected={selectedBeadId === bead.id}
139
+ onSelect={onSelectBead}
140
+ onChildClick={onChildClick ?? onSelectBead}
141
+ onNavigateToDependency={onNavigateToDependency}
142
+ projectPath={projectPath}
143
+ />
144
+ );
145
+ }
146
+
147
+ return (
148
+ <BeadCard
149
+ key={bead.id}
150
+ bead={bead}
151
+ ticketNumber={ticketNumbers?.get(bead.id)}
152
+ isSelected={selectedBeadId === bead.id}
153
+ branchStatus={branchStatuses[bead.id]}
154
+ onSelect={onSelectBead}
155
+ />
156
+ );
157
+ })}
158
+ {beads.length === 0 && (
159
+ <div className="text-center py-8 text-zinc-500 text-sm">
160
+ No beads
161
+ </div>
162
+ )}
163
+ </div>
164
+ </ScrollArea>
165
+ </div>
166
+ );
167
+ }