polish Nova research reports

This commit is contained in:
Ishaan Gupta 2026-08-01 20:08:09 +05:30
parent b4d6a21c19
commit bf2734be7d
9 changed files with 953 additions and 226 deletions

View file

@ -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 ? (
<ResearchProgress
run={researchRun}
apiBase={chatApiBase}
onCancel={handleStop}
className="mt-2"
onSubmitClarification={handleSubmitResearchClarification}
className="mt-1"
/>
) : null}
</div>
@ -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 ? <ChainOfThought messages={messages} /> : null

View file

@ -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") ? (
<span className="inline-flex h-4 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] px-1.5 text-[9px] font-medium leading-none text-white/50 transition-colors group-hover/source:text-white/70 group-focus-within/source:text-white/70">
{sourceId}
</span>
) : (
<>
{children}
<span className="ml-1 inline-flex h-3.5 min-w-3.5 translate-y-[-1px] items-center justify-center rounded-full border border-white/10 bg-white/[0.04] px-1 text-[9px] font-medium leading-none text-white/45 transition-colors group-hover/source:text-white/65 group-focus-within/source:text-white/65">
{sourceId}
</span>
</>
)
return (
<span className="group/source relative inline rounded-[3px] border-b border-dotted border-white/20 bg-white/[0.025] px-px text-white/90 transition-colors hover:border-white/35 hover:bg-white/[0.045] focus-within:border-white/35 focus-within:bg-white/[0.045]">
@ -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}
</a>
) : (
<button
type="button"
className="cursor-help border-0 bg-transparent p-0 text-inherit"
>
{children}
{citationContent}
</button>
)}
<span className="ml-1 inline-flex h-3.5 min-w-3.5 translate-y-[-1px] items-center justify-center rounded-full border border-white/10 bg-white/[0.04] px-1 text-[9px] font-medium leading-none text-white/45 transition-colors group-hover/source:text-white/65 group-focus-within/source:text-white/65">
{sourceId}
</span>
<span className="pointer-events-none absolute bottom-full left-1/2 z-[1000] hidden w-72 -translate-x-1/2 pb-2 group-hover/source:block group-focus-within/source:block">
<span className="pointer-events-auto block rounded-xl border border-white/10 bg-[#0B0F16]/95 p-3 text-left shadow-[0_16px_44px_rgba(0,0,0,0.48)] backdrop-blur-xl">
<span className="mb-1 flex items-center justify-between gap-2">
@ -942,7 +966,7 @@ function makeMarkdownComponents(
}
}
function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) {
function SourcesPill({ sources }: { sources: ActionSource[] }) {
const [expanded, setExpanded] = useState(false)
const ref = useRef<HTMLDivElement>(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"}`}
>
<span className="flex -space-x-1.5">
{faviconHosts.length > 0 ? (
@ -997,16 +1022,11 @@ function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) {
{expanded && (
<div className="absolute bottom-full left-0 z-[1000] mb-2 w-[min(22rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-white/10 bg-[#0B0F16]/95 p-1.5 shadow-[0_12px_32px_rgba(0,0,0,0.4)] backdrop-blur-xl">
<ul className="max-h-72 list-none space-y-0.5 overflow-y-auto">
{sources.map((s) => {
const host = sourceHost(s.url)
return (
<li key={s.sourceId}>
<a
href={s.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-start gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-white/[0.06]"
>
{sources.map((source) => {
const host = source.url ? sourceHost(source.url) : null
const content = (
<>
{host ? (
<img
src={faviconUrl(host)}
alt=""
@ -1014,15 +1034,35 @@ function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) {
loading="lazy"
referrerPolicy="no-referrer"
/>
<span className="min-w-0">
<span className="block truncate text-white/80">
{s.title?.trim() || host}
</span>
<span className="block truncate text-[11px] text-white/40">
{host}
</span>
) : (
<BookOpenIcon className="mt-0.5 size-4 shrink-0 text-white/35" />
)}
<span className="min-w-0">
<span className="block truncate text-white/80">
{source.title}
</span>
</a>
<span className="block truncate text-[11px] text-white/40">
{source.subtitle || host || "Saved memory"}
</span>
</span>
</>
)
return (
<li key={source.id}>
{source.url ? (
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-start gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-white/[0.06]"
>
{content}
</a>
) : (
<div className="flex items-start gap-2 rounded-lg px-2 py-1.5">
{content}
</div>
)}
</li>
)
})}
@ -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 (
<button
type="button"
onClick={() => void save()}
disabled={saved || saving}
title={label}
className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] transition-colors",
saved
? "text-emerald-400/75"
: saveState === "error"
? "text-red-300/75 hover:bg-red-400/10"
: "text-white/50 hover:bg-white/10 hover:text-white/80",
className,
)}
>
{saving ? (
<Loader2 className="size-3.5 animate-spin" />
) : saved ? (
<CheckIcon className="size-3.5" />
) : (
<BookOpenIcon className="size-3.5" />
)}
<span>{saving ? "Saving…" : label}</span>
</button>
)
}
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<string>()
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 (
<div className="flex flex-col gap-1 w-full">
<div className="flex gap-2">
<div className="flex flex-col gap-2 w-full">
{researchMetadata?.runId ? (
<div className="flex items-center justify-between gap-3 rounded-xl border border-[#267BF1]/20 bg-[linear-gradient(135deg,rgba(38,123,241,0.12),rgba(9,18,32,0.72))] px-3.5 py-3">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-[#267BF1]/12">
<TelescopeIcon className="size-4 text-[#8DBDFF]" />
</div>
<div className="min-w-0">
<div className="text-[10px] font-medium uppercase tracking-[0.12em] text-[#8DBDFF]/70">
Nova Research Report
</div>
<div className="mt-0.5 truncate text-sm font-medium text-white/90">
{researchMetadata.title || "Research complete"}
</div>
</div>
</div>
<a
href={`${researchApiBase}/chat/research/${researchMetadata.runId}/report.md`}
download
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-[#267BF1]/25 bg-[#267BF1]/10 px-2.5 py-1.5 text-[11px] text-[#A8CCFF] transition-colors hover:bg-[#267BF1]/20"
>
<DownloadIcon className="size-3" /> Markdown
</a>
</div>
) : null}
<RelatedMemories
message={message}
expandedMemories={expandedMemories}
onToggle={onToggleMemories}
/>
{researchDuration ? (
<p className="mb-1 text-xs text-white/40">
Research completed in{" "}
<span className="tabular-nums text-white/60">
{researchDuration}
</span>
</p>
) : null}
{message.parts.map((part, partIndex) => {
if (part.type === "source-url") {
@ -1445,17 +1611,18 @@ export function AgentMessage({
return (
<div
key={`${message.id}-${partIndex}`}
className={cn(
"text-sm text-white/90 chat-markdown-content",
researchMetadata?.runId &&
"rounded-xl border border-white/[0.06] bg-white/[0.02] px-4 py-3.5",
)}
className={cn("text-sm text-white/90 chat-markdown-content")}
>
<Streamdown components={markdownComponents}>
{
parseSourceAnnotatedMarkdown(runText, allowedSourceIds)
.markdown
}
{researchMetadata
? normalizeResearchMarkdownForDisplay(
parseSourceAnnotatedMarkdown(
runText,
allowedSourceIds,
).markdown,
)
: parseSourceAnnotatedMarkdown(runText, allowedSourceIds)
.markdown}
</Streamdown>
</div>
)
@ -1518,16 +1685,38 @@ export function AgentMessage({
onLike={onLike}
onDislike={onDislike}
/>
{webSources.length > 0 && (
{actionSources.length > 0 && (
<div
className={cn(
"transition-opacity duration-200",
isHovered || isLastAgentMessage ? "opacity-100" : "opacity-0",
)}
>
<WebSourcesPill sources={webSources} />
<SourcesPill sources={actionSources} />
</div>
)}
{researchMetadata?.runId ? (
<>
<SaveResearchToMemoryButton
runId={researchMetadata.runId}
apiBase={researchApiBase}
className={
isHovered || isLastAgentMessage ? "opacity-100" : "opacity-0"
}
/>
<a
href={`${researchApiBase}/chat/research/${researchMetadata.runId}/report.md`}
download
title="Download research as Markdown"
className={cn(
"rounded p-1.5 text-white/50 transition-colors hover:bg-white/10 hover:text-white/80",
isHovered || isLastAgentMessage ? "opacity-100" : "opacity-0",
)}
>
<DownloadIcon className="size-3.5" />
</a>
</>
) : null}
{responseModelLabel && (
<span
className={cn(

View file

@ -0,0 +1,207 @@
"use client"
import { useState } from "react"
import {
ArrowLeftIcon,
ArrowRightIcon,
CheckIcon,
Loader2Icon,
} from "lucide-react"
import { cn } from "@lib/utils"
import type {
NovaResearchClarificationAnswer,
NovaResearchClarificationRequest,
} from "@/lib/nova-research"
export function ResearchClarification({
request,
onSubmit,
}: {
request: NovaResearchClarificationRequest
onSubmit: (answers: NovaResearchClarificationAnswer[]) => Promise<void>
}) {
const [questionIndex, setQuestionIndex] = useState(0)
const [answers, setAnswers] = useState<Record<string, string>>({})
const [otherByQuestion, setOtherByQuestion] = useState<
Record<string, boolean>
>({})
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div className="mt-3 overflow-hidden rounded-2xl border border-blue-400/15 bg-blue-400/[0.035]">
<div className="border-white/[0.07] border-b px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-white/85">
A few details first
</p>
{request.intro ? (
<p className="mt-0.5 text-xs text-white/45">{request.intro}</p>
) : null}
</div>
<span className="shrink-0 text-xs tabular-nums text-white/35">
{questionIndex + 1} / {request.questions.length}
</span>
</div>
<div className="mt-3 flex gap-1">
{request.questions.map((item, index) => (
<span
key={item.id}
className={cn(
"h-1 flex-1 rounded-full transition-colors",
index <= questionIndex ? "bg-blue-400/75" : "bg-white/10",
)}
/>
))}
</div>
</div>
<div className="px-4 py-4">
<h3 className="text-sm leading-relaxed text-white/85">
{question.question}
</h3>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{question.options.map((option) => {
const selected = !usingOther && answer === option.label
return (
<button
type="button"
key={option.label}
onClick={() => {
setAnswers((current) => ({
...current,
[question.id]: option.label,
}))
setOtherByQuestion((current) => ({
...current,
[question.id]: false,
}))
}}
className={cn(
"flex min-h-12 items-start gap-2 rounded-xl border px-3 py-2.5 text-left transition-colors",
selected
? "border-blue-400/45 bg-blue-400/10"
: "border-white/[0.08] bg-white/[0.025] hover:border-white/15 hover:bg-white/[0.045]",
)}
>
<span
className={cn(
"mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border",
selected
? "border-blue-400 bg-blue-400 text-[#07111F]"
: "border-white/20",
)}
>
{selected ? <CheckIcon className="size-2.5" /> : null}
</span>
<span>
<span className="block text-xs font-medium text-white/78">
{option.label}
</span>
{option.description ? (
<span className="mt-0.5 block text-[11px] leading-snug text-white/38">
{option.description}
</span>
) : null}
</span>
</button>
)
})}
{question.allowOther !== false ? (
<button
type="button"
onClick={() => {
setOtherByQuestion((current) => ({
...current,
[question.id]: true,
}))
setAnswers((current) => ({ ...current, [question.id]: "" }))
}}
className={cn(
"min-h-12 rounded-xl border px-3 py-2.5 text-left text-xs font-medium transition-colors",
usingOther
? "border-blue-400/45 bg-blue-400/10 text-white/80"
: "border-white/[0.08] bg-white/[0.025] text-white/55 hover:border-white/15 hover:bg-white/[0.045]",
)}
>
Something else
</button>
) : null}
</div>
{usingOther ? (
<textarea
value={answer}
onChange={(event) =>
setAnswers((current) => ({
...current,
[question.id]: event.target.value,
}))
}
placeholder="Write your answer…"
maxLength={500}
className="mt-3 min-h-20 w-full resize-none rounded-xl border border-white/10 bg-black/15 px-3 py-2.5 text-xs text-white/80 outline-none placeholder:text-white/25 focus:border-blue-400/40"
/>
) : null}
{error ? <p className="mt-3 text-xs text-red-300">{error}</p> : null}
<div className="mt-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={() => setQuestionIndex((index) => Math.max(0, index - 1))}
disabled={questionIndex === 0 || submitting}
className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-2 text-xs text-white/45 transition-colors hover:bg-white/5 hover:text-white/70 disabled:pointer-events-none disabled:opacity-25"
>
<ArrowLeftIcon className="size-3.5" /> Back
</button>
<button
type="button"
onClick={() => {
if (isLast) void submit()
else setQuestionIndex((index) => index + 1)
}}
disabled={!canContinue || submitting}
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-500/90 px-3 py-2 text-xs font-medium text-white transition-colors hover:bg-blue-400 disabled:pointer-events-none disabled:opacity-35"
>
{submitting ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : isLast ? (
<CheckIcon className="size-3.5" />
) : (
<ArrowRightIcon className="size-3.5" />
)}
{isLast ? "Start research" : "Next"}
</button>
</div>
</div>
</div>
)
}

View file

@ -1,19 +1,27 @@
"use client"
import { useState } from "react"
import {
ArrowRightIcon,
CheckCircle2Icon,
ChevronDownIcon,
ChevronUpIcon,
CircleHelpIcon,
CircleIcon,
DownloadIcon,
ExternalLinkIcon,
Loader2Icon,
SearchIcon,
SquareIcon,
TelescopeIcon,
XCircleIcon,
} from "lucide-react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import type { NovaResearchEvent, NovaResearchRun } from "@/lib/nova-research"
import {
pendingResearchClarification,
type NovaResearchClarificationAnswer,
type NovaResearchEvent,
type NovaResearchRun,
} from "@/lib/nova-research"
import { ResearchClarification } from "./research-clarification"
function toolLabel(name: string | null, fallback: string | null): string {
if (fallback) return fallback
@ -22,17 +30,28 @@ function toolLabel(name: string | null, fallback: string | null): string {
.replace(/\b\w/g, (character) => character.toUpperCase())
}
function eventLabel(event: NovaResearchEvent): string | null {
if (event.title === "Research capabilities enabled" && event.message) {
return `Enabled ${event.message} research`
}
if (event.type === "tool") return toolLabel(event.toolName, event.title)
return event.message || event.title
}
function eventIcon(event: NovaResearchEvent) {
if (event.status === "running" || event.status === "pending") {
return <Loader2Icon className="size-3.5 animate-spin text-[#8DBDFF]" />
return <Loader2Icon className="size-3.5 animate-spin text-white/55" />
}
if (event.status === "failed" || event.type === "error") {
return <XCircleIcon className="size-3.5 text-red-400" />
}
if (event.type === "tool") {
return <CheckCircle2Icon className="size-3.5 text-emerald-400/85" />
return <CheckCircle2Icon className="size-3.5 text-emerald-400/80" />
}
return <CircleIcon className="size-3 fill-[#267BF1] text-[#267BF1]" />
if (event.type === "assistant") {
return <ArrowRightIcon className="size-3.5 text-blue-400/85" />
}
return <CircleIcon className="size-3 fill-white/35 text-white/35" />
}
function isUsefulTimelineEvent(event: NovaResearchEvent): boolean {
@ -47,157 +66,145 @@ function isUsefulTimelineEvent(event: NovaResearchEvent): boolean {
export function ResearchProgress({
run,
apiBase,
onCancel,
onSubmitClarification,
className,
}: {
run: NovaResearchRun
apiBase: string
onCancel: () => void
onSubmitClarification: (
requestId: string,
answers: NovaResearchClarificationAnswer[],
) => Promise<void>
className?: string
}) {
const active = run.status === "queued" || run.status === "running"
const active =
run.status === "queued" ||
run.status === "running" ||
run.status === "awaiting_input"
const failed = run.status === "failed"
const cancelled = run.status === "cancelled"
const awaitingInput = run.status === "awaiting_input"
const [expanded, setExpanded] = useState(true)
const timeline = run.events.filter(isUsefulTimelineEvent)
const statusLabel = active
? "Researching"
: failed
? "Research failed"
: cancelled
? "Research stopped"
: "Research complete"
const clarification = pendingResearchClarification(run)
const statusLabel = awaitingInput
? "Waiting for your answers"
: active
? "Researching"
: failed
? "Research failed"
: cancelled
? "Research stopped"
: "Research complete"
return (
<section
className={cn(
"w-full overflow-hidden rounded-2xl border border-[#1B2D47] bg-[linear-gradient(145deg,rgba(8,20,38,0.96),rgba(4,9,17,0.96))] shadow-[0_18px_60px_rgba(0,0,0,0.28)]",
dmSansClassName(),
className,
)}
className={cn("w-full py-1", dmSansClassName(), className)}
aria-live="polite"
data-testid="nova-research-activity"
>
<div className="flex items-start justify-between gap-3 border-[#1B2D47] border-b px-4 py-3.5">
<div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-xl border border-[#267BF1]/25 bg-[#267BF1]/10">
{active ? (
<Loader2Icon className="size-4 animate-spin text-[#8DBDFF]" />
) : (
<TelescopeIcon className="size-4 text-[#8DBDFF]" />
)}
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-medium text-sm text-white">{statusLabel}</h3>
<span className="rounded-full bg-white/[0.05] px-2 py-0.5 text-[10px] text-white/45">
{run.toolCallCount} tool call
{run.toolCallCount === 1 ? "" : "s"}
</span>
</div>
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-white/48">
{run.query}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{awaitingInput ? (
<CircleHelpIcon className="size-4 shrink-0 text-blue-400/85" />
) : active ? (
<Loader2Icon className="size-4 shrink-0 animate-spin text-white/65" />
) : failed ? (
<XCircleIcon className="size-4 shrink-0 text-red-400" />
) : (
<ArrowRightIcon className="size-4 shrink-0 text-blue-400/85" />
)}
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="flex min-w-0 items-center gap-1.5 text-left text-sm text-white/72 transition-colors hover:text-white"
aria-expanded={expanded}
>
<span className="font-medium">{statusLabel}</span>
<span className="text-xs text-white/35">
· {run.toolCallCount} tool call{run.toolCallCount === 1 ? "" : "s"}
</span>
{expanded ? (
<ChevronUpIcon className="size-3.5 text-white/35" />
) : (
<ChevronDownIcon className="size-3.5 text-white/35" />
)}
</button>
{active ? (
<button
type="button"
onClick={onCancel}
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-white/10 px-2.5 py-1.5 text-[11px] text-white/55 transition-colors hover:border-white/20 hover:bg-white/5 hover:text-white/85"
className="ml-auto flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-[11px] text-white/40 transition-colors hover:bg-white/[0.06] hover:text-white/75"
>
<SquareIcon className="size-2.5 fill-current" /> Stop
</button>
) : run.reportMarkdown ? (
<a
href={`${apiBase}/chat/research/${run.id}/report.md`}
download
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-[#267BF1]/30 bg-[#267BF1]/10 px-2.5 py-1.5 text-[11px] text-[#A8CCFF] transition-colors hover:bg-[#267BF1]/20"
>
<DownloadIcon className="size-3" /> Markdown
</a>
) : null}
</div>
{run.plan ? (
<div className="border-[#1B2D47]/80 border-b px-4 py-3">
<div className="mb-2 text-[10px] font-medium uppercase tracking-[0.12em] text-white/35">
Plan
</div>
<div className="grid gap-1.5 sm:grid-cols-2">
{run.plan.steps.map((step) => (
<div
key={step.id}
className="flex min-w-0 items-center gap-2 text-xs text-white/58"
>
{step.status === "complete" ? (
<CheckCircle2Icon className="size-3.5 shrink-0 text-emerald-400/80" />
) : step.status === "in_progress" ? (
<Loader2Icon className="size-3.5 shrink-0 animate-spin text-[#8DBDFF]" />
) : (
<CircleIcon className="size-3.5 shrink-0 text-white/20" />
)}
<span className="truncate">{step.title}</span>
</div>
))}
</div>
</div>
{clarification ? (
<ResearchClarification
key={clarification.id}
request={clarification}
onSubmit={(answers) =>
onSubmitClarification(clarification.id, answers)
}
/>
) : null}
<div className="max-h-72 overflow-y-auto px-4 py-3">
<div className="relative space-y-3 before:absolute before:top-2 before:bottom-2 before:left-[6px] before:w-px before:bg-[#1B2D47]">
{timeline.length === 0 ? (
<div className="flex items-center gap-2 text-xs text-white/45">
<Loader2Icon className="size-3.5 animate-spin text-[#8DBDFF]" />
Preparing the investigation
</div>
) : (
timeline.map((event) => (
<div key={event.id} className="relative flex items-start gap-3">
<div className="z-10 flex size-3.5 shrink-0 items-center justify-center bg-[#081426]">
{eventIcon(event)}
</div>
<div className="min-w-0 flex-1 -mt-0.5">
<div className="text-xs leading-relaxed text-white/72">
{event.type === "tool"
? toolLabel(event.toolName, event.title)
: event.message || event.title}
</div>
{event.type === "tool" && event.toolName ? (
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-white/28">
<SearchIcon className="size-2.5" /> {event.toolName}
</div>
) : null}
</div>
{expanded && !clarification ? (
<div className="mt-3 ml-[7px] border-white/10 border-l pl-5">
{run.plan ? (
<div className="mb-4">
<div className="mb-2 text-[10px] font-medium uppercase tracking-[0.12em] text-white/30">
Plan
</div>
))
)}
</div>
</div>
<div className="space-y-1.5">
{run.plan.steps.map((step) => (
<div
key={step.id}
className="flex min-w-0 items-center gap-2 text-xs text-white/55"
>
{step.status === "complete" ? (
<CheckCircle2Icon className="size-3.5 shrink-0 text-emerald-400/80" />
) : step.status === "in_progress" ? (
<Loader2Icon className="size-3.5 shrink-0 animate-spin text-white/60" />
) : (
<CircleIcon className="size-3.5 shrink-0 text-white/20" />
)}
<span>{step.title}</span>
</div>
))}
</div>
</div>
) : null}
{run.sources.length > 0 ? (
<div className="flex items-center gap-2 overflow-x-auto border-[#1B2D47]/80 border-t px-4 py-2.5">
<span className="shrink-0 text-[10px] text-white/30">Sources</span>
{run.sources.slice(0, 8).map((source) =>
source.url ? (
<a
key={source.id}
href={source.url}
target="_blank"
rel="noreferrer"
className="flex max-w-44 shrink-0 items-center gap-1 truncate rounded-full bg-white/[0.05] px-2 py-1 text-[10px] text-white/48 hover:text-white/75"
>
<span className="truncate">{source.title || source.url}</span>
<ExternalLinkIcon className="size-2.5 shrink-0" />
</a>
<div className="max-h-72 space-y-3 overflow-y-auto pr-2">
{timeline.length === 0 ? (
<div className="flex items-center gap-2 text-xs text-white/45">
<Loader2Icon className="size-3.5 animate-spin" />
Preparing the investigation
</div>
) : (
<span
key={source.id}
className="max-w-44 shrink-0 truncate rounded-full bg-white/[0.05] px-2 py-1 text-[10px] text-white/48"
>
{source.title || source.space || "Memory"}
</span>
),
)}
timeline.map((event) => {
const label = eventLabel(event)
if (!label) return null
return (
<div key={event.id} className="flex items-start gap-2.5">
<div className="mt-0.5 flex size-3.5 shrink-0 items-center justify-center">
{eventIcon(event)}
</div>
<div className="min-w-0 flex-1 text-xs leading-relaxed text-white/65">
{label}
{event.type === "tool" && event.toolName ? (
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-white/25">
<SearchIcon className="size-2.5" /> {event.toolName}
</div>
) : null}
</div>
</div>
)
})
)}
</div>
</div>
) : null}
</section>

View file

@ -192,6 +192,7 @@
/* Loose lists wrap item content in <p> whose top margin detaches the bullet */
.chat-markdown-content li > p:first-child {
display: inline;
margin-top: 0;
}

View file

@ -0,0 +1,90 @@
import { describe, expect, it } from "bun:test"
import {
isActiveResearchRun,
normalizeResearchMarkdownForDisplay,
pendingResearchClarification,
type NovaResearchRun,
} from "./nova-research"
function run(overrides: Partial<NovaResearchRun> = {}): NovaResearchRun {
return {
id: "run-1",
threadId: "thread-1",
userMessageId: "user-1",
assistantMessageId: "assistant-1",
workflowInstanceId: "workflow-1",
query: "Find a university",
model: "gpt-5.1",
reasoningEffort: "thinking",
spaceMode: "auto",
projectId: "sm_project_default",
status: "awaiting_input",
plan: null,
sources: [],
reportTitle: null,
reportMarkdown: null,
reportDocumentId: null,
error: null,
toolCallCount: 0,
startedAt: null,
completedAt: null,
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
events: [],
...overrides,
}
}
describe("research clarification state", () => {
it("keeps an awaiting-input run active", () => {
expect(isActiveResearchRun(run())).toBe(true)
})
it("restores the latest pending clarification from persisted events", () => {
const request = {
id: "clarification-1",
intro: "Help me narrow this down.",
questions: [
{
id: "intake",
question: "Which intake?",
options: [{ label: "2026" }, { label: "2027" }],
},
],
}
const current = run({
events: [
{
id: "event-1",
sequence: 1,
type: "clarification",
status: "pending",
title: "A few details first",
message: request.intro,
toolName: "request_clarification",
input: request,
output: null,
createdAt: new Date(0).toISOString(),
},
],
})
expect(pendingResearchClarification(current)).toEqual(request)
})
})
describe("research report display", () => {
it("keeps citations inline and removes the appended source list", () => {
const markdown = [
"Evidence.[^one]",
"",
"## Sources",
"",
"[^one]: [Source](https://example.com/evidence)",
].join("\n")
expect(normalizeResearchMarkdownForDisplay(markdown)).toBe(
"Evidence.[1](https://example.com/evidence)",
)
})
})

View file

@ -1,6 +1,7 @@
export type NovaResearchStatus =
| "queued"
| "running"
| "awaiting_input"
| "completed"
| "failed"
| "cancelled"
@ -24,12 +25,94 @@ export type NovaResearchSource = {
space?: string
}
const FOOTNOTE_REFERENCE_RE = /\[\^([A-Za-z0-9_-]+)\](?!:)/g
const MARKDOWN_LINK_RE = /\]\((https?:\/\/[^\s)]+)(?:\s+"[^"]*")?\)/g
const AUTOLINK_RE = /<(https?:\/\/[^\s>]+)>/g
const REFERENCE_SECTION_RE =
/(?:^|\n)(?:---\s*\n)?#{1,6}\s+(?:sources|references|footnotes)\s*\n[\s\S]*$/i
function markdownUrls(markdown: string): string[] {
return [
...markdown.matchAll(MARKDOWN_LINK_RE),
...markdown.matchAll(AUTOLINK_RE),
]
.map((match) => match[1])
.filter((url): url is string => Boolean(url))
}
export function normalizeResearchMarkdownForDisplay(markdown: string): string {
const definitions = new Map<string, string>()
const bodyLines: string[] = []
const lines = markdown.replace(/\r\n?/g, "\n").split("\n")
for (let index = 0; index < lines.length; index++) {
const line = lines[index] ?? ""
const match = line.match(/^\[\^([A-Za-z0-9_-]+)\]:\s*(.*)$/)
if (!match?.[1]) {
bodyLines.push(line)
continue
}
const parts = [match[2] ?? ""]
while (/^(?:\t| {2,})\S/.test(lines[index + 1] ?? "")) {
index++
parts.push((lines[index] ?? "").trim())
}
definitions.set(match[1], parts.join(" ").trim())
}
const citationNumberByUrl = new Map<string, number>()
let nextCitationNumber = 1
const withInlineCitations = bodyLines
.join("\n")
.replace(FOOTNOTE_REFERENCE_RE, (reference, footnoteId: string) => {
const definition = definitions.get(footnoteId)
if (!definition) return reference
const urls = [...new Set(markdownUrls(definition))]
if (urls.length === 0) return reference
return urls
.map((url) => {
let number = citationNumberByUrl.get(url)
if (!number) {
number = nextCitationNumber++
citationNumberByUrl.set(url, number)
}
return `[${number}](${url})`
})
.join(" ")
})
return withInlineCitations
.replace(REFERENCE_SECTION_RE, "")
.replace(/(?:^|\n)---\s*$/, "")
.replace(/\n{3,}/g, "\n\n")
.trim()
}
export function formatResearchDuration(durationMs?: number): string | null {
if (
typeof durationMs !== "number" ||
!Number.isFinite(durationMs) ||
durationMs < 0
) {
return null
}
const totalSeconds = Math.max(1, Math.round(durationMs / 1000))
if (totalSeconds < 60) return `${totalSeconds}s`
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`
}
export type NovaResearchEvent = {
id: string
sequence: number
type:
| "status"
| "assistant"
| "clarification"
| "plan"
| "tool"
| "source"
@ -70,6 +153,67 @@ export type NovaResearchRun = {
events: NovaResearchEvent[]
}
export function isActiveResearchRun(run: NovaResearchRun | null): boolean {
return run?.status === "queued" || run?.status === "running"
export type NovaResearchClarificationQuestion = {
id: string
question: string
options: Array<{ label: string; description?: string }>
allowOther?: boolean
}
export type NovaResearchClarificationRequest = {
id: string
intro?: string
questions: NovaResearchClarificationQuestion[]
}
export type NovaResearchClarificationAnswer = {
questionId: string
value: string
}
function isClarificationRequest(
value: unknown,
): value is NovaResearchClarificationRequest {
if (!value || typeof value !== "object") return false
const request = value as Partial<NovaResearchClarificationRequest>
return (
typeof request.id === "string" &&
Array.isArray(request.questions) &&
request.questions.length > 0 &&
request.questions.every(
(question) =>
question &&
typeof question.id === "string" &&
typeof question.question === "string" &&
Array.isArray(question.options) &&
question.options.length >= 2 &&
question.options.every(
(option) => option && typeof option.label === "string",
),
)
)
}
export function pendingResearchClarification(
run: NovaResearchRun,
): NovaResearchClarificationRequest | null {
for (let index = run.events.length - 1; index >= 0; index--) {
const event = run.events[index]
if (
event?.type === "clarification" &&
event.status === "pending" &&
isClarificationRequest(event.input)
) {
return event.input
}
}
return null
}
export function isActiveResearchRun(run: NovaResearchRun | null): boolean {
return (
run?.status === "queued" ||
run?.status === "running" ||
run?.status === "awaiting_input"
)
}

View file

@ -78,6 +78,35 @@ describe("source annotation parsing", () => {
)
})
it("turns known bare memory ids into internal citation links", () => {
const parsed = parseSourceAnnotatedMarkdown(
"Fact [S1]. Combined [S1, S2]. Unknown [S3].",
new Set(["S1", "S2"]),
)
expect(parsed.markdown).toBe(
"Fact [S1](#sm-source:S1). Combined [S1](#sm-source:S1) [S2](#sm-source:S2). Unknown [S3].",
)
})
it("does not reinterpret normal markdown as bare memory citations", () => {
const input = [
"[S1](https://example.com)",
"[S1][ref]",
"[S1]: https://example.com",
"![S1](image.png)",
"\\[S1]",
"`[S1]`",
"```",
"[S1]",
"```",
].join("\n")
expect(parseSourceAnnotatedMarkdown(input, new Set(["S1"])).markdown).toBe(
input,
)
})
it("strips source markup for copy text", () => {
expect(
stripSourceMarkup('Alpha <response source="S1">Beta</response>'),

View file

@ -225,6 +225,45 @@ export function parseSourceAnnotatedMarkdown(
}
}
if (
!codeState.inFence &&
!codeState.inInlineCode &&
text[i] === "[" &&
text[i - 1] !== "!" &&
text[i - 1] !== "\\"
) {
const closeIndex = text.indexOf("]", i + 1)
if (closeIndex !== -1 && !text.slice(i + 1, closeIndex).includes("\n")) {
const sourceIds = text
.slice(i + 1, closeIndex)
.split(",")
.map((sourceId) => sourceId.trim())
const next = text[closeIndex + 1]
if (
sourceIds.length > 0 &&
sourceIds.every(
(sourceId) =>
isSafeSourceId(sourceId) && allowedSourceIds.has(sourceId),
) &&
next !== "(" &&
next !== "[" &&
next !== ":"
) {
output.push(
sourceIds
.map(
(sourceId) =>
`[${escapeMarkdownLinkText(sourceId)}](#sm-source:${encodeURIComponent(sourceId)})`,
)
.join(" "),
)
i = closeIndex + 1
codeState.lineStart = false
continue
}
}
}
appendChar(text, i, output, codeState)
i++
}