From 51260b4e0240dc08d0bcf8a71d9f58623af17b97 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 31 Jul 2026 21:19:13 +0530 Subject: [PATCH 1/5] add Nova research experience Adds a persisted Chat/Research mode selector, durable progress timeline, cancellation, navigation restoration, and a rendered/downloadable Markdown report experience. Research state loads in parallel with thread state, and unsupported attachments are rejected instead of silently ignored. Validation: 2 focused web tests, Biome, and diff checks. --- apps/web/components/chat/index.tsx | 247 ++++++++++++++++-- apps/web/components/chat/input/index.tsx | 6 +- .../components/chat/message/agent-message.tsx | 41 ++- .../chat/research-mode-selector.tsx | 48 ++++ .../web/components/chat/research-progress.tsx | 205 +++++++++++++++ apps/web/lib/chat-thread-settings.ts | 4 +- apps/web/lib/nova-research.ts | 75 ++++++ 7 files changed, 603 insertions(+), 23 deletions(-) create mode 100644 apps/web/components/chat/research-mode-selector.tsx create mode 100644 apps/web/components/chat/research-progress.tsx create mode 100644 apps/web/lib/nova-research.ts diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index aa45c4c9..502ae374 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -73,10 +73,16 @@ import { } from "./attachments" import { cacheFileBlob, removeCachedFile } from "@/lib/file-cache" import { ReasoningSelector } from "./reasoning-selector" +import { + ResearchModeSelector, + type NovaChatMode, +} from "./research-mode-selector" +import { ResearchProgress } from "./research-progress" import { type ChatThreadSettings, readChatThreadSettings, } from "@/lib/chat-thread-settings" +import { isActiveResearchRun, type NovaResearchRun } from "@/lib/nova-research" type ChatMessageSendSource = "typed" | "suggested" | "highlight" | "home" @@ -216,6 +222,11 @@ export function ChatSidebar({ initialReasoningEffort ?? getDefaultReasoningEffort(initialSelectedModel ?? "grok-4.5"), ) + const [chatMode, setChatMode] = useState("chat") + const chatModeRef = useRef(chatMode) + chatModeRef.current = chatMode + const [researchRun, setResearchRun] = useState(null) + const researchIsActive = isActiveResearchRun(researchRun) const selectedModelRef = useRef(selectedModel) selectedModelRef.current = selectedModel const reasoningEffortRef = useRef(reasoningEffort) @@ -503,6 +514,7 @@ export function ChatSidebar({ setReasoningEffort(nextReasoningEffort) clearError() void persistThreadSettings({ + mode: chatModeRef.current, model: modelId, projectId: selectedProjectRef.current, reasoningEffort: nextReasoningEffort, @@ -517,6 +529,7 @@ export function ChatSidebar({ (nextReasoningEffort: ReasoningEffort) => { setReasoningEffort(nextReasoningEffort) void persistThreadSettings({ + mode: chatModeRef.current, model: selectedModelRef.current, projectId: selectedProjectRef.current, reasoningEffort: nextReasoningEffort, @@ -532,6 +545,7 @@ export function ChatSidebar({ const nextProject = nextProjects[0] ?? AUTO_CHAT_SPACE_ID setChatSpaceProjects([nextProject]) void persistThreadSettings({ + mode: chatModeRef.current, model: selectedModelRef.current, projectId: nextProject, reasoningEffort: reasoningEffortRef.current, @@ -541,6 +555,22 @@ export function ChatSidebar({ [persistThreadSettings], ) + const handleChatModeChange = useCallback( + (nextMode: NovaChatMode) => { + if (researchIsActive) return + setChatMode(nextMode) + void persistThreadSettings({ + mode: nextMode, + model: selectedModelRef.current, + projectId: selectedProjectRef.current, + reasoningEffort: reasoningEffortRef.current, + spaceMode: + selectedProjectRef.current === AUTO_CHAT_SPACE_ID ? "auto" : "manual", + }) + }, + [persistThreadSettings, researchIsActive], + ) + const setAttachmentDraftState = useCallback( (id: string, patch: Partial) => { setAttachmentDrafts((prev) => @@ -912,6 +942,12 @@ 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 @@ -923,6 +959,73 @@ export function ChatSidebar({ const messageText = trimmed || "Analyze the attached file(s)." const isRespondingNow = status === "submitted" || status === "streaming" + if (chatMode === "research") { + if (isRespondingNow || researchIsActive) return false + const userMessageId = generateId() + if (!threadId) setThreadId(fallbackChatId) + setMessages((current) => [ + ...current, + { + id: userMessageId, + role: "user", + parts: [{ type: "text", text: messageText }], + metadata: + uploadedAttachments.length > 0 + ? { attachments: uploadedAttachments } + : undefined, + }, + ]) + setInput("") + setAttachmentDrafts([]) + uploadPromisesRef.current.clear() + abortControllersRef.current.clear() + discardedDraftIdsRef.current.clear() + userJustSentRef.current = true + scrollToBottom() + analytics.chatMessageSent({ + source, + attachment_count: uploadedAttachments.length, + saved_attachment_count: uploadedAttachments.filter( + (attachment) => attachment.saveToMemory, + ).length, + temporary_attachment_count: uploadedAttachments.filter( + (attachment) => !attachment.saveToMemory, + ).length, + }) + + const response = await fetch(`${chatApiBase}/chat/research`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: messageText, + chatId: chatIdForSend, + userMessageId, + metadata: { + model: selectedModel, + reasoningEffort, + projectId: selectedProjectRef.current, + spaceMode: + selectedProjectRef.current === AUTO_CHAT_SPACE_ID + ? "auto" + : "manual", + }, + }), + }) + const payload = (await response.json().catch(() => ({}))) as { + run?: NovaResearchRun | null + error?: string + } + if (!response.ok || !payload.run) { + setMessages((current) => + current.filter((message) => message.id !== userMessageId), + ) + throw new Error(payload.error || "Failed to start research") + } + setResearchRun(payload.run) + return true + } + if (isRespondingNow) { if (messageQueue.length >= CHAT_QUEUE_LIMIT) return false setMessageQueue((prev) => { @@ -1003,13 +1106,17 @@ export function ChatSidebar({ }, [ attachmentDrafts, + chatApiBase, + chatMode, fallbackChatId, messageQueue.length, reasoningEffort, + researchIsActive, scrollToBottom, selectedModel, sendMessage, setThreadId, + setMessages, status, threadId, uploadAttachmentDrafts, @@ -1095,8 +1202,26 @@ 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`, { + method: "POST", + credentials: "include", + }).then((response) => { + if (!response.ok) return + setResearchRun((current) => + current?.id === researchRun.id + ? { + ...current, + status: "cancelled", + completedAt: new Date().toISOString(), + } + : current, + ) + }) + return + } stop() - }, [stop]) + }, [chatApiBase, researchRun, stop]) const handleCopyMessage = useCallback((messageId: string, text: string) => { analytics.chatMessageCopied({ message_id: messageId }) @@ -1164,6 +1289,8 @@ export function ChatSidebar({ pendingResponseModelsRef.current = [] seenAssistantMessageIdsRef.current = new Set() setResponseModelByMessageId({}) + setResearchRun(null) + setChatMode("chat") queuedDispatchInFlightRef.current = false queuedDispatchSawResponseRef.current = false }, [setThreadId, setMessages]) @@ -1197,11 +1324,21 @@ export function ChatSidebar({ const loadThread = useCallback( async (id: string) => { try { - const response = await fetch(`${chatApiBase}/chat/threads/${id}`, { - credentials: "include", - }) + const [response, researchResponse] = await Promise.all([ + fetch(`${chatApiBase}/chat/threads/${id}`, { + credentials: "include", + }), + fetch(`${chatApiBase}/chat/research/threads/${id}/latest`, { + credentials: "include", + }), + ]) if (response.ok) { const data = await response.json() + const researchData = researchResponse.ok + ? ((await researchResponse.json()) as { + run?: NovaResearchRun | null + }) + : null const restoredSettings = readChatThreadSettings( data.thread?.settings, data.thread?.space?.containerTag ?? @@ -1236,6 +1373,13 @@ export function ChatSidebar({ setResponseModelByMessageId({}) setSelectedModel(restoredSettings.model) setReasoningEffort(restoredSettings.reasoningEffort) + setChatMode(restoredSettings.mode) + const latestResearchRun = researchData?.run ?? null + setResearchRun( + latestResearchRun?.status === "completed" + ? null + : latestResearchRun, + ) setChatSpaceProjects([restoredSettings.projectId]) setThreadId(id) setPendingThreadLoad({ id, messages: uiMessages }) @@ -1253,6 +1397,38 @@ export function ChatSidebar({ [chatApiBase, selectedProject, setThreadId], ) + useEffect(() => { + const runId = researchRun?.id + if (!runId || !researchIsActive) return + let disposed = false + let timeoutId: number | undefined + + const poll = async () => { + try { + const response = await fetch(`${chatApiBase}/chat/research/${runId}`, { + credentials: "include", + }) + if (!response.ok || disposed) return + const data = (await response.json()) as { run?: NovaResearchRun } + if (!data.run) return + setResearchRun(data.run) + if (!isActiveResearchRun(data.run)) { + await loadThread(data.run.threadId) + return + } + } catch (error) { + console.error("Failed to refresh research progress", error) + } + if (!disposed) timeoutId = window.setTimeout(poll, 1500) + } + + timeoutId = window.setTimeout(poll, 700) + return () => { + disposed = true + if (timeoutId !== undefined) window.clearTimeout(timeoutId) + } + }, [chatApiBase, loadThread, researchIsActive, researchRun?.id]) + // Auto-restore thread from URL on mount (e.g. reload or direct link) const didAutoLoadRef = useRef(false) const initialThreadIdRef = useRef(threadId) @@ -1629,7 +1805,8 @@ export function ChatSidebar({ const isStackedInput = layout === "page" const showHeaderRow = !isPageDesktop || isMobile || !isStackedInput - const isResponding = status === "submitted" || status === "streaming" + const isResponding = + status === "submitted" || status === "streaming" || researchIsActive const isWebSearching = isResponding && (() => { @@ -1968,6 +2145,11 @@ export function ChatSidebar({ value={reasoningEffort} onChange={handleReasoningEffortChange} /> + ))} + {researchRun ? ( + + ) : null} @@ -2140,6 +2330,11 @@ export function ChatSidebar({ > setInput(e.target.value)} onSend={handleSend} onStop={handleStop} @@ -2152,18 +2347,24 @@ export function ChatSidebar({ canSend={canSendMessage} attachmentAccept={CHAT_ATTACHMENT_ACCEPT} disableFileDropZone - sendDisabled={isResponding && isQueueFull} - sendDisabledTooltip={`Queue is full (${CHAT_QUEUE_LIMIT} max)`} + sendDisabled={researchIsActive || (isResponding && isQueueFull)} + sendDisabledTooltip={ + researchIsActive + ? "Wait for the current research run or stop it" + : `Queue is full (${CHAT_QUEUE_LIMIT} max)` + } activeStatus={ - isResponding && isQueueFull - ? `Queue full (${CHAT_QUEUE_LIMIT} max)` - : isWebSearching - ? "Searching the web…" - : status === "submitted" - ? "Thinking…" - : status === "streaming" + researchIsActive + ? researchRun?.events.at(-1)?.message || "Researching…" + : isResponding && isQueueFull + ? `Queue full (${CHAT_QUEUE_LIMIT} max)` + : isWebSearching + ? "Searching the web…" + : status === "submitted" ? "Thinking…" - : "Waiting for input…" + : status === "streaming" + ? "Thinking…" + : "Waiting for input…" } queuedMessages={messageQueue} showStatusStrip={showInputStatusStrip} @@ -2182,10 +2383,18 @@ export function ChatSidebar({ } toolbarTrailing={ isStackedInput ? ( - + <> + + + ) : undefined } toolbarEnd={ diff --git a/apps/web/components/chat/input/index.tsx b/apps/web/components/chat/input/index.tsx index 0926af53..1e88a5b0 100644 --- a/apps/web/components/chat/input/index.tsx +++ b/apps/web/components/chat/input/index.tsx @@ -34,6 +34,7 @@ export interface QueuedChatMessagePreview { interface ChatInputProps { value: string + placeholder?: string onChange: (e: React.ChangeEvent) => void onSend: () => void onStop: () => void @@ -64,6 +65,7 @@ interface ChatInputProps { export default function ChatInput({ value, + placeholder = "Ask your supermemory...", onChange, onSend, onStop, @@ -349,7 +351,7 @@ export default function ChatInput({ value={value} onChange={handleChange} onKeyDown={onKeyDown} - placeholder="Ask your supermemory..." + placeholder={placeholder} className="w-full resize-none overflow-y-auto bg-transparent p-2 text-fg-primary transition-all duration-200 placeholder:text-fg-faint focus:outline-none" style={{ minHeight: "36px" }} rows={1} @@ -389,7 +391,7 @@ export default function ChatInput({ value={value} onChange={handleChange} onKeyDown={onKeyDown} - placeholder="Ask your supermemory..." + placeholder={placeholder} className="w-full resize-none overflow-y-auto bg-transparent p-2 text-fg-primary transition-all duration-200 placeholder:text-fg-faint focus:outline-none" style={{ minHeight: "36px" }} rows={1} diff --git a/apps/web/components/chat/message/agent-message.tsx b/apps/web/components/chat/message/agent-message.tsx index d9e490f7..c1f3de6c 100644 --- a/apps/web/components/chat/message/agent-message.tsx +++ b/apps/web/components/chat/message/agent-message.tsx @@ -11,6 +11,7 @@ import { ChevronRightIcon, ClockIcon, CopyIcon, + DownloadIcon, ExternalLinkIcon, GlobeIcon, ListIcon, @@ -18,6 +19,7 @@ import { PlusIcon, SearchIcon, TerminalIcon, + TelescopeIcon, WrenchIcon, XCircleIcon, ZapIcon, @@ -1350,11 +1352,44 @@ 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" return (
+ {researchMetadata?.runId ? ( +
+
+
+ +
+
+
+ Nova Research Report +
+
+ {researchMetadata.title || "Research complete"} +
+
+
+ + Markdown + +
+ ) : null} { diff --git a/apps/web/components/chat/research-mode-selector.tsx b/apps/web/components/chat/research-mode-selector.tsx new file mode 100644 index 00000000..7f0ff93e --- /dev/null +++ b/apps/web/components/chat/research-mode-selector.tsx @@ -0,0 +1,48 @@ +"use client" + +import { MessageCircleIcon, TelescopeIcon } from "lucide-react" +import { cn } from "@lib/utils" +import { dmSansClassName } from "@/lib/fonts" + +export type NovaChatMode = "chat" | "research" + +export function ResearchModeSelector({ + value, + onChange, + disabled = false, +}: { + value: NovaChatMode + onChange: (value: NovaChatMode) => void + disabled?: boolean +}) { + const isResearch = value === "research" + const Icon = isResearch ? TelescopeIcon : MessageCircleIcon + return ( + + ) +} diff --git a/apps/web/components/chat/research-progress.tsx b/apps/web/components/chat/research-progress.tsx new file mode 100644 index 00000000..a2253214 --- /dev/null +++ b/apps/web/components/chat/research-progress.tsx @@ -0,0 +1,205 @@ +"use client" + +import { + CheckCircle2Icon, + 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" + +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 eventIcon(event: NovaResearchEvent) { + if (event.status === "running" || event.status === "pending") { + return + } + if (event.status === "failed" || event.type === "error") { + return + } + if (event.type === "tool") { + return + } + return +} + +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, + apiBase, + onCancel, + className, +}: { + run: NovaResearchRun + apiBase: string + onCancel: () => void + className?: string +}) { + const active = run.status === "queued" || run.status === "running" + const failed = run.status === "failed" + const cancelled = run.status === "cancelled" + const timeline = run.events.filter(isUsefulTimelineEvent) + const statusLabel = active + ? "Researching" + : failed + ? "Research failed" + : cancelled + ? "Research stopped" + : "Research complete" + + return ( +
+
+
+
+ {active ? ( + + ) : ( + + )} +
+
+
+

{statusLabel}

+ + {run.toolCallCount} tool call + {run.toolCallCount === 1 ? "" : "s"} + +
+

+ {run.query} +

+
+
+ {active ? ( + + ) : run.reportMarkdown ? ( + + Markdown + + ) : null} +
+ + {run.plan ? ( +
+
+ Plan +
+
+ {run.plan.steps.map((step) => ( +
+ {step.status === "complete" ? ( + + ) : step.status === "in_progress" ? ( + + ) : ( + + )} + {step.title} +
+ ))} +
+
+ ) : null} + +
+
+ {timeline.length === 0 ? ( +
+ + Preparing the investigation… +
+ ) : ( + timeline.map((event) => ( +
+
+ {eventIcon(event)} +
+
+
+ {event.type === "tool" + ? toolLabel(event.toolName, event.title) + : event.message || event.title} +
+ {event.type === "tool" && event.toolName ? ( +
+ {event.toolName} +
+ ) : null} +
+
+ )) + )} +
+
+ + {run.sources.length > 0 ? ( +
+ Sources + {run.sources.slice(0, 8).map((source) => + source.url ? ( + + {source.title || source.url} + + + ) : ( + + {source.title || source.space || "Memory"} + + ), + )} +
+ ) : null} +
+ ) +} diff --git a/apps/web/lib/chat-thread-settings.ts b/apps/web/lib/chat-thread-settings.ts index 58fa2711..421be659 100644 --- a/apps/web/lib/chat-thread-settings.ts +++ b/apps/web/lib/chat-thread-settings.ts @@ -7,6 +7,7 @@ import { } from "./models" export type ChatThreadSettings = { + mode: "chat" | "research" model: ModelId reasoningEffort: ReasoningEffort spaceMode: "auto" | "manual" @@ -25,6 +26,7 @@ export function readChatThreadSettings( typeof value === "object" && value !== null ? (value as Record) : {} + const mode = settings.mode === "research" ? "research" : "chat" const model = isModelId(settings.model) ? settings.model : "grok-4.5" const reasoningEffort = settings.reasoningEffort === "instant" || @@ -38,5 +40,5 @@ export function readChatThreadSettings( : fallbackProjectId const projectId = spaceMode === "auto" ? AUTO_CHAT_SPACE_ID : storedProjectId - return { model, reasoningEffort, spaceMode, projectId } + return { mode, model, reasoningEffort, spaceMode, projectId } } diff --git a/apps/web/lib/nova-research.ts b/apps/web/lib/nova-research.ts new file mode 100644 index 00000000..01fb82b0 --- /dev/null +++ b/apps/web/lib/nova-research.ts @@ -0,0 +1,75 @@ +export type NovaResearchStatus = + | "queued" + | "running" + | "completed" + | "failed" + | "cancelled" + +export type NovaResearchPlan = { + goal: string + steps: Array<{ + id: string + title: string + status: "pending" | "in_progress" | "complete" | "skipped" + }> +} + +export type NovaResearchSource = { + id: string + type: "memory" | "web" + title?: string + url?: string + sourceId?: string + documentId?: string + space?: string +} + +export type NovaResearchEvent = { + id: string + sequence: number + type: + | "status" + | "assistant" + | "plan" + | "tool" + | "source" + | "artifact" + | "error" + status: string | null + title: string | null + message: string | null + toolName: string | null + input: unknown + output: unknown + createdAt: string +} + +export type NovaResearchRun = { + id: string + threadId: string + userMessageId: string + assistantMessageId: string + workflowInstanceId: string | null + query: string + model: string + reasoningEffort: "instant" | "thinking" + spaceMode: "auto" | "manual" + projectId: string + status: NovaResearchStatus + plan: NovaResearchPlan | null + sources: NovaResearchSource[] + reportTitle: string | null + reportMarkdown: string | null + reportDocumentId: string | null + error: string | null + toolCallCount: number + startedAt: string | null + completedAt: string | null + createdAt: string + updatedAt: string + events: NovaResearchEvent[] +} + +export function isActiveResearchRun(run: NovaResearchRun | null): boolean { + return run?.status === "queued" || run?.status === "running" +} From 06d2c3c2a90e22c66b21deea67ef4cc06415091e Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 1 Aug 2026 20:08:09 +0530 Subject: [PATCH 2/5] 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 ? ( +