Skip to content
File

Blob: src/client/components/workspace/summarize-sheet.tsx

typescript343 lines
1import { useCallback, useEffect, useRef, useState } from "react";
2import { FloatingPortal } from "@floating-ui/react";
3import { X, CornerDownLeft, Loader2, RefreshCw } from "lucide-react";
4import { summarizePage, streamAskPage, AiStreamError } from "@/client/lib/ai/api";
5import type { AiAskHistoryMessage } from "@/shared/types";
6import "./summarize-sheet.css";
7 
8interface SummarizeSheetProps {
9 workspaceId: string;
10 pageId: string;
11 canSummarize: boolean;
12 canAsk: boolean;
13 open: boolean;
14 onClose: () => void;
15}
16 
17interface SummaryState {
18 status: "idle" | "loading" | "ready" | "error";
19 text: string;
20 error: string | null;
21}
22 
23interface ChatMessage {
24 id: string;
25 role: "user" | "assistant";
26 content: string;
27 streaming?: boolean;
28 error?: boolean;
29}
30 
31const EMPTY_SUMMARY_COPY = "The model returned an empty summary. Try again.";
32const EMPTY_ANSWER_COPY = "The model returned no answer. Try again.";
33 
34function describeAiError(err: unknown, fallback: string): string {
35 if (err instanceof AiStreamError && err.code === "page_empty") {
36 return "Add something to this page before asking the model about it.";
37 }
38 if (err instanceof AiStreamError || err instanceof Error) return err.message;
39 return fallback;
40}
41 
42function SummarySection({ summary, onRetry }: { summary: SummaryState; onRetry: () => void }) {
43 const isError = summary.status === "error";
44 const isLoading = summary.status === "loading";
45 const retryLabel = isError ? "Retry summary" : "Regenerate summary";
46 
47 return (
48 <section>
49 <div className="mb-2 flex items-center justify-between">
50 <h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-400">Summary</h3>
51 <button
52 type="button"
53 onClick={onRetry}
54 disabled={isLoading}
55 aria-label={retryLabel}
56 title={retryLabel}
57 className={
58 isError
59 ? "inline-flex items-center gap-1.5 rounded border border-red-500/40 bg-red-500/10 px-2 py-1 text-xs font-medium text-red-300 transition-colors hover:border-red-400/60 hover:bg-red-500/15 hover:text-red-200 focus-visible:border-red-400/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/40 disabled:cursor-not-allowed disabled:opacity-50"
60 : "inline-flex items-center rounded p-1.5 text-zinc-400 transition-colors hover:bg-zinc-700/60 hover:text-zinc-200 focus-visible:bg-zinc-700/60 focus-visible:text-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/40 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-zinc-400"
61 }
62 data-testid="summarize-sheet-retry"
63 >
64 {isLoading ? (
65 <Loader2 size={isError ? 12 : 14} className="animate-spin" />
66 ) : (
67 <RefreshCw size={isError ? 12 : 14} />
68 )}
69 {isError && <span>Retry</span>}
70 </button>
71 </div>
72 {summary.status === "loading" && (
73 <div
74 role="status"
75 aria-busy="true"
76 aria-label="Summarizing the page"
77 data-testid="summarize-sheet-skeleton"
78 className="space-y-2"
79 >
80 <div className="h-3 rounded bg-zinc-700/55 motion-safe:animate-pulse" />
81 <div className="h-3 w-[94%] rounded bg-zinc-700/55 motion-safe:animate-pulse" />
82 <div className="h-3 w-[70%] rounded bg-zinc-700/55 motion-safe:animate-pulse" />
83 </div>
84 )}
85 {summary.status === "error" && (
86 <p className="text-red-300" data-testid="summarize-sheet-error">
87 {summary.error}
88 </p>
89 )}
90 {summary.status === "ready" && (
91 <p data-testid="summarize-sheet-summary" className="whitespace-pre-wrap leading-relaxed text-zinc-200">
92 {summary.text}
93 </p>
94 )}
95 </section>
96 );
97}
98 
99function ChatMessageRow({ message }: { message: ChatMessage }) {
100 // Note: sheet surface is bg-zinc-800. To keep contrast comfortable for
101 // astigmatic reading on longer passages, body text sits at zinc-200
102 // (not zinc-100) against both the neutral and accent-tinted fills.
103 const style = message.error
104 ? "rounded border border-red-500/30 bg-red-500/10 px-3 py-2 text-red-200"
105 : message.role === "user"
106 ? "rounded bg-accent-500/14 px-3 py-2 text-zinc-100"
107 : "rounded bg-zinc-700/45 px-3 py-2 text-zinc-200";
108 const testid =
109 message.role !== "assistant"
110 ? undefined
111 : message.error
112 ? "summarize-sheet-answer-error"
113 : "summarize-sheet-answer";
114 return (
115 <div className={style} data-testid={testid}>
116 <p className="whitespace-pre-wrap leading-relaxed">
117 {message.content || (message.streaming ? <span className="italic text-zinc-400">Thinking…</span> : "")}
118 </p>
119 </div>
120 );
121}
122 
123function PageChatList({
124 messages,
125 chatEndRef,
126}: {
127 messages: ChatMessage[];
128 chatEndRef: React.RefObject<HTMLDivElement | null>;
129}) {
130 return (
131 <section>
132 <h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-400">Dig deeper</h3>
133 <div className="space-y-3" aria-live="polite" aria-atomic="false">
134 {messages.map((m) => (
135 <ChatMessageRow key={m.id} message={m} />
136 ))}
137 <div ref={chatEndRef} />
138 </div>
139 </section>
140 );
141}
142 
143function ChatComposer({
144 input,
145 onChange,
146 onSubmit,
147 sending,
148 inputRef,
149}: {
150 input: string;
151 onChange: (next: string) => void;
152 onSubmit: () => void;
153 sending: boolean;
154 inputRef: React.RefObject<HTMLTextAreaElement | null>;
155}) {
156 return (
157 <form
158 className="flex items-stretch gap-2 border-t border-zinc-700 px-4 py-3"
159 onSubmit={(e) => {
160 e.preventDefault();
161 onSubmit();
162 }}
163 >
164 <textarea
165 ref={inputRef}
166 value={input}
167 onChange={(e) => onChange(e.target.value)}
168 onKeyDown={(e) => {
169 if (e.key === "Enter" && !e.shiftKey) {
170 e.preventDefault();
171 onSubmit();
172 }
173 }}
174 rows={2}
175 placeholder="Jarvis, …"
176 aria-label="Ask a question about this page"
177 className="flex-1 resize-none rounded border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-400 transition-colors focus:border-accent-500/50 focus:outline-none"
178 data-testid="summarize-sheet-input"
179 />
180 <button
181 type="submit"
182 disabled={sending || input.trim().length === 0}
183 aria-label="Send question (Enter)"
184 className="flex w-11 shrink-0 items-center justify-center rounded bg-accent-500 text-white transition-colors hover:bg-accent-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-400/60 disabled:cursor-not-allowed disabled:opacity-50"
185 data-testid="summarize-sheet-send"
186 >
187 {sending ? <Loader2 size={16} className="animate-spin" /> : <CornerDownLeft size={16} />}
188 </button>
189 </form>
190 );
191}
192 
193export function SummarizeSheet(props: SummarizeSheetProps) {
194 return <SummarizeSheetForPage key={`${props.workspaceId}:${props.pageId}`} {...props} />;
195}
196 
197function SummarizeSheetForPage({ workspaceId, pageId, canSummarize, canAsk, open, onClose }: SummarizeSheetProps) {
198 const [summary, setSummary] = useState<SummaryState>({ status: "idle", text: "", error: null });
199 const [input, setInput] = useState("");
200 const [messages, setMessages] = useState<ChatMessage[]>([]);
201 const [sending, setSending] = useState(false);
202 const [summaryRequestToken, setSummaryRequestToken] = useState(0);
203 const inputRef = useRef<HTMLTextAreaElement>(null);
204 const chatEndRef = useRef<HTMLDivElement>(null);
205 const lastFetchKey = useRef<string>("");
206 
207 // Fetch the summary the first time the sheet is opened for this (workspace, page),
208 // or whenever the user asks to retry. Dedupe so reopening the sheet on the same
209 // page reuses the cached result instead of refetching.
210 useEffect(() => {
211 if (!open || !canSummarize) return;
212 const fetchKey = `${workspaceId}:${pageId}:${summaryRequestToken}`;
213 if (lastFetchKey.current === fetchKey) return;
214 lastFetchKey.current = fetchKey;
215 
216 let cancelled = false;
217 setSummary({ status: "loading", text: "", error: null });
218 summarizePage(workspaceId, pageId)
219 .then((res) => {
220 if (cancelled) return;
221 const text = res.summary?.trim() ?? "";
222 if (text.length === 0) {
223 setSummary({ status: "error", text: "", error: EMPTY_SUMMARY_COPY });
224 return;
225 }
226 setSummary({ status: "ready", text: res.summary, error: null });
227 })
228 .catch((err: unknown) => {
229 if (cancelled) return;
230 setSummary({ status: "error", text: "", error: describeAiError(err, "Summary failed") });
231 });
232 return () => {
233 cancelled = true;
234 };
235 }, [open, canSummarize, pageId, workspaceId, summaryRequestToken]);
236 
237 useEffect(() => {
238 if (open) inputRef.current?.focus();
239 }, [open]);
240 
241 useEffect(() => {
242 if (!open) return;
243 const handler = (e: KeyboardEvent) => {
244 if (e.key === "Escape") onClose();
245 };
246 window.addEventListener("keydown", handler);
247 return () => window.removeEventListener("keydown", handler);
248 }, [open, onClose]);
249 
250 useEffect(() => {
251 chatEndRef.current?.scrollIntoView({ block: "end", behavior: "smooth" });
252 }, [messages]);
253 
254 const retrySummary = useCallback(() => setSummaryRequestToken((n) => n + 1), []);
255 
256 const handleSubmit = useCallback(async () => {
257 const trimmed = input.trim();
258 if (!trimmed || sending || !canAsk) return;
259 
260 const userMessage: ChatMessage = { id: `u-${Date.now()}`, role: "user", content: trimmed };
261 const assistantId = `a-${Date.now()}`;
262 const assistantMessage: ChatMessage = { id: assistantId, role: "assistant", content: "", streaming: true };
263 const history: AiAskHistoryMessage[] = messages
264 .filter((m): m is ChatMessage & { role: "user" | "assistant" } => !m.streaming && !m.error)
265 .slice(-6)
266 .map((m) => ({ role: m.role, content: m.content }));
267 
268 setMessages((prev) => [...prev, userMessage, assistantMessage]);
269 setInput("");
270 setSending(true);
271 
272 try {
273 let accumulated = "";
274 for await (const chunk of streamAskPage(workspaceId, pageId, { question: trimmed, history })) {
275 if (!chunk.text) continue;
276 accumulated += chunk.text;
277 setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + chunk.text } : m)));
278 }
279 if (accumulated.trim().length === 0) {
280 setMessages((prev) =>
281 prev.map((m) =>
282 m.id === assistantId ? { ...m, content: EMPTY_ANSWER_COPY, streaming: false, error: true } : m,
283 ),
284 );
285 } else {
286 setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, streaming: false } : m)));
287 }
288 } catch (err) {
289 const message = describeAiError(err, "Ask failed");
290 setMessages((prev) =>
291 prev.map((m) => (m.id === assistantId ? { ...m, content: message, streaming: false, error: true } : m)),
292 );
293 } finally {
294 setSending(false);
295 }
296 }, [input, sending, canAsk, workspaceId, pageId, messages]);
297 
298 if (!open) return null;
299 
300 return (
301 <FloatingPortal>
302 {/* Ambient dim — pointer-events: none so the editor underneath stays
303 interactive. Z-index sits below the sheet (z-40) and the top bar. */}
304 <div
305 className="summarize-sheet-backdrop pointer-events-none fixed inset-0 z-30 bg-zinc-950/25"
306 aria-hidden="true"
307 />
308 <aside
309 className="summarize-sheet fixed bottom-0 right-0 top-[60px] z-40 flex w-full max-w-md flex-col border-l border-zinc-700 bg-zinc-800 text-zinc-100 shadow-2xl"
310 aria-label="Summarize and ask page"
311 data-testid="summarize-sheet"
312 >
313 <header className="flex items-center justify-between border-b border-zinc-700 px-4 py-3">
314 <h2 className="text-sm font-semibold text-zinc-100">Quick take</h2>
315 <button
316 type="button"
317 aria-label="Close summary panel (Escape)"
318 onClick={onClose}
319 className="rounded p-1 text-zinc-400 transition-colors hover:bg-zinc-700/60 hover:text-zinc-200 focus-visible:bg-zinc-700/60 focus-visible:text-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/40"
320 >
321 <X size={16} />
322 </button>
323 </header>
324
325 <div className="flex-1 space-y-5 overflow-y-auto px-4 py-4 text-sm" data-testid="summarize-sheet-body">
326 {canSummarize && <SummarySection summary={summary} onRetry={retrySummary} />}
327 {canAsk && <PageChatList messages={messages} chatEndRef={chatEndRef} />}
328 </div>
329
330 {canAsk && (
331 <ChatComposer
332 input={input}
333 onChange={setInput}
334 onSubmit={() => void handleSubmit()}
335 sending={sending}
336 inputRef={inputRef}
337 />
338 )}
339 </aside>
340 </FloatingPortal>
341 );
342}