File
Blob: src/client/components/workspace/summarize-sheet.tsx
| 1 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 2 | import { FloatingPortal } from "@floating-ui/react"; |
| 3 | import { X, CornerDownLeft, Loader2, RefreshCw } from "lucide-react"; |
| 4 | import { summarizePage, streamAskPage, AiStreamError } from "@/client/lib/ai/api"; |
| 5 | import type { AiAskHistoryMessage } from "@/shared/types"; |
| 6 | import "./summarize-sheet.css"; |
| 7 | |
| 8 | interface SummarizeSheetProps { |
| 9 | workspaceId: string; |
| 10 | pageId: string; |
| 11 | canSummarize: boolean; |
| 12 | canAsk: boolean; |
| 13 | open: boolean; |
| 14 | onClose: () => void; |
| 15 | } |
| 16 | |
| 17 | interface SummaryState { |
| 18 | status: "idle" | "loading" | "ready" | "error"; |
| 19 | text: string; |
| 20 | error: string | null; |
| 21 | } |
| 22 | |
| 23 | interface ChatMessage { |
| 24 | id: string; |
| 25 | role: "user" | "assistant"; |
| 26 | content: string; |
| 27 | streaming?: boolean; |
| 28 | error?: boolean; |
| 29 | } |
| 30 | |
| 31 | const EMPTY_SUMMARY_COPY = "The model returned an empty summary. Try again."; |
| 32 | const EMPTY_ANSWER_COPY = "The model returned no answer. Try again."; |
| 33 | |
| 34 | function 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 | |
| 42 | function 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 | |
| 99 | function 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 | |
| 123 | function 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 | |
| 143 | function 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 | |
| 193 | export function SummarizeSheet(props: SummarizeSheetProps) { |
| 194 | return <SummarizeSheetForPage key={`${props.workspaceId}:${props.pageId}`} {...props} />; |
| 195 | } |
| 196 | |
| 197 | function 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 | } |