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 (