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:
co-authored by
Claude Sonnet 4.6
parent
251c6586f4
commit
08add81409
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user