import { useCallback, useEffect, useRef, useState } from "react";
import { FloatingPortal } from "@floating-ui/react";
import { X, CornerDownLeft, Loader2, RefreshCw } from "lucide-react";
import { summarizePage, streamAskPage, AiStreamError } from "@/client/lib/ai/api";
import type { AiAskHistoryMessage } from "@/shared/types";
import "./summarize-sheet.css";
interface SummarizeSheetProps {
workspaceId: string;
pageId: string;
canSummarize: boolean;
canAsk: boolean;
open: boolean;
onClose: () => void;
}
interface SummaryState {
status: "idle" | "loading" | "ready" | "error";
text: string;
error: string | null;
}
interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
streaming?: boolean;
error?: boolean;
}
const EMPTY_SUMMARY_COPY = "The model returned an empty summary. Try again.";
const EMPTY_ANSWER_COPY = "The model returned no answer. Try again.";
function describeAiError(err: unknown, fallback: string): string {
if (err instanceof AiStreamError && err.code === "page_empty") {
return "Add something to this page before asking the model about it.";
}
if (err instanceof AiStreamError || err instanceof Error) return err.message;
return fallback;
}
function SummarySection({ summary, onRetry }: { summary: SummaryState; onRetry: () => void }) {
const isError = summary.status === "error";
const isLoading = summary.status === "loading";
const retryLabel = isError ? "Retry summary" : "Regenerate summary";
return (
Summary
{summary.status === "loading" && (
)}
{summary.status === "error" && (
{summary.error}
)}
{summary.status === "ready" && (
{summary.text}
)}
);
}
function ChatMessageRow({ message }: { message: ChatMessage }) {
// Note: sheet surface is bg-zinc-800. To keep contrast comfortable for
// astigmatic reading on longer passages, body text sits at zinc-200
// (not zinc-100) against both the neutral and accent-tinted fills.
const style = message.error
? "rounded border border-red-500/30 bg-red-500/10 px-3 py-2 text-red-200"
: message.role === "user"
? "rounded bg-accent-500/14 px-3 py-2 text-zinc-100"
: "rounded bg-zinc-700/45 px-3 py-2 text-zinc-200";
const testid =
message.role !== "assistant"
? undefined
: message.error
? "summarize-sheet-answer-error"
: "summarize-sheet-answer";
return (
{message.content || (message.streaming ? Thinking… : "")}
);
}
function PageChatList({
messages,
chatEndRef,
}: {
messages: ChatMessage[];
chatEndRef: React.RefObject;
}) {
return (
Dig deeper
{messages.map((m) => (
))}
);
}
function ChatComposer({
input,
onChange,
onSubmit,
sending,
inputRef,
}: {
input: string;
onChange: (next: string) => void;
onSubmit: () => void;
sending: boolean;
inputRef: React.RefObject;
}) {
return (
);
}
export function SummarizeSheet(props: SummarizeSheetProps) {
return ;
}
function SummarizeSheetForPage({ workspaceId, pageId, canSummarize, canAsk, open, onClose }: SummarizeSheetProps) {
const [summary, setSummary] = useState({ status: "idle", text: "", error: null });
const [input, setInput] = useState("");
const [messages, setMessages] = useState([]);
const [sending, setSending] = useState(false);
const [summaryRequestToken, setSummaryRequestToken] = useState(0);
const inputRef = useRef(null);
const chatEndRef = useRef(null);
const lastFetchKey = useRef("");
// Fetch the summary the first time the sheet is opened for this (workspace, page),
// or whenever the user asks to retry. Dedupe so reopening the sheet on the same
// page reuses the cached result instead of refetching.
useEffect(() => {
if (!open || !canSummarize) return;
const fetchKey = `${workspaceId}:${pageId}:${summaryRequestToken}`;
if (lastFetchKey.current === fetchKey) return;
lastFetchKey.current = fetchKey;
let cancelled = false;
setSummary({ status: "loading", text: "", error: null });
summarizePage(workspaceId, pageId)
.then((res) => {
if (cancelled) return;
const text = res.summary?.trim() ?? "";
if (text.length === 0) {
setSummary({ status: "error", text: "", error: EMPTY_SUMMARY_COPY });
return;
}
setSummary({ status: "ready", text: res.summary, error: null });
})
.catch((err: unknown) => {
if (cancelled) return;
setSummary({ status: "error", text: "", error: describeAiError(err, "Summary failed") });
});
return () => {
cancelled = true;
};
}, [open, canSummarize, pageId, workspaceId, summaryRequestToken]);
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
useEffect(() => {
chatEndRef.current?.scrollIntoView({ block: "end", behavior: "smooth" });
}, [messages]);
const retrySummary = useCallback(() => setSummaryRequestToken((n) => n + 1), []);
const handleSubmit = useCallback(async () => {
const trimmed = input.trim();
if (!trimmed || sending || !canAsk) return;
const userMessage: ChatMessage = { id: `u-${Date.now()}`, role: "user", content: trimmed };
const assistantId = `a-${Date.now()}`;
const assistantMessage: ChatMessage = { id: assistantId, role: "assistant", content: "", streaming: true };
const history: AiAskHistoryMessage[] = messages
.filter((m): m is ChatMessage & { role: "user" | "assistant" } => !m.streaming && !m.error)
.slice(-6)
.map((m) => ({ role: m.role, content: m.content }));
setMessages((prev) => [...prev, userMessage, assistantMessage]);
setInput("");
setSending(true);
try {
let accumulated = "";
for await (const chunk of streamAskPage(workspaceId, pageId, { question: trimmed, history })) {
if (!chunk.text) continue;
accumulated += chunk.text;
setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + chunk.text } : m)));
}
if (accumulated.trim().length === 0) {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: EMPTY_ANSWER_COPY, streaming: false, error: true } : m,
),
);
} else {
setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, streaming: false } : m)));
}
} catch (err) {
const message = describeAiError(err, "Ask failed");
setMessages((prev) =>
prev.map((m) => (m.id === assistantId ? { ...m, content: message, streaming: false, error: true } : m)),
);
} finally {
setSending(false);
}
}, [input, sending, canAsk, workspaceId, pageId, messages]);
if (!open) return null;
return (
{/* Ambient dim — pointer-events: none so the editor underneath stays
interactive. Z-index sits below the sheet (z-40) and the top bar. */}
);
}