@deck-ui/review 0.2.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/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@deck-ui/review",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "files": ["src"],
8
+ "peerDependencies": {
9
+ "react": "^19.0.0",
10
+ "react-dom": "^19.0.0",
11
+ "@deck-ui/core": "^0.1.0"
12
+ },
13
+ "dependencies": {
14
+ "lucide-react": "^0.577.0",
15
+ "react-markdown": "^10.1.0"
16
+ },
17
+ "scripts": {
18
+ "typecheck": "tsc --noEmit"
19
+ }
20
+ }
@@ -0,0 +1,27 @@
1
+ import { cn } from "@deck-ui/core";
2
+ import ReactMarkdown from "react-markdown";
3
+
4
+ export function DeliverableCard({ content }: { content: string }) {
5
+ return (
6
+ <div className="rounded-xl border border-black/[0.06] bg-white p-6 shadow-[0_1px_3px_rgba(0,0,0,0.04)]">
7
+ <div className="prose prose-sm prose-stone max-w-none text-foreground">
8
+ <ReactMarkdown>{content}</ReactMarkdown>
9
+ </div>
10
+ </div>
11
+ );
12
+ }
13
+
14
+ export function UserFeedback({ content }: { content: string }) {
15
+ return (
16
+ <div className="flex justify-end">
17
+ <div
18
+ className={cn(
19
+ "max-w-[70%] rounded-3xl bg-[#f4f4f4] px-5 py-2.5",
20
+ "text-sm text-foreground",
21
+ )}
22
+ >
23
+ {content}
24
+ </div>
25
+ </div>
26
+ );
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ export { ReviewSplit } from "./review-split";
2
+ export type { ReviewSplitProps } from "./review-split";
3
+
4
+ export { ReviewSidebar } from "./review-sidebar";
5
+ export type { ReviewSidebarProps } from "./review-sidebar";
6
+
7
+ export { ReviewDetailPanel } from "./review-detail-panel";
8
+ export type { ReviewDetailPanelProps } from "./review-detail-panel";
9
+
10
+ export { ReviewDetail } from "./review-detail";
11
+ export type { ReviewDetailProps } from "./review-detail";
12
+
13
+ export { ReviewItem } from "./review-item";
14
+
15
+ export { ReviewEmpty } from "./review-empty";
16
+
17
+ export { DeliverableCard, UserFeedback } from "./deliverable-card";
18
+
19
+ export type { ReviewItemData, RunStatus } from "./types";
@@ -0,0 +1,29 @@
1
+ import type { ReactNode } from "react";
2
+ import type { ReviewItemData } from "./types";
3
+
4
+ export interface ReviewDetailPanelProps {
5
+ item: ReviewItemData;
6
+ /** Render prop: the host app provides its own chat panel implementation */
7
+ renderChat: (props: {
8
+ sessionKey: string;
9
+ placeholder: string;
10
+ item: ReviewItemData;
11
+ }) => ReactNode;
12
+ }
13
+
14
+ export function ReviewDetailPanel({
15
+ item,
16
+ renderChat,
17
+ }: ReviewDetailPanelProps) {
18
+ const sessionKey = item.sessionId ?? item.id;
19
+ const placeholder =
20
+ item.status === "needs_you"
21
+ ? "Give feedback or approve..."
22
+ : "Type a message...";
23
+
24
+ return (
25
+ <div className="flex-1 flex flex-col min-h-0">
26
+ {renderChat({ sessionKey, placeholder, item })}
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,140 @@
1
+ import { useState } from "react";
2
+ import { Button, Input } from "@deck-ui/core";
3
+ import { ArrowLeft, Loader2 } from "lucide-react";
4
+ import { DeliverableCard, UserFeedback } from "./deliverable-card";
5
+
6
+ export interface ReviewDetailProps {
7
+ parentLabel: string;
8
+ projectName: string;
9
+ deliverableContent: string;
10
+ onBack: () => void;
11
+ onApprove: (feedback: string) => Promise<void>;
12
+ onSendFeedback?: (text: string) => Promise<void>;
13
+ }
14
+
15
+ interface FeedbackEntry {
16
+ id: string;
17
+ from: "user" | "assistant";
18
+ content: string;
19
+ }
20
+
21
+ export function ReviewDetail({
22
+ parentLabel,
23
+ projectName,
24
+ deliverableContent,
25
+ onBack,
26
+ onApprove,
27
+ onSendFeedback,
28
+ }: ReviewDetailProps) {
29
+ const [feedbackText, setFeedbackText] = useState("");
30
+ const [conversation, setConversation] = useState<FeedbackEntry[]>([]);
31
+ const [isRevising, setIsRevising] = useState(false);
32
+
33
+ const handleSendFeedback = async () => {
34
+ const trimmed = feedbackText.trim();
35
+ if (!trimmed || isRevising) return;
36
+
37
+ const userEntry: FeedbackEntry = {
38
+ id: `fb-${Date.now()}`,
39
+ from: "user",
40
+ content: trimmed,
41
+ };
42
+ setConversation((prev) => [...prev, userEntry]);
43
+ setFeedbackText("");
44
+ setIsRevising(true);
45
+
46
+ try {
47
+ await onSendFeedback?.(trimmed);
48
+ } catch {
49
+ // Caller handles error display
50
+ }
51
+
52
+ setIsRevising(false);
53
+ };
54
+
55
+ const handleApprove = async () => {
56
+ const allFeedback = conversation
57
+ .filter((e) => e.from === "user")
58
+ .map((e) => e.content)
59
+ .join("\n");
60
+ const combinedFeedback = [allFeedback, feedbackText.trim()]
61
+ .filter(Boolean)
62
+ .join("\n");
63
+
64
+ await onApprove(combinedFeedback);
65
+ };
66
+
67
+ const handleKeyDown = (e: React.KeyboardEvent) => {
68
+ if (e.key === "Enter" && !e.shiftKey) {
69
+ e.preventDefault();
70
+ handleSendFeedback();
71
+ }
72
+ };
73
+
74
+ return (
75
+ <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
76
+ {/* Header */}
77
+ <div className="shrink-0 px-4 py-3 border-b border-black/[0.06]">
78
+ <div className="max-w-3xl mx-auto flex items-center gap-3">
79
+ <button
80
+ onClick={onBack}
81
+ className="flex items-center justify-center size-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-black/[0.05] transition-colors duration-200"
82
+ aria-label="Back to Review"
83
+ >
84
+ <ArrowLeft className="size-4" />
85
+ </button>
86
+ <div className="min-w-0">
87
+ <p className="text-sm font-medium text-foreground truncate">
88
+ {parentLabel}
89
+ </p>
90
+ <p className="text-xs text-muted-foreground">{projectName}</p>
91
+ </div>
92
+ </div>
93
+ </div>
94
+
95
+ {/* Body -- scrollable */}
96
+ <div className="flex-1 overflow-y-auto px-4 py-6">
97
+ <div className="max-w-3xl mx-auto space-y-6">
98
+ <DeliverableCard content={deliverableContent} />
99
+
100
+ {conversation.map((entry) =>
101
+ entry.from === "user" ? (
102
+ <UserFeedback key={entry.id} content={entry.content} />
103
+ ) : (
104
+ <div key={entry.id} className="space-y-4">
105
+ <p className="text-sm text-foreground">{entry.content}</p>
106
+ </div>
107
+ ),
108
+ )}
109
+
110
+ {isRevising && (
111
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
112
+ <Loader2 className="size-3.5 animate-spin" />
113
+ Revising...
114
+ </div>
115
+ )}
116
+ </div>
117
+ </div>
118
+
119
+ {/* Footer -- input + approve button */}
120
+ <div className="shrink-0 px-4 py-3 border-t border-black/[0.06]">
121
+ <div className="max-w-3xl mx-auto flex items-center gap-2">
122
+ <Input
123
+ value={feedbackText}
124
+ onChange={(e) => setFeedbackText(e.target.value)}
125
+ onKeyDown={handleKeyDown}
126
+ placeholder="Type feedback..."
127
+ disabled={isRevising}
128
+ className="flex-1 h-10 rounded-full pl-4 border-black/10"
129
+ />
130
+ <Button
131
+ onClick={handleApprove}
132
+ className="h-10 px-5 rounded-full bg-foreground text-background hover:bg-foreground/90"
133
+ >
134
+ Approve
135
+ </Button>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ );
140
+ }
@@ -0,0 +1,15 @@
1
+ export function ReviewEmpty() {
2
+ return (
3
+ <div className="flex-1 flex flex-col items-center pt-[20vh] gap-4 px-8">
4
+ <div className="space-y-2 text-center max-w-md">
5
+ <h1 className="text-2xl font-semibold text-foreground tracking-tight">
6
+ All caught up
7
+ </h1>
8
+ <p className="text-base text-muted-foreground leading-relaxed">
9
+ No items need your review. Your routines will produce new
10
+ outputs automatically, or ask Houston for a one-off task.
11
+ </p>
12
+ </div>
13
+ </div>
14
+ );
15
+ }
@@ -0,0 +1,124 @@
1
+ import { cn } from "@deck-ui/core";
2
+ import { ChevronRight, Loader2 } from "lucide-react";
3
+ import type { ReviewItemData, RunStatus } from "./types";
4
+
5
+ interface ReviewItemProps {
6
+ item: ReviewItemData;
7
+ onClick: () => void;
8
+ }
9
+
10
+ function statusIndicator(status: RunStatus) {
11
+ switch (status) {
12
+ case "needs_you":
13
+ return (
14
+ <span className="relative flex size-3 shrink-0">
15
+ <span className="absolute inline-flex size-full rounded-full bg-[#0d0d0d]" />
16
+ </span>
17
+ );
18
+ case "running":
19
+ return (
20
+ <Loader2 className="size-3.5 shrink-0 animate-spin text-[#9b9b9b]" />
21
+ );
22
+ case "done":
23
+ case "approved":
24
+ return (
25
+ <span className="flex size-2.5 shrink-0 rounded-full border-[1.5px] border-[#9b9b9b]/40" />
26
+ );
27
+ case "error":
28
+ case "failed":
29
+ return (
30
+ <span className="flex size-2.5 shrink-0 text-red-600 font-bold text-xs leading-none">
31
+ &times;
32
+ </span>
33
+ );
34
+ default:
35
+ return (
36
+ <span className="flex size-2.5 shrink-0 rounded-full border-[1.5px] border-[#9b9b9b]/30" />
37
+ );
38
+ }
39
+ }
40
+
41
+ function statusLabel(status: RunStatus): string {
42
+ switch (status) {
43
+ case "needs_you":
44
+ return "Ready for review";
45
+ case "running":
46
+ return "Running...";
47
+ case "done":
48
+ case "approved":
49
+ return "Approved";
50
+ case "completed":
51
+ return "Completed";
52
+ case "error":
53
+ case "failed":
54
+ return "Failed";
55
+ default:
56
+ return status;
57
+ }
58
+ }
59
+
60
+ function relativeTime(iso: string): string {
61
+ const now = Date.now();
62
+ const then = new Date(iso).getTime();
63
+ const diffSec = Math.floor((now - then) / 1000);
64
+
65
+ if (diffSec < 60) return "just now";
66
+ if (diffSec < 3600) return `${Math.floor(diffSec / 60)} min ago`;
67
+ if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
68
+ return `${Math.floor(diffSec / 86400)}d ago`;
69
+ }
70
+
71
+ export function ReviewItem({ item, onClick }: ReviewItemProps) {
72
+ const isUnreviewed = item.status === "needs_you";
73
+ const isRunning = item.status === "running";
74
+ const isFailed = item.status === "error" || item.status === "failed";
75
+
76
+ const displaySubtitle =
77
+ item.title === item.subtitle ? statusLabel(item.status) : item.subtitle;
78
+
79
+ return (
80
+ <button
81
+ onClick={onClick}
82
+ className={cn(
83
+ "w-full text-left flex items-center gap-3 px-4 py-3 transition-colors duration-200",
84
+ "hover:bg-black/[0.03]",
85
+ )}
86
+ >
87
+ {/* Status indicator */}
88
+ <div className="flex items-center justify-center w-4">
89
+ {statusIndicator(item.status)}
90
+ </div>
91
+
92
+ {/* Content */}
93
+ <div className="flex-1 min-w-0">
94
+ <p
95
+ className={cn(
96
+ "text-sm truncate",
97
+ isUnreviewed
98
+ ? "font-semibold text-[#0d0d0d]"
99
+ : "font-normal text-[#0d0d0d]/70",
100
+ isFailed && "text-red-600",
101
+ )}
102
+ >
103
+ {item.title}
104
+ </p>
105
+ <p className="text-xs text-[#9b9b9b] truncate mt-0.5">
106
+ {displaySubtitle}
107
+ {isRunning && item.title !== item.subtitle && (
108
+ <span className="animate-pulse"> &middot; Running...</span>
109
+ )}
110
+ </p>
111
+ </div>
112
+
113
+ {/* Timestamp */}
114
+ <span className="text-xs text-[#9b9b9b]/50 shrink-0">
115
+ {relativeTime(item.createdAt)}
116
+ </span>
117
+
118
+ {/* Right chevron for actionable items */}
119
+ {isUnreviewed && (
120
+ <ChevronRight className="size-4 shrink-0 text-[#9b9b9b]" />
121
+ )}
122
+ </button>
123
+ );
124
+ }
@@ -0,0 +1,75 @@
1
+ import { useMemo } from "react";
2
+ import { cn } from "@deck-ui/core";
3
+ import type { ReviewItemData } from "./types";
4
+
5
+ export interface ReviewSidebarProps {
6
+ items: ReviewItemData[];
7
+ selectedId: string | null;
8
+ onSelect: (id: string) => void;
9
+ }
10
+
11
+ const GROUPS = [
12
+ { label: "Needs You", statuses: ["needs_you"] },
13
+ { label: "Running", statuses: ["running"] },
14
+ { label: "Done", statuses: ["done", "approved", "completed"] },
15
+ { label: "Failed", statuses: ["error", "failed"] },
16
+ ];
17
+
18
+ export function ReviewSidebar({
19
+ items,
20
+ selectedId,
21
+ onSelect,
22
+ }: ReviewSidebarProps) {
23
+ const grouped = useMemo(() => {
24
+ return GROUPS.map((group) => ({
25
+ ...group,
26
+ items: items
27
+ .filter((item) => group.statuses.includes(item.status))
28
+ .sort(
29
+ (a, b) =>
30
+ new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
31
+ ),
32
+ }));
33
+ }, [items]);
34
+
35
+ return (
36
+ <div className="flex flex-col h-full bg-[#f9f9f9] border-r border-black/5">
37
+ <div className="flex-1 overflow-y-auto px-2 py-3">
38
+ {grouped.map((group) => (
39
+ <div key={group.label} className="mb-1">
40
+ <div className="flex items-center justify-between px-3 py-1">
41
+ <span className="text-[11px] text-[#9b9b9b]">
42
+ {group.label}
43
+ </span>
44
+ {group.items.length > 0 && (
45
+ <span className="text-[11px] text-[#cdcdcd]">
46
+ {group.items.length}
47
+ </span>
48
+ )}
49
+ </div>
50
+ {group.items.length > 0 ? (
51
+ group.items.map((item) => (
52
+ <button
53
+ key={item.id}
54
+ onClick={() => onSelect(item.id)}
55
+ className={cn(
56
+ "w-full text-left px-3 py-1.5 rounded-lg transition-colors duration-100 text-[13px] truncate",
57
+ item.id === selectedId
58
+ ? "bg-[#ececec] text-[#0d0d0d]"
59
+ : "text-[#424242] hover:bg-[#ececec]/50",
60
+ )}
61
+ >
62
+ {item.title}
63
+ </button>
64
+ ))
65
+ ) : (
66
+ <p className="px-3 py-1 text-[12px] text-[#cdcdcd] italic">
67
+ None
68
+ </p>
69
+ )}
70
+ </div>
71
+ ))}
72
+ </div>
73
+ </div>
74
+ );
75
+ }
@@ -0,0 +1,57 @@
1
+ import { useMemo, useState, type ReactNode } from "react";
2
+ import { ReviewSidebar } from "./review-sidebar";
3
+ import { ReviewDetailPanel } from "./review-detail-panel";
4
+ import type { ReviewItemData } from "./types";
5
+
6
+ export interface ReviewSplitProps {
7
+ items: ReviewItemData[];
8
+ initialSelectedId?: string | null;
9
+ /** Render prop: the host app provides its own chat panel for the detail view */
10
+ renderChat: (props: {
11
+ sessionKey: string;
12
+ placeholder: string;
13
+ item: ReviewItemData;
14
+ }) => ReactNode;
15
+ /** Shown when no item is selected */
16
+ emptyMessage?: string;
17
+ }
18
+
19
+ export function ReviewSplit({
20
+ items,
21
+ initialSelectedId = null,
22
+ renderChat,
23
+ emptyMessage = "Select a conversation to review",
24
+ }: ReviewSplitProps) {
25
+ const [selectedId, setSelectedId] = useState<string | null>(
26
+ initialSelectedId,
27
+ );
28
+
29
+ const selectedItem = useMemo(
30
+ () => items.find((i) => i.id === selectedId) ?? null,
31
+ [items, selectedId],
32
+ );
33
+
34
+ return (
35
+ <div className="flex flex-1 min-h-0 overflow-hidden">
36
+ {/* Left panel -- fixed width sidebar */}
37
+ <div className="w-[270px] shrink-0">
38
+ <ReviewSidebar
39
+ items={items}
40
+ selectedId={selectedId}
41
+ onSelect={setSelectedId}
42
+ />
43
+ </div>
44
+
45
+ {/* Right panel -- detail or empty */}
46
+ <div className="flex-1 min-w-0 flex flex-col min-h-0">
47
+ {selectedItem ? (
48
+ <ReviewDetailPanel item={selectedItem} renderChat={renderChat} />
49
+ ) : (
50
+ <div className="flex h-full items-center justify-center bg-white">
51
+ <p className="text-sm text-[#9b9b9b]">{emptyMessage}</p>
52
+ </div>
53
+ )}
54
+ </div>
55
+ </div>
56
+ );
57
+ }
package/src/styles.css ADDED
@@ -0,0 +1,2 @@
1
+ /* @deck-ui/review — Tell Tailwind to scan this package's components */
2
+ @source ".";
package/src/types.ts ADDED
@@ -0,0 +1,18 @@
1
+ export type RunStatus =
2
+ | "running"
3
+ | "completed"
4
+ | "failed"
5
+ | "approved"
6
+ | "needs_you"
7
+ | "done"
8
+ | "error";
9
+
10
+ export interface ReviewItemData {
11
+ id: string;
12
+ title: string;
13
+ subtitle: string;
14
+ status: RunStatus;
15
+ createdAt: string;
16
+ sessionId: string | null;
17
+ routineId: string | null;
18
+ }