harden Nova research experience

This commit is contained in:
Ishaan Gupta 2026-08-03 20:41:00 +05:30
parent d81f307fc8
commit d6edf7c03d
4 changed files with 764 additions and 69 deletions

View file

@ -85,7 +85,9 @@ import {
import {
isActiveResearchRun,
type NovaResearchClarificationAnswer,
type NovaResearchConnectionState,
type NovaResearchRun,
researchPollDelayMs,
} from "@/lib/nova-research"
type ChatMessageSendSource = "typed" | "suggested" | "highlight" | "home"
@ -230,6 +232,12 @@ export function ChatSidebar({
const chatModeRef = useRef(chatMode)
chatModeRef.current = chatMode
const [researchRun, setResearchRun] = useState<NovaResearchRun | null>(null)
const [researchConnection, setResearchConnection] =
useState<NovaResearchConnectionState>({
status: "connected",
attempts: 0,
})
const [researchPollRevision, setResearchPollRevision] = useState(0)
const researchIsActive = isActiveResearchRun(researchRun)
const selectedModelRef = useRef(selectedModel)
selectedModelRef.current = selectedModel
@ -936,6 +944,8 @@ export function ChatSidebar({
text: string,
source: ChatMessageSendSource,
drafts = attachmentDrafts,
modeOverride?: NovaChatMode,
preserveComposer = false,
): Promise<boolean> => {
const trimmed = text.trim()
if (!trimmed && drafts.length === 0) return false
@ -957,7 +967,7 @@ export function ChatSidebar({
const messageText = trimmed || "Analyze the attached file(s)."
const isRespondingNow = status === "submitted" || status === "streaming"
if (chatMode === "research") {
if ((modeOverride ?? chatMode) === "research") {
if (isRespondingNow || researchIsActive) return false
const userMessageId = generateId()
if (!threadId) setThreadId(fallbackChatId)
@ -973,11 +983,13 @@ export function ChatSidebar({
: undefined,
},
])
setInput("")
setAttachmentDrafts([])
uploadPromisesRef.current.clear()
abortControllersRef.current.clear()
discardedDraftIdsRef.current.clear()
if (!preserveComposer) {
setInput("")
setAttachmentDrafts([])
uploadPromisesRef.current.clear()
abortControllersRef.current.clear()
discardedDraftIdsRef.current.clear()
}
userJustSentRef.current = true
scrollToBottom()
analytics.chatMessageSent({
@ -1200,27 +1212,96 @@ export function ChatSidebar({
// Keep the user message on stop so it isn't lost when generation is halted
// before any assistant response arrives (ENG-732).
const handleStop = useCallback(() => {
if (researchRun && isActiveResearchRun(researchRun)) {
void fetch(`${chatApiBase}/chat/research/${researchRun.id}/cancel`, {
const cancelResearch = useCallback(async () => {
if (!researchRun || !isActiveResearchRun(researchRun)) return
const runId = researchRun.id
const response = await fetch(
`${chatApiBase}/chat/research/${runId}/cancel`,
{
method: "POST",
credentials: "include",
}).then((response) => {
if (!response.ok) return
setResearchRun((current) =>
current?.id === researchRun.id
? {
...current,
status: "cancelled",
completedAt: new Date().toISOString(),
}
: current,
)
},
)
if (!response.ok) {
const latestResponse = await fetch(
`${chatApiBase}/chat/research/${runId}`,
{
credentials: "include",
cache: "no-store",
},
).catch(() => null)
const latestData = latestResponse?.ok
? ((await latestResponse.json().catch(() => null)) as {
run?: NovaResearchRun | null
} | null)
: null
if (latestData?.run && !isActiveResearchRun(latestData.run)) {
setResearchRun(latestData.run)
return
}
throw new Error("Research cancellation failed")
}
const data = (await response.json().catch(() => null)) as {
run?: NovaResearchRun | null
} | null
setResearchRun((current) => {
if (current?.id !== runId) return current
return (
data?.run ?? {
...current,
status: "cancelled",
completedAt: new Date().toISOString(),
}
)
})
}, [chatApiBase, researchRun])
const handleStop = useCallback(() => {
if (researchRun && isActiveResearchRun(researchRun)) {
void cancelResearch().catch(() => {
toast.error("Could not stop research", {
description: "Please try again. The run may still be active.",
})
})
return
}
stop()
}, [chatApiBase, researchRun, stop])
}, [cancelResearch, researchRun, stop])
const handleRetryResearchFinalization = useCallback(async () => {
if (!researchRun || researchRun.status !== "failed") {
throw new Error("Research run is unavailable")
}
const response = await fetch(
`${chatApiBase}/chat/research/${researchRun.id}/retry-finalization`,
{
method: "POST",
credentials: "include",
},
)
const data = (await response.json().catch(() => null)) as {
run?: NovaResearchRun | null
} | null
if (!response.ok || !data?.run) {
throw new Error("Report finalization retry failed")
}
setResearchRun(data.run)
setResearchConnection({ status: "connected", attempts: 0 })
}, [chatApiBase, researchRun])
const handleRunResearchAgain = useCallback(async () => {
const query = researchRun?.query.trim()
if (!query) throw new Error("Research query is unavailable")
const started = await submitChatMessage(
query,
"typed",
[],
"research",
true,
)
if (!started) throw new Error("Research could not restart")
setChatMode("research")
}, [researchRun?.query, submitChatMessage])
const handleSubmitResearchClarification = useCallback(
async (requestId: string, answers: NovaResearchClarificationAnswer[]) => {
@ -1425,32 +1506,87 @@ export function ChatSidebar({
if (!runId || !researchIsActive) return
let disposed = false
let timeoutId: number | undefined
let requestTimeoutId: number | undefined
let requestController: AbortController | null = null
let consecutiveFailures = 0
let latestRunStatus = researchRun?.status
setResearchConnection({ status: "connected", attempts: 0 })
const schedulePoll = (delayMs: number) => {
if (disposed) return
timeoutId = window.setTimeout(poll, delayMs)
}
const poll = async () => {
requestController = new AbortController()
requestTimeoutId = window.setTimeout(
() => requestController?.abort(),
10_000,
)
try {
const response = await fetch(`${chatApiBase}/chat/research/${runId}`, {
credentials: "include",
cache: "no-store",
signal: requestController.signal,
})
if (!response.ok || disposed) return
if (disposed) return
if (!response.ok) {
throw new Error(`Research polling returned ${response.status}`)
}
const data = (await response.json()) as { run?: NovaResearchRun }
if (!data.run) return
if (!data.run) throw new Error("Research polling response had no run")
consecutiveFailures = 0
latestRunStatus = data.run.status
setResearchConnection({ status: "connected", attempts: 0 })
setResearchRun(data.run)
if (!isActiveResearchRun(data.run)) {
await loadThread(data.run.threadId)
return
}
} catch (error) {
if (disposed) return
consecutiveFailures += 1
setResearchConnection({
status: "reconnecting",
attempts: consecutiveFailures,
})
console.error("Failed to refresh research progress", error)
} finally {
if (requestTimeoutId !== undefined) {
window.clearTimeout(requestTimeoutId)
requestTimeoutId = undefined
}
requestController = null
}
if (!disposed) timeoutId = window.setTimeout(poll, 1500)
schedulePoll(researchPollDelayMs(consecutiveFailures, latestRunStatus))
}
timeoutId = window.setTimeout(poll, 700)
schedulePoll(
researchPollRevision > 0
? 0
: researchRun?.status === "awaiting_input"
? researchPollDelayMs(0, "awaiting_input")
: 700,
)
return () => {
disposed = true
if (timeoutId !== undefined) window.clearTimeout(timeoutId)
if (requestTimeoutId !== undefined) window.clearTimeout(requestTimeoutId)
requestController?.abort()
}
}, [chatApiBase, loadThread, researchIsActive, researchRun?.id])
}, [
chatApiBase,
loadThread,
researchIsActive,
researchPollRevision,
researchRun?.id,
researchRun?.status,
])
const handleRetryResearchConnection = useCallback(() => {
setResearchPollRevision((revision) => revision + 1)
}, [])
// Auto-restore thread from URL on mount (e.g. reload or direct link)
const didAutoLoadRef = useRef(false)
@ -2268,8 +2404,12 @@ export function ChatSidebar({
{researchRun ? (
<ResearchProgress
run={researchRun}
onCancel={handleStop}
onCancel={cancelResearch}
onSubmitClarification={handleSubmitResearchClarification}
connectionState={researchConnection}
onRetryConnection={handleRetryResearchConnection}
onRetryFinalization={handleRetryResearchFinalization}
onRunAgain={handleRunResearchAgain}
className="mt-1"
/>
) : null}

View file

@ -57,8 +57,8 @@ export function ResearchClarification({
}
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="mt-3 overflow-hidden rounded-[14px] bg-[#191D24] shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
<div className="border-white/[0.06] border-b px-4 py-4 sm:px-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-white/85">
@ -72,20 +72,24 @@ export function ResearchClarification({
{questionIndex + 1} / {request.questions.length}
</span>
</div>
<div className="mt-3 flex gap-1">
<div className="mt-3 flex gap-1.5">
{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",
index === questionIndex
? "bg-[#A1A1AA]"
: index < questionIndex
? "bg-[#525D6E]"
: "bg-[#0D121A]",
)}
/>
))}
</div>
</div>
<div className="px-4 py-4">
<div className="px-4 py-4 sm:px-5 sm:py-5">
<h3 className="text-sm leading-relaxed text-white/85">
{question.question}
</h3>
@ -96,6 +100,7 @@ export function ResearchClarification({
<button
type="button"
key={option.label}
aria-pressed={selected}
onClick={() => {
setAnswers((current) => ({
...current,
@ -107,28 +112,28 @@ export function ResearchClarification({
}))
}}
className={cn(
"flex min-h-12 items-start gap-2 rounded-xl border px-3 py-2.5 text-left transition-colors",
"flex min-h-[72px] items-start gap-3 rounded-[12px] bg-[#14161A] px-4 py-3 text-left shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/25",
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]",
? "bg-[#1B1F24] ring-1 ring-inset ring-white/15"
: "hover:bg-[#16181D]",
)}
>
<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",
? "border-[#FAFAFA] bg-[#FAFAFA] text-[#0D121A]"
: "border-[#525D6E] bg-[#0D121A]",
)}
>
{selected ? <CheckIcon className="size-2.5" /> : null}
</span>
<span>
<span className="block text-xs font-medium text-white/78">
<span className="block text-xs font-medium text-[#FAFAFA]">
{option.label}
</span>
{option.description ? (
<span className="mt-0.5 block text-[11px] leading-snug text-white/38">
<span className="mt-0.5 block text-[11px] leading-snug text-[#A1A1AA]">
{option.description}
</span>
) : null}
@ -137,40 +142,70 @@ export function ResearchClarification({
)
})}
{question.allowOther !== false ? (
<button
type="button"
onClick={() => {
setOtherByQuestion((current) => ({
...current,
[question.id]: true,
}))
setAnswers((current) => ({ ...current, [question.id]: "" }))
}}
<div
className={cn(
"min-h-12 rounded-xl border px-3 py-2.5 text-left text-xs font-medium transition-colors",
"overflow-hidden rounded-[12px] bg-[#14161A] shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] 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]",
? "bg-[#1B1F24] ring-1 ring-inset ring-white/15 sm:col-span-2 sm:flex sm:items-center"
: "hover:bg-[#16181D]",
)}
>
Something else
</button>
<button
type="button"
aria-expanded={usingOther}
aria-pressed={usingOther}
onClick={() => {
setOtherByQuestion((current) => ({
...current,
[question.id]: true,
}))
setAnswers((current) => ({ ...current, [question.id]: "" }))
}}
className={cn(
"flex min-h-14 w-full items-center gap-3 px-4 py-3 text-left focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-white/25",
usingOther && "sm:w-[220px] sm:shrink-0",
)}
>
<span
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-full border",
usingOther
? "border-[#FAFAFA] bg-[#FAFAFA] text-[#0D121A]"
: "border-[#525D6E] bg-[#0D121A]",
)}
>
{usingOther ? <CheckIcon className="size-2.5" /> : null}
</span>
<span>
<span className="block text-xs font-medium text-[#FAFAFA]">
Something else
</span>
<span className="mt-0.5 block text-[11px] leading-snug text-[#A1A1AA]">
Write a custom answer
</span>
</span>
</button>
{usingOther ? (
<div className="flex-1 px-3 pb-3 sm:py-2.5 sm:pl-0">
<input
type="text"
aria-label="Custom answer"
value={answer}
onChange={(event) =>
setAnswers((current) => ({
...current,
[question.id]: event.target.value,
}))
}
placeholder="Write your answer…"
maxLength={500}
className="h-10 w-full rounded-[10px] bg-[#0D121A] px-3.5 text-xs text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.55)] outline-none ring-1 ring-inset ring-white/[0.06] placeholder:text-[#737373] focus:ring-white/15"
/>
</div>
) : null}
</div>
) : 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">
@ -178,7 +213,7 @@ export function ResearchClarification({
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"
className="inline-flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-4 text-xs text-[#A1A1AA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:pointer-events-none disabled:opacity-25"
>
<ArrowLeftIcon className="size-3.5" /> Back
</button>
@ -189,7 +224,7 @@ export function ResearchClarification({
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"
className="inline-flex h-9 min-w-[116px] items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-xs font-medium text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:pointer-events-none disabled:opacity-35"
>
{submitting ? (
<Loader2Icon className="size-3.5 animate-spin" />

View file

@ -0,0 +1,379 @@
"use client"
import { useState } from "react"
import {
ArrowRightIcon,
CheckCircle2Icon,
ChevronDownIcon,
ChevronUpIcon,
CircleHelpIcon,
CircleIcon,
RefreshCwIcon,
RotateCcwIcon,
SearchIcon,
SquareIcon,
XCircleIcon,
} from "lucide-react"
import { cn } from "@lib/utils"
import { SuperLoader } from "@/components/superloader"
import { dmSansClassName } from "@/lib/fonts"
import {
pendingResearchClarification,
type NovaResearchClarificationAnswer,
type NovaResearchConnectionState,
type NovaResearchEvent,
type NovaResearchRun,
researchFailurePresentation,
researchPlanStepDisplayStatus,
researchToolFailureDescription,
} from "@/lib/nova-research"
import { ResearchClarification } from "./research-clarification"
function toolLabel(name: string | null, fallback: string | null): string {
if (fallback) return fallback
return (name ?? "Research tool")
.replace(/_/g, " ")
.replace(/\b\w/g, (character) => character.toUpperCase())
}
function eventLabel(event: NovaResearchEvent): string | null {
if (event.type === "error") return "A research step could not complete."
if (event.type === "tool" && event.status === "failed") {
return `${toolLabel(event.toolName, null)} failed`
}
if (event.status === "failed") return "A research step could not complete."
if (event.title === "Research capabilities enabled" && event.message) {
return `Enabled ${event.message} research`
}
if (event.type === "tool") return toolLabel(event.toolName, event.title)
const label = event.message || event.title
if (!label) return null
if (
event.type === "assistant" &&
(label.length > 600 || /<response\b|(?:^|\n)#{1,6}\s/.test(label))
) {
return "Nova prepared a draft report."
}
return label.length > 600 ? `${label.slice(0, 597)}…` : label
}
function eventIcon(event: NovaResearchEvent) {
if (event.status === "running" || event.status === "pending") {
return <span className="size-2 animate-pulse rounded-full bg-white/45" />
}
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/80" />
}
if (event.type === "assistant") {
return <ArrowRightIcon className="size-3.5 text-white/45" />
}
return <CircleIcon className="size-3 fill-white/35 text-white/35" />
}
function isUsefulTimelineEvent(event: NovaResearchEvent): boolean {
return (
event.type === "assistant" ||
event.type === "tool" ||
event.type === "status" ||
event.type === "artifact" ||
event.type === "error"
)
}
export function ResearchProgress({
run,
onCancel,
onSubmitClarification,
connectionState = { status: "connected", attempts: 0 },
onRetryConnection,
onRetryFinalization,
onRunAgain,
className,
}: {
run: NovaResearchRun
onCancel: () => Promise<void> | void
onSubmitClarification: (
requestId: string,
answers: NovaResearchClarificationAnswer[],
) => Promise<void>
connectionState?: NovaResearchConnectionState
onRetryConnection?: () => void
onRetryFinalization?: () => Promise<void>
onRunAgain?: () => Promise<void>
className?: string
}) {
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 [pendingAction, setPendingAction] = useState<
"cancel" | "retry-finalization" | "run-again" | null
>(null)
const [actionError, setActionError] = useState<string | null>(null)
const timeline = run.events.filter(isUsefulTimelineEvent)
const clarification = pendingResearchClarification(run)
const failure = failed ? researchFailurePresentation(run) : null
const statusLabel = awaitingInput
? "Waiting for your answers"
: active
? "Researching"
: failed
? (failure?.title ?? "Research could not complete")
: cancelled
? "Research stopped"
: "Research complete"
const invokeAction = async (
action: NonNullable<typeof pendingAction>,
callback: (() => Promise<void> | void) | undefined,
) => {
if (!callback || pendingAction) return
setPendingAction(action)
setActionError(null)
try {
await callback()
} catch {
setActionError(
action === "cancel"
? "Could not stop this research. Please try again."
: action === "retry-finalization"
? "Could not retry the report yet. Your collected sources are still preserved."
: "Could not start a new research run. Please try again.",
)
} finally {
setPendingAction(null)
}
}
return (
<section
className={cn("w-full py-1", dmSansClassName(), className)}
aria-live="polite"
data-testid="nova-research-activity"
>
<div className="flex items-center gap-2">
{awaitingInput ? (
<CircleHelpIcon className="size-4 shrink-0 text-white/45" />
) : active ? (
<SuperLoader
size={34}
label={statusLabel}
colorClassName="text-[#4BA0FA]"
className="!min-w-0 !gap-0 [&>span]:hidden"
/>
) : failed ? (
<XCircleIcon className="size-4 shrink-0 text-red-400" />
) : cancelled ? (
<SquareIcon className="size-3.5 shrink-0 text-white/40" />
) : (
<CheckCircle2Icon className="size-4 shrink-0 text-emerald-400/80" />
)}
<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={() => void invokeAction("cancel", onCancel)}
disabled={pendingAction !== null}
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" />
{pendingAction === "cancel" ? "Stopping…" : "Stop"}
</button>
) : null}
</div>
{active && connectionState.status === "reconnecting" ? (
<output className="mt-3 flex items-center gap-2 rounded-xl bg-[#14161A] px-3 py-2 text-xs text-white/55 shadow-[inset_1.5px_1.5px_3px_rgba(0,0,0,0.55)]">
<RefreshCwIcon className="size-3.5 animate-spin text-white/45" />
<span className="min-w-0 flex-1">
{connectionState.attempts >= 3
? "Connection lost. Reconnecting to research updates…"
: "Research updates are delayed. Reconnecting…"}
</span>
{onRetryConnection ? (
<button
type="button"
onClick={onRetryConnection}
className="shrink-0 rounded-full bg-[#0D121A] px-3 py-1.5 text-white/70 transition-colors hover:text-white"
>
Retry now
</button>
) : null}
</output>
) : null}
{failure ? (
<div
className="mt-3 rounded-xl bg-[#191D24] p-3.5 shadow-[inset_1.5px_1.5px_3px_rgba(0,0,0,0.55)]"
role="alert"
>
<p className="text-sm font-medium text-white/80">{failure.title}</p>
<p className="mt-1 text-xs leading-relaxed text-white/50">
{failure.description}
</p>
{failure.incidentId ? (
<p className="mt-1.5 font-mono text-[10px] text-white/30">
Reference: {failure.incidentId}
</p>
) : null}
<div className="mt-3 flex flex-wrap gap-2">
{failure.canRetryFinalization && onRetryFinalization ? (
<button
type="button"
onClick={() =>
void invokeAction("retry-finalization", onRetryFinalization)
}
disabled={pendingAction !== null}
className="rounded-full bg-[#0D121A] px-3 py-1.5 text-xs text-white/75 transition-colors hover:text-white disabled:opacity-50"
>
{pendingAction === "retry-finalization"
? "Retrying…"
: (failure.retryLabel ?? "Retry report")}
</button>
) : null}
{onRunAgain ? (
<button
type="button"
onClick={() => void invokeAction("run-again", onRunAgain)}
disabled={pendingAction !== null}
className="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs text-white/45 transition-colors hover:bg-white/[0.05] hover:text-white/75 disabled:opacity-50"
>
<RotateCcwIcon className="size-3" />
{pendingAction === "run-again" ? "Starting…" : "Run again"}
</button>
) : null}
</div>
</div>
) : cancelled && onRunAgain ? (
<div className="mt-3">
<button
type="button"
onClick={() => void invokeAction("run-again", onRunAgain)}
disabled={pendingAction !== null}
className="inline-flex items-center gap-1.5 rounded-full bg-[#14161A] px-3 py-1.5 text-xs text-white/55 transition-colors hover:text-white disabled:opacity-50"
>
<RotateCcwIcon className="size-3" />
{pendingAction === "run-again" ? "Starting…" : "Run again"}
</button>
</div>
) : null}
{actionError ? (
<p className="mt-2 text-xs text-red-300/80" role="alert">
{actionError}
</p>
) : null}
{clarification ? (
<ResearchClarification
key={clarification.id}
request={clarification}
onSubmit={(answers) =>
onSubmitClarification(clarification.id, answers)
}
/>
) : null}
{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 className="space-y-1.5">
{run.plan.steps.map((step) => {
const displayStatus = researchPlanStepDisplayStatus(
run.status,
step.status,
)
return (
<div
key={step.id}
className={cn(
"flex min-w-0 items-center gap-2 text-xs",
displayStatus === "complete"
? "text-white/55"
: "text-white/35",
)}
>
{displayStatus === "complete" ? (
<CheckCircle2Icon className="size-3.5 shrink-0 text-emerald-400/80" />
) : displayStatus === "in_progress" ? (
<span className="size-2 shrink-0 animate-pulse rounded-full bg-white/45" />
) : displayStatus === "failed" ? (
<XCircleIcon className="size-3.5 shrink-0 text-red-400/75" />
) : displayStatus === "cancelled" ? (
<SquareIcon className="size-3 shrink-0 text-white/30" />
) : (
<CircleIcon className="size-3.5 shrink-0 text-white/15" />
)}
<span>{step.title}</span>
</div>
)
})}
</div>
</div>
) : null}
<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">
<CircleIcon className="size-3 fill-white/30 text-white/30" />
Preparing the investigation…
</div>
) : (
timeline.map((event) => {
const label = eventLabel(event)
if (!label) return null
const toolFailureDescription =
researchToolFailureDescription(event)
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}
{toolFailureDescription ? (
<div className="mt-0.5 text-[10px] text-white/30">
{toolFailureDescription}
</div>
) : 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

@ -6,6 +6,20 @@ export type NovaResearchStatus =
| "failed"
| "cancelled"
export type NovaResearchFailure = {
code: string
stage: string
retryable: boolean
incidentId?: string | null
/** A user-safe explanation supplied by the API. Legacy `run.error` is never rendered. */
message?: string | null
}
export type NovaResearchConnectionState = {
status: "connected" | "reconnecting"
attempts: number
}
export type NovaResearchPlan = {
goal: string
steps: Array<{
@ -144,6 +158,8 @@ export type NovaResearchRun = {
reportTitle: string | null
reportMarkdown: string | null
reportDocumentId: string | null
failure?: NovaResearchFailure | null
/** @deprecated Legacy internal error detail. Keep for response compatibility only. */
error: string | null
toolCallCount: number
startedAt: string | null
@ -153,6 +169,130 @@ export type NovaResearchRun = {
events: NovaResearchEvent[]
}
export type NovaResearchPlanStepDisplayStatus =
| NovaResearchPlan["steps"][number]["status"]
| "failed"
| "cancelled"
const SAFE_RESEARCH_TOOL_FAILURE_MESSAGES = new Set([
"Web search could not be completed.",
"The webpage could not be read.",
"Memory retrieval could not be completed.",
"This research step could not be completed.",
])
function safeResearchIncidentId(
failure?: NovaResearchFailure | null,
): string | null {
const incidentId = failure?.incidentId?.trim()
return incidentId && /^[A-Za-z0-9_-]{1,64}$/.test(incidentId)
? incidentId
: null
}
export function researchToolFailureDescription(
event: NovaResearchEvent,
): string | null {
if (event.type !== "tool" || event.status !== "failed") return null
const output =
event.output && typeof event.output === "object"
? (event.output as { error?: unknown })
: null
const message = typeof output?.error === "string" ? output.error.trim() : ""
return SAFE_RESEARCH_TOOL_FAILURE_MESSAGES.has(message)
? message
: "This research tool could not be completed."
}
export function researchFailurePresentation(run: NovaResearchRun): {
title: string
description: string
incidentId: string | null
canRetryFinalization: boolean
retryLabel: string | null
} {
const failure = run.failure
const incidentId = safeResearchIncidentId(failure)
const retryable = failure?.retryable === true
switch (failure?.code?.trim().toUpperCase()) {
case "START_FAILED":
return {
title: "Research could not start",
description: "Research could not be started. Please try again.",
incidentId,
canRetryFinalization: false,
retryLabel: null,
}
case "RESEARCH_FAILED":
return {
title: "A research step failed",
description:
"Research stopped before the evidence review finished. You can run it again.",
incidentId,
canRetryFinalization: false,
retryLabel: null,
}
case "FINALIZATION_FAILED":
return {
title: "Report finalization failed",
description:
"The evidence was collected, but Nova could not finish a properly cited report. Your collected sources are preserved.",
incidentId,
canRetryFinalization: retryable,
retryLabel: retryable ? "Retry finalizing" : null,
}
case "PERSISTENCE_FAILED":
return {
title: "Saving the report failed",
description:
"The report was prepared but could not be saved. Your collected sources are preserved.",
incidentId,
canRetryFinalization: retryable,
retryLabel: retryable ? "Retry saving" : null,
}
default:
return {
title: "Research could not complete",
description:
"Nova stopped safely before producing a report. You can run the research again.",
incidentId,
canRetryFinalization: false,
retryLabel: null,
}
}
}
export function researchPlanStepDisplayStatus(
runStatus: NovaResearchStatus,
stepStatus: NovaResearchPlan["steps"][number]["status"],
): NovaResearchPlanStepDisplayStatus {
if (stepStatus === "complete" || stepStatus === "skipped") return stepStatus
if (runStatus === "failed") {
return stepStatus === "in_progress" ? "failed" : "skipped"
}
if (runStatus === "cancelled") return "cancelled"
if (runStatus === "completed") return "skipped"
return stepStatus
}
const RESEARCH_POLL_RETRY_DELAYS_MS = [1500, 2500, 4000, 7000, 12000, 15000]
export function researchPollDelayMs(
consecutiveFailures: number,
runStatus?: NovaResearchStatus,
): number {
if (!Number.isFinite(consecutiveFailures) || consecutiveFailures <= 0) {
if (runStatus === "awaiting_input") return 30_000
return RESEARCH_POLL_RETRY_DELAYS_MS[0] ?? 1500
}
const index = Math.min(
Math.floor(consecutiveFailures),
RESEARCH_POLL_RETRY_DELAYS_MS.length - 1,
)
return RESEARCH_POLL_RETRY_DELAYS_MS[index] ?? 15000
}
export type NovaResearchClarificationQuestion = {
id: string
question: string
@ -197,6 +337,7 @@ function isClarificationRequest(
export function pendingResearchClarification(
run: NovaResearchRun,
): NovaResearchClarificationRequest | null {
if (run.status !== "awaiting_input") return null
for (let index = run.events.length - 1; index >= 0; index--) {
const event = run.events[index]
if (