feat: Phase 2 - CanvasPanel drawer + sidebar search & date groups

- Add CanvasPanel.tsx: right-side 480px drawer, renders markdown via
  MessageBubble or code via SyntaxHighlighter, with Copy/Download/Close
  header; listens to window 'open-canvas' CustomEvent dispatched by Gen-UI cards
- Update ThreadSidebar.tsx: add search input below new-chat button with
  live filtering; group filtered threads by date (今天/昨天/本周/更早)
  with section headings; internal state only, Props interface unchanged
- Update main.tsx: import CanvasPanel, add canvasDoc state, register
  open-canvas event listener on mount, render CanvasPanel alongside
  the main chat column inside a flex-row wrapper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-11 01:48:25 +08:00
co-authored by Claude Sonnet 4.6
parent 251c6586f4
commit 08add81409
3 changed files with 253 additions and 41 deletions
+139
View File
@@ -0,0 +1,139 @@
import { X, Copy, Download, Check, FileText } from "lucide-react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
oneDark,
oneLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";
import { useState } from "react";
import { cn } from "@/lib/utils";
import MessageBubble from "@/components/MessageBubble.tsx";
export interface CanvasDoc {
doc_id: string;
title: string;
content: string;
type: "markdown" | "code";
language?: string;
}
interface CanvasPanelProps {
doc: CanvasDoc | null;
onClose: () => void;
}
export default function CanvasPanel({ doc, onClose }: CanvasPanelProps) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
if (!doc) return;
navigator.clipboard.writeText(doc.content).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const handleDownload = () => {
if (!doc) return;
const ext = doc.type === "code" ? (doc.language ?? "txt") : "md";
const filename = `${doc.title.replace(/\s+/g, "_")}.${ext}`;
const blob = new Blob([doc.content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
// Detect dark mode via document class
const isDark =
typeof document !== "undefined" &&
document.documentElement.classList.contains("dark");
return (
<div
className={cn(
"w-[480px] shrink-0 border-l border-border flex flex-col bg-background transition-all duration-300",
doc != null ? "translate-x-0 opacity-100" : "translate-x-full opacity-0",
)}
>
{doc && (
<>
{/* Header */}
<div className="shrink-0 border-b border-border px-4 py-3 flex items-center gap-2">
<FileText className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 text-sm font-medium truncate text-foreground">
{doc.title}
</span>
{/* Copy */}
<button
type="button"
onClick={handleCopy}
title="复制内容"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded hover:bg-accent"
>
{copied ? (
<Check className="size-3.5 text-green-500" />
) : (
<Copy className="size-3.5" />
)}
{copied ? "已复制" : "复制"}
</button>
{/* Download */}
<button
type="button"
onClick={handleDownload}
title="下载文件"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded hover:bg-accent"
>
<Download className="size-3.5" />
下载
</button>
{/* Close */}
<button
type="button"
onClick={onClose}
title="关闭"
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<X className="size-4" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{doc.type === "markdown" ? (
<div className="text-sm">
<MessageBubble content={doc.content} role="ai" />
</div>
) : (
<div className="rounded-lg overflow-hidden border border-border">
<div className="flex items-center justify-between px-3 py-1.5 bg-muted border-b border-border">
<span className="text-xs font-mono text-muted-foreground">
{doc.language ?? "text"}
</span>
</div>
<SyntaxHighlighter
language={doc.language ?? "text"}
style={isDark ? oneDark : oneLight}
customStyle={{
margin: 0,
borderRadius: 0,
fontSize: "0.75rem",
background: "transparent",
}}
PreTag="div"
>
{doc.content}
</SyntaxHighlighter>
</div>
)}
</div>
</>
)}
</div>
);
}
+98 -41
View File
@@ -1,4 +1,5 @@
import { Plus, MessageSquare, Trash2 } from "lucide-react";
import { Plus, MessageSquare, Trash2, Search } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
@@ -16,6 +17,28 @@ type Props = {
onDeleteThread: (threadId: string) => void;
};
function groupByDate(threads: ThreadItem[]): { label: string; threads: ThreadItem[] }[] {
const now = new Date();
const groups: Record<string, ThreadItem[]> = {};
threads.forEach((t) => {
const d = new Date(t.created_at);
const diffDays = (now.getTime() - d.getTime()) / 86400000;
let label: string;
if (diffDays < 1) label = "今天";
else if (diffDays < 2) label = "昨天";
else if (diffDays < 7) label = "本周";
else label = "更早";
if (!groups[label]) groups[label] = [];
groups[label].push(t);
});
return ["今天", "昨天", "本周", "更早"]
.filter((label) => groups[label]?.length)
.map((label) => ({ label, threads: groups[label] }));
}
function formatTime(iso: string) {
try {
const d = new Date(iso);
@@ -38,10 +61,22 @@ export function ThreadSidebar({
onSelectThread,
onDeleteThread,
}: Props) {
const [searchQuery, setSearchQuery] = useState("");
const filtered = threads.filter((t) => {
const label =
(t.metadata?.title as string) ??
(t.metadata?.firstMessage as string) ??
t.thread_id;
return label.toLowerCase().includes(searchQuery.toLowerCase());
});
const groups = groupByDate(filtered);
return (
<div className="w-64 shrink-0 border-r border-border flex flex-col bg-muted/30">
{/* New chat button */}
<div className="p-3 border-b border-border">
<div className="p-3 border-b border-border flex flex-col gap-2">
<Button
variant="outline"
size="sm"
@@ -51,52 +86,74 @@ export function ThreadSidebar({
<Plus className="size-4" />
新建对话
</Button>
{/* Search input */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索对话..."
className="w-full text-xs border border-border rounded-md pl-8 pr-3 py-1.5 bg-background/50 text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring"
/>
</div>
</div>
{/* Thread list */}
<div className="flex-1 overflow-y-auto py-2">
{threads.length === 0 && (
{filtered.length === 0 && (
<p className="text-xs text-muted-foreground text-center mt-8 px-4">
暂无历史对话
{searchQuery ? "未找到匹配对话" : "暂无历史对话"}
</p>
)}
{threads.map((t) => {
const isActive = t.thread_id === currentThreadId;
const label =
(t.metadata?.title as string) ??
(t.metadata?.firstMessage as string) ??
t.thread_id.slice(0, 8) + "…";
return (
<div
key={t.thread_id}
className={cn(
"group flex items-center gap-2 px-3 py-2 mx-1 rounded-lg cursor-pointer transition-colors",
isActive
? "bg-primary/10 text-foreground"
: "hover:bg-accent text-muted-foreground hover:text-foreground",
)}
onClick={() => onSelectThread(t.thread_id)}
>
<MessageSquare className="size-4 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{label}</p>
<p className="text-[10px] text-muted-foreground">
{formatTime(t.created_at)}
</p>
</div>
<button
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeleteThread(t.thread_id);
}}
title="删除对话"
>
<Trash2 className="size-3.5" />
</button>
</div>
);
})}
{groups.map(({ label, threads: groupThreads }) => (
<div key={label}>
{/* Group heading */}
<p className="text-[10px] text-muted-foreground uppercase tracking-wider px-3 py-1.5 mt-2">
{label}
</p>
{groupThreads.map((t) => {
const isActive = t.thread_id === currentThreadId;
const itemLabel =
(t.metadata?.title as string) ??
(t.metadata?.firstMessage as string) ??
t.thread_id.slice(0, 8) + "…";
return (
<div
key={t.thread_id}
className={cn(
"group flex items-center gap-2 px-3 py-2 mx-1 rounded-lg cursor-pointer transition-colors",
isActive
? "bg-primary/10 text-foreground"
: "hover:bg-accent text-muted-foreground hover:text-foreground",
)}
onClick={() => onSelectThread(t.thread_id)}
>
<MessageSquare className="size-4 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{itemLabel}</p>
<p className="text-[10px] text-muted-foreground">
{formatTime(t.created_at)}
</p>
</div>
<button
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeleteThread(t.thread_id);
}}
title="删除对话"
>
<Trash2 className="size-3.5" />
</button>
</div>
);
})}
</div>
))}
</div>
</div>
);
+16
View File
@@ -16,6 +16,7 @@ import { ThreadSidebar, type ThreadItem } from "@/components/ThreadSidebar.tsx";
import { ThemeProvider } from "next-themes";
import ThemeToggle from "@/components/ThemeToggle.tsx";
import MessageBubble from "@/components/MessageBubble.tsx";
import CanvasPanel, { type CanvasDoc } from "@/components/CanvasPanel.tsx";
const LANGGRAPH_URL =
import.meta.env.VITE_LANGGRAPH_URL ?? "http://localhost:2024";
@@ -52,6 +53,9 @@ function App() {
const [threads, setThreads] = useState<ThreadItem[]>([]);
const [currentThreadId, setCurrentThreadId] = useState<string | null>(null);
// Canvas panel state
const [canvasDoc, setCanvasDoc] = useState<CanvasDoc | null>(null);
const thread = useStream<{ messages: Message[]; ui: UIMsgLocal[] }>({
apiUrl: LANGGRAPH_URL,
assistantId: "agent",
@@ -64,6 +68,13 @@ function App() {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [thread.messages]);
// Listen for open-canvas events from Gen-UI cards
useEffect(() => {
const handler = (e: Event) => setCanvasDoc((e as CustomEvent<CanvasDoc>).detail);
window.addEventListener("open-canvas", handler);
return () => window.removeEventListener("open-canvas", handler);
}, []);
// Load threads on mount
useEffect(() => {
client.threads
@@ -150,6 +161,7 @@ function App() {
/>
{/* ── Right main area ── */}
<div className="flex-1 flex flex-row min-w-0 overflow-hidden">
<div className="flex-1 flex flex-col min-w-0">
{/* Header */}
<header className="shrink-0 border-b border-border px-6 py-3 flex items-center gap-3">
@@ -316,6 +328,10 @@ function App() {
</form>
</div>
</div>
{/* ── Canvas panel (right drawer) ── */}
<CanvasPanel doc={canvasDoc} onClose={() => setCanvasDoc(null)} />
</div>
</div>
);
}