152 lines
5.9 KiB
TypeScript
152 lines
5.9 KiB
TypeScript
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Check, Copy, PanelRightClose, Square } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import type { CodePreviewPayload } from "@/components/chat/CodePreviewContext";
|
|
|
|
interface CodePreviewPanelProps {
|
|
code: CodePreviewPayload | null;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export const CodePreviewPanel = memo(function CodePreviewPanel({ code, onClose }: CodePreviewPanelProps) {
|
|
const [copied, setCopied] = useState(false);
|
|
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
|
|
const lines = useMemo(() => code?.code.replace(/\n$/, "").split("\n") ?? [], [code?.code]);
|
|
const canPreview = code?.language === "html";
|
|
const isSubAgent = code?.kind === "subagent";
|
|
|
|
const handleCopy = useCallback(() => {
|
|
if (!code) return;
|
|
navigator.clipboard.writeText(code.code).then(() => {
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 1600);
|
|
});
|
|
}, [code]);
|
|
|
|
// Sync viewMode when code changes
|
|
useEffect(() => {
|
|
if (code?.previewMode) {
|
|
setViewMode(code.previewMode);
|
|
}
|
|
}, [code?.previewMode]);
|
|
|
|
return (
|
|
<aside
|
|
className="h-full shrink-0 overflow-hidden border-l transition-[width,opacity,transform] duration-300 ease-out"
|
|
style={{
|
|
width: code ? "min(48vw, 760px)" : 0,
|
|
opacity: code ? 1 : 0,
|
|
transform: code ? "translateX(0)" : "translateX(18px)",
|
|
borderColor: "var(--border-subtle)",
|
|
background: "var(--surface-ground)",
|
|
}}
|
|
aria-hidden={!code}
|
|
>
|
|
<div className="flex h-full w-[min(48vw,760px)] flex-col">
|
|
<div
|
|
className={isSubAgent ? "flex min-h-14 shrink-0 items-center justify-between gap-3 border-b px-4 py-3" : "flex h-12 shrink-0 items-center justify-between gap-3 border-b px-3"}
|
|
style={{ borderColor: "var(--border-subtle)" }}
|
|
>
|
|
<div className="min-w-0">
|
|
<div className={isSubAgent ? "truncate text-sm font-medium" : "truncate text-sm font-medium"} style={{ color: "var(--text-primary)" }}>
|
|
{code?.title || code?.language || "text"}
|
|
</div>
|
|
<div className={isSubAgent ? "mt-0.5 line-clamp-1 text-xs" : "text-xs"} style={{ color: "var(--text-muted)" }}>
|
|
{code?.subtitle || `${code?.lineCount ?? 0} lines`}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{isSubAgent && code?.status && (
|
|
<span
|
|
className="mr-2 rounded-md border px-2 py-1 text-xs"
|
|
style={{
|
|
borderColor: "var(--border-subtle)",
|
|
color: "var(--text-muted)",
|
|
backgroundColor: "var(--surface-elevated)",
|
|
}}
|
|
>
|
|
{code.status === "ok" ? "completed" : code.status}
|
|
</span>
|
|
)}
|
|
{isSubAgent && code?.status === "pending" && (
|
|
<Button variant="ghost" size="sm" onClick={code.onStop}>
|
|
<Square data-icon="inline-start" />
|
|
Stop
|
|
</Button>
|
|
)}
|
|
{canPreview && (
|
|
<div className="flex items-center rounded-md border overflow-hidden mr-2" style={{ borderColor: "var(--border-default)" }}>
|
|
<button
|
|
onClick={() => setViewMode("code")}
|
|
className="px-3 py-1 text-xs font-medium transition-colors"
|
|
style={{
|
|
backgroundColor: viewMode === "code" ? "var(--accent)" : "transparent",
|
|
color: viewMode === "code" ? "var(--accent-fg)" : "var(--text-secondary)",
|
|
}}
|
|
>
|
|
Code
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode("preview")}
|
|
className="px-3 py-1 text-xs font-medium transition-colors"
|
|
style={{
|
|
backgroundColor: viewMode === "preview" ? "var(--accent)" : "transparent",
|
|
color: viewMode === "preview" ? "var(--accent-fg)" : "var(--text-secondary)",
|
|
}}
|
|
>
|
|
Preview
|
|
</button>
|
|
</div>
|
|
)}
|
|
<Button variant="ghost" size="sm" onClick={handleCopy} disabled={!code}>
|
|
{copied ? <Check data-icon="inline-start" /> : <Copy data-icon="inline-start" />}
|
|
{copied ? "Copied" : "Copy"}
|
|
</Button>
|
|
<Button variant="ghost" size="icon-sm" onClick={onClose} aria-label="Close code preview">
|
|
<PanelRightClose />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1 overflow-auto">
|
|
{viewMode === "preview" && canPreview ? (
|
|
<iframe
|
|
title="HTML Preview"
|
|
srcDoc={code?.code ?? ""}
|
|
className="w-full h-full border-0"
|
|
sandbox="allow-scripts"
|
|
/>
|
|
) : (
|
|
<div
|
|
className="grid min-w-max grid-cols-[auto_1fr] text-[13px] leading-[1.65]"
|
|
>
|
|
<div
|
|
className="select-none border-r px-3 py-4 text-right font-mono tabular-nums"
|
|
style={{
|
|
borderColor: "var(--border-subtle)",
|
|
color: "var(--text-muted)",
|
|
backgroundColor: "var(--surface-elevated)",
|
|
}}
|
|
aria-hidden="true"
|
|
>
|
|
{lines.map((_, index) => (
|
|
<div key={index}>{index + 1}</div>
|
|
))}
|
|
</div>
|
|
<pre
|
|
className="m-0 min-h-full px-4 py-4 font-mono outline-none"
|
|
style={{
|
|
color: "var(--text-primary)",
|
|
backgroundColor: "var(--surface-ground)",
|
|
}}
|
|
>
|
|
<code>{code?.code ?? ""}</code>
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
});
|