mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-26 21:39:43 +02:00
fix: make artifact navigation robust
This commit is contained in:
parent
050d6bf998
commit
6efc3bf517
4 changed files with 52 additions and 25 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue