From fb29ea129233d2c090d8505c215c78a1ea764622 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 22:32:51 +0200 Subject: [PATCH 01/33] feat: add artifacts right-panel tab --- surfsense_web/atoms/layout/right-panel.atom.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/surfsense_web/atoms/layout/right-panel.atom.ts b/surfsense_web/atoms/layout/right-panel.atom.ts index d296587ed..7394093cb 100644 --- a/surfsense_web/atoms/layout/right-panel.atom.ts +++ b/surfsense_web/atoms/layout/right-panel.atom.ts @@ -1,6 +1,12 @@ import { atom } from "jotai"; -export type RightPanelTab = "sources" | "report" | "editor" | "hitl-edit" | "citation"; +export type RightPanelTab = + | "sources" + | "report" + | "editor" + | "hitl-edit" + | "citation" + | "artifacts"; export const rightPanelTabAtom = atom("sources"); From 89c807030f1ce89b673773ca247e89957157fcd9 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 22:33:07 +0200 Subject: [PATCH 02/33] feat: add chat artifact model types --- .../features/chat-artifacts/model/artifact.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 surfsense_web/features/chat-artifacts/model/artifact.ts diff --git a/surfsense_web/features/chat-artifacts/model/artifact.ts b/surfsense_web/features/chat-artifacts/model/artifact.ts new file mode 100644 index 000000000..e63f84a85 --- /dev/null +++ b/surfsense_web/features/chat-artifacts/model/artifact.ts @@ -0,0 +1,23 @@ +/** Deliverable kinds the agent can produce and surface in the artifacts sidebar. */ +export type ArtifactKind = "report" | "resume" | "podcast" | "video" | "image"; + +export type ArtifactStatus = "running" | "ready" | "error"; + +/** + * A chat deliverable, aggregated from the assistant message stream. One entry + * per deliverable tool call; the heavy content stays in the inline card and is + * fetched lazily by the panel/card on demand. + */ +export interface ChatArtifact { + /** Stable identity for list keys + dedupe — entity id when known, else the tool call id. */ + key: string; + kind: ArtifactKind; + title: string; + status: ArtifactStatus; + /** Anchors the scroll-to-card jump back into the conversation. */ + toolCallId: string; + /** Backing entity id for report/resume/podcast/video; null for images. */ + entityId: number | null; + /** Report panel content type — "typst" for resumes, "markdown" otherwise. */ + contentType: "markdown" | "typst"; +} From a7829ca9b3b7c791d3734cc2349d16a194ecdb6d Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 22:33:21 +0200 Subject: [PATCH 03/33] feat: map deliverable tools to artifact kinds --- .../features/chat-artifacts/model/artifact.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/surfsense_web/features/chat-artifacts/model/artifact.ts b/surfsense_web/features/chat-artifacts/model/artifact.ts index e63f84a85..d8fff5bdd 100644 --- a/surfsense_web/features/chat-artifacts/model/artifact.ts +++ b/surfsense_web/features/chat-artifacts/model/artifact.ts @@ -21,3 +21,13 @@ export interface ChatArtifact { /** Report panel content type — "typst" for resumes, "markdown" otherwise. */ contentType: "markdown" | "typst"; } + +/** Maps deliverable tool names to artifact kinds. Mirrors the body tools in assistant-message. */ +export const ARTIFACT_TOOL_KINDS: Record = { + generate_report: "report", + generate_resume: "resume", + generate_podcast: "podcast", + generate_video_presentation: "video", + generate_image: "image", + display_image: "image", +}; From 315dd29ec24697fa3dc3eb14a598bf407c2339d5 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 22:33:26 +0200 Subject: [PATCH 04/33] feat: add scroll-to-artifact helper --- .../chat-artifacts/lib/scroll-to-artifact.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 surfsense_web/features/chat-artifacts/lib/scroll-to-artifact.ts diff --git a/surfsense_web/features/chat-artifacts/lib/scroll-to-artifact.ts b/surfsense_web/features/chat-artifacts/lib/scroll-to-artifact.ts new file mode 100644 index 000000000..24cf4ba8b --- /dev/null +++ b/surfsense_web/features/chat-artifacts/lib/scroll-to-artifact.ts @@ -0,0 +1,28 @@ +/** Data attribute stamped on each deliverable card wrapper by `ArtifactAnchor`. */ +export const ARTIFACT_ANCHOR_ATTR = "data-artifact-tool-call-id"; + +const HIGHLIGHT_CLASSES = ["ring-2", "ring-primary/60"]; +const HIGHLIGHT_DURATION_MS = 1600; + +/** + * Scroll the inline card for `toolCallId` into view and pulse a ring so the + * user can spot it after jumping from the artifacts sidebar. Returns false when + * the card isn't mounted (e.g. outside the loaded message window). + */ +export function scrollToArtifact(toolCallId: string): boolean { + if (typeof document === "undefined") return false; + + const anchor = document.querySelector( + `[${ARTIFACT_ANCHOR_ATTR}="${CSS.escape(toolCallId)}"]` + ); + if (!anchor) return false; + + anchor.scrollIntoView({ behavior: "smooth", block: "start" }); + + // The wrapper is full-width; highlight the card itself so the ring hugs its corners. + const card = (anchor.firstElementChild as HTMLElement | null) ?? anchor; + card.classList.add(...HIGHLIGHT_CLASSES); + window.setTimeout(() => card.classList.remove(...HIGHLIGHT_CLASSES), HIGHLIGHT_DURATION_MS); + + return true; +} From e9deb1dcb1b822ddd464abaca6595465411f3f39 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 22:33:57 +0200 Subject: [PATCH 05/33] feat: describe deliverable tool calls --- .../chat-artifacts/lib/collect-artifacts.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 surfsense_web/features/chat-artifacts/lib/collect-artifacts.ts diff --git a/surfsense_web/features/chat-artifacts/lib/collect-artifacts.ts b/surfsense_web/features/chat-artifacts/lib/collect-artifacts.ts new file mode 100644 index 000000000..74da8fd0b --- /dev/null +++ b/surfsense_web/features/chat-artifacts/lib/collect-artifacts.ts @@ -0,0 +1,66 @@ +import type { ArtifactKind, ArtifactStatus } from "../model/artifact"; + +function firstString(...values: unknown[]): string | null { + for (const value of values) { + if (typeof value === "string" && value.trim().length > 0) return value; + } + return null; +} + +function numericId(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** Extracts entity id, title, and status for a single deliverable tool call. */ +function describeArtifact( + kind: ArtifactKind, + args: Record, + result: Record, + hasResult: boolean +): { title: string; entityId: number | null; status: ArtifactStatus } { + const resultStatus = typeof result.status === "string" ? result.status : null; + const failed = resultStatus === "failed" || resultStatus === "error" || !!result.error; + + switch (kind) { + case "report": { + const entityId = numericId(result.report_id); + return { + title: firstString(result.title, args.topic) ?? "Report", + entityId, + status: failed ? "error" : entityId != null ? "ready" : "running", + }; + } + case "resume": { + const entityId = numericId(result.report_id); + return { + title: firstString(result.title) ?? "Resume", + entityId, + status: failed ? "error" : entityId != null ? "ready" : "running", + }; + } + case "podcast": { + const entityId = numericId(result.podcast_id); + return { + title: firstString(result.title, args.podcast_title) ?? "Podcast", + entityId, + status: failed ? "error" : entityId != null ? "ready" : "running", + }; + } + case "video": { + const entityId = numericId(result.video_presentation_id); + return { + title: firstString(result.title, args.video_title) ?? "Presentation", + entityId, + status: failed ? "error" : entityId != null ? "ready" : "running", + }; + } + case "image": { + const ready = typeof result.src === "string" && result.src.length > 0; + return { + title: firstString(result.title, args.prompt) ?? "Image", + entityId: null, + status: failed ? "error" : ready ? "ready" : hasResult ? "ready" : "running", + }; + } + } +} From cba2a726a23a09c6dd1f33cd5e08a515afc27693 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 22:35:36 +0200 Subject: [PATCH 06/33] feat: aggregate artifacts from messages --- .../chat-artifacts/lib/collect-artifacts.ts | 75 ++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/surfsense_web/features/chat-artifacts/lib/collect-artifacts.ts b/surfsense_web/features/chat-artifacts/lib/collect-artifacts.ts index 74da8fd0b..1e01fda94 100644 --- a/surfsense_web/features/chat-artifacts/lib/collect-artifacts.ts +++ b/surfsense_web/features/chat-artifacts/lib/collect-artifacts.ts @@ -1,4 +1,32 @@ -import type { ArtifactKind, ArtifactStatus } from "../model/artifact"; +import type { ThreadMessageLike } from "@assistant-ui/react"; +import { + ARTIFACT_TOOL_KINDS, + type ArtifactKind, + type ArtifactStatus, + type ChatArtifact, +} from "../model/artifact"; + +interface ToolCallPart { + type: "tool-call"; + toolCallId: string; + toolName: string; + args?: Record; + result?: unknown; +} + +function isToolCallPart(part: unknown): part is ToolCallPart { + return ( + typeof part === "object" && + part !== null && + (part as { type?: unknown }).type === "tool-call" && + typeof (part as { toolCallId?: unknown }).toolCallId === "string" && + typeof (part as { toolName?: unknown }).toolName === "string" + ); +} + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null ? (value as Record) : {}; +} function firstString(...values: unknown[]): string | null { for (const value of values) { @@ -64,3 +92,48 @@ function describeArtifact( } } } + +/** + * Aggregate the deliverable artifacts referenced across a thread's messages. + * + * Scans assistant tool-call parts, keeps recognized deliverable tools, and + * dedupes by backing entity (so a regenerated report collapses to one entry, + * refreshed in place to keep chronological order). Errored deliverables are + * dropped — they have nothing to open or jump to. + */ +export function collectArtifacts(messages: readonly ThreadMessageLike[]): ChatArtifact[] { + const byKey = new Map(); + + for (const message of messages) { + if (message.role !== "assistant" || !Array.isArray(message.content)) continue; + + for (const part of message.content) { + if (!isToolCallPart(part)) continue; + const kind = ARTIFACT_TOOL_KINDS[part.toolName]; + if (!kind) continue; + + const args = asRecord(part.args); + const result = asRecord(part.result); + const { title, entityId, status } = describeArtifact( + kind, + args, + result, + part.result !== undefined + ); + if (status === "error") continue; + + const key = entityId != null ? `${kind}:${entityId}` : part.toolCallId; + byKey.set(key, { + key, + kind, + title, + status, + toolCallId: part.toolCallId, + entityId, + contentType: kind === "resume" ? "typst" : "markdown", + }); + } + } + + return Array.from(byKey.values()); +} From 71f16c93ddca6df5a39a7a2993045ede08775801 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 22:35:50 +0200 Subject: [PATCH 07/33] feat: add artifacts panel state atoms --- .../features/chat-artifacts/state/artifacts-panel.atom.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 surfsense_web/features/chat-artifacts/state/artifacts-panel.atom.ts diff --git a/surfsense_web/features/chat-artifacts/state/artifacts-panel.atom.ts b/surfsense_web/features/chat-artifacts/state/artifacts-panel.atom.ts new file mode 100644 index 000000000..c29a24845 --- /dev/null +++ b/surfsense_web/features/chat-artifacts/state/artifacts-panel.atom.ts @@ -0,0 +1,8 @@ +import { atom } from "jotai"; +import type { ChatArtifact } from "../model/artifact"; + +/** Artifacts of the active thread, synced from the message stream by `useSyncChatArtifacts`. */ +export const chatArtifactsAtom = atom([]); + +/** Whether the artifacts sidebar is open in the right panel. */ +export const artifactsPanelOpenAtom = atom(false); From d568905661a4e426e225028a11f9e1f5eff042f3 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 22:36:06 +0200 Subject: [PATCH 08/33] feat: add artifacts open/close actions --- .../state/artifacts-panel.atom.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/surfsense_web/features/chat-artifacts/state/artifacts-panel.atom.ts b/surfsense_web/features/chat-artifacts/state/artifacts-panel.atom.ts index c29a24845..2ed0f7479 100644 --- a/surfsense_web/features/chat-artifacts/state/artifacts-panel.atom.ts +++ b/surfsense_web/features/chat-artifacts/state/artifacts-panel.atom.ts @@ -1,4 +1,5 @@ import { atom } from "jotai"; +import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom"; import type { ChatArtifact } from "../model/artifact"; /** Artifacts of the active thread, synced from the message stream by `useSyncChatArtifacts`. */ @@ -6,3 +7,25 @@ export const chatArtifactsAtom = atom([]); /** Whether the artifacts sidebar is open in the right panel. */ export const artifactsPanelOpenAtom = atom(false); + +/** Snapshot of `rightPanelCollapsedAtom` taken before the panel opens, restored on close. */ +const preArtifactsCollapsedAtom = atom(null); + +export const openArtifactsPanelAtom = atom(null, (get, set) => { + if (!get(artifactsPanelOpenAtom)) { + set(preArtifactsCollapsedAtom, get(rightPanelCollapsedAtom)); + } + set(artifactsPanelOpenAtom, true); + set(rightPanelTabAtom, "artifacts"); + set(rightPanelCollapsedAtom, false); +}); + +export const closeArtifactsPanelAtom = atom(null, (get, set) => { + set(artifactsPanelOpenAtom, false); + set(rightPanelTabAtom, "sources"); + const prev = get(preArtifactsCollapsedAtom); + if (prev !== null) { + set(rightPanelCollapsedAtom, prev); + set(preArtifactsCollapsedAtom, null); + } +}); From 64c3db60bd41e2f276974512c73db9429ec965ab Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 22:36:20 +0200 Subject: [PATCH 09/33] feat: add artifacts panel toggle action --- .../features/chat-artifacts/state/artifacts-panel.atom.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/surfsense_web/features/chat-artifacts/state/artifacts-panel.atom.ts b/surfsense_web/features/chat-artifacts/state/artifacts-panel.atom.ts index 2ed0f7479..1a0f5e738 100644 --- a/surfsense_web/features/chat-artifacts/state/artifacts-panel.atom.ts +++ b/surfsense_web/features/chat-artifacts/state/artifacts-panel.atom.ts @@ -29,3 +29,8 @@ export const closeArtifactsPanelAtom = atom(null, (get, set) => { set(preArtifactsCollapsedAtom, null); } }); + +export const toggleArtifactsPanelAtom = atom(null, (get, set) => { + if (get(artifactsPanelOpenAtom)) set(closeArtifactsPanelAtom); + else set(openArtifactsPanelAtom); +}); From 87224b0239693269a9df607258d1923177955420 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 22:36:26 +0200 Subject: [PATCH 10/33] feat: sync thread artifacts to panel state --- .../hooks/use-sync-chat-artifacts.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 surfsense_web/features/chat-artifacts/hooks/use-sync-chat-artifacts.ts diff --git a/surfsense_web/features/chat-artifacts/hooks/use-sync-chat-artifacts.ts b/surfsense_web/features/chat-artifacts/hooks/use-sync-chat-artifacts.ts new file mode 100644 index 000000000..e7991d846 --- /dev/null +++ b/surfsense_web/features/chat-artifacts/hooks/use-sync-chat-artifacts.ts @@ -0,0 +1,22 @@ +import type { ThreadMessageLike } from "@assistant-ui/react"; +import { useSetAtom } from "jotai"; +import { useEffect, useMemo } from "react"; +import { collectArtifacts } from "../lib/collect-artifacts"; +import { chatArtifactsAtom } from "../state/artifacts-panel.atom"; + +/** + * Keep `chatArtifactsAtom` in sync with the active thread's messages so the + * right-panel sidebar (rendered in the layout shell, outside the chat runtime) + * can read the deliverable list. Clears on unmount and on thread switch (a new + * `messages` array recomputes to the new thread's artifacts). + */ +export function useSyncChatArtifacts(messages: readonly ThreadMessageLike[]): void { + const setArtifacts = useSetAtom(chatArtifactsAtom); + const artifacts = useMemo(() => collectArtifacts(messages), [messages]); + + useEffect(() => { + setArtifacts(artifacts); + }, [artifacts, setArtifacts]); + + useEffect(() => () => setArtifacts([]), [setArtifacts]); +} From bcbb0099f45b50980208d71ebcdd7f88a78dfe49 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 22:36:26 +0200 Subject: [PATCH 11/33] feat: anchor deliverable cards for scroll jumps --- .../chat-artifacts/ui/artifact-anchor.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 surfsense_web/features/chat-artifacts/ui/artifact-anchor.tsx diff --git a/surfsense_web/features/chat-artifacts/ui/artifact-anchor.tsx b/surfsense_web/features/chat-artifacts/ui/artifact-anchor.tsx new file mode 100644 index 000000000..a6e1a390d --- /dev/null +++ b/surfsense_web/features/chat-artifacts/ui/artifact-anchor.tsx @@ -0,0 +1,20 @@ +import type { ToolCallMessagePartComponent, ToolCallMessagePartProps } from "@assistant-ui/react"; +import { ARTIFACT_ANCHOR_ATTR } from "../lib/scroll-to-artifact"; + +/** + * Wrap a body tool component so its rendered card carries a DOM anchor keyed by + * tool call id. The artifacts sidebar uses it to scroll a deliverable back into + * view. The wrapper is layout-neutral — the card keeps its own margins. + */ +export function withArtifactAnchor( + Tool: ToolCallMessagePartComponent +): ToolCallMessagePartComponent { + function AnchoredTool(props: ToolCallMessagePartProps) { + return ( +
+ +
+ ); + } + return AnchoredTool; +} From 302ebcf6176db91de27f0375bfb9fd0bf7fbc0af Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 22:36:26 +0200 Subject: [PATCH 12/33] feat: add artifact list row --- .../chat-artifacts/ui/artifact-row.tsx | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 surfsense_web/features/chat-artifacts/ui/artifact-row.tsx diff --git a/surfsense_web/features/chat-artifacts/ui/artifact-row.tsx b/surfsense_web/features/chat-artifacts/ui/artifact-row.tsx new file mode 100644 index 000000000..f7ae39a41 --- /dev/null +++ b/surfsense_web/features/chat-artifacts/ui/artifact-row.tsx @@ -0,0 +1,56 @@ +import { useSetAtom } from "jotai"; +import { AudioLines, Contact, FileText, ImageIcon, Presentation } from "lucide-react"; +import type { ComponentType } from "react"; +import { openReportPanelAtom } from "@/atoms/chat/report-panel.atom"; +import { Button } from "@/components/ui/button"; +import { scrollToArtifact } from "../lib/scroll-to-artifact"; +import type { ArtifactKind, ChatArtifact } from "../model/artifact"; + +const KIND_META: Record< + ArtifactKind, + { icon: ComponentType<{ className?: string }>; label: string } +> = { + report: { icon: FileText, label: "Report" }, + resume: { icon: Contact, label: "Resume" }, + podcast: { icon: AudioLines, label: "Podcast" }, + video: { icon: Presentation, label: "Presentation" }, + image: { icon: ImageIcon, label: "Image" }, +}; + +export function ArtifactRow({ artifact }: { artifact: ChatArtifact }) { + const openReportPanel = useSetAtom(openReportPanelAtom); + const meta = KIND_META[artifact.kind]; + const Icon = meta.icon; + const isReportLike = artifact.kind === "report" || artifact.kind === "resume"; + + const handleOpen = () => { + scrollToArtifact(artifact.toolCallId); + // Reports and resumes get the richer side-panel viewer in addition to the jump. + if (isReportLike && artifact.entityId != null) { + openReportPanel({ + reportId: artifact.entityId, + title: artifact.title, + contentType: artifact.contentType, + }); + } + }; + + return ( + + ); +} From 7e2c3e388e7c934636a9f205c6b24bac7f38e5b8 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 22:37:02 +0200 Subject: [PATCH 13/33] feat: add desktop artifacts panel --- .../chat-artifacts/ui/artifacts-panel.tsx | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx diff --git a/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx b/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx new file mode 100644 index 000000000..136faf8dd --- /dev/null +++ b/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { LayersIcon, XIcon } from "lucide-react"; +import { useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import type { ArtifactKind, ChatArtifact } from "../model/artifact"; +import { chatArtifactsAtom } from "../state/artifacts-panel.atom"; +import { ArtifactRow } from "./artifact-row"; + +const GROUP_ORDER: { kind: ArtifactKind; label: string }[] = [ + { kind: "report", label: "Reports" }, + { kind: "resume", label: "Resumes" }, + { kind: "podcast", label: "Podcasts" }, + { kind: "video", label: "Presentations" }, + { kind: "image", label: "Images" }, +]; + +function groupByKind(artifacts: ChatArtifact[]): { label: string; items: ChatArtifact[] }[] { + return GROUP_ORDER.map(({ kind, label }) => ({ + label, + items: artifacts.filter((a) => a.kind === kind), + })).filter((group) => group.items.length > 0); +} + +function EmptyState() { + return ( +
+ +

No artifacts yet

+

+ Reports, podcasts, presentations, and images you generate will appear here. +

+
+ ); +} + +function ArtifactGroups({ artifacts }: { artifacts: ChatArtifact[] }) { + const groups = useMemo(() => groupByKind(artifacts), [artifacts]); + + if (groups.length === 0) return ; + + return ( +
+ {groups.map((group) => ( +
+

+ {group.label} +

+
+ {group.items.map((artifact) => ( + + ))} +
+
+ ))} +
+ ); +} + +/** Inner content shared by the desktop right-panel tab and the mobile drawer. */ +export function ArtifactsPanelContent({ onClose }: { onClose?: () => void }) { + const artifacts = useAtomValue(chatArtifactsAtom); + + return ( + <> +
+

Artifacts

+ {onClose && ( + + )} +
+ + + ); +} From c03991ce3ab1968a063a1f78b436f959871b5118 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 22:37:28 +0200 Subject: [PATCH 14/33] feat: add mobile artifacts drawer --- .../chat-artifacts/ui/artifacts-panel.tsx | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx b/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx index 136faf8dd..c22d412f2 100644 --- a/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx +++ b/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx @@ -1,11 +1,17 @@ "use client"; -import { useAtomValue } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { LayersIcon, XIcon } from "lucide-react"; import { useMemo } from "react"; import { Button } from "@/components/ui/button"; +import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; +import { useMediaQuery } from "@/hooks/use-media-query"; import type { ArtifactKind, ChatArtifact } from "../model/artifact"; -import { chatArtifactsAtom } from "../state/artifacts-panel.atom"; +import { + artifactsPanelOpenAtom, + chatArtifactsAtom, + closeArtifactsPanelAtom, +} from "../state/artifacts-panel.atom"; import { ArtifactRow } from "./artifact-row"; const GROUP_ORDER: { kind: ArtifactKind; label: string }[] = [ @@ -82,3 +88,36 @@ export function ArtifactsPanelContent({ onClose }: { onClose?: () => void }) { ); } + +/** + * Mobile artifacts drawer. Desktop renders inside the layout-level RightPanel + * tab instead, so this no-ops on large screens. + */ +export function MobileArtifactsPanel() { + const isOpen = useAtomValue(artifactsPanelOpenAtom); + const close = useSetAtom(closeArtifactsPanelAtom); + const isDesktop = useMediaQuery("(min-width: 1024px)"); + + if (isDesktop || !isOpen) return null; + + return ( + { + if (!open) close(); + }} + shouldScaleBackground={false} + > + + + Artifacts +
+ +
+
+
+ ); +} From 9ea6e3eacd1452877ac8c39cd092f1c01053caa7 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 22:37:33 +0200 Subject: [PATCH 15/33] feat: add artifacts toggle button --- .../ui/artifacts-toggle-button.tsx | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 surfsense_web/features/chat-artifacts/ui/artifacts-toggle-button.tsx diff --git a/surfsense_web/features/chat-artifacts/ui/artifacts-toggle-button.tsx b/surfsense_web/features/chat-artifacts/ui/artifacts-toggle-button.tsx new file mode 100644 index 000000000..a99836d30 --- /dev/null +++ b/surfsense_web/features/chat-artifacts/ui/artifacts-toggle-button.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useAtomValue, useSetAtom } from "jotai"; +import { LayersIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { + artifactsPanelOpenAtom, + chatArtifactsAtom, + toggleArtifactsPanelAtom, +} from "../state/artifacts-panel.atom"; + +/** Header toggle that opens the artifacts sidebar. Hidden when the thread has none. */ +export function ArtifactsToggleButton() { + const artifacts = useAtomValue(chatArtifactsAtom); + const isOpen = useAtomValue(artifactsPanelOpenAtom); + const toggle = useSetAtom(toggleArtifactsPanelAtom); + + if (artifacts.length === 0) return null; + + const label = isOpen ? "Hide artifacts" : "Show artifacts"; + + return ( + + + + + {label} + + ); +} From 00082cb1ac4e1f1619893ea9379ffaa3fbebc875 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 22:37:33 +0200 Subject: [PATCH 16/33] feat: export chat-artifacts slice --- surfsense_web/features/chat-artifacts/index.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 surfsense_web/features/chat-artifacts/index.ts diff --git a/surfsense_web/features/chat-artifacts/index.ts b/surfsense_web/features/chat-artifacts/index.ts new file mode 100644 index 000000000..f5c39a4a4 --- /dev/null +++ b/surfsense_web/features/chat-artifacts/index.ts @@ -0,0 +1,14 @@ +export { useSyncChatArtifacts } from "./hooks/use-sync-chat-artifacts"; +export { collectArtifacts } from "./lib/collect-artifacts"; +export { ARTIFACT_ANCHOR_ATTR, scrollToArtifact } from "./lib/scroll-to-artifact"; +export type { ArtifactKind, ArtifactStatus, ChatArtifact } from "./model/artifact"; +export { + artifactsPanelOpenAtom, + chatArtifactsAtom, + closeArtifactsPanelAtom, + openArtifactsPanelAtom, + toggleArtifactsPanelAtom, +} from "./state/artifacts-panel.atom"; +export { withArtifactAnchor } from "./ui/artifact-anchor"; +export { ArtifactsPanelContent, MobileArtifactsPanel } from "./ui/artifacts-panel"; +export { ArtifactsToggleButton } from "./ui/artifacts-toggle-button"; From 365c8a1088e08a61385c6d6fe2e980cf91510640 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 22:38:01 +0200 Subject: [PATCH 17/33] feat: anchor body tool cards --- .../components/assistant-ui/assistant-message.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 59006b26e..616d3a797 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -58,6 +58,7 @@ import { DrawerTitle, } from "@/components/ui/drawer"; import { DropdownMenuLabel } from "@/components/ui/dropdown-menu"; +import { withArtifactAnchor } from "@/features/chat-artifacts"; import { useComments } from "@/hooks/use-comments"; import { useMediaQuery } from "@/hooks/use-media-query"; import { useElectronAPI } from "@/hooks/use-platform"; @@ -433,12 +434,12 @@ const MessageInfoDropdown: FC<{ chatTurnId: string | null | undefined }> = ({ ch * body and is picked up by the timeline instead. */ const BODY_TOOLS = { - generate_report: GenerateReportToolUI, - generate_resume: GenerateResumeToolUI, - generate_podcast: GeneratePodcastToolUI, - generate_video_presentation: GenerateVideoPresentationToolUI, - display_image: GenerateImageToolUI, - generate_image: GenerateImageToolUI, + generate_report: withArtifactAnchor(GenerateReportToolUI), + generate_resume: withArtifactAnchor(GenerateResumeToolUI), + generate_podcast: withArtifactAnchor(GeneratePodcastToolUI), + generate_video_presentation: withArtifactAnchor(GenerateVideoPresentationToolUI), + display_image: withArtifactAnchor(GenerateImageToolUI), + generate_image: withArtifactAnchor(GenerateImageToolUI), } as const; const NullBodyTool: ToolCallMessagePartComponent = () => null; From ec907431d5c41b88f4323a736ab4656d5924b6ce Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 22:38:15 +0200 Subject: [PATCH 18/33] feat: surface chat page artifacts --- .../new-chat/[[...chat_id]]/page.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 3594e15eb..e8e24d1e8 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -52,6 +52,7 @@ import { } from "@/components/assistant-ui/token-usage-context"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; +import { useSyncChatArtifacts } from "@/features/chat-artifacts"; import { type HitlDecision, PendingInterruptProvider, @@ -138,6 +139,13 @@ const MobileReportPanel = dynamic( })), { ssr: false } ); +const MobileArtifactsPanel = dynamic( + () => + import("@/features/chat-artifacts/ui/artifacts-panel").then((m) => ({ + default: m.MobileArtifactsPanel, + })), + { ssr: false } +); /** * Generate a synthetic ``toolCallId`` for an action_request that has no @@ -2501,6 +2509,9 @@ export default function NewChatPage() { await handleRegenerate(null); }, [handleRegenerate]); + // Surface the thread's deliverables to the layout-level artifacts sidebar. + useSyncChatArtifacts(messages); + // Create external store runtime const runtime = useExternalStoreRuntime({ messages, @@ -2560,6 +2571,7 @@ export default function NewChatPage() { + Date: Mon, 22 Jun 2026 22:38:15 +0200 Subject: [PATCH 19/33] feat: add header artifacts toggle --- surfsense_web/components/layout/ui/header/Header.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/surfsense_web/components/layout/ui/header/Header.tsx b/surfsense_web/components/layout/ui/header/Header.tsx index ea700391a..af997ad5c 100644 --- a/surfsense_web/components/layout/ui/header/Header.tsx +++ b/surfsense_web/components/layout/ui/header/Header.tsx @@ -7,6 +7,7 @@ import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-quer import { activeTabAtom } from "@/atoms/tabs/tabs.atom"; import { ActionLogButton } from "@/components/agent-action-log/action-log-button"; import { ChatShareButton } from "@/components/new-chat/chat-share-button"; +import { ArtifactsToggleButton } from "@/features/chat-artifacts"; import type { ThreadRecord } from "@/lib/chat/thread-persistence"; interface HeaderProps { @@ -71,6 +72,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) { {/* Right side - Actions */}
{hasThread && } + {hasThread && } {threadForButton && }
From 050d6bf998f444177f4ebc1e2a6b7d8a87c7368a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 22:38:15 +0200 Subject: [PATCH 20/33] feat: render artifacts in right panel --- .../layout/ui/right-panel/RightPanel.tsx | 95 +++++++++++++------ 1 file changed, 64 insertions(+), 31 deletions(-) diff --git a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx index 6662d7830..42e682df4 100644 --- a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx +++ b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx @@ -8,9 +8,14 @@ import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel import { citationPanelAtom, closeCitationPanelAtom } from "@/atoms/citation/citation-panel.atom"; import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom"; -import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom"; +import { + type RightPanelTab, + rightPanelCollapsedAtom, + rightPanelTabAtom, +} from "@/atoms/layout/right-panel.atom"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { artifactsPanelOpenAtom, closeArtifactsPanelAtom } from "@/features/chat-artifacts"; import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/features/chat-messages/hitl"; import { useMediaQuery } from "@/hooks/use-media-query"; import { cn } from "@/lib/utils"; @@ -48,6 +53,14 @@ const ReportPanelContent = dynamic( { ssr: false, loading: () => null } ); +const ArtifactsPanelContent = dynamic( + () => + import("@/features/chat-artifacts").then((m) => ({ + default: m.ArtifactsPanelContent, + })), + { ssr: false, loading: () => null } +); + interface RightPanelProps { documentsPanel?: { open: boolean; @@ -101,6 +114,7 @@ export function RightPanelToggleButton({ const editorState = useAtomValue(editorPanelAtom); const hitlEditState = useAtomValue(hitlEditPanelAtom); const citationState = useAtomValue(citationPanelAtom); + const artifactsOpen = useAtomValue(artifactsPanelOpenAtom); const reportOpen = reportState.isOpen && !!reportState.reportId; const editorOpen = editorState.isOpen && @@ -111,7 +125,8 @@ export function RightPanelToggleButton({ : !!editorState.localFilePath); const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave; const citationOpen = citationState.isOpen && citationState.chunkId != null; - const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen; + const hasContent = + documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen; const label = collapsed ? "Expand panel" : "Collapse panel"; if (!hasContent) return null; @@ -153,6 +168,7 @@ export function RightPanelExpandButton() { const editorState = useAtomValue(editorPanelAtom); const hitlEditState = useAtomValue(hitlEditPanelAtom); const citationState = useAtomValue(citationPanelAtom); + const artifactsOpen = useAtomValue(artifactsPanelOpenAtom); const reportOpen = reportState.isOpen && !!reportState.reportId; const editorOpen = editorState.isOpen && @@ -163,7 +179,8 @@ export function RightPanelExpandButton() { : !!editorState.localFilePath); const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave; const citationOpen = citationState.isOpen && citationState.chunkId != null; - const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen; + const hasContent = + documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen; if (!collapsed || !hasContent) return null; @@ -180,8 +197,31 @@ const PANEL_WIDTHS = { editor: 640, "hitl-edit": 640, citation: 560, + artifacts: 420, } as const; +/** + * Priority order used to fall back to another open surface when the active + * tab's content closes. Artifacts sit just above the always-available sources + * tab. + */ +const TAB_FALLBACK_ORDER: RightPanelTab[] = [ + "hitl-edit", + "citation", + "editor", + "report", + "artifacts", + "sources", +]; + +function resolveEffectiveTab( + activeTab: RightPanelTab, + openByTab: Record +): RightPanelTab { + if (openByTab[activeTab]) return activeTab; + return TAB_FALLBACK_ORDER.find((tab) => openByTab[tab]) ?? "sources"; +} + export function RightPanel({ documentsPanel, showCollapseButton = true, @@ -196,6 +236,8 @@ export function RightPanel({ const closeHitlEdit = useSetAtom(closeHitlEditPanelAtom); const citationState = useAtomValue(citationPanelAtom); const closeCitation = useSetAtom(closeCitationPanelAtom); + const artifactsOpen = useAtomValue(artifactsPanelOpenAtom); + const closeArtifacts = useSetAtom(closeArtifactsPanelAtom); const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom); // Desktop-only surface; mobile uses the dedicated Mobile* drawers. Without // this guard both render together and two editors fight over one model. @@ -214,13 +256,14 @@ export function RightPanel({ const citationOpen = citationState.isOpen && citationState.chunkId != null; useEffect(() => { - if (!reportOpen && !editorOpen && !hitlEditOpen && !citationOpen) return; + if (!reportOpen && !editorOpen && !hitlEditOpen && !citationOpen && !artifactsOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { if (hitlEditOpen) closeHitlEdit(); else if (citationOpen) closeCitation(); else if (editorOpen) closeEditor(); else if (reportOpen) closeReport(); + else if (artifactsOpen) closeArtifacts(); } }; document.addEventListener("keydown", handleKeyDown); @@ -230,41 +273,26 @@ export function RightPanel({ editorOpen, hitlEditOpen, citationOpen, + artifactsOpen, closeReport, closeEditor, closeHitlEdit, closeCitation, + closeArtifacts, ]); const isVisible = - (documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen) && !collapsed; + (documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen) && + !collapsed; - let effectiveTab = activeTab; - if (effectiveTab === "hitl-edit" && !hitlEditOpen) { - effectiveTab = citationOpen - ? "citation" - : editorOpen - ? "editor" - : reportOpen - ? "report" - : "sources"; - } else if (effectiveTab === "citation" && !citationOpen) { - effectiveTab = editorOpen ? "editor" : reportOpen ? "report" : "sources"; - } else if (effectiveTab === "editor" && !editorOpen) { - effectiveTab = citationOpen ? "citation" : reportOpen ? "report" : "sources"; - } else if (effectiveTab === "report" && !reportOpen) { - effectiveTab = citationOpen ? "citation" : editorOpen ? "editor" : "sources"; - } else if (effectiveTab === "sources" && !documentsOpen) { - effectiveTab = hitlEditOpen - ? "hitl-edit" - : citationOpen - ? "citation" - : editorOpen - ? "editor" - : reportOpen - ? "report" - : "sources"; - } + const effectiveTab = resolveEffectiveTab(activeTab, { + sources: documentsOpen, + report: reportOpen, + editor: editorOpen, + "hitl-edit": hitlEditOpen, + citation: citationOpen, + artifacts: artifactsOpen, + }); const targetWidth = PANEL_WIDTHS[effectiveTab]; const collapseButton = showCollapseButton ? ( @@ -335,6 +363,11 @@ export function RightPanel({ )} + {effectiveTab === "artifacts" && artifactsOpen && ( +
+ +
+ )} ); From 6efc3bf51796da351aa1d056b92ed4a96248142d Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 22 Jun 2026 23:24:25 +0200 Subject: [PATCH 21/33] fix: make artifact navigation robust --- .../chat-artifacts/lib/scroll-to-artifact.ts | 46 ++++++++++++------- .../state/artifacts-panel.atom.ts | 15 +++--- .../chat-artifacts/ui/artifact-anchor.tsx | 2 +- .../chat-artifacts/ui/artifact-row.tsx | 14 +++++- 4 files changed, 52 insertions(+), 25 deletions(-) diff --git a/surfsense_web/features/chat-artifacts/lib/scroll-to-artifact.ts b/surfsense_web/features/chat-artifacts/lib/scroll-to-artifact.ts index 24cf4ba8b..5a4ed2160 100644 --- a/surfsense_web/features/chat-artifacts/lib/scroll-to-artifact.ts +++ b/surfsense_web/features/chat-artifacts/lib/scroll-to-artifact.ts @@ -3,26 +3,40 @@ export const ARTIFACT_ANCHOR_ATTR = "data-artifact-tool-call-id"; const HIGHLIGHT_CLASSES = ["ring-2", "ring-primary/60"]; const HIGHLIGHT_DURATION_MS = 1600; +const RETRY_INTERVAL_MS = 120; +const MAX_WAIT_MS = 1500; + +function isInView(el: HTMLElement): boolean { + const { top, bottom } = el.getBoundingClientRect(); + return bottom > window.innerHeight * 0.2 && top < window.innerHeight * 0.8; +} /** - * Scroll the inline card for `toolCallId` into view and pulse a ring so the - * user can spot it after jumping from the artifacts sidebar. Returns false when - * the card isn't mounted (e.g. outside the loaded message window). + * Scroll the inline card for `toolCallId` into view and pulse a ring. Retries + * because the thread viewport's initialize auto-scroll can fire after the first + * jump and snap back to the bottom; scrolling off-bottom disengages it. */ -export function scrollToArtifact(toolCallId: string): boolean { - if (typeof document === "undefined") return false; +export function scrollToArtifact(toolCallId: string): void { + if (typeof document === "undefined") return; - const anchor = document.querySelector( - `[${ARTIFACT_ANCHOR_ATTR}="${CSS.escape(toolCallId)}"]` - ); - if (!anchor) return false; + const selector = `[${ARTIFACT_ANCHOR_ATTR}="${CSS.escape(toolCallId)}"]`; + const deadline = Date.now() + MAX_WAIT_MS; + let highlighted = false; - anchor.scrollIntoView({ behavior: "smooth", block: "start" }); + const attempt = () => { + const anchor = document.querySelector(selector); + if (anchor) { + anchor.scrollIntoView({ behavior: "smooth", block: "center" }); + if (!highlighted) { + highlighted = true; + const card = (anchor.firstElementChild as HTMLElement | null) ?? anchor; + card.classList.add(...HIGHLIGHT_CLASSES); + window.setTimeout(() => card.classList.remove(...HIGHLIGHT_CLASSES), HIGHLIGHT_DURATION_MS); + } + if (isInView(anchor)) return; + } + if (Date.now() < deadline) window.setTimeout(attempt, RETRY_INTERVAL_MS); + }; - // The wrapper is full-width; highlight the card itself so the ring hugs its corners. - const card = (anchor.firstElementChild as HTMLElement | null) ?? anchor; - card.classList.add(...HIGHLIGHT_CLASSES); - window.setTimeout(() => card.classList.remove(...HIGHLIGHT_CLASSES), HIGHLIGHT_DURATION_MS); - - return true; + attempt(); } diff --git a/surfsense_web/features/chat-artifacts/state/artifacts-panel.atom.ts b/surfsense_web/features/chat-artifacts/state/artifacts-panel.atom.ts index 1a0f5e738..caa809d78 100644 --- a/surfsense_web/features/chat-artifacts/state/artifacts-panel.atom.ts +++ b/surfsense_web/features/chat-artifacts/state/artifacts-panel.atom.ts @@ -5,23 +5,24 @@ import type { ChatArtifact } from "../model/artifact"; /** Artifacts of the active thread, synced from the message stream by `useSyncChatArtifacts`. */ export const chatArtifactsAtom = atom([]); -/** Whether the artifacts sidebar is open in the right panel. */ -export const artifactsPanelOpenAtom = atom(false); +/** Open === artifacts owns the tab; derived so the toggle can't drift. */ +export const artifactsPanelOpenAtom = atom((get) => get(rightPanelTabAtom) === "artifacts"); /** Snapshot of `rightPanelCollapsedAtom` taken before the panel opens, restored on close. */ const preArtifactsCollapsedAtom = atom(null); export const openArtifactsPanelAtom = atom(null, (get, set) => { - if (!get(artifactsPanelOpenAtom)) { + if (get(rightPanelTabAtom) !== "artifacts") { set(preArtifactsCollapsedAtom, get(rightPanelCollapsedAtom)); } - set(artifactsPanelOpenAtom, true); set(rightPanelTabAtom, "artifacts"); set(rightPanelCollapsedAtom, false); }); export const closeArtifactsPanelAtom = atom(null, (get, set) => { - set(artifactsPanelOpenAtom, false); + // Don't clobber the tab when another surface owns it. + if (get(rightPanelTabAtom) !== "artifacts") return; + // RightPanel's fallback then re-reveals any surface underneath (e.g. a report). set(rightPanelTabAtom, "sources"); const prev = get(preArtifactsCollapsedAtom); if (prev !== null) { @@ -31,6 +32,8 @@ export const closeArtifactsPanelAtom = atom(null, (get, set) => { }); export const toggleArtifactsPanelAtom = atom(null, (get, set) => { - if (get(artifactsPanelOpenAtom)) set(closeArtifactsPanelAtom); + // Only close when artifacts is actually visible; otherwise a click always opens it. + const shown = get(rightPanelTabAtom) === "artifacts" && !get(rightPanelCollapsedAtom); + if (shown) set(closeArtifactsPanelAtom); else set(openArtifactsPanelAtom); }); diff --git a/surfsense_web/features/chat-artifacts/ui/artifact-anchor.tsx b/surfsense_web/features/chat-artifacts/ui/artifact-anchor.tsx index a6e1a390d..de5baa08c 100644 --- a/surfsense_web/features/chat-artifacts/ui/artifact-anchor.tsx +++ b/surfsense_web/features/chat-artifacts/ui/artifact-anchor.tsx @@ -11,7 +11,7 @@ export function withArtifactAnchor( ): ToolCallMessagePartComponent { function AnchoredTool(props: ToolCallMessagePartProps) { return ( -
+
); diff --git a/surfsense_web/features/chat-artifacts/ui/artifact-row.tsx b/surfsense_web/features/chat-artifacts/ui/artifact-row.tsx index f7ae39a41..3bf2dbc0c 100644 --- a/surfsense_web/features/chat-artifacts/ui/artifact-row.tsx +++ b/surfsense_web/features/chat-artifacts/ui/artifact-row.tsx @@ -3,8 +3,10 @@ import { AudioLines, Contact, FileText, ImageIcon, Presentation } from "lucide-r import type { ComponentType } from "react"; import { openReportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { Button } from "@/components/ui/button"; +import { useMediaQuery } from "@/hooks/use-media-query"; import { scrollToArtifact } from "../lib/scroll-to-artifact"; import type { ArtifactKind, ChatArtifact } from "../model/artifact"; +import { closeArtifactsPanelAtom } from "../state/artifacts-panel.atom"; const KIND_META: Record< ArtifactKind, @@ -19,20 +21,28 @@ const KIND_META: Record< export function ArtifactRow({ artifact }: { artifact: ChatArtifact }) { const openReportPanel = useSetAtom(openReportPanelAtom); + const closeArtifactsPanel = useSetAtom(closeArtifactsPanelAtom); + const isDesktop = useMediaQuery("(min-width: 1024px)"); const meta = KIND_META[artifact.kind]; const Icon = meta.icon; const isReportLike = artifact.kind === "report" || artifact.kind === "resume"; const handleOpen = () => { - scrollToArtifact(artifact.toolCallId); - // Reports and resumes get the richer side-panel viewer in addition to the jump. + // Reports/resumes open in the report viewer, which claims the tab itself. if (isReportLike && artifact.entityId != null) { openReportPanel({ reportId: artifact.entityId, title: artifact.title, contentType: artifact.contentType, }); + scrollToArtifact(artifact.toolCallId); + return; } + + // Inline media has no viewer — just jump to the card. Mobile dismisses the + // drawer first since it covers the chat; desktop leaves the panel open. + if (!isDesktop) closeArtifactsPanel(); + scrollToArtifact(artifact.toolCallId); }; return ( From 875d3040fa9b8a3e5d2671ae4886fa08f7ccb17a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 23 Jun 2026 15:18:00 +0200 Subject: [PATCH 22/33] feat: add artifact list contracts --- .../types/image-generations.types.ts | 27 +++++++++++++++++++ .../contracts/types/podcast.types.ts | 12 +++++++++ .../contracts/types/reports.types.ts | 24 +++++++++++++++++ .../types/video-presentations.types.ts | 19 +++++++++++++ 4 files changed, 82 insertions(+) create mode 100644 surfsense_web/contracts/types/image-generations.types.ts create mode 100644 surfsense_web/contracts/types/reports.types.ts create mode 100644 surfsense_web/contracts/types/video-presentations.types.ts diff --git a/surfsense_web/contracts/types/image-generations.types.ts b/surfsense_web/contracts/types/image-generations.types.ts new file mode 100644 index 000000000..d972dad78 --- /dev/null +++ b/surfsense_web/contracts/types/image-generations.types.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +// ============================================================================= +// Image generations — mirror app/schemas/image_generation.py. +// ============================================================================= + +export const imageGenerationListItem = z.object({ + id: z.number(), + prompt: z.string(), + search_space_id: z.number(), + created_at: z.string(), + is_success: z.boolean(), + image_count: z.number().nullish(), +}); +export type ImageGenerationListItem = z.infer; + +export const imageGenerationList = z.array(imageGenerationListItem); + +// Detail carries the raw provider response, which holds the displayable image +// as either a hosted url or inline base64. +export const imageGenerationDetail = z.object({ + id: z.number(), + prompt: z.string(), + response_data: z.record(z.string(), z.unknown()).nullish(), + error_message: z.string().nullish(), +}); +export type ImageGenerationDetail = z.infer; diff --git a/surfsense_web/contracts/types/podcast.types.ts b/surfsense_web/contracts/types/podcast.types.ts index 31311c469..c8247a7fe 100644 --- a/surfsense_web/contracts/types/podcast.types.ts +++ b/surfsense_web/contracts/types/podcast.types.ts @@ -155,3 +155,15 @@ export const podcastDetail = z.object({ thread_id: z.number().nullable(), }); export type PodcastDetail = z.infer; + +// Lightweight list item — mirror app/podcasts/api/schemas.py PodcastSummary. +export const podcastSummary = z.object({ + id: z.number(), + title: z.string(), + status: podcastStatus, + created_at: z.string(), + search_space_id: z.number(), +}); +export type PodcastSummary = z.infer; + +export const podcastSummaryList = z.array(podcastSummary); diff --git a/surfsense_web/contracts/types/reports.types.ts b/surfsense_web/contracts/types/reports.types.ts new file mode 100644 index 000000000..25a682084 --- /dev/null +++ b/surfsense_web/contracts/types/reports.types.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +// ============================================================================= +// Reports — mirror app/schemas/reports.py ReportRead (list view, no content). +// Resumes are reports with content_type === "typst". +// ============================================================================= + +export const reportMetadata = z + .object({ + status: z.enum(["ready", "failed"]).nullish(), + word_count: z.number().nullish(), + }) + .nullish(); + +export const reportListItem = z.object({ + id: z.number(), + title: z.string(), + content_type: z.string().default("markdown"), + report_metadata: reportMetadata, + created_at: z.string(), +}); +export type ReportListItem = z.infer; + +export const reportList = z.array(reportListItem); diff --git a/surfsense_web/contracts/types/video-presentations.types.ts b/surfsense_web/contracts/types/video-presentations.types.ts new file mode 100644 index 000000000..45b062840 --- /dev/null +++ b/surfsense_web/contracts/types/video-presentations.types.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +// ============================================================================= +// Video presentations — mirror app/schemas/video_presentations.py status enum. +// ============================================================================= + +export const videoPresentationStatus = z.enum(["pending", "generating", "ready", "failed"]); +export type VideoPresentationStatus = z.infer; + +export const videoPresentationListItem = z.object({ + id: z.number(), + title: z.string(), + status: videoPresentationStatus.default("ready"), + created_at: z.string(), + search_space_id: z.number(), +}); +export type VideoPresentationListItem = z.infer; + +export const videoPresentationList = z.array(videoPresentationListItem); From 11d63e4c68a8e36e7c0a6c96b4e23b0bdfac8e3a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 23 Jun 2026 15:18:00 +0200 Subject: [PATCH 23/33] feat: add artifact list api services --- .../lib/apis/image-generations-api.service.ts | 23 +++++++++++++++++++ .../lib/apis/podcasts-api.service.ts | 9 ++++++++ surfsense_web/lib/apis/reports-api.service.ts | 16 +++++++++++++ .../apis/video-presentations-api.service.ts | 16 +++++++++++++ 4 files changed, 64 insertions(+) create mode 100644 surfsense_web/lib/apis/image-generations-api.service.ts create mode 100644 surfsense_web/lib/apis/reports-api.service.ts create mode 100644 surfsense_web/lib/apis/video-presentations-api.service.ts diff --git a/surfsense_web/lib/apis/image-generations-api.service.ts b/surfsense_web/lib/apis/image-generations-api.service.ts new file mode 100644 index 000000000..6aa17854d --- /dev/null +++ b/surfsense_web/lib/apis/image-generations-api.service.ts @@ -0,0 +1,23 @@ +import { + imageGenerationDetail, + imageGenerationList, +} from "@/contracts/types/image-generations.types"; +import { baseApiService } from "./base-api.service"; + +const BASE = "/api/v1/image-generations"; + +class ImageGenerationsApiService { + list = async (searchSpaceId: number, limit = 100) => { + const qs = new URLSearchParams({ + search_space_id: String(searchSpaceId), + limit: String(limit), + }).toString(); + return baseApiService.get(`${BASE}?${qs}`, imageGenerationList); + }; + + getDetail = async (imageGenId: number) => { + return baseApiService.get(`${BASE}/${imageGenId}`, imageGenerationDetail); + }; +} + +export const imageGenerationsApiService = new ImageGenerationsApiService(); diff --git a/surfsense_web/lib/apis/podcasts-api.service.ts b/surfsense_web/lib/apis/podcasts-api.service.ts index 2e13d63cc..3a18c7951 100644 --- a/surfsense_web/lib/apis/podcasts-api.service.ts +++ b/surfsense_web/lib/apis/podcasts-api.service.ts @@ -3,6 +3,7 @@ import { languageOptions, type PodcastSpec, podcastDetail, + podcastSummaryList, updateSpecRequest, voiceOption, } from "@/contracts/types/podcast.types"; @@ -14,6 +15,14 @@ const BASE = "/api/v1/podcasts"; const voiceOptionList = z.array(voiceOption); class PodcastsApiService { + list = async (searchSpaceId: number, limit = 200) => { + const qs = new URLSearchParams({ + search_space_id: String(searchSpaceId), + limit: String(limit), + }).toString(); + return baseApiService.get(`${BASE}?${qs}`, podcastSummaryList); + }; + // Full state including the deserialized brief and transcript; thin lifecycle // fields (status, spec, spec_version) also arrive live via Zero. getDetail = async (podcastId: number) => { diff --git a/surfsense_web/lib/apis/reports-api.service.ts b/surfsense_web/lib/apis/reports-api.service.ts new file mode 100644 index 000000000..bc4483f37 --- /dev/null +++ b/surfsense_web/lib/apis/reports-api.service.ts @@ -0,0 +1,16 @@ +import { reportList } from "@/contracts/types/reports.types"; +import { baseApiService } from "./base-api.service"; + +const BASE = "/api/v1/reports"; + +class ReportsApiService { + list = async (searchSpaceId: number, limit = 200) => { + const qs = new URLSearchParams({ + search_space_id: String(searchSpaceId), + limit: String(limit), + }).toString(); + return baseApiService.get(`${BASE}?${qs}`, reportList); + }; +} + +export const reportsApiService = new ReportsApiService(); diff --git a/surfsense_web/lib/apis/video-presentations-api.service.ts b/surfsense_web/lib/apis/video-presentations-api.service.ts new file mode 100644 index 000000000..ef3ac21ed --- /dev/null +++ b/surfsense_web/lib/apis/video-presentations-api.service.ts @@ -0,0 +1,16 @@ +import { videoPresentationList } from "@/contracts/types/video-presentations.types"; +import { baseApiService } from "./base-api.service"; + +const BASE = "/api/v1/video-presentations"; + +class VideoPresentationsApiService { + list = async (searchSpaceId: number, limit = 200) => { + const qs = new URLSearchParams({ + search_space_id: String(searchSpaceId), + limit: String(limit), + }).toString(); + return baseApiService.get(`${BASE}?${qs}`, videoPresentationList); + }; +} + +export const videoPresentationsApiService = new VideoPresentationsApiService(); From e04627e185a70c0899fa445a97c77d5a4026824c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 23 Jun 2026 15:18:00 +0200 Subject: [PATCH 24/33] feat: export video presentation viewer --- .../video-presentation/generate-video-presentation.tsx | 2 +- surfsense_web/components/tool-ui/video-presentation/index.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx b/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx index 9f2115073..1eb9e7d26 100644 --- a/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx +++ b/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx @@ -485,7 +485,7 @@ function VideoPresentationPlayer({ ); } -function StatusPoller({ +export function StatusPoller({ presentationId, title, shareToken, diff --git a/surfsense_web/components/tool-ui/video-presentation/index.ts b/surfsense_web/components/tool-ui/video-presentation/index.ts index 7298a08ad..fbc982690 100644 --- a/surfsense_web/components/tool-ui/video-presentation/index.ts +++ b/surfsense_web/components/tool-ui/video-presentation/index.ts @@ -1 +1,4 @@ -export { GenerateVideoPresentationToolUI } from "./generate-video-presentation"; +export { + GenerateVideoPresentationToolUI, + StatusPoller as VideoPresentationViewer, +} from "./generate-video-presentation"; From fd9763417baf1aa02140761ea69a179c3dab6b53 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 23 Jun 2026 15:18:00 +0200 Subject: [PATCH 25/33] feat: add library artifact model --- .../artifacts-library/model/artifact.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 surfsense_web/features/artifacts-library/model/artifact.ts diff --git a/surfsense_web/features/artifacts-library/model/artifact.ts b/surfsense_web/features/artifacts-library/model/artifact.ts new file mode 100644 index 000000000..577d15bf2 --- /dev/null +++ b/surfsense_web/features/artifacts-library/model/artifact.ts @@ -0,0 +1,21 @@ +/** Deliverable kinds surfaced in the search-space-wide artifacts library. */ +export type LibraryArtifactKind = "report" | "resume" | "podcast" | "video" | "image"; + +export type LibraryArtifactStatus = "ready" | "running" | "error"; + +/** + * A deliverable aggregated from the search space's list endpoints. The heavy + * content (report body, audio, video frames, image bytes) is fetched lazily by + * the viewer when a card is opened. + */ +export interface LibraryArtifact { + /** Stable list key — `${kind}-${entityId}`. */ + key: string; + kind: LibraryArtifactKind; + entityId: number; + title: string; + status: LibraryArtifactStatus; + createdAt: string; + /** Report panel content type — "typst" for resumes, "markdown" otherwise. */ + contentType: "markdown" | "typst"; +} From 09eaa371b6f33b0565ec91e5d6ab17457149711b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 23 Jun 2026 15:18:00 +0200 Subject: [PATCH 26/33] feat: add library artifacts hook --- .../hooks/use-library-artifacts.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 surfsense_web/features/artifacts-library/hooks/use-library-artifacts.ts diff --git a/surfsense_web/features/artifacts-library/hooks/use-library-artifacts.ts b/surfsense_web/features/artifacts-library/hooks/use-library-artifacts.ts new file mode 100644 index 000000000..15f65d9b0 --- /dev/null +++ b/surfsense_web/features/artifacts-library/hooks/use-library-artifacts.ts @@ -0,0 +1,95 @@ +import { useQuery } from "@tanstack/react-query"; +import { imageGenerationsApiService } from "@/lib/apis/image-generations-api.service"; +import { podcastsApiService } from "@/lib/apis/podcasts-api.service"; +import { reportsApiService } from "@/lib/apis/reports-api.service"; +import { videoPresentationsApiService } from "@/lib/apis/video-presentations-api.service"; +import type { LibraryArtifact, LibraryArtifactStatus } from "../model/artifact"; + +function podcastStatus(status: string): LibraryArtifactStatus { + if (status === "ready") return "ready"; + if (status === "failed" || status === "cancelled") return "error"; + return "running"; +} + +function videoStatus(status: string): LibraryArtifactStatus { + if (status === "ready") return "ready"; + if (status === "failed") return "error"; + return "running"; +} + +// Each list is fetched independently; one failing source shouldn't blank the +// whole library, so failures degrade to an empty slice. +async function fetchLibraryArtifacts(searchSpaceId: number): Promise { + const [reports, podcasts, videos, images] = await Promise.all([ + reportsApiService.list(searchSpaceId).catch(() => []), + podcastsApiService.list(searchSpaceId).catch(() => []), + videoPresentationsApiService.list(searchSpaceId).catch(() => []), + imageGenerationsApiService.list(searchSpaceId).catch(() => []), + ]); + + const artifacts: LibraryArtifact[] = []; + + for (const report of reports) { + const isResume = report.content_type === "typst"; + artifacts.push({ + key: `report-${report.id}`, + kind: isResume ? "resume" : "report", + entityId: report.id, + title: report.title, + status: report.report_metadata?.status === "failed" ? "error" : "ready", + createdAt: report.created_at, + contentType: isResume ? "typst" : "markdown", + }); + } + + for (const podcast of podcasts) { + artifacts.push({ + key: `podcast-${podcast.id}`, + kind: "podcast", + entityId: podcast.id, + title: podcast.title, + status: podcastStatus(podcast.status), + createdAt: podcast.created_at, + contentType: "markdown", + }); + } + + for (const video of videos) { + artifacts.push({ + key: `video-${video.id}`, + kind: "video", + entityId: video.id, + title: video.title, + status: videoStatus(video.status), + createdAt: video.created_at, + contentType: "markdown", + }); + } + + for (const image of images) { + artifacts.push({ + key: `image-${image.id}`, + kind: "image", + entityId: image.id, + title: image.prompt, + status: image.is_success ? "ready" : "error", + createdAt: image.created_at, + contentType: "markdown", + }); + } + + return artifacts.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); +} + +export function useLibraryArtifacts(searchSpaceId: number) { + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["artifacts-library", searchSpaceId], + queryFn: () => fetchLibraryArtifacts(searchSpaceId), + enabled: Number.isFinite(searchSpaceId) && searchSpaceId > 0, + staleTime: 60 * 1000, + }); + + return { artifacts: data ?? [], loading: isLoading, error, refresh: refetch }; +} From b63e95e98733b00eb3f8f0c4025acdb15bf80c88 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 23 Jun 2026 15:18:08 +0200 Subject: [PATCH 27/33] feat: add artifact card and kind meta --- .../artifacts-library/ui/artifact-card.tsx | 43 +++++++++++++++++++ .../artifacts-library/ui/kind-meta.ts | 16 +++++++ 2 files changed, 59 insertions(+) create mode 100644 surfsense_web/features/artifacts-library/ui/artifact-card.tsx create mode 100644 surfsense_web/features/artifacts-library/ui/kind-meta.ts diff --git a/surfsense_web/features/artifacts-library/ui/artifact-card.tsx b/surfsense_web/features/artifacts-library/ui/artifact-card.tsx new file mode 100644 index 000000000..fae7f1ab8 --- /dev/null +++ b/surfsense_web/features/artifacts-library/ui/artifact-card.tsx @@ -0,0 +1,43 @@ +import { formatRelativeDate } from "@/lib/format-date"; +import type { LibraryArtifact } from "../model/artifact"; +import { KIND_META } from "./kind-meta"; + +export function ArtifactCard({ + artifact, + onOpen, +}: { + artifact: LibraryArtifact; + onOpen: (artifact: LibraryArtifact) => void; +}) { + const meta = KIND_META[artifact.kind]; + const Icon = meta.icon; + + const subtitle = + artifact.status === "running" + ? "Generating…" + : artifact.status === "error" + ? "Failed" + : meta.label; + + return ( + + ); +} diff --git a/surfsense_web/features/artifacts-library/ui/kind-meta.ts b/surfsense_web/features/artifacts-library/ui/kind-meta.ts new file mode 100644 index 000000000..5241f812f --- /dev/null +++ b/surfsense_web/features/artifacts-library/ui/kind-meta.ts @@ -0,0 +1,16 @@ +import { AudioLines, Contact, FileText, ImageIcon, Presentation } from "lucide-react"; +import type { ComponentType } from "react"; +import type { LibraryArtifactKind } from "../model/artifact"; + +export const KIND_META: Record< + LibraryArtifactKind, + { icon: ComponentType<{ className?: string }>; label: string; group: string } +> = { + report: { icon: FileText, label: "Report", group: "Reports" }, + resume: { icon: Contact, label: "Resume", group: "Resumes" }, + podcast: { icon: AudioLines, label: "Podcast", group: "Podcasts" }, + video: { icon: Presentation, label: "Presentation", group: "Presentations" }, + image: { icon: ImageIcon, label: "Image", group: "Images" }, +}; + +export const KIND_ORDER: LibraryArtifactKind[] = ["report", "resume", "podcast", "video", "image"]; From 19698bcc0b7490fda16c98774c30d077c722d8e8 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 23 Jun 2026 15:18:08 +0200 Subject: [PATCH 28/33] feat: add library media viewers --- .../ui/library-image-viewer.tsx | 45 ++++++++++ .../ui/media-viewer-dialog.tsx | 85 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 surfsense_web/features/artifacts-library/ui/library-image-viewer.tsx create mode 100644 surfsense_web/features/artifacts-library/ui/media-viewer-dialog.tsx diff --git a/surfsense_web/features/artifacts-library/ui/library-image-viewer.tsx b/surfsense_web/features/artifacts-library/ui/library-image-viewer.tsx new file mode 100644 index 000000000..5509ec50b --- /dev/null +++ b/surfsense_web/features/artifacts-library/ui/library-image-viewer.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Image, ImageLoading } from "@/components/tool-ui/image"; +import { imageGenerationsApiService } from "@/lib/apis/image-generations-api.service"; + +function extractImageSrc(responseData: Record | null | undefined): string | null { + const data = (responseData as { data?: unknown } | null | undefined)?.data; + if (!Array.isArray(data) || data.length === 0) return null; + const first = data[0] as { url?: string; b64_json?: string }; + if (first?.url) return first.url; + if (first?.b64_json) return `data:image/png;base64,${first.b64_json}`; + return null; +} + +export function LibraryImageViewer({ imageId, prompt }: { imageId: number; prompt: string }) { + const { data, isLoading, error } = useQuery({ + queryKey: ["image-generation-detail", imageId], + queryFn: () => imageGenerationsApiService.getDetail(imageId), + }); + + if (isLoading) return ; + + const src = extractImageSrc(data?.response_data); + if (error || !src) { + return ( +

+ {data?.error_message || "Image not available"} +

+ ); + } + + return ( + {prompt} + ); +} diff --git a/surfsense_web/features/artifacts-library/ui/media-viewer-dialog.tsx b/surfsense_web/features/artifacts-library/ui/media-viewer-dialog.tsx new file mode 100644 index 000000000..26954be02 --- /dev/null +++ b/surfsense_web/features/artifacts-library/ui/media-viewer-dialog.tsx @@ -0,0 +1,85 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; +import type { LibraryArtifact, LibraryArtifactKind } from "../model/artifact"; +import { LibraryImageViewer } from "./library-image-viewer"; + +const ViewerFallback = () => ( +
+ +
+); + +const PodcastPlayer = dynamic( + () => import("@/components/tool-ui/podcast/player").then((m) => m.PodcastPlayer), + { ssr: false, loading: ViewerFallback } +); + +const VideoPresentationViewer = dynamic( + () => import("@/components/tool-ui/video-presentation").then((m) => m.VideoPresentationViewer), + { ssr: false, loading: ViewerFallback } +); + +// `stretch` overrides the players' inline-chat max-w/margins so they fill the dialog. +function dialogLayout(kind: LibraryArtifactKind): { width: string; stretch: boolean } { + if (kind === "video") return { width: "max-w-4xl", stretch: true }; + if (kind === "podcast") return { width: "max-w-2xl", stretch: true }; + return { width: "max-w-2xl", stretch: false }; +} + +function MediaViewerBody({ artifact }: { artifact: LibraryArtifact }) { + if (artifact.kind === "podcast") { + return ; + } + if (artifact.kind === "video") { + return ; + } + return ; +} + +/** + * Modal viewer for inline-media artifacts (podcast, video, image). Reports and + * resumes use the shared report panel instead and never reach this dialog. + */ +export function MediaViewerDialog({ + artifact, + onClose, +}: { + artifact: LibraryArtifact | null; + onClose: () => void; +}) { + const layout = artifact ? dialogLayout(artifact.kind) : null; + + return ( + { + if (!open) onClose(); + }} + > + + {artifact?.title ?? "Artifact"} + {artifact ? ( +
div]:!my-0 [&>div]:!max-w-none [&>div>*]:!max-w-none" + : "flex justify-center" + )} + > + +
+ ) : null} +
+
+ ); +} From a5be3fbcf8165c170f3f7e99cf7db70978addedf Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 23 Jun 2026 15:18:08 +0200 Subject: [PATCH 29/33] feat: add artifacts library page --- .../[search_space_id]/artifacts/page.tsx | 11 ++ .../features/artifacts-library/index.ts | 1 + .../ui/artifacts-library.tsx | 137 ++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 surfsense_web/app/dashboard/[search_space_id]/artifacts/page.tsx create mode 100644 surfsense_web/features/artifacts-library/index.ts create mode 100644 surfsense_web/features/artifacts-library/ui/artifacts-library.tsx diff --git a/surfsense_web/app/dashboard/[search_space_id]/artifacts/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/artifacts/page.tsx new file mode 100644 index 000000000..8f8109156 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/artifacts/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { ArtifactsLibrary } from "@/features/artifacts-library"; + +export default function ArtifactsPage() { + const params = useParams(); + const searchSpaceId = Number(params.search_space_id); + + return ; +} diff --git a/surfsense_web/features/artifacts-library/index.ts b/surfsense_web/features/artifacts-library/index.ts new file mode 100644 index 000000000..f086f50ae --- /dev/null +++ b/surfsense_web/features/artifacts-library/index.ts @@ -0,0 +1 @@ +export { ArtifactsLibrary } from "./ui/artifacts-library"; diff --git a/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx b/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx new file mode 100644 index 000000000..0c354c331 --- /dev/null +++ b/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useSetAtom } from "jotai"; +import { Boxes, RefreshCw, TriangleAlert } from "lucide-react"; +import { useMemo, useState } from "react"; +import { openReportPanelAtom } from "@/atoms/chat/report-panel.atom"; +import { MobileReportPanel } from "@/components/report-panel/report-panel"; +import { Button } from "@/components/ui/button"; +import { useLibraryArtifacts } from "../hooks/use-library-artifacts"; +import type { LibraryArtifact, LibraryArtifactKind } from "../model/artifact"; +import { ArtifactCard } from "./artifact-card"; +import { KIND_META, KIND_ORDER } from "./kind-meta"; +import { MediaViewerDialog } from "./media-viewer-dialog"; + +const SKELETON_KEYS = ["s1", "s2", "s3", "s4", "s5", "s6"]; + +function LoadingState() { + return ( +
+ {SKELETON_KEYS.map((key) => ( +
+ ))} +
+ ); +} + +function ErrorState({ onRetry }: { onRetry: () => void }) { + return ( +
+ + + +
+

Couldn't load artifacts

+

+ Something went wrong fetching this search space's deliverables. +

+
+ +
+ ); +} + +function EmptyState() { + return ( +
+ + + +
+

No artifacts yet

+

+ Reports, resumes, podcasts, presentations, and images you generate appear here. +

+
+
+ ); +} + +export function ArtifactsLibrary({ searchSpaceId }: { searchSpaceId: number }) { + const { artifacts, loading, error, refresh } = useLibraryArtifacts(searchSpaceId); + const openReportPanel = useSetAtom(openReportPanelAtom); + const [selectedMedia, setSelectedMedia] = useState(null); + + const grouped = useMemo(() => { + const map = new Map(); + for (const artifact of artifacts) { + const bucket = map.get(artifact.kind); + if (bucket) bucket.push(artifact); + else map.set(artifact.kind, [artifact]); + } + return map; + }, [artifacts]); + + const handleOpen = (artifact: LibraryArtifact) => { + // Reports/resumes reuse the shared report panel; the rest open in the dialog. + if (artifact.kind === "report" || artifact.kind === "resume") { + openReportPanel({ + reportId: artifact.entityId, + title: artifact.title, + contentType: artifact.contentType, + }); + return; + } + setSelectedMedia(artifact); + }; + + return ( +
+
+
+

Artifacts

+

+ Every deliverable created across this search space. +

+
+ {!loading && artifacts.length > 0 ? ( + {artifacts.length} total + ) : null} +
+ + {loading ? ( + + ) : error ? ( + refresh()} /> + ) : artifacts.length === 0 ? ( + + ) : ( +
+ {KIND_ORDER.map((kind) => { + const items = grouped.get(kind); + if (!items || items.length === 0) return null; + return ( +
+

+ {KIND_META[kind].group} + {items.length} +

+
+ {items.map((artifact) => ( + + ))} +
+
+ ); + })} +
+ )} + + setSelectedMedia(null)} /> + +
+ ); +} From cc624076c9893f75846e2ddd1eb6a3b7c0909aa5 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 23 Jun 2026 15:18:08 +0200 Subject: [PATCH 30/33] feat: add artifacts sidebar nav --- .../layout/providers/LayoutDataProvider.tsx | 10 +++++++++- .../components/layout/ui/sidebar/Sidebar.tsx | 19 ++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 429a1fde8..433d66353 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { AlarmClock, AlertTriangle, Inbox, LibraryBig } from "lucide-react"; +import { AlarmClock, AlertTriangle, Boxes, Inbox, LibraryBig } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; @@ -328,6 +328,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid // in the sidebar (also surfaced in the icon rail's collapsed mode via this // list). Announcements has been moved to the avatar dropdown. const isAutomationsActive = pathname?.includes("/automations") === true; + const isArtifactsActive = pathname?.endsWith("/artifacts") === true; const navItems: NavItem[] = useMemo( () => ( @@ -345,6 +346,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid icon: AlarmClock, isActive: isAutomationsActive, }, + { + title: "Artifacts", + url: `/dashboard/${searchSpaceId}/artifacts`, + icon: Boxes, + isActive: isArtifactsActive, + }, isMobile ? { title: "Documents", @@ -362,6 +369,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid totalUnreadCount, searchSpaceId, isAutomationsActive, + isArtifactsActive, ] ); diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index ee891d78b..c274e1f97 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -145,6 +145,10 @@ export function Sidebar({ () => navItems.find((item) => item.url.endsWith("/automations")), [navItems] ); + const artifactsItem = useMemo( + () => navItems.find((item) => item.url.endsWith("/artifacts")), + [navItems] + ); const documentsItem = useMemo( () => navItems.find((item) => item.url === "#documents"), [navItems] @@ -153,7 +157,10 @@ export function Sidebar({ () => navItems.filter( (item) => - item.url !== "#inbox" && item.url !== "#documents" && !item.url.endsWith("/automations") + item.url !== "#inbox" && + item.url !== "#documents" && + !item.url.endsWith("/automations") && + !item.url.endsWith("/artifacts") ), [navItems] ); @@ -242,6 +249,16 @@ export function Sidebar({ tooltipContent={isCollapsed ? automationsItem.title : undefined} /> )} + {artifactsItem && ( + onNavItemClick?.(artifactsItem)} + isCollapsed={isCollapsed} + isActive={artifactsItem.isActive} + tooltipContent={isCollapsed ? artifactsItem.title : undefined} + /> + )} {documentsItem && ( Date: Tue, 23 Jun 2026 15:18:08 +0200 Subject: [PATCH 31/33] refactor: unify artifacts icon --- surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx | 4 ++-- .../features/chat-artifacts/ui/artifacts-toggle-button.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx b/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx index c22d412f2..7b3567d73 100644 --- a/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx +++ b/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx @@ -1,7 +1,7 @@ "use client"; import { useAtomValue, useSetAtom } from "jotai"; -import { LayersIcon, XIcon } from "lucide-react"; +import { Boxes, XIcon } from "lucide-react"; import { useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; @@ -32,7 +32,7 @@ function groupByKind(artifacts: ChatArtifact[]): { label: string; items: ChatArt function EmptyState() { return (
- +

No artifacts yet

Reports, podcasts, presentations, and images you generate will appear here. diff --git a/surfsense_web/features/chat-artifacts/ui/artifacts-toggle-button.tsx b/surfsense_web/features/chat-artifacts/ui/artifacts-toggle-button.tsx index a99836d30..be02c6956 100644 --- a/surfsense_web/features/chat-artifacts/ui/artifacts-toggle-button.tsx +++ b/surfsense_web/features/chat-artifacts/ui/artifacts-toggle-button.tsx @@ -1,7 +1,7 @@ "use client"; import { useAtomValue, useSetAtom } from "jotai"; -import { LayersIcon } from "lucide-react"; +import { Boxes } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; @@ -34,7 +34,7 @@ export function ArtifactsToggleButton() { isOpen && "bg-accent text-accent-foreground" )} > - + {artifacts.length} From 9c622ae3f34701fd8b1f350fd2446071c68da504 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 23 Jun 2026 15:28:42 +0200 Subject: [PATCH 32/33] fix: don't autoplay video presentations --- .../components/tool-ui/video-presentation/combined-player.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/surfsense_web/components/tool-ui/video-presentation/combined-player.tsx b/surfsense_web/components/tool-ui/video-presentation/combined-player.tsx index c630008db..47eb5a758 100644 --- a/surfsense_web/components/tool-ui/video-presentation/combined-player.tsx +++ b/surfsense_web/components/tool-ui/video-presentation/combined-player.tsx @@ -127,7 +127,6 @@ export function CombinedPlayer({ slides }: CombinedPlayerProps) { compositionHeight={1080} style={{ width: "100%", aspectRatio: "16/9" }} controls - autoPlay loop acknowledgeRemotionLicense /> From 8b0a2f8964fe506418e6557772dbb44136c1acb0 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 23 Jun 2026 15:47:21 +0200 Subject: [PATCH 33/33] feat: link artifacts to source chat --- surfsense_backend/app/podcasts/api/schemas.py | 1 + surfsense_backend/app/schemas/reports.py | 1 + .../app/schemas/video_presentations.py | 2 ++ .../contracts/types/podcast.types.ts | 1 + .../contracts/types/reports.types.ts | 1 + .../types/video-presentations.types.ts | 1 + .../hooks/use-library-artifacts.ts | 3 ++ .../artifacts-library/model/artifact.ts | 2 ++ .../artifacts-library/ui/artifact-card.tsx | 32 +++++++++++++++---- .../ui/artifacts-library.tsx | 7 +++- 10 files changed, 44 insertions(+), 7 deletions(-) diff --git a/surfsense_backend/app/podcasts/api/schemas.py b/surfsense_backend/app/podcasts/api/schemas.py index cb8559651..e9d6e6b0c 100644 --- a/surfsense_backend/app/podcasts/api/schemas.py +++ b/surfsense_backend/app/podcasts/api/schemas.py @@ -84,6 +84,7 @@ class PodcastSummary(BaseModel): status: PodcastStatus created_at: datetime search_space_id: int + thread_id: int | None = None class PodcastDetail(BaseModel): diff --git a/surfsense_backend/app/schemas/reports.py b/surfsense_backend/app/schemas/reports.py index 25ca50607..cfd9d89ca 100644 --- a/surfsense_backend/app/schemas/reports.py +++ b/surfsense_backend/app/schemas/reports.py @@ -24,6 +24,7 @@ class ReportRead(BaseModel): report_metadata: dict[str, Any] | None = None report_group_id: int | None = None content_type: str = "markdown" + thread_id: int | None = None created_at: datetime class Config: diff --git a/surfsense_backend/app/schemas/video_presentations.py b/surfsense_backend/app/schemas/video_presentations.py index ec29147ef..68ef3f5ba 100644 --- a/surfsense_backend/app/schemas/video_presentations.py +++ b/surfsense_backend/app/schemas/video_presentations.py @@ -44,6 +44,7 @@ class VideoPresentationRead(VideoPresentationBase): status: VideoPresentationStatusEnum = VideoPresentationStatusEnum.READY created_at: datetime slide_count: int | None = None + thread_id: int | None = None class Config: from_attributes = True @@ -68,6 +69,7 @@ class VideoPresentationRead(VideoPresentationBase): "status": obj.status, "created_at": obj.created_at, "slide_count": len(obj.slides) if obj.slides else None, + "thread_id": obj.thread_id, } return cls(**data) diff --git a/surfsense_web/contracts/types/podcast.types.ts b/surfsense_web/contracts/types/podcast.types.ts index c8247a7fe..365847668 100644 --- a/surfsense_web/contracts/types/podcast.types.ts +++ b/surfsense_web/contracts/types/podcast.types.ts @@ -163,6 +163,7 @@ export const podcastSummary = z.object({ status: podcastStatus, created_at: z.string(), search_space_id: z.number(), + thread_id: z.number().nullish(), }); export type PodcastSummary = z.infer; diff --git a/surfsense_web/contracts/types/reports.types.ts b/surfsense_web/contracts/types/reports.types.ts index 25a682084..8c7b1fe72 100644 --- a/surfsense_web/contracts/types/reports.types.ts +++ b/surfsense_web/contracts/types/reports.types.ts @@ -17,6 +17,7 @@ export const reportListItem = z.object({ title: z.string(), content_type: z.string().default("markdown"), report_metadata: reportMetadata, + thread_id: z.number().nullish(), created_at: z.string(), }); export type ReportListItem = z.infer; diff --git a/surfsense_web/contracts/types/video-presentations.types.ts b/surfsense_web/contracts/types/video-presentations.types.ts index 45b062840..7e0603c75 100644 --- a/surfsense_web/contracts/types/video-presentations.types.ts +++ b/surfsense_web/contracts/types/video-presentations.types.ts @@ -13,6 +13,7 @@ export const videoPresentationListItem = z.object({ status: videoPresentationStatus.default("ready"), created_at: z.string(), search_space_id: z.number(), + thread_id: z.number().nullish(), }); export type VideoPresentationListItem = z.infer; diff --git a/surfsense_web/features/artifacts-library/hooks/use-library-artifacts.ts b/surfsense_web/features/artifacts-library/hooks/use-library-artifacts.ts index 15f65d9b0..e9ed68633 100644 --- a/surfsense_web/features/artifacts-library/hooks/use-library-artifacts.ts +++ b/surfsense_web/features/artifacts-library/hooks/use-library-artifacts.ts @@ -39,6 +39,7 @@ async function fetchLibraryArtifacts(searchSpaceId: number): Promise void; }) { const meta = KIND_META[artifact.kind]; @@ -20,11 +24,16 @@ export function ArtifactCard({ : meta.label; return ( - + @@ -38,6 +47,17 @@ export function ArtifactCard({ {formatRelativeDate(artifact.createdAt)} - + + {artifact.sourceThreadId ? ( + + + Open source chat + + ) : null} +

); } diff --git a/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx b/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx index 0c354c331..3441f626e 100644 --- a/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx +++ b/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx @@ -121,7 +121,12 @@ export function ArtifactsLibrary({ searchSpaceId }: { searchSpaceId: number }) {
{items.map((artifact) => ( - + ))}