fix: make artifact navigation robust

This commit is contained in:
CREDO23 2026-06-22 23:24:25 +02:00
parent 050d6bf998
commit 6efc3bf517
4 changed files with 52 additions and 25 deletions

View file

@ -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<HTMLElement>(
`[${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<HTMLElement>(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();
}

View file

@ -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<ChatArtifact[]>([]);
/** 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<boolean | null>(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);
});

View file

@ -11,7 +11,7 @@ export function withArtifactAnchor(
): ToolCallMessagePartComponent {
function AnchoredTool(props: ToolCallMessagePartProps) {
return (
<div {...{ [ARTIFACT_ANCHOR_ATTR]: props.toolCallId }} className="scroll-mt-4">
<div {...{ [ARTIFACT_ANCHOR_ATTR]: props.toolCallId }}>
<Tool {...props} />
</div>
);

View file

@ -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 (