- {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}
-
+ {expanded && !clarification ? (
+
+
+ {run.plan.steps.map((step) => (
+
+ {step.status === "complete" ? (
+
+ ) : step.status === "in_progress" ? (
+
+ ) : (
+
+ )}
+ {step.title}
+
+ ))}
+
+
+ ) : null}
- {run.sources.length > 0 ? (
-
-
Sources
- {run.sources.slice(0, 8).map((source) =>
- source.url ? (
-
- {source.title || source.url}
-
-
+
+ {timeline.length === 0 ? (
+
+
+ Preparing the investigation…
+
) : (
-
- {source.title || source.space || "Memory"}
-
- ),
- )}
+ timeline.map((event) => {
+ const label = eventLabel(event)
+ if (!label) return null
+ return (
+
+
+ {eventIcon(event)}
+
+
+ {label}
+ {event.type === "tool" && event.toolName ? (
+
+ {event.toolName}
+
+ ) : null}
+
+
+ )
+ })
+ )}
+
) : null}
diff --git a/apps/web/globals.css b/apps/web/globals.css
index ff8d4e90..28bd9072 100644
--- a/apps/web/globals.css
+++ b/apps/web/globals.css
@@ -192,6 +192,7 @@
/* Loose lists wrap item content in
whose top margin detaches the bullet */
.chat-markdown-content li > p:first-child {
+ display: inline;
margin-top: 0;
}
diff --git a/apps/web/lib/nova-research.test.ts b/apps/web/lib/nova-research.test.ts
new file mode 100644
index 00000000..36867923
--- /dev/null
+++ b/apps/web/lib/nova-research.test.ts
@@ -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 {
+ 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)",
+ )
+ })
+})
diff --git a/apps/web/lib/nova-research.ts b/apps/web/lib/nova-research.ts
index 01fb82b0..be59dbc9 100644
--- a/apps/web/lib/nova-research.ts
+++ b/apps/web/lib/nova-research.ts
@@ -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()
+ 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()
+ 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
+ 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"
+ )
}
diff --git a/apps/web/lib/source-annotations.test.ts b/apps/web/lib/source-annotations.test.ts
index d2aa10b2..c5096801 100644
--- a/apps/web/lib/source-annotations.test.ts
+++ b/apps/web/lib/source-annotations.test.ts
@@ -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]",
+ "`[S1]`",
+ "```",
+ "[S1]",
+ "```",
+ ].join("\n")
+
+ expect(parseSourceAnnotatedMarkdown(input, new Set(["S1"])).markdown).toBe(
+ input,
+ )
+ })
+
it("strips source markup for copy text", () => {
expect(
stripSourceMarkup('Alpha Beta'),
diff --git a/apps/web/lib/source-annotations.ts b/apps/web/lib/source-annotations.ts
index eb64c422..be5ef521 100644
--- a/apps/web/lib/source-annotations.ts
+++ b/apps/web/lib/source-annotations.ts
@@ -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++
}