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.
- package/.designs/beads-kanban-ui-bj0.md +73 -0
- package/.designs/beads-kanban-ui-qxq.md +144 -0
- package/.designs/epic-support.md +282 -0
- package/.env.local.example +2 -0
- package/.eslintrc.json +3 -0
- package/.gitattributes +3 -0
- package/.github/workflows/release.yml +123 -0
- package/.history/README_20260121193710.md +227 -0
- package/.history/README_20260121193918.md +227 -0
- package/.history/README_20260121193921.md +227 -0
- package/.history/README_20260121193933.md +227 -0
- package/.history/README_20260121193934.md +227 -0
- package/.history/README_20260121193944.md +227 -0
- package/.history/README_20260121193953.md +227 -0
- package/.history/src/app/page_20260121133429.tsx +134 -0
- package/.history/src/app/page_20260121133928.tsx +134 -0
- package/.history/src/app/page_20260121144850.tsx +138 -0
- package/.history/src/app/page_20260121144854.tsx +138 -0
- package/.history/src/app/page_20260121144858.tsx +138 -0
- package/.history/src/app/page_20260121144902.tsx +138 -0
- package/.history/src/app/page_20260121144906.tsx +138 -0
- package/.history/src/app/page_20260121144911.tsx +138 -0
- package/.history/src/app/page_20260121144928.tsx +138 -0
- package/.playwright-mcp/.playwright-mcp/morphing-dialog-wheel-scroll-fix.png +0 -0
- package/.playwright-mcp/beams-test.png +0 -0
- package/.playwright-mcp/card-verification.png +0 -0
- package/.playwright-mcp/design-doc-dialog-fix-verification.png +0 -0
- package/.playwright-mcp/dialog-width-test.png +0 -0
- package/.playwright-mcp/homepage.png +0 -0
- package/.playwright-mcp/morphing-dialog-expanded.png +0 -0
- package/.playwright-mcp/morphing-dialog-fixes-final.png +0 -0
- package/.playwright-mcp/morphing-dialog-open.png +0 -0
- package/.playwright-mcp/page-2026-01-21T14-08-31-529Z.png +0 -0
- package/.playwright-mcp/page-2026-01-21T14-09-23-431Z.png +0 -0
- package/.playwright-mcp/page-2026-01-21T14-10-28-773Z.png +0 -0
- package/.playwright-mcp/page-2026-01-21T14-10-47-432Z.png +0 -0
- package/.playwright-mcp/page-2026-01-21T14-11-12-350Z.png +0 -0
- package/.playwright-mcp/screenshot-after-click.png +0 -0
- package/.playwright-mcp/screenshot-after-dialog-click.png +0 -0
- package/.playwright-mcp/sheet-restored-after-dialog-close.png +0 -0
- package/.playwright-mcp/test-1-sheet-open-with-overlay.png +0 -0
- package/.playwright-mcp/test-2-morphing-dialog-with-overlay.png +0 -0
- package/.playwright-mcp/test-3-sheet-open-dark-overlay.png +0 -0
- package/.playwright-mcp/test-4-morphing-dialog-with-dark-overlay.png +0 -0
- package/.playwright-mcp/test-5-morphing-dialog-scrolled.png +0 -0
- package/.playwright-mcp/test-6-sheet-restored-after-dialog-close.png +0 -0
- package/.playwright-mcp/wheel-scroll-fixed.png +0 -0
- package/README.md +243 -0
- package/Screenshots/bead-detail.png +0 -0
- package/Screenshots/dashboard.png +0 -0
- package/Screenshots/kanban-board.png +0 -0
- package/components.json +27 -0
- package/logo/logo.svg +1 -0
- package/next.config.js +9 -0
- package/npm/README.md +37 -0
- package/npm/bin/cli.js +107 -0
- package/npm/package.json +20 -0
- package/npm/scripts/postinstall.js +132 -0
- package/package.json +62 -0
- package/postcss.config.js +6 -0
- package/public/logo.svg +1 -0
- package/restart.sh +5 -0
- package/server/Cargo.lock +1685 -0
- package/server/Cargo.toml +24 -0
- package/server/src/db.rs +570 -0
- package/server/src/main.rs +141 -0
- package/server/src/routes/beads.rs +413 -0
- package/server/src/routes/cli.rs +150 -0
- package/server/src/routes/fs.rs +360 -0
- package/server/src/routes/git.rs +169 -0
- package/server/src/routes/mod.rs +107 -0
- package/server/src/routes/projects.rs +177 -0
- package/server/src/routes/watch.rs +211 -0
- package/src/app/globals.css +101 -0
- package/src/app/layout.tsx +36 -0
- package/src/app/page.tsx +348 -0
- package/src/app/project/kanban-board.tsx +356 -0
- package/src/app/project/page.tsx +18 -0
- package/src/app/settings/page.tsx +224 -0
- package/src/components/Beams.css +5 -0
- package/src/components/Beams.jsx +307 -0
- package/src/components/Galaxy.css +5 -0
- package/src/components/Galaxy.jsx +333 -0
- package/src/components/activity-timeline.tsx +172 -0
- package/src/components/add-project-dialog.tsx +219 -0
- package/src/components/bead-card.tsx +196 -0
- package/src/components/bead-detail.tsx +306 -0
- package/src/components/color-picker.tsx +101 -0
- package/src/components/comment-input.tsx +155 -0
- package/src/components/comment-list.tsx +147 -0
- package/src/components/dependency-badge.tsx +106 -0
- package/src/components/design-doc-dialog.tsx +58 -0
- package/src/components/design-doc-preview.tsx +97 -0
- package/src/components/design-doc-viewer.tsx +199 -0
- package/src/components/editable-project-name.tsx +178 -0
- package/src/components/epic-card.tsx +263 -0
- package/src/components/folder-browser.tsx +273 -0
- package/src/components/footer.tsx +27 -0
- package/src/components/kanban/default.tsx +184 -0
- package/src/components/kanban-column.tsx +167 -0
- package/src/components/project-card.tsx +191 -0
- package/src/components/quick-filter-bar.tsx +279 -0
- package/src/components/scan-directory-dialog.tsx +368 -0
- package/src/components/status-donut.tsx +197 -0
- package/src/components/subtask-list.tsx +128 -0
- package/src/components/tag-picker.tsx +252 -0
- package/src/components/ui/.gitkeep +0 -0
- package/src/components/ui/alert-dialog.tsx +141 -0
- package/src/components/ui/avatar.tsx +67 -0
- package/src/components/ui/badge.tsx +230 -0
- package/src/components/ui/button.tsx +433 -0
- package/src/components/ui/card/index.tsx +24 -0
- package/src/components/ui/card/roiui-card.module.css +197 -0
- package/src/components/ui/card/roiui-card.tsx +154 -0
- package/src/components/ui/card/shadcn-card.tsx +76 -0
- package/src/components/ui/chart.tsx +369 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/dropdown-menu.tsx +201 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/kanban.tsx +522 -0
- package/src/components/ui/morphing-dialog.tsx +457 -0
- package/src/components/ui/popover.tsx +33 -0
- package/src/components/ui/progress.tsx +28 -0
- package/src/components/ui/scroll-area.tsx +48 -0
- package/src/components/ui/select.tsx +159 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/sheet.tsx +142 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/toast.tsx +129 -0
- package/src/components/ui/toaster.tsx +35 -0
- package/src/components/ui/tooltip.tsx +30 -0
- package/src/hooks/.gitkeep +0 -0
- package/src/hooks/use-bead-filters.ts +261 -0
- package/src/hooks/use-beads.ts +162 -0
- package/src/hooks/use-branch-statuses.ts +161 -0
- package/src/hooks/use-epics.ts +173 -0
- package/src/hooks/use-file-watcher.ts +111 -0
- package/src/hooks/use-keyboard-navigation.ts +282 -0
- package/src/hooks/use-project.ts +61 -0
- package/src/hooks/use-projects.ts +93 -0
- package/src/hooks/use-toast.ts +194 -0
- package/src/hooks/useClickOutside.tsx +26 -0
- package/src/lib/.gitkeep +0 -0
- package/src/lib/api.ts +186 -0
- package/src/lib/beads-parser.ts +252 -0
- package/src/lib/cli.ts +193 -0
- package/src/lib/db.ts +145 -0
- package/src/lib/design-doc.ts +74 -0
- package/src/lib/epic-parser.ts +242 -0
- package/src/lib/git.ts +102 -0
- package/src/lib/utils.ts +12 -0
- package/src/types/index.ts +107 -0
- package/tailwind.config.ts +85 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { formatDistanceToNow } from "date-fns";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Input } from "@/components/ui/input";
|
|
8
|
+
import { beads as beadsApi } from "@/lib/api";
|
|
9
|
+
import type { Comment } from "@/types";
|
|
10
|
+
|
|
11
|
+
export interface CommentListProps {
|
|
12
|
+
comments: Comment[];
|
|
13
|
+
beadId: string;
|
|
14
|
+
projectPath: string;
|
|
15
|
+
onCommentAdded?: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Format a date string to relative time (e.g., "2 hours ago")
|
|
20
|
+
*/
|
|
21
|
+
function formatRelativeTime(dateString: string): string {
|
|
22
|
+
try {
|
|
23
|
+
const date = new Date(dateString);
|
|
24
|
+
return formatDistanceToNow(date, { addSuffix: true });
|
|
25
|
+
} catch {
|
|
26
|
+
return dateString;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Single comment card component
|
|
32
|
+
*/
|
|
33
|
+
function CommentCard({ comment }: { comment: Comment }) {
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
className={cn(
|
|
37
|
+
"rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 space-y-1.5"
|
|
38
|
+
)}
|
|
39
|
+
>
|
|
40
|
+
{/* Author and timestamp */}
|
|
41
|
+
<div className="flex items-center gap-2 text-sm">
|
|
42
|
+
<span className="font-semibold text-zinc-200">{comment.author}</span>
|
|
43
|
+
<span className="text-zinc-500">
|
|
44
|
+
{formatRelativeTime(comment.created_at)}
|
|
45
|
+
</span>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
{/* Comment text */}
|
|
49
|
+
<p className="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap">
|
|
50
|
+
{comment.text}
|
|
51
|
+
</p>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Comments list component displaying all comments for a bead
|
|
58
|
+
*/
|
|
59
|
+
export function CommentList({
|
|
60
|
+
comments,
|
|
61
|
+
beadId,
|
|
62
|
+
projectPath,
|
|
63
|
+
onCommentAdded,
|
|
64
|
+
}: CommentListProps) {
|
|
65
|
+
const [newComment, setNewComment] = useState("");
|
|
66
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
67
|
+
const [error, setError] = useState<string | null>(null);
|
|
68
|
+
|
|
69
|
+
const handleAddComment = async () => {
|
|
70
|
+
if (!newComment.trim()) return;
|
|
71
|
+
|
|
72
|
+
setIsSubmitting(true);
|
|
73
|
+
setError(null);
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
// Get author from localStorage or use default
|
|
77
|
+
const author = localStorage.getItem("beads-author") || "user";
|
|
78
|
+
await beadsApi.addComment(projectPath, beadId, newComment.trim(), author);
|
|
79
|
+
setNewComment("");
|
|
80
|
+
onCommentAdded?.();
|
|
81
|
+
} catch (err) {
|
|
82
|
+
setError(err instanceof Error ? err.message : "Failed to add comment");
|
|
83
|
+
} finally {
|
|
84
|
+
setIsSubmitting(false);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
89
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
handleAddComment();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className="space-y-4">
|
|
97
|
+
{/* Header */}
|
|
98
|
+
<div className="flex items-center gap-2">
|
|
99
|
+
<h3 className="font-semibold text-sm">
|
|
100
|
+
Comments ({comments.length})
|
|
101
|
+
</h3>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{/* Divider */}
|
|
105
|
+
<div className="border-t" />
|
|
106
|
+
|
|
107
|
+
{/* Comments or empty state */}
|
|
108
|
+
{comments.length === 0 ? (
|
|
109
|
+
<p className="text-sm text-zinc-500 py-4 text-center">
|
|
110
|
+
No comments yet
|
|
111
|
+
</p>
|
|
112
|
+
) : (
|
|
113
|
+
<div className="space-y-3">
|
|
114
|
+
{comments.map((comment) => (
|
|
115
|
+
<CommentCard key={comment.id} comment={comment} />
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
{/* Add Comment Form */}
|
|
121
|
+
<div className="pt-2 space-y-2">
|
|
122
|
+
<div className="flex gap-2">
|
|
123
|
+
<Input
|
|
124
|
+
type="text"
|
|
125
|
+
placeholder="Add a comment…"
|
|
126
|
+
aria-label="Add a comment"
|
|
127
|
+
value={newComment}
|
|
128
|
+
onChange={(e) => setNewComment(e.target.value)}
|
|
129
|
+
onKeyDown={handleKeyDown}
|
|
130
|
+
disabled={isSubmitting}
|
|
131
|
+
className="flex-1"
|
|
132
|
+
/>
|
|
133
|
+
<Button
|
|
134
|
+
onClick={handleAddComment}
|
|
135
|
+
disabled={isSubmitting || !newComment.trim()}
|
|
136
|
+
size="sm"
|
|
137
|
+
>
|
|
138
|
+
{isSubmitting ? "Adding…" : "Add"}
|
|
139
|
+
</Button>
|
|
140
|
+
</div>
|
|
141
|
+
{error && (
|
|
142
|
+
<p className="text-sm text-destructive">{error}</p>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Badge } from "@/components/ui/badge";
|
|
4
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import { AlertCircle, Lock } from "lucide-react";
|
|
7
|
+
|
|
8
|
+
export interface DependencyBadgeProps {
|
|
9
|
+
/** Bead IDs that this task depends on (blockers) */
|
|
10
|
+
deps?: string[];
|
|
11
|
+
/** Bead IDs that depend on this task (this task blocks them) */
|
|
12
|
+
blockers?: string[];
|
|
13
|
+
/** Callback when clicking on a dependency to navigate */
|
|
14
|
+
onNavigate?: (beadId: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Shows blocked/blocking status with tooltip
|
|
19
|
+
* Red badge if this task is blocked (has unresolved deps)
|
|
20
|
+
* Orange badge if this task blocks others
|
|
21
|
+
*/
|
|
22
|
+
export function DependencyBadge({ deps, blockers, onNavigate }: DependencyBadgeProps) {
|
|
23
|
+
// Handle null values from data (default params only work for undefined)
|
|
24
|
+
const safeDeps = deps ?? [];
|
|
25
|
+
const safeBlockers = blockers ?? [];
|
|
26
|
+
const isBlocked = safeDeps.length > 0;
|
|
27
|
+
const isBlocking = safeBlockers.length > 0;
|
|
28
|
+
|
|
29
|
+
if (!isBlocked && !isBlocking) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Show blocked status with priority
|
|
34
|
+
if (isBlocked) {
|
|
35
|
+
return (
|
|
36
|
+
<TooltipProvider>
|
|
37
|
+
<Tooltip>
|
|
38
|
+
<TooltipTrigger asChild>
|
|
39
|
+
<Badge
|
|
40
|
+
variant="destructive"
|
|
41
|
+
className="text-[10px] px-1.5 py-0 cursor-help"
|
|
42
|
+
>
|
|
43
|
+
<Lock className="h-3 w-3 mr-0.5" aria-hidden="true" />
|
|
44
|
+
BLOCKED
|
|
45
|
+
</Badge>
|
|
46
|
+
</TooltipTrigger>
|
|
47
|
+
<TooltipContent side="top" className="max-w-xs">
|
|
48
|
+
<div className="space-y-1">
|
|
49
|
+
<p className="font-semibold">Blocked by:</p>
|
|
50
|
+
{safeDeps.map((depId) => (
|
|
51
|
+
<button
|
|
52
|
+
key={depId}
|
|
53
|
+
onClick={(e) => {
|
|
54
|
+
e.stopPropagation();
|
|
55
|
+
onNavigate?.(depId);
|
|
56
|
+
}}
|
|
57
|
+
aria-label={`Navigate to blocker ${depId}`}
|
|
58
|
+
className="block text-left hover:underline w-full"
|
|
59
|
+
>
|
|
60
|
+
{depId}
|
|
61
|
+
</button>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
</TooltipContent>
|
|
65
|
+
</Tooltip>
|
|
66
|
+
</TooltipProvider>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Show blocking status
|
|
71
|
+
return (
|
|
72
|
+
<TooltipProvider>
|
|
73
|
+
<Tooltip>
|
|
74
|
+
<TooltipTrigger asChild>
|
|
75
|
+
<Badge
|
|
76
|
+
className={cn(
|
|
77
|
+
"text-[10px] px-1.5 py-0 cursor-help",
|
|
78
|
+
"bg-orange-500 text-white hover:bg-orange-500/80 border-transparent"
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
<AlertCircle className="h-3 w-3 mr-0.5" aria-hidden="true" />
|
|
82
|
+
BLOCKING
|
|
83
|
+
</Badge>
|
|
84
|
+
</TooltipTrigger>
|
|
85
|
+
<TooltipContent side="top" className="max-w-xs">
|
|
86
|
+
<div className="space-y-1">
|
|
87
|
+
<p className="font-semibold">Blocking:</p>
|
|
88
|
+
{safeBlockers.map((blockerId) => (
|
|
89
|
+
<button
|
|
90
|
+
key={blockerId}
|
|
91
|
+
onClick={(e) => {
|
|
92
|
+
e.stopPropagation();
|
|
93
|
+
onNavigate?.(blockerId);
|
|
94
|
+
}}
|
|
95
|
+
aria-label={`Navigate to blocked task ${blockerId}`}
|
|
96
|
+
className="block text-left hover:underline w-full"
|
|
97
|
+
>
|
|
98
|
+
{blockerId}
|
|
99
|
+
</button>
|
|
100
|
+
))}
|
|
101
|
+
</div>
|
|
102
|
+
</TooltipContent>
|
|
103
|
+
</Tooltip>
|
|
104
|
+
</TooltipProvider>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import ReactMarkdown from "react-markdown";
|
|
4
|
+
import rehypeHighlight from "rehype-highlight";
|
|
5
|
+
import {
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
} from "@/components/ui/dialog";
|
|
11
|
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
12
|
+
import { designDocProseClasses } from "@/lib/design-doc";
|
|
13
|
+
import { FileText } from "lucide-react";
|
|
14
|
+
import "highlight.js/styles/github-dark.css";
|
|
15
|
+
|
|
16
|
+
export interface DesignDocDialogProps {
|
|
17
|
+
/** Whether dialog is open */
|
|
18
|
+
open: boolean;
|
|
19
|
+
/** Callback when dialog state changes */
|
|
20
|
+
onOpenChange: (open: boolean) => void;
|
|
21
|
+
/** Markdown content to display */
|
|
22
|
+
content: string;
|
|
23
|
+
/** Epic ID for display in title */
|
|
24
|
+
epicId: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Full-screen design document dialog with markdown rendering
|
|
29
|
+
* Shows complete design doc at 60% viewport width with syntax highlighting
|
|
30
|
+
*/
|
|
31
|
+
export function DesignDocDialog({
|
|
32
|
+
open,
|
|
33
|
+
onOpenChange,
|
|
34
|
+
content,
|
|
35
|
+
epicId,
|
|
36
|
+
}: DesignDocDialogProps) {
|
|
37
|
+
return (
|
|
38
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
39
|
+
<DialogContent className="w-[60vw] max-w-[60vw] max-h-[80vh] p-0 bg-zinc-900 border-zinc-800">
|
|
40
|
+
<DialogHeader className="px-6 pt-6 pb-3 border-b border-zinc-800">
|
|
41
|
+
<div className="flex items-center gap-2">
|
|
42
|
+
<FileText className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
|
43
|
+
<DialogTitle className="text-base font-semibold">
|
|
44
|
+
Design Document - {epicId}
|
|
45
|
+
</DialogTitle>
|
|
46
|
+
</div>
|
|
47
|
+
</DialogHeader>
|
|
48
|
+
<ScrollArea className="h-[calc(80vh-5rem)]">
|
|
49
|
+
<div className={`p-6 ${designDocProseClasses}`}>
|
|
50
|
+
<ReactMarkdown rehypePlugins={[rehypeHighlight]}>
|
|
51
|
+
{content}
|
|
52
|
+
</ReactMarkdown>
|
|
53
|
+
</div>
|
|
54
|
+
</ScrollArea>
|
|
55
|
+
</DialogContent>
|
|
56
|
+
</Dialog>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { fetchDesignDoc, truncateMarkdownToPlainText } from "@/lib/design-doc";
|
|
6
|
+
import { DesignDocDialog } from "@/components/design-doc-dialog";
|
|
7
|
+
import { Loader2, FileText, AlertCircle } from "lucide-react";
|
|
8
|
+
|
|
9
|
+
export interface DesignDocPreviewProps {
|
|
10
|
+
/** Path to design doc (e.g., ".designs/BD-001.md") */
|
|
11
|
+
designDocPath: string;
|
|
12
|
+
/** Epic ID for display */
|
|
13
|
+
epicId: string;
|
|
14
|
+
/** Project root path (absolute) */
|
|
15
|
+
projectPath: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Design doc preview component with collapsible preview and full document dialog
|
|
20
|
+
* Shows truncated plain text preview (~180 chars) with "View Full Document" button
|
|
21
|
+
*/
|
|
22
|
+
export function DesignDocPreview({
|
|
23
|
+
designDocPath,
|
|
24
|
+
epicId,
|
|
25
|
+
projectPath,
|
|
26
|
+
}: DesignDocPreviewProps) {
|
|
27
|
+
const [content, setContent] = useState<string>("");
|
|
28
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
29
|
+
const [error, setError] = useState<string | null>(null);
|
|
30
|
+
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const loadDoc = async () => {
|
|
34
|
+
try {
|
|
35
|
+
setIsLoading(true);
|
|
36
|
+
setError(null);
|
|
37
|
+
const docContent = await fetchDesignDoc(designDocPath, projectPath);
|
|
38
|
+
setContent(docContent);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
setError(err instanceof Error ? err.message : "Failed to load design doc");
|
|
41
|
+
} finally {
|
|
42
|
+
setIsLoading(false);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
void loadDoc();
|
|
47
|
+
}, [designDocPath, projectPath]);
|
|
48
|
+
|
|
49
|
+
if (isLoading) {
|
|
50
|
+
return (
|
|
51
|
+
<div className="flex items-center gap-2 text-muted-foreground text-xs py-2">
|
|
52
|
+
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
|
|
53
|
+
<span>Loading design document…</span>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (error) {
|
|
59
|
+
return (
|
|
60
|
+
<div className="flex items-center gap-2 text-destructive text-xs py-2">
|
|
61
|
+
<AlertCircle className="h-3 w-3" aria-hidden="true" />
|
|
62
|
+
<span>{error}</span>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const preview = truncateMarkdownToPlainText(content, 180);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<>
|
|
71
|
+
<div className="space-y-2">
|
|
72
|
+
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
73
|
+
{preview}
|
|
74
|
+
</p>
|
|
75
|
+
<Button
|
|
76
|
+
variant="outline"
|
|
77
|
+
size="sm"
|
|
78
|
+
onClick={(e) => {
|
|
79
|
+
e.stopPropagation();
|
|
80
|
+
setIsDialogOpen(true);
|
|
81
|
+
}}
|
|
82
|
+
className="text-xs h-7"
|
|
83
|
+
>
|
|
84
|
+
<FileText className="h-3 w-3 mr-1.5" aria-hidden="true" />
|
|
85
|
+
View Full Document
|
|
86
|
+
</Button>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<DesignDocDialog
|
|
90
|
+
open={isDialogOpen}
|
|
91
|
+
onOpenChange={setIsDialogOpen}
|
|
92
|
+
content={content}
|
|
93
|
+
epicId={epicId}
|
|
94
|
+
/>
|
|
95
|
+
</>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from "react";
|
|
4
|
+
import ReactMarkdown from "react-markdown";
|
|
5
|
+
import rehypeHighlight from "rehype-highlight";
|
|
6
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { Badge } from "@/components/ui/badge";
|
|
8
|
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
9
|
+
import { cn } from "@/lib/utils";
|
|
10
|
+
import { FileText, Loader2 } from "lucide-react";
|
|
11
|
+
import {
|
|
12
|
+
MorphingDialog,
|
|
13
|
+
MorphingDialogTrigger,
|
|
14
|
+
MorphingDialogContent,
|
|
15
|
+
MorphingDialogContainer,
|
|
16
|
+
MorphingDialogClose,
|
|
17
|
+
MorphingDialogTitle,
|
|
18
|
+
MorphingDialogDescription,
|
|
19
|
+
} from "@/components/ui/morphing-dialog";
|
|
20
|
+
import "highlight.js/styles/github-dark.css";
|
|
21
|
+
|
|
22
|
+
const API_BASE = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3008';
|
|
23
|
+
|
|
24
|
+
export interface DesignDocViewerProps {
|
|
25
|
+
/** Path to design doc (e.g., ".designs/{EPIC_ID}.md") */
|
|
26
|
+
designDocPath: string;
|
|
27
|
+
/** Epic ID for display */
|
|
28
|
+
epicId: string;
|
|
29
|
+
/** Project root path (absolute) */
|
|
30
|
+
projectPath: string;
|
|
31
|
+
/** Callback when fullscreen state changes */
|
|
32
|
+
onFullScreenChange?: (isFullScreen: boolean) => void;
|
|
33
|
+
/** Whether the dialog should start in open state */
|
|
34
|
+
defaultOpen?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Fetch design doc content from API
|
|
39
|
+
*/
|
|
40
|
+
async function fetchDesignDoc(path: string, projectPath: string): Promise<string> {
|
|
41
|
+
const encodedPath = encodeURIComponent(path);
|
|
42
|
+
const encodedProjectPath = encodeURIComponent(projectPath);
|
|
43
|
+
const response = await fetch(
|
|
44
|
+
`${API_BASE}/api/fs/read?path=${encodedPath}&project_path=${encodedProjectPath}`
|
|
45
|
+
);
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new Error('Failed to fetch design doc: ' + response.statusText);
|
|
48
|
+
}
|
|
49
|
+
const data = await response.json();
|
|
50
|
+
return data.content || '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Prose styles for markdown rendering */
|
|
54
|
+
const proseStyles = cn(
|
|
55
|
+
"prose prose-sm dark:prose-invert max-w-none",
|
|
56
|
+
"prose-headings:scroll-mt-20",
|
|
57
|
+
"prose-pre:bg-zinc-900 prose-pre:text-zinc-100",
|
|
58
|
+
"prose-code:text-sm prose-code:bg-zinc-100 dark:prose-code:bg-zinc-800",
|
|
59
|
+
"prose-code:px-1 prose-code:py-0.5 prose-code:rounded"
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Markdown renderer for design docs with syntax highlighting
|
|
64
|
+
* Uses MorphingDialog for smooth expand/collapse animation
|
|
65
|
+
*/
|
|
66
|
+
export function DesignDocViewer({ designDocPath, epicId, projectPath, onFullScreenChange, defaultOpen }: DesignDocViewerProps) {
|
|
67
|
+
const [content, setContent] = useState<string>("");
|
|
68
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
69
|
+
const [error, setError] = useState<string | null>(null);
|
|
70
|
+
|
|
71
|
+
const handleOpenChange = useCallback((isOpen: boolean) => {
|
|
72
|
+
onFullScreenChange?.(isOpen);
|
|
73
|
+
}, [onFullScreenChange]);
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
const loadDoc = async () => {
|
|
77
|
+
try {
|
|
78
|
+
setIsLoading(true);
|
|
79
|
+
setError(null);
|
|
80
|
+
const docContent = await fetchDesignDoc(designDocPath, projectPath);
|
|
81
|
+
setContent(docContent);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
setError(err instanceof Error ? err.message : "Failed to load design doc");
|
|
84
|
+
} finally {
|
|
85
|
+
setIsLoading(false);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
loadDoc();
|
|
90
|
+
}, [designDocPath, projectPath]);
|
|
91
|
+
|
|
92
|
+
if (isLoading) {
|
|
93
|
+
return (
|
|
94
|
+
<Card>
|
|
95
|
+
<CardContent className="p-6">
|
|
96
|
+
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
|
97
|
+
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
|
98
|
+
<span className="text-sm">Loading design document…</span>
|
|
99
|
+
</div>
|
|
100
|
+
</CardContent>
|
|
101
|
+
</Card>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (error) {
|
|
106
|
+
return (
|
|
107
|
+
<Card>
|
|
108
|
+
<CardContent className="p-6">
|
|
109
|
+
<div className="text-sm text-destructive">
|
|
110
|
+
<p className="font-semibold">Error loading design document</p>
|
|
111
|
+
<p className="text-xs mt-1">{error}</p>
|
|
112
|
+
</div>
|
|
113
|
+
</CardContent>
|
|
114
|
+
</Card>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Extract first heading or first line as preview
|
|
119
|
+
const firstLine = content.split('\n').find(line => line.trim()) || 'Design Document';
|
|
120
|
+
const previewText = firstLine.replace(/^#+\s*/, '').slice(0, 100);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<MorphingDialog
|
|
124
|
+
transition={{
|
|
125
|
+
type: 'spring',
|
|
126
|
+
stiffness: 200,
|
|
127
|
+
damping: 24,
|
|
128
|
+
}}
|
|
129
|
+
onOpenChange={handleOpenChange}
|
|
130
|
+
defaultOpen={defaultOpen}
|
|
131
|
+
>
|
|
132
|
+
<MorphingDialogTrigger className="w-full text-left">
|
|
133
|
+
<Card className="cursor-pointer hover:bg-accent/50 transition-colors">
|
|
134
|
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
|
135
|
+
<div className="flex items-center gap-2">
|
|
136
|
+
<FileText className="size-4 text-muted-foreground" aria-hidden="true" />
|
|
137
|
+
<MorphingDialogTitle>
|
|
138
|
+
<CardTitle className="text-sm font-semibold">Design Document</CardTitle>
|
|
139
|
+
</MorphingDialogTitle>
|
|
140
|
+
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
|
141
|
+
{epicId}
|
|
142
|
+
</Badge>
|
|
143
|
+
</div>
|
|
144
|
+
</CardHeader>
|
|
145
|
+
<CardContent className="pt-0 pb-4">
|
|
146
|
+
<MorphingDialogDescription
|
|
147
|
+
disableLayoutAnimation
|
|
148
|
+
variants={{
|
|
149
|
+
initial: { opacity: 1 },
|
|
150
|
+
animate: { opacity: 1 },
|
|
151
|
+
exit: { opacity: 0 },
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
<p className="text-xs text-muted-foreground line-clamp-2">
|
|
155
|
+
{previewText}
|
|
156
|
+
</p>
|
|
157
|
+
</MorphingDialogDescription>
|
|
158
|
+
</CardContent>
|
|
159
|
+
</Card>
|
|
160
|
+
</MorphingDialogTrigger>
|
|
161
|
+
|
|
162
|
+
<MorphingDialogContainer>
|
|
163
|
+
<MorphingDialogContent
|
|
164
|
+
className="relative bg-zinc-900 border-zinc-800 rounded-lg shadow-lg w-[60vw] max-h-[80vh] overflow-hidden"
|
|
165
|
+
>
|
|
166
|
+
{/* Fixed header outside scroll area */}
|
|
167
|
+
<div className="flex items-center gap-2 px-6 pt-6 pb-3 border-b border-zinc-800">
|
|
168
|
+
<FileText className="size-4 text-muted-foreground" aria-hidden="true" />
|
|
169
|
+
<MorphingDialogTitle>
|
|
170
|
+
<h2 className="text-sm font-semibold">Design Document</h2>
|
|
171
|
+
</MorphingDialogTitle>
|
|
172
|
+
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
|
173
|
+
{epicId}
|
|
174
|
+
</Badge>
|
|
175
|
+
</div>
|
|
176
|
+
{/* Scrollable content with explicit height */}
|
|
177
|
+
<ScrollArea className="h-[calc(80vh-5rem)]">
|
|
178
|
+
<div className="p-6">
|
|
179
|
+
<MorphingDialogDescription
|
|
180
|
+
disableLayoutAnimation
|
|
181
|
+
variants={{
|
|
182
|
+
initial: { opacity: 0, scale: 0.98 },
|
|
183
|
+
animate: { opacity: 1, scale: 1 },
|
|
184
|
+
exit: { opacity: 0, scale: 0.98 },
|
|
185
|
+
}}
|
|
186
|
+
className={proseStyles}
|
|
187
|
+
>
|
|
188
|
+
<ReactMarkdown rehypePlugins={[rehypeHighlight]}>
|
|
189
|
+
{content}
|
|
190
|
+
</ReactMarkdown>
|
|
191
|
+
</MorphingDialogDescription>
|
|
192
|
+
</div>
|
|
193
|
+
</ScrollArea>
|
|
194
|
+
<MorphingDialogClose className="absolute top-4 right-4" />
|
|
195
|
+
</MorphingDialogContent>
|
|
196
|
+
</MorphingDialogContainer>
|
|
197
|
+
</MorphingDialog>
|
|
198
|
+
);
|
|
199
|
+
}
|