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.
This commit is contained in:
Ishaan Gupta 2026-07-31 21:19:13 +05:30
parent 878d6c2ab9
commit b4d6a21c19
7 changed files with 603 additions and 23 deletions

View file

@ -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<NovaChatMode>("chat")
const chatModeRef = useRef(chatMode)
chatModeRef.current = chatMode
const [researchRun, setResearchRun] = useState<NovaResearchRun | null>(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<ChatAttachmentDraft>) => {
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}
/>
<ResearchModeSelector
value={chatMode}
onChange={handleChatModeChange}
disabled={researchIsActive}
/>
<SpaceSelector
selectedProjects={chatSpaceProjects}
onValueChange={handleChatSpaceProjectsChange}
@ -2060,6 +2242,14 @@ export function ChatSidebar({
)}
</div>
))}
{researchRun ? (
<ResearchProgress
run={researchRun}
apiBase={chatApiBase}
onCancel={handleStop}
className="mt-2"
/>
) : null}
</div>
</div>
@ -2140,6 +2330,11 @@ export function ChatSidebar({
>
<ChatInput
value={input}
placeholder={
chatMode === "research"
? "Describe what you want Nova to research..."
: "Ask your supermemory..."
}
onChange={(e) => 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 ? (
<ReasoningSelector
value={reasoningEffort}
onChange={handleReasoningEffortChange}
/>
<>
<ReasoningSelector
value={reasoningEffort}
onChange={handleReasoningEffortChange}
disabled={researchIsActive}
/>
<ResearchModeSelector
value={chatMode}
onChange={handleChatModeChange}
disabled={researchIsActive}
/>
</>
) : undefined
}
toolbarEnd={

View file

@ -34,6 +34,7 @@ export interface QueuedChatMessagePreview {
interface ChatInputProps {
value: string
placeholder?: string
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => 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}

View file

@ -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 (
<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}
@ -1410,7 +1445,11 @@ export function AgentMessage({
return (
<div
key={`${message.id}-${partIndex}`}
className="text-sm text-white/90 chat-markdown-content"
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",
)}
>
<Streamdown components={markdownComponents}>
{

View file

@ -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 (
<button
type="button"
disabled={disabled}
onClick={() => onChange(isResearch ? "chat" : "research")}
className={cn(
"group flex shrink-0 cursor-pointer items-center gap-1 rounded-md px-1.5 py-1 text-xs transition-colors hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-50",
isResearch ? "text-[#8DBDFF]" : "text-white/80",
dmSansClassName(),
)}
title={
isResearch
? "Research mode: durable multi-step investigation"
: "Chat mode: quick conversational answer"
}
aria-label={`Mode: ${isResearch ? "Research" : "Chat"}. Click to switch.`}
>
<Icon
className={cn(
"size-3.5",
isResearch
? "text-[#8DBDFF]"
: "text-white/45 group-hover:text-white/70",
)}
/>
<span>{isResearch ? "Research" : "Chat"}</span>
</button>
)
}

View file

@ -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 <Loader2Icon className="size-3.5 animate-spin text-[#8DBDFF]" />
}
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 <CircleIcon className="size-3 fill-[#267BF1] text-[#267BF1]" />
}
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 (
<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,
)}
aria-live="polite"
>
<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>
{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"
>
<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>
) : 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>
</div>
))
)}
</div>
</div>
{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>
) : (
<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>
),
)}
</div>
) : null}
</section>
)
}

View file

@ -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<string, unknown>)
: {}
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 }
}

View file

@ -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"
}