From bf2734be7dbc8631fb1487ee79ee67563c5f73cf Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 1 Aug 2026 20:08:09 +0530 Subject: [PATCH] polish Nova research reports --- apps/web/components/chat/index.tsx | 61 ++-- .../components/chat/message/agent-message.tsx | 339 ++++++++++++++---- .../chat/research-clarification.tsx | 207 +++++++++++ .../web/components/chat/research-progress.tsx | 265 +++++++------- apps/web/globals.css | 1 + apps/web/lib/nova-research.test.ts | 90 +++++ apps/web/lib/nova-research.ts | 148 +++++++- apps/web/lib/source-annotations.test.ts | 29 ++ apps/web/lib/source-annotations.ts | 39 ++ 9 files changed, 953 insertions(+), 226 deletions(-) create mode 100644 apps/web/components/chat/research-clarification.tsx create mode 100644 apps/web/lib/nova-research.test.ts diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index 502ae374..9772bcd9 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -82,7 +82,11 @@ import { type ChatThreadSettings, readChatThreadSettings, } from "@/lib/chat-thread-settings" -import { isActiveResearchRun, type NovaResearchRun } from "@/lib/nova-research" +import { + isActiveResearchRun, + type NovaResearchClarificationAnswer, + type NovaResearchRun, +} from "@/lib/nova-research" type ChatMessageSendSource = "typed" | "suggested" | "highlight" | "home" @@ -942,12 +946,6 @@ export function ChatSidebar({ if (hasBusy) return false const hasErrored = drafts.some((d) => d.status === "error") if (hasErrored) return false - if (chatMode === "research" && drafts.length > 0) { - toast.error( - "Research mode currently works from saved memories and web sources. Save the attachment first, then research it from its space.", - ) - return false - } const chatIdForSend = threadId ?? fallbackChatId @@ -1001,6 +999,7 @@ export function ChatSidebar({ query: messageText, chatId: chatIdForSend, userMessageId, + attachments: uploadedAttachments, metadata: { model: selectedModel, reasoningEffort, @@ -1223,6 +1222,30 @@ export function ChatSidebar({ stop() }, [chatApiBase, researchRun, stop]) + const handleSubmitResearchClarification = useCallback( + async (requestId: string, answers: NovaResearchClarificationAnswer[]) => { + if (!researchRun) throw new Error("Research run is unavailable.") + const response = await fetch( + `${chatApiBase}/chat/research/${researchRun.id}/clarification`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requestId, answers }), + }, + ) + const data = (await response.json().catch(() => null)) as { + error?: string + run?: NovaResearchRun | null + } | null + if (!response.ok) { + throw new Error(data?.error || "Could not submit your answers.") + } + if (data?.run) setResearchRun(data.run) + }, + [chatApiBase, researchRun], + ) + const handleCopyMessage = useCallback((messageId: string, text: string) => { analytics.chatMessageCopied({ message_id: messageId }) navigator.clipboard.writeText(text) @@ -2245,9 +2268,9 @@ export function ChatSidebar({ {researchRun ? ( ) : null} @@ -2354,20 +2377,18 @@ export function ChatSidebar({ : `Queue is full (${CHAT_QUEUE_LIMIT} max)` } activeStatus={ - researchIsActive - ? researchRun?.events.at(-1)?.message || "Researching…" - : isResponding && isQueueFull - ? `Queue full (${CHAT_QUEUE_LIMIT} max)` - : isWebSearching - ? "Searching the web…" - : status === "submitted" + isResponding && isQueueFull + ? `Queue full (${CHAT_QUEUE_LIMIT} max)` + : isWebSearching + ? "Searching the web…" + : status === "submitted" + ? "Thinking…" + : status === "streaming" ? "Thinking…" - : status === "streaming" - ? "Thinking…" - : "Waiting for input…" + : "Waiting for input…" } queuedMessages={messageQueue} - showStatusStrip={showInputStatusStrip} + showStatusStrip={showInputStatusStrip && !researchIsActive} onExpandedChange={setIsInputExpanded} chainOfThoughtComponent={ messages.length > 0 ? : null diff --git a/apps/web/components/chat/message/agent-message.tsx b/apps/web/components/chat/message/agent-message.tsx index c1f3de6c..4b8581fe 100644 --- a/apps/web/components/chat/message/agent-message.tsx +++ b/apps/web/components/chat/message/agent-message.tsx @@ -19,7 +19,6 @@ import { PlusIcon, SearchIcon, TerminalIcon, - TelescopeIcon, WrenchIcon, XCircleIcon, ZapIcon, @@ -37,10 +36,16 @@ import { extractMemoryToolOutputs, } from "@/lib/chat-memory-tools" import { + isSafeSourceId, parseSourceAnnotatedMarkdown, stripSourceMarkup, } from "@/lib/source-annotations" import { modelNames, type ModelId } from "@/lib/models" +import { + formatResearchDuration, + normalizeResearchMarkdownForDisplay, + type NovaResearchSource, +} from "@/lib/nova-research" import { RelatedMemories } from "./related-memories" import { MessageActions } from "./message-actions" @@ -78,6 +83,14 @@ type SourceUrlPart = { title?: string } +type ActionSource = { + id: string + title: string + subtitle?: string + url?: string + type: "memory" | "web" +} + function sourceHost(url: string): string { try { return new URL(url).hostname.replace(/^www\./, "") @@ -839,6 +852,20 @@ function SourceCitationLink({ ) const title = sourceTitle(target, document) const summary = sourceSummary(target, document) + const citationContent = + typeof children === "string" && + (children === sourceId || children === "memory") ? ( + + {sourceId} + + ) : ( + <> + {children} + + {sourceId} + + + ) return ( @@ -849,19 +876,16 @@ function SourceCitationLink({ rel="noopener noreferrer" className="text-inherit no-underline outline-none focus-visible:ring-1 focus-visible:ring-white/25" > - {children} + {citationContent} ) : ( )} - - {sourceId} - @@ -942,7 +966,7 @@ function makeMarkdownComponents( } } -function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) { +function SourcesPill({ sources }: { sources: ActionSource[] }) { const [expanded, setExpanded] = useState(false) const ref = useRef(null) @@ -960,8 +984,9 @@ function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) { if (sources.length === 0) return null const faviconHosts: string[] = [] - for (const s of sources) { - const host = sourceHost(s.url) + for (const source of sources) { + if (!source.url) continue + const host = sourceHost(source.url) if (!faviconHosts.includes(host)) faviconHosts.push(host) if (faviconHosts.length >= 3) break } @@ -974,7 +999,7 @@ function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) { onClick={() => setExpanded((v) => !v)} className="flex cursor-pointer items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] py-1 pr-2.5 pl-1.5 text-xs text-white/65 transition-colors hover:bg-white/[0.08] hover:text-white/80" aria-expanded={expanded} - aria-label={`${count} web ${count === 1 ? "source" : "sources"}`} + aria-label={`${count} ${count === 1 ? "source" : "sources"}`} > {faviconHosts.length > 0 ? ( @@ -997,16 +1022,11 @@ function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) { {expanded && (
    - {sources.map((s) => { - const host = sourceHost(s.url) - return ( -
  • - + {sources.map((source) => { + const host = source.url ? sourceHost(source.url) : null + const content = ( + <> + {host ? ( - - - {s.title?.trim() || host} - - - {host} - + ) : ( + + )} + + + {source.title} - + + {source.subtitle || host || "Saved memory"} + + + + ) + return ( +
  • + {source.url ? ( + + {content} + + ) : ( +
    + {content} +
    + )}
  • ) })} @@ -1263,6 +1303,89 @@ function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) { ) } +function SaveResearchToMemoryButton({ + runId, + apiBase, + className, +}: { + runId: string + apiBase: string + className?: string +}) { + const [saveState, setSaveState] = useState< + "idle" | "saving" | "saved" | "error" + >("idle") + const { data } = useQuery({ + queryKey: ["nova-research-memory-status", runId], + queryFn: async () => { + const response = await fetch(`${apiBase}/chat/research/${runId}`, { + credentials: "include", + }) + if (!response.ok) throw new Error("Could not load research status") + return (await response.json()) as { + run?: { reportDocumentId?: string | null } + } + }, + staleTime: 30 * 1000, + }) + const saved = saveState === "saved" || Boolean(data?.run?.reportDocumentId) + const saving = saveState === "saving" + + const save = async () => { + if (saved || saving) return + setSaveState("saving") + try { + const response = await fetch( + `${apiBase}/chat/research/${runId}/save-to-memory`, + { method: "POST", credentials: "include" }, + ) + const result = (await response.json().catch(() => null)) as { + error?: string + saved?: boolean + } | null + if (!response.ok || !result?.saved) { + throw new Error(result?.error || "Could not save this report") + } + setSaveState("saved") + } catch { + setSaveState("error") + } + } + + const label = saved + ? "Saved to memory" + : saveState === "error" + ? "Try saving again" + : "Save to memory" + + return ( + + ) +} + interface AgentMessageProps { message: UIMessage index: number @@ -1299,15 +1422,47 @@ export function AgentMessage({ .filter((part) => part.type === "text") .map((part) => part.text) .join(" ") - const copyText = stripSourceMarkup(messageText) + const researchMetadata = ( + message as UIMessage & { + metadata?: { + research?: { + runId?: string + title?: string + artifact?: string + durationMs?: number + sources?: NovaResearchSource[] + } + } + } + ).metadata?.research + const copyText = researchMetadata + ? normalizeResearchMarkdownForDisplay(stripSourceMarkup(messageText)) + : stripSourceMarkup(messageText) const memoryOutputs = useMemo( () => extractMemoryToolOutputs(message), [message], ) - const citationIndex = useMemo( - () => buildCitationIndex(memoryOutputs), - [memoryOutputs], - ) + const citationIndex = useMemo(() => { + const index = buildCitationIndex(memoryOutputs) + for (const source of researchMetadata?.sources ?? []) { + if ( + source.type !== "memory" || + !source.sourceId || + !isSafeSourceId(source.sourceId) || + index.has(source.sourceId) + ) { + continue + } + index.set(source.sourceId, { + sourceId: source.sourceId, + documentId: source.documentId, + title: source.title ?? source.space, + type: "memory", + url: source.url, + }) + } + return index + }, [memoryOutputs, researchMetadata?.sources]) const allowedSourceIds = useMemo( () => new Set(citationIndex.keys()), [citationIndex], @@ -1342,6 +1497,39 @@ export function AgentMessage({ } return out }, [message.parts]) + const actionSources = useMemo(() => { + const seen = new Set() + const sources: ActionSource[] = [] + for (const source of webSources) { + const id = `web:${source.url}` + if (seen.has(id)) continue + seen.add(id) + sources.push({ + id, + type: "web", + title: source.title?.trim() || sourceHost(source.url), + subtitle: sourceHost(source.url), + url: source.url, + }) + } + for (const source of researchMetadata?.sources ?? []) { + const id = source.url ? `web:${source.url}` : source.id + if (seen.has(id)) continue + seen.add(id) + sources.push({ + id, + type: source.type, + title: + source.title?.trim() || + source.space || + (source.type === "memory" ? "Saved memory" : "Web source"), + subtitle: + source.space || source.sourceId || source.documentId || undefined, + url: source.url, + }) + } + return sources + }, [researchMetadata?.sources, webSources]) const hasAssistantText = message.parts.some( (p) => p.type === "text" && (p as { text?: string }).text?.trim(), ) @@ -1352,49 +1540,27 @@ export function AgentMessage({ const responseModelLabel = responseModel ? `${modelNames[responseModel].name} ${modelNames[responseModel].version}` : null - const researchMetadata = ( - message as UIMessage & { - metadata?: { - research?: { runId?: string; title?: string; artifact?: string } - } - } - ).metadata?.research const researchApiBase = process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + const researchDuration = formatResearchDuration(researchMetadata?.durationMs) return (
    - {researchMetadata?.runId ? ( -
    -
    -
    - -
    -
    -
    - Nova Research Report -
    -
    - {researchMetadata.title || "Research complete"} -
    -
    -
    - - Markdown - -
    - ) : null} + {researchDuration ? ( +

    + Research completed in{" "} + + {researchDuration} + +

    + ) : null} {message.parts.map((part, partIndex) => { if (part.type === "source-url") { @@ -1445,17 +1611,18 @@ export function AgentMessage({ return (
    - { - parseSourceAnnotatedMarkdown(runText, allowedSourceIds) - .markdown - } + {researchMetadata + ? normalizeResearchMarkdownForDisplay( + parseSourceAnnotatedMarkdown( + runText, + allowedSourceIds, + ).markdown, + ) + : parseSourceAnnotatedMarkdown(runText, allowedSourceIds) + .markdown}
    ) @@ -1518,16 +1685,38 @@ export function AgentMessage({ onLike={onLike} onDislike={onDislike} /> - {webSources.length > 0 && ( + {actionSources.length > 0 && (
    - +
    )} + {researchMetadata?.runId ? ( + <> + + + + + + ) : null} {responseModelLabel && ( Promise +}) { + const [questionIndex, setQuestionIndex] = useState(0) + const [answers, setAnswers] = useState>({}) + const [otherByQuestion, setOtherByQuestion] = useState< + Record + >({}) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const question = request.questions[questionIndex] + if (!question) return null + + const answer = answers[question.id] ?? "" + const usingOther = otherByQuestion[question.id] === true + const isLast = questionIndex === request.questions.length - 1 + const canContinue = answer.trim().length > 0 + + const submit = async () => { + if (!canContinue || submitting) return + setSubmitting(true) + setError(null) + try { + await onSubmit( + request.questions.map((item) => ({ + questionId: item.id, + value: answers[item.id]?.trim() ?? "", + })), + ) + } catch (submitError) { + setError( + submitError instanceof Error + ? submitError.message + : "Could not submit your answers.", + ) + setSubmitting(false) + } + } + + return ( +
    +
    +
    +
    +

    + A few details first +

    + {request.intro ? ( +

    {request.intro}

    + ) : null} +
    + + {questionIndex + 1} / {request.questions.length} + +
    +
    + {request.questions.map((item, index) => ( + + ))} +
    +
    + +
    +

    + {question.question} +

    +
    + {question.options.map((option) => { + const selected = !usingOther && answer === option.label + return ( + + ) + })} + {question.allowOther !== false ? ( + + ) : null} +
    + {usingOther ? ( +