mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-12 22:42:13 +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_CLASSES = ["ring-2", "ring-primary/60"];
|
||||||
const HIGHLIGHT_DURATION_MS = 1600;
|
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
|
* Scroll the inline card for `toolCallId` into view and pulse a ring. Retries
|
||||||
* user can spot it after jumping from the artifacts sidebar. Returns false when
|
* because the thread viewport's initialize auto-scroll can fire after the first
|
||||||
* the card isn't mounted (e.g. outside the loaded message window).
|
* jump and snap back to the bottom; scrolling off-bottom disengages it.
|
||||||
*/
|
*/
|
||||||
export function scrollToArtifact(toolCallId: string): boolean {
|
export function scrollToArtifact(toolCallId: string): void {
|
||||||
if (typeof document === "undefined") return false;
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
const anchor = document.querySelector<HTMLElement>(
|
const selector = `[${ARTIFACT_ANCHOR_ATTR}="${CSS.escape(toolCallId)}"]`;
|
||||||
`[${ARTIFACT_ANCHOR_ATTR}="${CSS.escape(toolCallId)}"]`
|
const deadline = Date.now() + MAX_WAIT_MS;
|
||||||
);
|
let highlighted = false;
|
||||||
if (!anchor) return 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.
|
attempt();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,24 @@ import type { ChatArtifact } from "../model/artifact";
|
||||||
/** Artifacts of the active thread, synced from the message stream by `useSyncChatArtifacts`. */
|
/** Artifacts of the active thread, synced from the message stream by `useSyncChatArtifacts`. */
|
||||||
export const chatArtifactsAtom = atom<ChatArtifact[]>([]);
|
export const chatArtifactsAtom = atom<ChatArtifact[]>([]);
|
||||||
|
|
||||||
/** Whether the artifacts sidebar is open in the right panel. */
|
/** Open === artifacts owns the tab; derived so the toggle can't drift. */
|
||||||
export const artifactsPanelOpenAtom = atom(false);
|
export const artifactsPanelOpenAtom = atom((get) => get(rightPanelTabAtom) === "artifacts");
|
||||||
|
|
||||||
/** Snapshot of `rightPanelCollapsedAtom` taken before the panel opens, restored on close. */
|
/** Snapshot of `rightPanelCollapsedAtom` taken before the panel opens, restored on close. */
|
||||||
const preArtifactsCollapsedAtom = atom<boolean | null>(null);
|
const preArtifactsCollapsedAtom = atom<boolean | null>(null);
|
||||||
|
|
||||||
export const openArtifactsPanelAtom = atom(null, (get, set) => {
|
export const openArtifactsPanelAtom = atom(null, (get, set) => {
|
||||||
if (!get(artifactsPanelOpenAtom)) {
|
if (get(rightPanelTabAtom) !== "artifacts") {
|
||||||
set(preArtifactsCollapsedAtom, get(rightPanelCollapsedAtom));
|
set(preArtifactsCollapsedAtom, get(rightPanelCollapsedAtom));
|
||||||
}
|
}
|
||||||
set(artifactsPanelOpenAtom, true);
|
|
||||||
set(rightPanelTabAtom, "artifacts");
|
set(rightPanelTabAtom, "artifacts");
|
||||||
set(rightPanelCollapsedAtom, false);
|
set(rightPanelCollapsedAtom, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const closeArtifactsPanelAtom = atom(null, (get, set) => {
|
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");
|
set(rightPanelTabAtom, "sources");
|
||||||
const prev = get(preArtifactsCollapsedAtom);
|
const prev = get(preArtifactsCollapsedAtom);
|
||||||
if (prev !== null) {
|
if (prev !== null) {
|
||||||
|
|
@ -31,6 +32,8 @@ export const closeArtifactsPanelAtom = atom(null, (get, set) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
export const toggleArtifactsPanelAtom = 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);
|
else set(openArtifactsPanelAtom);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export function withArtifactAnchor(
|
||||||
): ToolCallMessagePartComponent {
|
): ToolCallMessagePartComponent {
|
||||||
function AnchoredTool(props: ToolCallMessagePartProps) {
|
function AnchoredTool(props: ToolCallMessagePartProps) {
|
||||||
return (
|
return (
|
||||||
<div {...{ [ARTIFACT_ANCHOR_ATTR]: props.toolCallId }} className="scroll-mt-4">
|
<div {...{ [ARTIFACT_ANCHOR_ATTR]: props.toolCallId }}>
|
||||||
<Tool {...props} />
|
<Tool {...props} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ import { AudioLines, Contact, FileText, ImageIcon, Presentation } from "lucide-r
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import { openReportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
import { openReportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { scrollToArtifact } from "../lib/scroll-to-artifact";
|
import { scrollToArtifact } from "../lib/scroll-to-artifact";
|
||||||
import type { ArtifactKind, ChatArtifact } from "../model/artifact";
|
import type { ArtifactKind, ChatArtifact } from "../model/artifact";
|
||||||
|
import { closeArtifactsPanelAtom } from "../state/artifacts-panel.atom";
|
||||||
|
|
||||||
const KIND_META: Record<
|
const KIND_META: Record<
|
||||||
ArtifactKind,
|
ArtifactKind,
|
||||||
|
|
@ -19,20 +21,28 @@ const KIND_META: Record<
|
||||||
|
|
||||||
export function ArtifactRow({ artifact }: { artifact: ChatArtifact }) {
|
export function ArtifactRow({ artifact }: { artifact: ChatArtifact }) {
|
||||||
const openReportPanel = useSetAtom(openReportPanelAtom);
|
const openReportPanel = useSetAtom(openReportPanelAtom);
|
||||||
|
const closeArtifactsPanel = useSetAtom(closeArtifactsPanelAtom);
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
||||||
const meta = KIND_META[artifact.kind];
|
const meta = KIND_META[artifact.kind];
|
||||||
const Icon = meta.icon;
|
const Icon = meta.icon;
|
||||||
const isReportLike = artifact.kind === "report" || artifact.kind === "resume";
|
const isReportLike = artifact.kind === "report" || artifact.kind === "resume";
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
scrollToArtifact(artifact.toolCallId);
|
// Reports/resumes open in the report viewer, which claims the tab itself.
|
||||||
// Reports and resumes get the richer side-panel viewer in addition to the jump.
|
|
||||||
if (isReportLike && artifact.entityId != null) {
|
if (isReportLike && artifact.entityId != null) {
|
||||||
openReportPanel({
|
openReportPanel({
|
||||||
reportId: artifact.entityId,
|
reportId: artifact.entityId,
|
||||||
title: artifact.title,
|
title: artifact.title,
|
||||||
contentType: artifact.contentType,
|
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 (
|
return (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue