@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 +20 -0
- package/src/deliverable-card.tsx +27 -0
- package/src/index.ts +19 -0
- package/src/review-detail-panel.tsx +29 -0
- package/src/review-detail.tsx +140 -0
- package/src/review-empty.tsx +15 -0
- package/src/review-item.tsx +124 -0
- package/src/review-sidebar.tsx +75 -0
- package/src/review-split.tsx +57 -0
- package/src/styles.css +2 -0
- package/src/types.ts +18 -0
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
|
+
×
|
|
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"> · 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
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
|
+
}
|