From 414d93eda7886715ed6ba7c628f5009ad46a9a93 Mon Sep 17 00:00:00 2001 From: whoisthey Date: Thu, 25 Jun 2026 15:29:35 -0700 Subject: [PATCH 01/15] feat(web): render mermaid diagrams in Ask answers Render fenced mermaid code blocks in Ask Sourcebot answers as interactive diagrams: pan/zoom, copy source/image, SVG/PNG export, fullscreen, and in-thread deep links. Diagrams are mirrored in the right panel, pre-collapsed and interleaved with referenced sources by order of appearance, with click-to-scroll between the inline diagram and its panel item. The agent is prompted to emit mermaid blocks proactively when a visual aids understanding. --- packages/web/package.json | 1 + packages/web/src/ee/features/chat/agent.ts | 5 + .../chat/components/chatThread/answerCard.tsx | 1 + .../chatThread/chatThreadListItem.tsx | 114 ++- .../chatThread/diagramPanelListItem.tsx | 67 ++ .../chatThread/markdownRenderer.tsx | 16 +- .../components/chatThread/mermaidDiagram.tsx | 500 ++++++++++ .../chatThread/referencedSourcesListView.tsx | 65 +- .../ee/features/chat/diagramPanelContext.tsx | 13 + .../web/src/ee/features/chat/diagramUtils.ts | 18 + .../ee/features/chat/useExtractDiagrams.ts | 48 + yarn.lock | 862 +++++++++++++++++- 12 files changed, 1674 insertions(+), 36 deletions(-) create mode 100644 packages/web/src/ee/features/chat/components/chatThread/diagramPanelListItem.tsx create mode 100644 packages/web/src/ee/features/chat/components/chatThread/mermaidDiagram.tsx create mode 100644 packages/web/src/ee/features/chat/diagramPanelContext.tsx create mode 100644 packages/web/src/ee/features/chat/diagramUtils.ts create mode 100644 packages/web/src/ee/features/chat/useExtractDiagrams.ts diff --git a/packages/web/package.json b/packages/web/package.json index 82543adbd..9127d994d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -157,6 +157,7 @@ "langfuse-vercel": "^3.38.4", "linguist-languages": "^9.3.1", "lucide-react": "^1.7.0", + "mermaid": "^11.16.0", "micromatch": "^4.0.8", "minidenticons": "^4.2.1", "next": "^16.2.6", diff --git a/packages/web/src/ee/features/chat/agent.ts b/packages/web/src/ee/features/chat/agent.ts index d2f3a4761..265d756e8 100644 --- a/packages/web/src/ee/features/chat/agent.ts +++ b/packages/web/src/ee/features/chat/agent.ts @@ -655,6 +655,11 @@ const createPrompt = ({ - If you cannot provide a code reference for something you're discussing, do not mention that specific code element - Always prefer to use \`${FILE_REFERENCE_PREFIX}\` over \`\`\`code\`\`\` blocks. + **Diagrams:** + - Proactively include a diagram when a visual communicates the answer better than prose, e.g. architecture overviews, control/data flow, sequences of interactions, state machines, or entity relationships. Use your judgement, do not force a diagram for simple answers. + - Render diagrams as a \`\`\`mermaid fenced code block. This is an explicit exception to the rule above: it is OK to use a \`\`\`mermaid block even though you otherwise prefer \`${FILE_REFERENCE_PREFIX}\` over code blocks. Continue to use \`${FILE_REFERENCE_PREFIX}\` for code references in your prose. + - Mermaid syntax rules: do NOT put spaces or special characters in node IDs (use camelCase or underscores), wrap node and edge labels that contain special characters (parentheses, commas, colons) in double quotes, avoid reserved keywords (\`end\`, \`graph\`, \`subgraph\`) as node IDs, and do NOT use \`click\` events or custom colors/styling (the theme is applied automatically). + **Example answer structure:** \`\`\`markdown ${ANSWER_TAG} diff --git a/packages/web/src/ee/features/chat/components/chatThread/answerCard.tsx b/packages/web/src/ee/features/chat/components/chatThread/answerCard.tsx index 2aeb2ac95..3ed22370e 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/answerCard.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/answerCard.tsx @@ -155,6 +155,7 @@ const AnswerCardComponent = forwardRef(({ (undefined); + + // Reveal a diagram in the right panel: the panel list expands it and scrolls + // it into view when `selectedDiagramId` changes. + const revealDiagramInPanel = useCallback((diagramId: string) => { + setSelectedDiagramId(undefined); + requestAnimationFrame(() => setSelectedDiagramId(diagramId)); + }, []); + + const jumpToInlineDiagram = useCallback((diagramId: string) => { + document.getElementById(`diagram-${diagramId}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, []); + + const diagramPanelContextValue = useMemo(() => ({ revealInPanel: revealDiagramInPanel }), [revealDiagramInPanel]); + // Extract the file sources that are referenced by the answer part. const referencedFileSources = useMemo(() => { const fileSources = sources.filter((source) => source.type === 'file'); @@ -344,8 +363,83 @@ const ChatThreadListItemComponent = forwardRef(() => { + const text = answerPart?.text ?? ''; + const items: PanelItem[] = []; + const seenSources = new Set(); + const seenDiagrams = new Set(); + const diagramById = new Map(diagrams.map((diagram) => [diagram.id, diagram])); + const diagramIndexById = new Map(diagrams.map((diagram, i) => [diagram.id, i])); + const sourceKey = (source: FileSource) => `${source.repo}::${source.path}::${source.revision}`; + + const combined = /```mermaid\s*\n([\s\S]*?)```|@file:\{([^:}]+)::([^:}]+)(?::(\d+)(?:-(\d+))?)?\}/g; + let match: RegExpExecArray | null; + while ((match = combined.exec(text)) !== null) { + if (match[1] !== undefined) { + const code = match[1].trim(); + if (!code) { + continue; + } + const id = getDiagramId(code); + const diagram = diagramById.get(id); + if (!diagram || seenDiagrams.has(id)) { + continue; + } + seenDiagrams.add(id); + items.push({ kind: 'diagram', diagram, diagramIndex: diagramIndexById.get(id) ?? 0 }); + } else if (match[2] !== undefined && match[3] !== undefined) { + const reference = createFileReference({ repo: match[2], path: match[3], startLine: match[4], endLine: match[5] }); + const source = tryResolveFileReference(reference, referencedFileSources); + if (!source) { + continue; + } + const key = sourceKey(source); + if (seenSources.has(key)) { + continue; + } + seenSources.add(key); + items.push({ kind: 'source', source }); + } + } + + // Safety net: append anything resolved but not matched in the scan. + for (const source of referencedFileSources) { + const key = sourceKey(source); + if (!seenSources.has(key)) { + seenSources.add(key); + items.push({ kind: 'source', source }); + } + } + for (const diagram of diagrams) { + if (!seenDiagrams.has(diagram.id)) { + seenDiagrams.add(diagram.id); + items.push({ kind: 'diagram', diagram, diagramIndex: diagramIndexById.get(diagram.id) ?? 0 }); + } + } + + return items; + }, [answerPart, referencedFileSources, diagrams]); + + const sourcesView = ( + + ); return ( +
- {referencedFileSources.length > 0 ? ( - + {(referencedFileSources.length > 0 || diagrams.length > 0) ? ( + sourcesView ) : isNetworkActive ? (
{Array.from({ length: 3 }).map((_, index) => ( @@ -466,6 +551,7 @@ const ChatThreadListItemComponent = forwardRef
+ ) }); diff --git a/packages/web/src/ee/features/chat/components/chatThread/diagramPanelListItem.tsx b/packages/web/src/ee/features/chat/components/chatThread/diagramPanelListItem.tsx new file mode 100644 index 000000000..05659f2b7 --- /dev/null +++ b/packages/web/src/ee/features/chat/components/chatThread/diagramPanelListItem.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ExtractedDiagram } from "@/ee/features/chat/useExtractDiagrams"; +import { ChevronDown, ChevronRight, CornerUpLeft, Workflow } from "lucide-react"; +import { MermaidDiagram } from "./mermaidDiagram"; + +interface DiagramPanelListItemProps { + diagram: ExtractedDiagram; + index: number; + isExpanded: boolean; + isHighlighted: boolean; + onToggle: () => void; + onJumpToInline: () => void; +} + +export const DiagramPanelListItem = ({ + diagram, + index, + isExpanded, + isHighlighted, + onToggle, + onJumpToInline, +}: DiagramPanelListItemProps) => { + return ( +
+
+ + +
+ + {isExpanded && ( + + )} +
+ ); +}; diff --git a/packages/web/src/ee/features/chat/components/chatThread/markdownRenderer.tsx b/packages/web/src/ee/features/chat/components/chatThread/markdownRenderer.tsx index 5ef177dc1..1444f81ef 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/markdownRenderer.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/markdownRenderer.tsx @@ -18,6 +18,7 @@ import type { PluggableList, Plugin } from "unified"; import { visit } from 'unist-util-visit'; import { CodeBlock } from './codeBlock'; import { LinearIssueCard } from './linearIssueCard'; +import { MermaidDiagram } from './mermaidDiagram'; import { FILE_REFERENCE_REGEX } from '@/features/chat/constants'; import { createFileReference } from '@/features/chat/utils'; import isEqual from "fast-deep-equal/react"; @@ -129,9 +130,14 @@ interface MarkdownRendererProps { * instead of being parsed as HTML. File references (@file:{...}) are unaffected. */ escapeHtml?: boolean; + /** + * When true, fenced ```mermaid blocks are rendered as diagrams instead of + * code. Enabled only on the answer body (see answerCard.tsx). + */ + enableDiagrams?: boolean; } -const MarkdownRendererComponent = forwardRef(({ content, className, escapeHtml = false }, ref) => { +const MarkdownRendererComponent = forwardRef(({ content, className, escapeHtml = false, enableDiagrams = false }, ref) => { const router = useRouter(); const remarkPlugins = useMemo((): PluggableList => { @@ -204,6 +210,12 @@ const MarkdownRendererComponent = forwardRef + ) + } + return ( ) - }, [router]); + }, [router, enableDiagrams]); return (
| null = null; +const loadMermaid = async () => { + if (!mermaidModulePromise) { + mermaidModulePromise = import('mermaid').then((mod) => mod.default); + } + return mermaidModulePromise; +}; + +let renderCounter = 0; + +const renderMermaidToSvg = async (code: string, theme: 'dark' | 'default'): Promise => { + const mermaid = await loadMermaid(); + mermaid.initialize({ + startOnLoad: false, + // `strict` sanitizes mermaid's own SVG output (via DOMPurify) and + // disables click bindings / arbitrary HTML in labels. + securityLevel: 'strict', + suppressErrorRendering: true, + // Render labels as native SVG rather than HTML . + // foreignObject content taints the canvas, which breaks PNG export + // (`toBlob` throws a SecurityError on a tainted canvas). + htmlLabels: false, + flowchart: { htmlLabels: false }, + theme, + }); + + // Validate first so partial / invalid input throws before we attempt a + // render (this is what drives the fallback-to-code behavior). + await mermaid.parse(code); + + const id = `sb-mermaid-${Date.now()}-${renderCounter++}`; + const { svg } = await mermaid.render(id, code); + return svg; +}; + +const triggerDownload = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); +}; + +const PNG_TARGET_LONGEST_SIDE = 2400; +const PNG_MAX_CANVAS_DIM = 8000; + +const getSvgBaseSize = (svgEl: Element): { width: number; height: number } => { + const viewBox = svgEl.getAttribute('viewBox'); + if (viewBox) { + const parts = viewBox.split(/[\s,]+/).map(Number).filter((n) => !Number.isNaN(n)); + if (parts.length === 4 && parts[2] > 0 && parts[3] > 0) { + return { width: parts[2], height: parts[3] }; + } + } + + const width = parseFloat(svgEl.getAttribute('width') || ''); + const height = parseFloat(svgEl.getAttribute('height') || ''); + if (width > 0 && height > 0) { + return { width, height }; + } + + return { width: 1024, height: 768 }; +}; + +/** + * Rasterize a mermaid SVG to a high-resolution PNG. We bake explicit pixel + * dimensions (scaled up from the SVG's viewBox) into the SVG before drawing so + * the browser rasterizes the vector at full resolution instead of upscaling a + * small default bitmap (which produced blurry exports). + */ +const svgToPngBlob = (svg: string, background: string): Promise => { + return new Promise((resolve) => { + const doc = new DOMParser().parseFromString(svg, 'image/svg+xml'); + const svgEl = doc.documentElement; + const { width: baseWidth, height: baseHeight } = getSvgBaseSize(svgEl); + + const longestSide = Math.max(baseWidth, baseHeight); + let scale = Math.max(2, PNG_TARGET_LONGEST_SIDE / longestSide); + if (baseWidth * scale > PNG_MAX_CANVAS_DIM || baseHeight * scale > PNG_MAX_CANVAS_DIM) { + scale = Math.min(PNG_MAX_CANVAS_DIM / baseWidth, PNG_MAX_CANVAS_DIM / baseHeight); + } + + const outWidth = Math.round(baseWidth * scale); + const outHeight = Math.round(baseHeight * scale); + + svgEl.setAttribute('width', String(outWidth)); + svgEl.setAttribute('height', String(outHeight)); + const serialized = new XMLSerializer().serializeToString(svgEl); + + const url = URL.createObjectURL(new Blob([serialized], { type: 'image/svg+xml;charset=utf-8' })); + const image = new Image(); + + image.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = outWidth; + canvas.height = outHeight; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + URL.revokeObjectURL(url); + resolve(null); + return; + } + + // Mermaid SVGs are transparent; paint a theme-matched background so + // the exported PNG is readable on any viewer. + ctx.fillStyle = background; + ctx.fillRect(0, 0, outWidth, outHeight); + ctx.drawImage(image, 0, 0, outWidth, outHeight); + URL.revokeObjectURL(url); + + canvas.toBlob((pngBlob) => resolve(pngBlob), 'image/png'); + }; + + image.onerror = () => { + URL.revokeObjectURL(url); + resolve(null); + }; + + image.src = url; + }); +}; + +const ZOOM_MIN = 0.25; +const ZOOM_MAX = 4; +const ZOOM_STEP = 0.25; + +/** + * Interactive diagram surface: renders the SVG with click-drag panning and + * zoom controls that reveal on hover. Shared by the inline and fullscreen + * views so both behave identically. + */ +const DiagramViewport = ({ svg, className, controlsClassName, actions, fill, forceControlsVisible }: { svg: string; className?: string; controlsClassName?: string; actions?: ReactNode; fill?: boolean; forceControlsVisible?: boolean }) => { + const [zoom, setZoom] = useState(1); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const draggingRef = useRef(false); + const startRef = useRef({ x: 0, y: 0 }); + + const onPointerDown = useCallback((e: React.PointerEvent) => { + draggingRef.current = true; + startRef.current = { x: e.clientX - offset.x, y: e.clientY - offset.y }; + e.currentTarget.setPointerCapture(e.pointerId); + }, [offset]); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + if (!draggingRef.current) { + return; + } + setOffset({ x: e.clientX - startRef.current.x, y: e.clientY - startRef.current.y }); + }, []); + + const onPointerUp = useCallback((e: React.PointerEvent) => { + draggingRef.current = false; + try { + e.currentTarget.releasePointerCapture(e.pointerId); + } catch { + // no-op: pointer may already be released + } + }, []); + + const reset = useCallback(() => { + setZoom(1); + setOffset({ x: 0, y: 0 }); + }, []); + + return ( +
+
+
+
+ +
+ {actions} + {actions &&
} + + {Math.round(zoom * 100)}% + + +
+
+ ); +}; + +interface MermaidDiagramProps { + code: string; + /** DOM id for the container. Defaults to the canonical inline anchor. */ + domId?: string; + /** 'inline' (in the answer) offers a "view in panel" action; 'panel' is the right-pane mirror. */ + variant?: 'inline' | 'panel'; + /** Whether to scroll/highlight when the URL hash targets this diagram. */ + listenToDeepLink?: boolean; + /** Optional jump-to-counterpart action (used by the panel to jump to the inline diagram). */ + onJump?: () => void; + jumpLabel?: string; + /** Optional override classes for the root container (e.g. to drop the default margin). */ + className?: string; +} + +export const MermaidDiagram = ({ + code, + domId, + variant = 'inline', + listenToDeepLink = true, + onJump, + jumpLabel, + className, +}: MermaidDiagramProps) => { + const { theme } = useThemeNormalized(); + const mermaidTheme = theme === 'dark' ? 'dark' : 'default'; + const pngBackground = mermaidTheme === 'dark' ? '#1e1e1e' : '#ffffff'; + const { toast } = useToast(); + + const [svg, setSvg] = useState(null); + const [renderError, setRenderError] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [isHighlighted, setIsHighlighted] = useState(false); + // Keep the hover controls visible while a dropdown is open (the open menu + // lives in a portal, so moving to it would otherwise drop the hover state). + const [isCopyMenuOpen, setIsCopyMenuOpen] = useState(false); + const [isExportMenuOpen, setIsExportMenuOpen] = useState(false); + const isAnyMenuOpen = isCopyMenuOpen || isExportMenuOpen; + + // Stable anchor derived from the (persisted) source, used for in-thread + // deep links: a link to `#${canonicalAnchorId}` re-renders and scrolls to + // the inline diagram on load. `containerId` may differ (e.g. the panel + // mirror) to avoid duplicate DOM ids. + const canonicalAnchorId = useMemo(() => getDiagramAnchorId(code), [code]); + const containerId = domId ?? canonicalAnchorId; + const diagramPanel = useDiagramPanel(); + const containerRef = useRef(null); + + // Render (debounced) whenever the source or theme changes. A try/catch + // drives the fallback: partial or invalid mermaid leaves `svg` null and + // sets `renderError`, so we render the raw code instead. + useEffect(() => { + let cancelled = false; + const handle = setTimeout(async () => { + try { + const result = await renderMermaidToSvg(code, mermaidTheme); + if (!cancelled) { + setSvg(result); + setRenderError(false); + } + } catch { + if (!cancelled) { + setSvg(null); + setRenderError(true); + } + } + }, 150); + + return () => { + cancelled = true; + clearTimeout(handle); + }; + }, [code, mermaidTheme]); + + const diagramReady = svg !== null && !renderError; + + // Scroll to (and briefly highlight) this diagram when the URL hash targets + // its anchor. Re-runs once the diagram renders so we land on final layout. + useEffect(() => { + if (!listenToDeepLink) { + return; + } + const checkHash = () => { + if (typeof window === 'undefined' || window.location.hash !== `#${canonicalAnchorId}`) { + return; + } + containerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + setIsHighlighted(true); + window.setTimeout(() => setIsHighlighted(false), 2000); + }; + + checkHash(); + window.addEventListener('hashchange', checkHash); + return () => window.removeEventListener('hashchange', checkHash); + }, [canonicalAnchorId, diagramReady, listenToDeepLink]); + + const onCopyLink = useCallback(() => { + const url = new URL(window.location.href); + url.hash = canonicalAnchorId; + navigator.clipboard.writeText(url.toString()); + toast({ description: '✅ Copied link to diagram' }); + }, [canonicalAnchorId, toast]); + + const onCopySource = useCallback(() => { + navigator.clipboard.writeText(code); + toast({ description: '✅ Copied diagram source' }); + }, [code, toast]); + + const onCopyImage = useCallback(async () => { + if (!svg) { + return; + } + try { + const blob = await svgToPngBlob(svg, pngBackground); + if (!blob) { + throw new Error('Failed to rasterize diagram'); + } + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); + toast({ description: '✅ Copied diagram image' }); + } catch { + toast({ description: '❌ Failed to copy image', variant: 'destructive' }); + } + }, [svg, pngBackground, toast]); + + const onExportSvg = useCallback(() => { + if (!svg) { + return; + } + triggerDownload(new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }), 'diagram.svg'); + }, [svg]); + + const onExportPng = useCallback(async () => { + if (!svg) { + return; + } + const blob = await svgToPngBlob(svg, pngBackground); + if (blob) { + triggerDownload(blob, 'diagram.png'); + } + }, [svg, pngBackground]); + + // Inline is capped (the answer pane shouldn't be dominated by one diagram); + // the panel sizes to the diagram's full height and lets the right pane scroll. + const viewportSizeClass = variant === 'panel' ? '' : 'max-h-[480px]'; + + // On-hover controls overlaid on the diagram (no separate header bar). + const actions = ( + <> + {variant === 'inline' && diagramPanel && ( + + )} + {onJump && ( + + )} + + + + + + Copy link to diagram + Copy source + Copy as image + + + + + + + + Export as SVG + Export as PNG + + + + + ); + + return ( +
+ {diagramReady ? ( + + ) : renderError ? ( + // Invalid / mid-stream source: show it as code until it renders cleanly. + + ) : ( + // Initial render pending: a neutral placeholder avoids a code-to-diagram pop-in. +
+ +
+ )} + + + {/* pt-12 reserves a top strip for the close (X) button so the + viewport's hover controls sit clearly below it. */} + + Diagram + {svg && ( + + )} + + +
+ ); +}; diff --git a/packages/web/src/ee/features/chat/components/chatThread/referencedSourcesListView.tsx b/packages/web/src/ee/features/chat/components/chatThread/referencedSourcesListView.tsx index 9e7438faf..46b3fc436 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/referencedSourcesListView.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/referencedSourcesListView.tsx @@ -7,8 +7,16 @@ import scrollIntoView from 'scroll-into-view-if-needed'; import { FileReference, FileSource, Reference } from "@/features/chat/types"; import { tryResolveFileReference } from '@/features/chat/utils'; import { ReferencedFileSourceListItemContainer } from "./referencedFileSourceListItemContainer"; +import { DiagramPanelListItem } from "./diagramPanelListItem"; +import { ExtractedDiagram } from "@/ee/features/chat/useExtractDiagrams"; import isEqual from 'fast-deep-equal/react'; +// An ordered entry in the right panel: either a referenced file source or a +// diagram, interleaved by their order of appearance in the answer. +export type PanelItem = + | { kind: 'source'; source: FileSource } + | { kind: 'diagram'; diagram: ExtractedDiagram; diagramIndex: number }; + interface ReferencedSourcesListViewProps { references: FileReference[]; sources: FileSource[]; @@ -18,6 +26,9 @@ interface ReferencedSourcesListViewProps { selectedReference?: Reference; onSelectedReferenceChanged: (reference?: Reference) => void; style: React.CSSProperties; + orderedItems?: PanelItem[]; + selectedDiagramId?: string; + onJumpToInlineDiagram?: (diagramId: string) => void; } const ReferencedSourcesListViewComponent = ({ @@ -29,10 +40,40 @@ const ReferencedSourcesListViewComponent = ({ style, onHoveredReferenceChanged, onSelectedReferenceChanged, + orderedItems = [], + selectedDiagramId, + onJumpToInlineDiagram, }: ReferencedSourcesListViewProps) => { const scrollAreaRef = useRef(null); const editorRefsMap = useRef>(new Map()); const [collapsedFileIds, setCollapsedFileIds] = useState([]); + // Diagrams render pre-collapsed; expand by id (or via reveal-from-answer). + const [expandedDiagramIds, setExpandedDiagramIds] = useState([]); + // Transient highlight applied when a diagram is revealed from the answer + // (cleared after a moment so the panel item isn't permanently outlined). + const [highlightedDiagramId, setHighlightedDiagramId] = useState(undefined); + + // When a diagram is revealed from the answer, expand it, scroll it into + // view, and briefly highlight it. + useEffect(() => { + if (!selectedDiagramId) { + return; + } + setExpandedDiagramIds((prev) => (prev.includes(selectedDiagramId) ? prev : [...prev, selectedDiagramId])); + const element = document.getElementById(`diagram-panel-${selectedDiagramId}`); + if (element) { + scrollIntoView(element, { scrollMode: 'if-needed', block: 'center', behavior: 'smooth' }); + } + setHighlightedDiagramId(selectedDiagramId); + const timeout = window.setTimeout(() => setHighlightedDiagramId(undefined), 2000); + return () => window.clearTimeout(timeout); + }, [selectedDiagramId]); + + const onToggleDiagram = useCallback((diagramId: string) => { + setExpandedDiagramIds((prev) => ( + prev.includes(diagramId) ? prev.filter((id) => id !== diagramId) : [...prev, diagramId] + )); + }, []); const getFileId = useCallback((fileSource: FileSource) => { // @note: we include the index to ensure that the file id is unique @@ -174,10 +215,10 @@ const ReferencedSourcesListViewComponent = ({ } }, []); - if (sources.length === 0) { + if (orderedItems.length === 0) { return (
- No file references found + No references found
); } @@ -187,8 +228,24 @@ const ReferencedSourcesListViewComponent = ({ ref={scrollAreaRef} style={style} > -
- {sources.map((fileSource) => { + {/* px-2 leaves room for the diagram reveal ring so it isn't clipped on the edges */} +
+ {orderedItems.map((item) => { + if (item.kind === 'diagram') { + return ( + onToggleDiagram(item.diagram.id)} + onJumpToInline={() => onJumpToInlineDiagram?.(item.diagram.id)} + /> + ); + } + + const fileSource = item.source; const fileId = getFileId(fileSource); const referencesInFile = referencesGroupedByFile.get(fileId) || []; const hoveredReferenceInFile = referencesInFile.some(r => r.id === hoveredReference?.id) ? hoveredReference : undefined; diff --git a/packages/web/src/ee/features/chat/diagramPanelContext.tsx b/packages/web/src/ee/features/chat/diagramPanelContext.tsx new file mode 100644 index 000000000..7a22d7ec1 --- /dev/null +++ b/packages/web/src/ee/features/chat/diagramPanelContext.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { createContext, useContext } from "react"; + +export interface DiagramPanelContextValue { + // Reveal the right-panel mirror of the given diagram (switch to the + // Diagrams tab and scroll it into view). + revealInPanel: (diagramId: string) => void; +} + +export const DiagramPanelContext = createContext(null); + +export const useDiagramPanel = () => useContext(DiagramPanelContext); diff --git a/packages/web/src/ee/features/chat/diagramUtils.ts b/packages/web/src/ee/features/chat/diagramUtils.ts new file mode 100644 index 000000000..1cc395388 --- /dev/null +++ b/packages/web/src/ee/features/chat/diagramUtils.ts @@ -0,0 +1,18 @@ +// Matches fenced ```mermaid code blocks in an answer's markdown. +export const MERMAID_BLOCK_REGEX = /```mermaid\s*\n([\s\S]*?)```/g; + +// Stable, deterministic hash of the diagram source so a diagram keeps the same +// id across reloads (the source is persisted in the message) and so the inline +// and right-panel instances of the same diagram resolve to the same id. +const hashString = (value: string): string => { + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (hash * 31 + value.charCodeAt(i)) | 0; + } + return Math.abs(hash).toString(36); +}; + +export const getDiagramId = (code: string): string => hashString(code.trim()); + +// Canonical DOM id / deep-link anchor for the inline instance of a diagram. +export const getDiagramAnchorId = (code: string): string => `diagram-${getDiagramId(code)}`; diff --git a/packages/web/src/ee/features/chat/useExtractDiagrams.ts b/packages/web/src/ee/features/chat/useExtractDiagrams.ts new file mode 100644 index 000000000..859d4a3c1 --- /dev/null +++ b/packages/web/src/ee/features/chat/useExtractDiagrams.ts @@ -0,0 +1,48 @@ +'use client'; + +import { TextUIPart } from "ai"; +import { useMemo } from "react"; +import { MERMAID_BLOCK_REGEX, getDiagramId } from "./diagramUtils"; + +export interface ExtractedDiagram { + id: string; + code: string; +} + +/** + * Extracts the mermaid diagrams embedded in an answer's text so they can be + * mirrored in the right panel. Mirrors the approach of useExtractReferences + * (regex over the message text). Duplicate diagrams (same source) are + * de-duplicated since they would share an id/anchor. + */ +export const useExtractDiagrams = (part?: TextUIPart): ExtractedDiagram[] => { + return useMemo(() => { + if (!part?.text) { + return []; + } + + const diagrams: ExtractedDiagram[] = []; + const seen = new Set(); + + // Use a fresh regex instance so the shared lastIndex isn't carried over. + const regex = new RegExp(MERMAID_BLOCK_REGEX.source, 'g'); + + let match; + while ((match = regex.exec(part.text)) !== null) { + const code = match[1].trim(); + if (!code) { + continue; + } + + const id = getDiagramId(code); + if (seen.has(id)) { + continue; + } + + seen.add(id); + diagrams.push({ id, code }); + } + + return diagrams; + }, [part]); +}; diff --git a/yarn.lock b/yarn.lock index be48b2e71..bd262a904 100644 --- a/yarn.lock +++ b/yarn.lock @@ -226,6 +226,16 @@ __metadata: languageName: node linkType: hard +"@antfu/install-pkg@npm:^1.1.0": + version: 1.1.0 + resolution: "@antfu/install-pkg@npm:1.1.0" + dependencies: + package-manager-detector: "npm:^1.3.0" + tinyexec: "npm:^1.0.1" + checksum: 10c0/140d5994c76fd3d0e824c88f1ce91b3370e8066a8bc2f5729ae133bf768caa239f7915e29c78f239b7ead253113ace51293e95127fafe2b786b88eb615b3be47 + languageName: node + linkType: hard + "@antfu/ni@npm:^0.23.0": version: 0.23.2 resolution: "@antfu/ni@npm:0.23.2" @@ -1323,6 +1333,13 @@ __metadata: languageName: node linkType: hard +"@braintree/sanitize-url@npm:^7.1.2": + version: 7.1.2 + resolution: "@braintree/sanitize-url@npm:7.1.2" + checksum: 10c0/62f2aa0cf58626e3880b2dc1025c42064b4639abd157ae4e1c35f4c2f5031e9273772046a423979845069c814e27ff818e8e669280dc53585e6f033d5b7a59cb + languageName: node + linkType: hard + "@cfworker/json-schema@npm:^4.0.2": version: 4.1.1 resolution: "@cfworker/json-schema@npm:4.1.1" @@ -1330,6 +1347,13 @@ __metadata: languageName: node linkType: hard +"@chevrotain/types@npm:~11.1.2": + version: 11.1.2 + resolution: "@chevrotain/types@npm:11.1.2" + checksum: 10c0/c0c4679a3d407df34e18d5adfa7ac599b4a2bfddbf68da6e43678b9b3e16ab911de7766b37b9fc466261c3dead3db1b620e2e344f800fa9f0f381720475eda8f + languageName: node + linkType: hard + "@codemirror/autocomplete@npm:^6.0.0, @codemirror/autocomplete@npm:^6.16.2, @codemirror/autocomplete@npm:^6.3.2, @codemirror/autocomplete@npm:^6.7.1": version: 6.18.6 resolution: "@codemirror/autocomplete@npm:6.18.6" @@ -2493,6 +2517,17 @@ __metadata: languageName: node linkType: hard +"@iconify/utils@npm:^3.0.2": + version: 3.1.3 + resolution: "@iconify/utils@npm:3.1.3" + dependencies: + "@antfu/install-pkg": "npm:^1.1.0" + "@iconify/types": "npm:^2.0.0" + import-meta-resolve: "npm:^4.2.0" + checksum: 10c0/6d01196172ef062a9cd0ee299dff3a405221e65057d6cca9ac9fb4bacd7d78168d54b0837b75d5f163038d770a3a7e4449d68ea2fc7a577c01b9ab51bfd53c98 + languageName: node + linkType: hard + "@iizukak/codemirror-lang-wgsl@npm:^0.3.0": version: 0.3.0 resolution: "@iizukak/codemirror-lang-wgsl@npm:0.3.0" @@ -3688,6 +3723,15 @@ __metadata: languageName: node linkType: hard +"@mermaid-js/parser@npm:^1.2.0": + version: 1.2.0 + resolution: "@mermaid-js/parser@npm:1.2.0" + dependencies: + "@chevrotain/types": "npm:~11.1.2" + checksum: 10c0/907d41167b23160c88f292b0dd79162d2543445ecf1d784ed09747467705666669f818e3ab91e1182da2127720fb40cb707e6fd56e8a445020fd3e7b8b16f013 + languageName: node + linkType: hard + "@modelcontextprotocol/sdk@npm:^1.25.0": version: 1.27.1 resolution: "@modelcontextprotocol/sdk@npm:1.27.1" @@ -9445,6 +9489,7 @@ __metadata: langfuse-vercel: "npm:^3.38.4" linguist-languages: "npm:^9.3.1" lucide-react: "npm:^1.7.0" + mermaid: "npm:^11.16.0" micromatch: "npm:^4.0.8" minidenticons: "npm:^4.2.1" next: "npm:^16.2.6" @@ -9774,6 +9819,13 @@ __metadata: languageName: node linkType: hard +"@types/d3-array@npm:*": + version: 3.2.2 + resolution: "@types/d3-array@npm:3.2.2" + checksum: 10c0/6137cb97302f8a4f18ca22c0560c585cfcb823f276b23d89f2c0c005d72697ec13bca671c08e68b4b0cabd622e3f0e91782ee221580d6774074050be96dd7028 + languageName: node + linkType: hard + "@types/d3-array@npm:^3.0.3": version: 3.2.1 resolution: "@types/d3-array@npm:3.2.1" @@ -9781,6 +9833,31 @@ __metadata: languageName: node linkType: hard +"@types/d3-axis@npm:*": + version: 3.0.6 + resolution: "@types/d3-axis@npm:3.0.6" + dependencies: + "@types/d3-selection": "npm:*" + checksum: 10c0/d756d42360261f44d8eefd0950c5bb0a4f67a46dd92069da3f723ac36a1e8cb2b9ce6347d836ef19d5b8aef725dbcf8fdbbd6cfbff676ca4b0642df2f78b599a + languageName: node + linkType: hard + +"@types/d3-brush@npm:*": + version: 3.0.6 + resolution: "@types/d3-brush@npm:3.0.6" + dependencies: + "@types/d3-selection": "npm:*" + checksum: 10c0/fd6e2ac7657a354f269f6b9c58451ffae9d01b89ccb1eb6367fd36d635d2f1990967215ab498e0c0679ff269429c57fad6a2958b68f4d45bc9f81d81672edc01 + languageName: node + linkType: hard + +"@types/d3-chord@npm:*": + version: 3.0.6 + resolution: "@types/d3-chord@npm:3.0.6" + checksum: 10c0/c5a25eb5389db01e63faec0c5c2ec7cc41c494e9b3201630b494c4e862a60f1aa83fabbc33a829e7e1403941e3c30d206c741559b14406ac2a4239cfdf4b4c17 + languageName: node + linkType: hard + "@types/d3-color@npm:*": version: 3.1.3 resolution: "@types/d3-color@npm:3.1.3" @@ -9788,14 +9865,93 @@ __metadata: languageName: node linkType: hard -"@types/d3-ease@npm:^3.0.0": +"@types/d3-contour@npm:*": + version: 3.0.6 + resolution: "@types/d3-contour@npm:3.0.6" + dependencies: + "@types/d3-array": "npm:*" + "@types/geojson": "npm:*" + checksum: 10c0/e7d83e94719af4576ceb5ac7f277c5806f83ba6c3631744ae391cffc3641f09dfa279470b83053cd0b2acd6784e8749c71141d05bdffa63ca58ffb5b31a0f27c + languageName: node + linkType: hard + +"@types/d3-delaunay@npm:*": + version: 6.0.4 + resolution: "@types/d3-delaunay@npm:6.0.4" + checksum: 10c0/d154a8864f08c4ea23ecb9bdabcef1c406a25baa8895f0cb08a0ed2799de0d360e597552532ce7086ff0cdffa8f3563f9109d18f0191459d32bb620a36939123 + languageName: node + linkType: hard + +"@types/d3-dispatch@npm:*": + version: 3.0.7 + resolution: "@types/d3-dispatch@npm:3.0.7" + checksum: 10c0/38c6605ebf0bf0099dfb70eafe0dd4ae8213368b40b8f930b72a909ff2e7259d2bd8a54d100bb5a44eb4b36f4f2a62dcb37f8be59613ca6b507c7a2f910b3145 + languageName: node + linkType: hard + +"@types/d3-drag@npm:*": + version: 3.0.7 + resolution: "@types/d3-drag@npm:3.0.7" + dependencies: + "@types/d3-selection": "npm:*" + checksum: 10c0/65e29fa32a87c72d26c44b5e2df3bf15af21cd128386bcc05bcacca255927c0397d0cd7e6062aed5f0abd623490544a9d061c195f5ed9f018fe0b698d99c079d + languageName: node + linkType: hard + +"@types/d3-dsv@npm:*": + version: 3.0.7 + resolution: "@types/d3-dsv@npm:3.0.7" + checksum: 10c0/c0f01da862465594c8a28278b51c850af3b4239cc22b14fd1a19d7a98f93d94efa477bf59d8071beb285dca45bf614630811451e18e7c52add3a0abfee0a1871 + languageName: node + linkType: hard + +"@types/d3-ease@npm:*, @types/d3-ease@npm:^3.0.0": version: 3.0.2 resolution: "@types/d3-ease@npm:3.0.2" checksum: 10c0/aff5a1e572a937ee9bff6465225d7ba27d5e0c976bd9eacdac2e6f10700a7cb0c9ea2597aff6b43a6ed850a3210030870238894a77ec73e309b4a9d0333f099c languageName: node linkType: hard -"@types/d3-interpolate@npm:^3.0.1": +"@types/d3-fetch@npm:*": + version: 3.0.7 + resolution: "@types/d3-fetch@npm:3.0.7" + dependencies: + "@types/d3-dsv": "npm:*" + checksum: 10c0/3d147efa52a26da1a5d40d4d73e6cebaaa964463c378068062999b93ea3731b27cc429104c21ecbba98c6090e58ef13429db6399238c5e3500162fb3015697a0 + languageName: node + linkType: hard + +"@types/d3-force@npm:*": + version: 3.0.10 + resolution: "@types/d3-force@npm:3.0.10" + checksum: 10c0/c82b459079a106b50e346c9b79b141f599f2fc4f598985a5211e72c7a2e20d35bd5dc6e91f306b323c8bfa325c02c629b1645f5243f1c6a55bd51bc85cccfa92 + languageName: node + linkType: hard + +"@types/d3-format@npm:*": + version: 3.0.4 + resolution: "@types/d3-format@npm:3.0.4" + checksum: 10c0/3ac1600bf9061a59a228998f7cd3f29e85cbf522997671ba18d4d84d10a2a1aff4f95aceb143fa9960501c3ec351e113fc75884e6a504ace44dc1744083035ee + languageName: node + linkType: hard + +"@types/d3-geo@npm:*": + version: 3.1.0 + resolution: "@types/d3-geo@npm:3.1.0" + dependencies: + "@types/geojson": "npm:*" + checksum: 10c0/3745a93439038bb5b0b38facf435f7079812921d46406f5d38deaee59e90084ff742443c7ea0a8446df81a0d81eaf622fe7068cf4117a544bd4aa3b2dc182f88 + languageName: node + linkType: hard + +"@types/d3-hierarchy@npm:*": + version: 3.1.7 + resolution: "@types/d3-hierarchy@npm:3.1.7" + checksum: 10c0/873711737d6b8e7b6f1dda0bcd21294a48f75024909ae510c5d2c21fad2e72032e0958def4d9f68319d3aaac298ad09c49807f8bfc87a145a82693b5208613c7 + languageName: node + linkType: hard + +"@types/d3-interpolate@npm:*, @types/d3-interpolate@npm:^3.0.1": version: 3.0.4 resolution: "@types/d3-interpolate@npm:3.0.4" dependencies: @@ -9811,7 +9967,35 @@ __metadata: languageName: node linkType: hard -"@types/d3-scale@npm:^4.0.2": +"@types/d3-polygon@npm:*": + version: 3.0.2 + resolution: "@types/d3-polygon@npm:3.0.2" + checksum: 10c0/f46307bb32b6c2aef8c7624500e0f9b518de8f227ccc10170b869dc43e4c542560f6c8d62e9f087fac45e198d6e4b623e579c0422e34c85baf56717456d3f439 + languageName: node + linkType: hard + +"@types/d3-quadtree@npm:*": + version: 3.0.6 + resolution: "@types/d3-quadtree@npm:3.0.6" + checksum: 10c0/7eaa0a4d404adc856971c9285e1c4ab17e9135ea669d847d6db7e0066126a28ac751864e7ce99c65d526e130f56754a2e437a1617877098b3bdcc3ef23a23616 + languageName: node + linkType: hard + +"@types/d3-random@npm:*": + version: 3.0.3 + resolution: "@types/d3-random@npm:3.0.3" + checksum: 10c0/5f4fea40080cd6d4adfee05183d00374e73a10c530276a6455348983dda341003a251def28565a27c25d9cf5296a33e870e397c9d91ff83fb7495a21c96b6882 + languageName: node + linkType: hard + +"@types/d3-scale-chromatic@npm:*": + version: 3.1.0 + resolution: "@types/d3-scale-chromatic@npm:3.1.0" + checksum: 10c0/93c564e02d2e97a048e18fe8054e4a935335da6ab75a56c3df197beaa87e69122eef0dfbeb7794d4a444a00e52e3123514ee27cec084bd21f6425b7037828cc2 + languageName: node + linkType: hard + +"@types/d3-scale@npm:*, @types/d3-scale@npm:^4.0.2": version: 4.0.9 resolution: "@types/d3-scale@npm:4.0.9" dependencies: @@ -9820,6 +10004,22 @@ __metadata: languageName: node linkType: hard +"@types/d3-selection@npm:*": + version: 3.0.11 + resolution: "@types/d3-selection@npm:3.0.11" + checksum: 10c0/0c512956c7503ff5def4bb32e0c568cc757b9a2cc400a104fc0f4cfe5e56d83ebde2a97821b6f2cb26a7148079d3b86a2f28e11d68324ed311cf35c2ed980d1d + languageName: node + linkType: hard + +"@types/d3-shape@npm:*": + version: 3.1.8 + resolution: "@types/d3-shape@npm:3.1.8" + dependencies: + "@types/d3-path": "npm:*" + checksum: 10c0/49ec2172b9eb66fc1d036e2a23966216bb972e9af51ddbed134df24bd71aedf207bb1ef81903a119eb4e1f5e927cf44beacaf64c9af86474e5548594b102b574 + languageName: node + linkType: hard + "@types/d3-shape@npm:^3.1.0": version: 3.1.7 resolution: "@types/d3-shape@npm:3.1.7" @@ -9829,6 +10029,13 @@ __metadata: languageName: node linkType: hard +"@types/d3-time-format@npm:*": + version: 4.0.3 + resolution: "@types/d3-time-format@npm:4.0.3" + checksum: 10c0/9ef5e8e2b96b94799b821eed5d61a3d432c7903247966d8ad951b8ce5797fe46554b425cb7888fa5bf604b4663c369d7628c0328ffe80892156671c58d1a7f90 + languageName: node + linkType: hard + "@types/d3-time@npm:*, @types/d3-time@npm:^3.0.0": version: 3.0.4 resolution: "@types/d3-time@npm:3.0.4" @@ -9836,13 +10043,70 @@ __metadata: languageName: node linkType: hard -"@types/d3-timer@npm:^3.0.0": +"@types/d3-timer@npm:*, @types/d3-timer@npm:^3.0.0": version: 3.0.2 resolution: "@types/d3-timer@npm:3.0.2" checksum: 10c0/c644dd9571fcc62b1aa12c03bcad40571553020feeb5811f1d8a937ac1e65b8a04b759b4873aef610e28b8714ac71c9885a4d6c127a048d95118f7e5b506d9e1 languageName: node linkType: hard +"@types/d3-transition@npm:*": + version: 3.0.9 + resolution: "@types/d3-transition@npm:3.0.9" + dependencies: + "@types/d3-selection": "npm:*" + checksum: 10c0/4f68b9df7ac745b3491216c54203cbbfa0f117ae4c60e2609cdef2db963582152035407fdff995b10ee383bae2f05b7743493f48e1b8e46df54faa836a8fb7b5 + languageName: node + linkType: hard + +"@types/d3-zoom@npm:*": + version: 3.0.8 + resolution: "@types/d3-zoom@npm:3.0.8" + dependencies: + "@types/d3-interpolate": "npm:*" + "@types/d3-selection": "npm:*" + checksum: 10c0/1dbdbcafddcae12efb5beb6948546963f29599e18bc7f2a91fb69cc617c2299a65354f2d47e282dfb86fec0968406cd4fb7f76ba2d2fb67baa8e8d146eb4a547 + languageName: node + linkType: hard + +"@types/d3@npm:^7.4.3": + version: 7.4.3 + resolution: "@types/d3@npm:7.4.3" + dependencies: + "@types/d3-array": "npm:*" + "@types/d3-axis": "npm:*" + "@types/d3-brush": "npm:*" + "@types/d3-chord": "npm:*" + "@types/d3-color": "npm:*" + "@types/d3-contour": "npm:*" + "@types/d3-delaunay": "npm:*" + "@types/d3-dispatch": "npm:*" + "@types/d3-drag": "npm:*" + "@types/d3-dsv": "npm:*" + "@types/d3-ease": "npm:*" + "@types/d3-fetch": "npm:*" + "@types/d3-force": "npm:*" + "@types/d3-format": "npm:*" + "@types/d3-geo": "npm:*" + "@types/d3-hierarchy": "npm:*" + "@types/d3-interpolate": "npm:*" + "@types/d3-path": "npm:*" + "@types/d3-polygon": "npm:*" + "@types/d3-quadtree": "npm:*" + "@types/d3-random": "npm:*" + "@types/d3-scale": "npm:*" + "@types/d3-scale-chromatic": "npm:*" + "@types/d3-selection": "npm:*" + "@types/d3-shape": "npm:*" + "@types/d3-time": "npm:*" + "@types/d3-time-format": "npm:*" + "@types/d3-timer": "npm:*" + "@types/d3-transition": "npm:*" + "@types/d3-zoom": "npm:*" + checksum: 10c0/a9c6d65b13ef3b42c87f2a89ea63a6d5640221869f97d0657b0cb2f1dac96a0f164bf5605643c0794e0de3aa2bf05df198519aaf15d24ca135eb0e8bd8a9d879 + languageName: node + linkType: hard + "@types/debug@npm:^4.0.0": version: 4.1.12 resolution: "@types/debug@npm:4.1.12" @@ -9905,6 +10169,13 @@ __metadata: languageName: node linkType: hard +"@types/geojson@npm:*": + version: 7946.0.16 + resolution: "@types/geojson@npm:7946.0.16" + checksum: 10c0/1ff24a288bd5860b766b073ead337d31d73bdc715e5b50a2cee5cb0af57a1ed02cc04ef295f5fa68dc40fe3e4f104dd31282b2b818a5ba3231bc1001ba084e3c + languageName: node + linkType: hard + "@types/glob-to-regexp@npm:^0.4.4": version: 0.4.4 resolution: "@types/glob-to-regexp@npm:0.4.4" @@ -10748,6 +11019,21 @@ __metadata: languageName: node linkType: hard +"@upsetjs/venn.js@npm:^2.0.0": + version: 2.0.0 + resolution: "@upsetjs/venn.js@npm:2.0.0" + dependencies: + d3-selection: "npm:^3.0.0" + d3-transition: "npm:^3.0.1" + dependenciesMeta: + d3-selection: + optional: true + d3-transition: + optional: true + checksum: 10c0/b12014d94708ab4df7f5a4b6205c6f23ff235cca2ffe91df3314862b109b826e52f9020c2a2f7527d3712d21c578d6db9cdb60ce46a528739cc18e58d111f724 + languageName: node + linkType: hard + "@vercel/oidc@npm:3.1.0": version: 3.1.0 resolution: "@vercel/oidc@npm:3.1.0" @@ -12276,6 +12562,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:7": + version: 7.2.0 + resolution: "commander@npm:7.2.0" + checksum: 10c0/8d690ff13b0356df7e0ebbe6c59b4712f754f4b724d4f473d3cc5b3fdcf978e3a5dc3078717858a2ceb50b0f84d0660a7f22a96cdc50fb877d0c9bb31593d23a + languageName: node + linkType: hard + "commander@npm:^13.0.0": version: 13.1.0 resolution: "commander@npm:13.1.0" @@ -12304,6 +12597,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^8.3.0": + version: 8.3.0 + resolution: "commander@npm:8.3.0" + checksum: 10c0/8b043bb8322ea1c39664a1598a95e0495bfe4ca2fad0d84a92d7d1d8d213e2a155b441d2470c8e08de7c4a28cf2bc6e169211c49e1b21d9f7edc6ae4d9356060 + languageName: node + linkType: hard + "commondir@npm:^1.0.1": version: 1.0.1 resolution: "commondir@npm:1.0.1" @@ -12436,6 +12736,24 @@ __metadata: languageName: node linkType: hard +"cose-base@npm:^1.0.0": + version: 1.0.3 + resolution: "cose-base@npm:1.0.3" + dependencies: + layout-base: "npm:^1.0.0" + checksum: 10c0/a6e400b1d101393d6af0967c1353355777c1106c40417c5acaef6ca8bdda41e2fc9398f466d6c85be30290943ad631f2590569f67b3fd5368a0d8318946bd24f + languageName: node + linkType: hard + +"cose-base@npm:^2.2.0": + version: 2.2.0 + resolution: "cose-base@npm:2.2.0" + dependencies: + layout-base: "npm:^2.0.0" + checksum: 10c0/14b9f8100ac322a00777ffb1daeb3321af368bbc9cabe3103943361273baee2003202ffe38e4ab770960b600214224e9c196195a78d589521540aa694df7cdec + languageName: node + linkType: hard + "crelt@npm:^1.0.5": version: 1.0.6 resolution: "crelt@npm:1.0.6" @@ -12540,7 +12858,45 @@ __metadata: languageName: node linkType: hard -"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:^3.1.6": +"cytoscape-cose-bilkent@npm:^4.1.0": + version: 4.1.0 + resolution: "cytoscape-cose-bilkent@npm:4.1.0" + dependencies: + cose-base: "npm:^1.0.0" + peerDependencies: + cytoscape: ^3.2.0 + checksum: 10c0/5e2480ddba9da1a68e700ed2c674cbfd51e9efdbd55788f1971a68de4eb30708e3b3a5e808bf5628f7a258680406bbe6586d87a9133e02a9bdc1ab1a92f512f2 + languageName: node + linkType: hard + +"cytoscape-fcose@npm:^2.2.0": + version: 2.2.0 + resolution: "cytoscape-fcose@npm:2.2.0" + dependencies: + cose-base: "npm:^2.2.0" + peerDependencies: + cytoscape: ^3.2.0 + checksum: 10c0/ce472c9f85b9057e75c5685396f8e1f2468895e71b184913e05ad56dcf3092618fe59a1054f29cb0995051ba8ebe566ad0dd49a58d62845145624bd60cd44917 + languageName: node + linkType: hard + +"cytoscape@npm:^3.33.3": + version: 3.34.0 + resolution: "cytoscape@npm:3.34.0" + checksum: 10c0/cb17b3dcacb9492f058cff653debf7b5a713e7532a9866cc72ae79d207757e8a9b68430874fe986f2a6404a8161b4fb5c1a2f1ca9ac5b1a21e316f57ed9d1243 + languageName: node + linkType: hard + +"d3-array@npm:1 - 2": + version: 2.12.1 + resolution: "d3-array@npm:2.12.1" + dependencies: + internmap: "npm:^1.0.0" + checksum: 10c0/7eca10427a9f113a4ca6a0f7301127cab26043fd5e362631ef5a0edd1c4b2dd70c56ed317566700c31e4a6d88b55f3951aaba192291817f243b730cb2352882e + languageName: node + linkType: hard + +"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:2.5.0 - 3, d3-array@npm:3, d3-array@npm:^3.1.6, d3-array@npm:^3.2.0": version: 3.2.4 resolution: "d3-array@npm:3.2.4" dependencies: @@ -12549,20 +12905,125 @@ __metadata: languageName: node linkType: hard -"d3-color@npm:1 - 3": +"d3-axis@npm:3": + version: 3.0.0 + resolution: "d3-axis@npm:3.0.0" + checksum: 10c0/a271e70ba1966daa5aaf6a7f959ceca3e12997b43297e757c7b945db2e1ead3c6ee226f2abcfa22abbd4e2e28bd2b71a0911794c4e5b911bbba271328a582c78 + languageName: node + linkType: hard + +"d3-brush@npm:3": + version: 3.0.0 + resolution: "d3-brush@npm:3.0.0" + dependencies: + d3-dispatch: "npm:1 - 3" + d3-drag: "npm:2 - 3" + d3-interpolate: "npm:1 - 3" + d3-selection: "npm:3" + d3-transition: "npm:3" + checksum: 10c0/07baf00334c576da2f68a91fc0da5732c3a5fa19bd3d7aed7fd24d1d674a773f71a93e9687c154176f7246946194d77c48c2d8fed757f5dcb1a4740067ec50a8 + languageName: node + linkType: hard + +"d3-chord@npm:3": + version: 3.0.1 + resolution: "d3-chord@npm:3.0.1" + dependencies: + d3-path: "npm:1 - 3" + checksum: 10c0/baa6013914af3f4fe1521f0d16de31a38eb8a71d08ff1dec4741f6f45a828661e5cd3935e39bd14e3032bdc78206c283ca37411da21d46ec3cfc520be6e7a7ce + languageName: node + linkType: hard + +"d3-color@npm:1 - 3, d3-color@npm:3": version: 3.1.0 resolution: "d3-color@npm:3.1.0" checksum: 10c0/a4e20e1115fa696fce041fbe13fbc80dc4c19150fa72027a7c128ade980bc0eeeba4bcf28c9e21f0bce0e0dbfe7ca5869ef67746541dcfda053e4802ad19783c languageName: node linkType: hard -"d3-ease@npm:^3.0.1": +"d3-contour@npm:4": + version: 4.0.2 + resolution: "d3-contour@npm:4.0.2" + dependencies: + d3-array: "npm:^3.2.0" + checksum: 10c0/98bc5fbed6009e08707434a952076f39f1cd6ed8b9288253cc3e6a3286e4e80c63c62d84954b20e64bf6e4ededcc69add54d3db25e990784a59c04edd3449032 + languageName: node + linkType: hard + +"d3-delaunay@npm:6": + version: 6.0.4 + resolution: "d3-delaunay@npm:6.0.4" + dependencies: + delaunator: "npm:5" + checksum: 10c0/57c3aecd2525664b07c4c292aa11cf49b2752c0cf3f5257f752999399fe3c592de2d418644d79df1f255471eec8057a9cc0c3062ed7128cb3348c45f69597754 + languageName: node + linkType: hard + +"d3-dispatch@npm:1 - 3, d3-dispatch@npm:3": + version: 3.0.1 + resolution: "d3-dispatch@npm:3.0.1" + checksum: 10c0/6eca77008ce2dc33380e45d4410c67d150941df7ab45b91d116dbe6d0a3092c0f6ac184dd4602c796dc9e790222bad3ff7142025f5fd22694efe088d1d941753 + languageName: node + linkType: hard + +"d3-drag@npm:2 - 3, d3-drag@npm:3": + version: 3.0.0 + resolution: "d3-drag@npm:3.0.0" + dependencies: + d3-dispatch: "npm:1 - 3" + d3-selection: "npm:3" + checksum: 10c0/d2556e8dc720741a443b595a30af403dd60642dfd938d44d6e9bfc4c71a962142f9a028c56b61f8b4790b65a34acad177d1263d66f103c3c527767b0926ef5aa + languageName: node + linkType: hard + +"d3-dsv@npm:1 - 3, d3-dsv@npm:3": + version: 3.0.1 + resolution: "d3-dsv@npm:3.0.1" + dependencies: + commander: "npm:7" + iconv-lite: "npm:0.6" + rw: "npm:1" + bin: + csv2json: bin/dsv2json.js + csv2tsv: bin/dsv2dsv.js + dsv2dsv: bin/dsv2dsv.js + dsv2json: bin/dsv2json.js + json2csv: bin/json2dsv.js + json2dsv: bin/json2dsv.js + json2tsv: bin/json2dsv.js + tsv2csv: bin/dsv2dsv.js + tsv2json: bin/dsv2json.js + checksum: 10c0/10e6af9e331950ed258f34ab49ac1b7060128ef81dcf32afc790bd1f7e8c3cc2aac7f5f875250a83f21f39bb5925fbd0872bb209f8aca32b3b77d32bab8a65ab + languageName: node + linkType: hard + +"d3-ease@npm:1 - 3, d3-ease@npm:3, d3-ease@npm:^3.0.1": version: 3.0.1 resolution: "d3-ease@npm:3.0.1" checksum: 10c0/fec8ef826c0cc35cda3092c6841e07672868b1839fcaf556e19266a3a37e6bc7977d8298c0fcb9885e7799bfdcef7db1baaba9cd4dcf4bc5e952cf78574a88b0 languageName: node linkType: hard +"d3-fetch@npm:3": + version: 3.0.1 + resolution: "d3-fetch@npm:3.0.1" + dependencies: + d3-dsv: "npm:1 - 3" + checksum: 10c0/4f467a79bf290395ac0cbb5f7562483f6a18668adc4c8eb84c9d3eff048b6f6d3b6f55079ba1ebf1908dabe000c941d46be447f8d78453b2dad5fb59fb6aa93b + languageName: node + linkType: hard + +"d3-force@npm:3": + version: 3.0.0 + resolution: "d3-force@npm:3.0.0" + dependencies: + d3-dispatch: "npm:1 - 3" + d3-quadtree: "npm:1 - 3" + d3-timer: "npm:1 - 3" + checksum: 10c0/220a16a1a1ac62ba56df61028896e4b52be89c81040d20229c876efc8852191482c233f8a52bb5a4e0875c321b8e5cb6413ef3dfa4d8fe79eeb7d52c587f52cf + languageName: node + linkType: hard + "d3-format@npm:1 - 3": version: 3.1.0 resolution: "d3-format@npm:3.1.0" @@ -12570,7 +13031,30 @@ __metadata: languageName: node linkType: hard -"d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:^3.0.1": +"d3-format@npm:3": + version: 3.1.2 + resolution: "d3-format@npm:3.1.2" + checksum: 10c0/0de452ae07585238e7f01607a7e0066665c34609652188b6ac7dc9f424f69465a425e07d16d79bd0e5955202ac7f241c66d0c76f68a79fc6f4857c94cf420652 + languageName: node + linkType: hard + +"d3-geo@npm:3": + version: 3.1.1 + resolution: "d3-geo@npm:3.1.1" + dependencies: + d3-array: "npm:2.5.0 - 3" + checksum: 10c0/d32270dd2dc8ac3ea63e8805d63239c4c8ec6c0d339d73b5e5a30a87f8f54db22a78fb434369799465eae169503b25f9a107c642c8a16c32a3285bc0e6d8e8c1 + languageName: node + linkType: hard + +"d3-hierarchy@npm:3": + version: 3.1.2 + resolution: "d3-hierarchy@npm:3.1.2" + checksum: 10c0/6dcdb480539644aa7fc0d72dfc7b03f99dfbcdf02714044e8c708577e0d5981deb9d3e99bbbb2d26422b55bcc342ac89a0fa2ea6c9d7302e2fc0951dd96f89cf + languageName: node + linkType: hard + +"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:3, d3-interpolate@npm:^3.0.1": version: 3.0.1 resolution: "d3-interpolate@npm:3.0.1" dependencies: @@ -12579,14 +13063,62 @@ __metadata: languageName: node linkType: hard -"d3-path@npm:^3.1.0": +"d3-path@npm:1": + version: 1.0.9 + resolution: "d3-path@npm:1.0.9" + checksum: 10c0/e35e84df5abc18091f585725b8235e1fa97efc287571585427d3a3597301e6c506dea56b11dfb3c06ca5858b3eb7f02c1bf4f6a716aa9eade01c41b92d497eb5 + languageName: node + linkType: hard + +"d3-path@npm:1 - 3, d3-path@npm:3, d3-path@npm:^3.1.0": version: 3.1.0 resolution: "d3-path@npm:3.1.0" checksum: 10c0/dc1d58ec87fa8319bd240cf7689995111a124b141428354e9637aa83059eb12e681f77187e0ada5dedfce346f7e3d1f903467ceb41b379bfd01cd8e31721f5da languageName: node linkType: hard -"d3-scale@npm:^4.0.2": +"d3-polygon@npm:3": + version: 3.0.1 + resolution: "d3-polygon@npm:3.0.1" + checksum: 10c0/e236aa7f33efa9a4072907af7dc119f85b150a0716759d4fe5f12f62573018264a6cbde8617fbfa6944a7ae48c1c0c8d3f39ae72e11f66dd471e9b5e668385df + languageName: node + linkType: hard + +"d3-quadtree@npm:1 - 3, d3-quadtree@npm:3": + version: 3.0.1 + resolution: "d3-quadtree@npm:3.0.1" + checksum: 10c0/18302d2548bfecaef788152397edec95a76400fd97d9d7f42a089ceb68d910f685c96579d74e3712d57477ed042b056881b47cd836a521de683c66f47ce89090 + languageName: node + linkType: hard + +"d3-random@npm:3": + version: 3.0.1 + resolution: "d3-random@npm:3.0.1" + checksum: 10c0/987a1a1bcbf26e6cf01fd89d5a265b463b2cea93560fc17d9b1c45e8ed6ff2db5924601bcceb808de24c94133f000039eb7fa1c469a7a844ccbf1170cbb25b41 + languageName: node + linkType: hard + +"d3-sankey@npm:^0.12.3": + version: 0.12.3 + resolution: "d3-sankey@npm:0.12.3" + dependencies: + d3-array: "npm:1 - 2" + d3-shape: "npm:^1.2.0" + checksum: 10c0/261debb01a13269f6fc53b9ebaef174a015d5ad646242c23995bf514498829ab8b8f920a7873724a7494288b46bea3ce7ebc5a920b745bc8ae4caa5885cf5204 + languageName: node + linkType: hard + +"d3-scale-chromatic@npm:3": + version: 3.1.0 + resolution: "d3-scale-chromatic@npm:3.1.0" + dependencies: + d3-color: "npm:1 - 3" + d3-interpolate: "npm:1 - 3" + checksum: 10c0/9a3f4671ab0b971f4a411b42180d7cf92bfe8e8584e637ce7e698d705e18d6d38efbd20ec64f60cc0dfe966c20d40fc172565bc28aaa2990c0a006360eed91af + languageName: node + linkType: hard + +"d3-scale@npm:4, d3-scale@npm:^4.0.2": version: 4.0.2 resolution: "d3-scale@npm:4.0.2" dependencies: @@ -12599,7 +13131,14 @@ __metadata: languageName: node linkType: hard -"d3-shape@npm:^3.1.0": +"d3-selection@npm:2 - 3, d3-selection@npm:3, d3-selection@npm:^3.0.0": + version: 3.0.0 + resolution: "d3-selection@npm:3.0.0" + checksum: 10c0/e59096bbe8f0cb0daa1001d9bdd6dbc93a688019abc97d1d8b37f85cd3c286a6875b22adea0931b0c88410d025563e1643019161a883c516acf50c190a11b56b + languageName: node + linkType: hard + +"d3-shape@npm:3, d3-shape@npm:^3.1.0": version: 3.2.0 resolution: "d3-shape@npm:3.2.0" dependencies: @@ -12608,7 +13147,16 @@ __metadata: languageName: node linkType: hard -"d3-time-format@npm:2 - 4": +"d3-shape@npm:^1.2.0": + version: 1.3.7 + resolution: "d3-shape@npm:1.3.7" + dependencies: + d3-path: "npm:1" + checksum: 10c0/548057ce59959815decb449f15632b08e2a1bdce208f9a37b5f98ec7629dda986c2356bc7582308405ce68aedae7d47b324df41507404df42afaf352907577ae + languageName: node + linkType: hard + +"d3-time-format@npm:2 - 4, d3-time-format@npm:4": version: 4.1.0 resolution: "d3-time-format@npm:4.1.0" dependencies: @@ -12617,7 +13165,7 @@ __metadata: languageName: node linkType: hard -"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:^3.0.0": +"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:3, d3-time@npm:^3.0.0": version: 3.1.0 resolution: "d3-time@npm:3.1.0" dependencies: @@ -12626,13 +13174,89 @@ __metadata: languageName: node linkType: hard -"d3-timer@npm:^3.0.1": +"d3-timer@npm:1 - 3, d3-timer@npm:3, d3-timer@npm:^3.0.1": version: 3.0.1 resolution: "d3-timer@npm:3.0.1" checksum: 10c0/d4c63cb4bb5461d7038aac561b097cd1c5673969b27cbdd0e87fa48d9300a538b9e6f39b4a7f0e3592ef4f963d858c8a9f0e92754db73116770856f2fc04561a languageName: node linkType: hard +"d3-transition@npm:2 - 3, d3-transition@npm:3, d3-transition@npm:^3.0.1": + version: 3.0.1 + resolution: "d3-transition@npm:3.0.1" + dependencies: + d3-color: "npm:1 - 3" + d3-dispatch: "npm:1 - 3" + d3-ease: "npm:1 - 3" + d3-interpolate: "npm:1 - 3" + d3-timer: "npm:1 - 3" + peerDependencies: + d3-selection: 2 - 3 + checksum: 10c0/4e74535dda7024aa43e141635b7522bb70cf9d3dfefed975eb643b36b864762eca67f88fafc2ca798174f83ca7c8a65e892624f824b3f65b8145c6a1a88dbbad + languageName: node + linkType: hard + +"d3-zoom@npm:3": + version: 3.0.0 + resolution: "d3-zoom@npm:3.0.0" + dependencies: + d3-dispatch: "npm:1 - 3" + d3-drag: "npm:2 - 3" + d3-interpolate: "npm:1 - 3" + d3-selection: "npm:2 - 3" + d3-transition: "npm:2 - 3" + checksum: 10c0/ee2036479049e70d8c783d594c444fe00e398246048e3f11a59755cd0e21de62ece3126181b0d7a31bf37bcf32fd726f83ae7dea4495ff86ec7736ce5ad36fd3 + languageName: node + linkType: hard + +"d3@npm:^7.9.0": + version: 7.9.0 + resolution: "d3@npm:7.9.0" + dependencies: + d3-array: "npm:3" + d3-axis: "npm:3" + d3-brush: "npm:3" + d3-chord: "npm:3" + d3-color: "npm:3" + d3-contour: "npm:4" + d3-delaunay: "npm:6" + d3-dispatch: "npm:3" + d3-drag: "npm:3" + d3-dsv: "npm:3" + d3-ease: "npm:3" + d3-fetch: "npm:3" + d3-force: "npm:3" + d3-format: "npm:3" + d3-geo: "npm:3" + d3-hierarchy: "npm:3" + d3-interpolate: "npm:3" + d3-path: "npm:3" + d3-polygon: "npm:3" + d3-quadtree: "npm:3" + d3-random: "npm:3" + d3-scale: "npm:4" + d3-scale-chromatic: "npm:3" + d3-selection: "npm:3" + d3-shape: "npm:3" + d3-time: "npm:3" + d3-time-format: "npm:4" + d3-timer: "npm:3" + d3-transition: "npm:3" + d3-zoom: "npm:3" + checksum: 10c0/3dd9c08c73cfaa69c70c49e603c85e049c3904664d9c79a1a52a0f52795828a1ff23592dc9a7b2257e711d68a615472a13103c212032f38e016d609796e087e8 + languageName: node + linkType: hard + +"dagre-d3-es@npm:7.0.14": + version: 7.0.14 + resolution: "dagre-d3-es@npm:7.0.14" + dependencies: + d3: "npm:^7.9.0" + lodash-es: "npm:^4.17.21" + checksum: 10c0/0dc91fc79300eb0a4eab5a48a76c2baf3ce439c389d19e2f015729bb57dafd75e1e9a4c2880daf016e81ee45caca7b21745c13b23b6cd2a786ce84767e88323e + languageName: node + linkType: hard + "damerau-levenshtein@npm:^1.0.8": version: 1.0.8 resolution: "damerau-levenshtein@npm:1.0.8" @@ -12704,6 +13328,13 @@ __metadata: languageName: node linkType: hard +"dayjs@npm:^1.11.20": + version: 1.11.21 + resolution: "dayjs@npm:1.11.21" + checksum: 10c0/bd97dfdc4bfea3c66268635690313828b386faa040fbc1f829ff42a2bd748b72c9d9b3c8f9616ce9e61fcb78923f1461a462c969c54b1084458ae1b715898fb0 + languageName: node + linkType: hard + "debounce-fn@npm:^6.0.0": version: 6.0.0 resolution: "debounce-fn@npm:6.0.0" @@ -12871,6 +13502,15 @@ __metadata: languageName: node linkType: hard +"delaunator@npm:5": + version: 5.1.0 + resolution: "delaunator@npm:5.1.0" + dependencies: + robust-predicates: "npm:^3.0.2" + checksum: 10c0/6489e3598212ab8759575e30f3ac26063471846e25c779048f441f2ccefd25efb9a52275e7e8e3dcc5bf5bc5c35169cf1de27f985d359fd5a24057be88fd1817 + languageName: node + linkType: hard + "delayed-stream@npm:~1.0.0": version: 1.0.0 resolution: "delayed-stream@npm:1.0.0" @@ -13029,7 +13669,7 @@ __metadata: languageName: node linkType: hard -"dompurify@npm:^3.3.2": +"dompurify@npm:^3.3.2, dompurify@npm:^3.3.3": version: 3.4.11 resolution: "dompurify@npm:3.4.11" dependencies: @@ -13561,6 +14201,20 @@ __metadata: languageName: node linkType: hard +"es-toolkit@npm:^1.45.1": + version: 1.48.1 + resolution: "es-toolkit@npm:1.48.1" + dependenciesMeta: + "@trivago/prettier-plugin-sort-imports@4.3.0": + unplugged: true + prettier-plugin-sort-re-exports@0.0.1: + unplugged: true + vitepress-plugin-sandpack@1.1.4: + unplugged: true + checksum: 10c0/29bfccb8aaf088f27ea91b5bf6448b8a46ef5d809139ad9335bea6919d0bc3aadc3206c957ab6c72d65864906fd75cf9ab41fbd936aaf69e58c6259d2821187d + languageName: node + linkType: hard + "esbuild-register@npm:3.6.0": version: 3.6.0 resolution: "esbuild-register@npm:3.6.0" @@ -15056,6 +15710,13 @@ __metadata: languageName: node linkType: hard +"hachure-fill@npm:^0.5.2": + version: 0.5.2 + resolution: "hachure-fill@npm:0.5.2" + checksum: 10c0/307e3b6f9f2d3c11a82099c3f71eecbb9c440c00c1f896ac1732c23e6dbff16a92bb893d222b8b721b89cf11e58649ca60b4c24e5663f705f877cefd40153429 + languageName: node + linkType: hard + "has-bigints@npm:^1.0.2": version: 1.1.0 resolution: "has-bigints@npm:1.1.0" @@ -15445,7 +16106,7 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2": +"iconv-lite@npm:0.6, iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" dependencies: @@ -15525,6 +16186,13 @@ __metadata: languageName: node linkType: hard +"import-meta-resolve@npm:^4.2.0": + version: 4.2.0 + resolution: "import-meta-resolve@npm:4.2.0" + checksum: 10c0/3ee8aeecb61d19b49d2703987f977e9d1c7d4ba47db615a570eaa02fe414f40dfa63f7b953e842cbe8470d26df6371332bfcf21b2fd92b0112f9fea80dde2c4c + languageName: node + linkType: hard + "imurmurhash@npm:^0.1.4": version: 0.1.4 resolution: "imurmurhash@npm:0.1.4" @@ -15594,6 +16262,13 @@ __metadata: languageName: node linkType: hard +"internmap@npm:^1.0.0": + version: 1.0.1 + resolution: "internmap@npm:1.0.1" + checksum: 10c0/60942be815ca19da643b6d4f23bd0bf4e8c97abbd080fb963fe67583b60bdfb3530448ad4486bae40810e92317bded9995cc31411218acc750d72cd4e8646eee + languageName: node + linkType: hard + "ioredis@npm:^5.4.1, ioredis@npm:^5.4.2": version: 5.6.0 resolution: "ioredis@npm:5.6.0" @@ -16350,6 +17025,17 @@ __metadata: languageName: node linkType: hard +"katex@npm:^0.16.45": + version: 0.16.47 + resolution: "katex@npm:0.16.47" + dependencies: + commander: "npm:^8.3.0" + bin: + katex: cli.js + checksum: 10c0/b10f4d0651c60771a48444879e4227255e26e2b2ec061b1ee4b08934863ad2324ba8dbb772455f7768aeb14dfcc13bcd309174a0ddd5ef954a607f644a197710 + languageName: node + linkType: hard + "keyv@npm:^4.5.4": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -16359,6 +17045,13 @@ __metadata: languageName: node linkType: hard +"khroma@npm:^2.1.0": + version: 2.1.0 + resolution: "khroma@npm:2.1.0" + checksum: 10c0/634d98753ff5d2540491cafeb708fc98de0d43f4e6795256d5c8f6e3ad77de93049ea41433928fda3697adf7bbe6fe27351858f6d23b78f8b5775ef314c59891 + languageName: node + linkType: hard + "kleur@npm:^3.0.3": version: 3.0.3 resolution: "kleur@npm:3.0.3" @@ -16460,6 +17153,20 @@ __metadata: languageName: node linkType: hard +"layout-base@npm:^1.0.0": + version: 1.0.2 + resolution: "layout-base@npm:1.0.2" + checksum: 10c0/2a55d0460fd9f6ed53d7e301b9eb3dea19bda03815d616a40665ce6dc75c1f4d62e1ca19a897da1cfaf6de1b91de59cd6f2f79ba1258f3d7fccc7d46ca7f3337 + languageName: node + linkType: hard + +"layout-base@npm:^2.0.0": + version: 2.0.1 + resolution: "layout-base@npm:2.0.1" + checksum: 10c0/a44df9ef3cbff9916a10f616635e22b5787c89fa62b2fec6f99e8e6ee512c7cebd22668ce32dab5a83c934ba0a309c51a678aa0b40d70853de6c357893c0a88b + languageName: node + linkType: hard + "leac@npm:^0.6.0": version: 0.6.0 resolution: "leac@npm:0.6.0" @@ -16688,6 +17395,13 @@ __metadata: languageName: node linkType: hard +"lodash-es@npm:^4.17.21": + version: 4.18.1 + resolution: "lodash-es@npm:4.18.1" + checksum: 10c0/35d4dcf87ef07f8d090f409447575800108057e360b445f590d0d25d09e3d1e33a163d2fc100d4d072b0f901d5e2fc533cd7c4bfd8eeb38a06abec693823c8b8 + languageName: node + linkType: hard + "lodash.camelcase@npm:^4.3.0": version: 4.3.0 resolution: "lodash.camelcase@npm:4.3.0" @@ -16943,6 +17657,15 @@ __metadata: languageName: node linkType: hard +"marked@npm:^16.3.0": + version: 16.4.2 + resolution: "marked@npm:16.4.2" + bin: + marked: bin/marked.js + checksum: 10c0/fc6051142172454f2023f3d6b31cca92879ec8e1b96457086a54c70354c74b00e1b6543a76a1fad6d399366f52b90a848f6ffb8e1d65a5baff87f3ba9b8f1847 + languageName: node + linkType: hard + "math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" @@ -17223,6 +17946,35 @@ __metadata: languageName: node linkType: hard +"mermaid@npm:^11.16.0": + version: 11.16.0 + resolution: "mermaid@npm:11.16.0" + dependencies: + "@braintree/sanitize-url": "npm:^7.1.2" + "@iconify/utils": "npm:^3.0.2" + "@mermaid-js/parser": "npm:^1.2.0" + "@types/d3": "npm:^7.4.3" + "@upsetjs/venn.js": "npm:^2.0.0" + cytoscape: "npm:^3.33.3" + cytoscape-cose-bilkent: "npm:^4.1.0" + cytoscape-fcose: "npm:^2.2.0" + d3: "npm:^7.9.0" + d3-sankey: "npm:^0.12.3" + dagre-d3-es: "npm:7.0.14" + dayjs: "npm:^1.11.20" + dompurify: "npm:^3.3.3" + es-toolkit: "npm:^1.45.1" + katex: "npm:^0.16.45" + khroma: "npm:^2.1.0" + marked: "npm:^16.3.0" + roughjs: "npm:^4.6.6" + stylis: "npm:^4.3.6" + ts-dedent: "npm:^2.2.0" + uuid: "npm:^11.1.0 || ^12 || ^13 || ^14.0.0" + checksum: 10c0/a14f9bc9db7f1dea65b0d6c0b920236d12ad2812f2a1b11c8b39b3dfb3c22bfce39c77f6a69493203b85880d8008d345c539efe7ae6a31d3dad512833ccfb517 + languageName: node + linkType: hard + "methods@npm:~1.1.2": version: 1.1.2 resolution: "methods@npm:1.1.2" @@ -18663,6 +19415,13 @@ __metadata: languageName: node linkType: hard +"package-manager-detector@npm:^1.3.0": + version: 1.6.0 + resolution: "package-manager-detector@npm:1.6.0" + checksum: 10c0/6419d0b840be64fd45bcdcb7a19f09b81b65456d5e7f7a3daac305a4c90643052122f6ac0308afe548ffee75e36148532a2002ea9d292754f1e385aa2e1ea03b + languageName: node + linkType: hard + "parent-module@npm:^1.0.0": version: 1.0.1 resolution: "parent-module@npm:1.0.1" @@ -18758,6 +19517,13 @@ __metadata: languageName: node linkType: hard +"path-data-parser@npm:0.1.0, path-data-parser@npm:^0.1.0": + version: 0.1.0 + resolution: "path-data-parser@npm:0.1.0" + checksum: 10c0/ba22d54669a8bc4a3df27431fe667900685585d1196085b803d0aa4066b83e709bbf2be7c1d2b56e706b49cc698231d55947c22abbfc4843ca424bbf8c985745 + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -19006,6 +19772,23 @@ __metadata: languageName: node linkType: hard +"points-on-curve@npm:0.2.0, points-on-curve@npm:^0.2.0": + version: 0.2.0 + resolution: "points-on-curve@npm:0.2.0" + checksum: 10c0/f0d92343fcc2ad1f48334633e580574c1e0e28038a756133e171e537f270d6d64203feada5ee556e36f448a1b46e0306dee07b30f589f4e3ad720f6ee38ef48c + languageName: node + linkType: hard + +"points-on-path@npm:^0.2.1": + version: 0.2.1 + resolution: "points-on-path@npm:0.2.1" + dependencies: + path-data-parser: "npm:0.1.0" + points-on-curve: "npm:0.2.0" + checksum: 10c0/a7010340f9f196976f61838e767bb7b0b7f6273ab4fb9eb37c61001fe26fbfc3fcd63c96d5e85b9a4ab579213ab366f2ddaaf60e2a9253e2b91a62db33f395ba + languageName: node + linkType: hard + "possible-typed-array-names@npm:^1.0.0": version: 1.1.0 resolution: "possible-typed-array-names@npm:1.1.0" @@ -20324,6 +21107,13 @@ __metadata: languageName: node linkType: hard +"robust-predicates@npm:^3.0.2": + version: 3.0.3 + resolution: "robust-predicates@npm:3.0.3" + checksum: 10c0/ae23a0318be809545f091a076818747848f144495491e24e6df5d767f2bad0a1501bbeac34e78b74681d1437ecff0585f593ebfe6c8d49a50e79c5053b962693 + languageName: node + linkType: hard + "rolldown@npm:1.0.3": version: 1.0.3 resolution: "rolldown@npm:1.0.3" @@ -20482,6 +21272,18 @@ __metadata: languageName: unknown linkType: soft +"roughjs@npm:^4.6.6": + version: 4.6.6 + resolution: "roughjs@npm:4.6.6" + dependencies: + hachure-fill: "npm:^0.5.2" + path-data-parser: "npm:^0.1.0" + points-on-curve: "npm:^0.2.0" + points-on-path: "npm:^0.2.1" + checksum: 10c0/68c11bf4516aa014cef2fe52426a9bab237c2f500d13e1a4f13b523cb5723667bf2d92b9619325efdc5bc2a193588ff5af8d51683df17cfb8720e96fe2b92b0c + languageName: node + linkType: hard + "router@npm:^2.2.0": version: 2.2.0 resolution: "router@npm:2.2.0" @@ -20560,6 +21362,13 @@ __metadata: languageName: node linkType: hard +"rw@npm:1": + version: 1.3.3 + resolution: "rw@npm:1.3.3" + checksum: 10c0/b1e1ef37d1e79d9dc7050787866e30b6ddcb2625149276045c262c6b4d53075ddc35f387a856a8e76f0d0df59f4cd58fe24707e40797ebee66e542b840ed6a53 + languageName: node + linkType: hard + "rxjs@npm:7.8.2": version: 7.8.2 resolution: "rxjs@npm:7.8.2" @@ -21810,6 +22619,13 @@ __metadata: languageName: node linkType: hard +"stylis@npm:^4.3.6": + version: 4.4.0 + resolution: "stylis@npm:4.4.0" + checksum: 10c0/259be096d90dfbfe903c8656dcb7591e52a421e577e950ef42ebd9ca02f387623a1165dd08761492fb6e92a7a562d62a53a694a10b0a2f6dcd7a0db107b4bf55 + languageName: node + linkType: hard + "sucrase@npm:^3.35.0": version: 3.35.0 resolution: "sucrase@npm:3.35.0" @@ -22061,6 +22877,13 @@ __metadata: languageName: node linkType: hard +"tinyexec@npm:^1.0.1": + version: 1.2.4 + resolution: "tinyexec@npm:1.2.4" + checksum: 10c0/153b8db6b080194b558ff145b9cffc36b80a6e07babd644dcfbe49c807eee668c876049d28bdee90b96304476f883352f2dad91b3f86bc23832532f4363e66ff + languageName: node + linkType: hard + "tinyexec@npm:^1.0.2": version: 1.1.1 resolution: "tinyexec@npm:1.1.1" @@ -22242,6 +23065,13 @@ __metadata: languageName: node linkType: hard +"ts-dedent@npm:^2.2.0": + version: 2.3.0 + resolution: "ts-dedent@npm:2.3.0" + checksum: 10c0/bfc3331e0740191c0134fb526e7f01e16657e50955c9395de14703c6ef17119c91651fa044cf558dc9c1dbb0fe1b2a74e303aeebcbd5e3b31acd14f72f545a54 + languageName: node + linkType: hard + "ts-essentials@npm:>=10.0.0": version: 10.1.1 resolution: "ts-essentials@npm:10.1.1" From 83347e63485ac1d36490ee0c95c67cf244310fef Mon Sep 17 00:00:00 2001 From: whoisthey Date: Thu, 25 Jun 2026 15:30:22 -0700 Subject: [PATCH 02/15] docs: add changelog entry for mermaid diagrams in Ask [#1369] --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 689718d36..d7e3c80e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added per-step token cost tracking and estimated tool call token usage to Ask Sourcebot chat history. [#1353](https://github.com/sourcebot-dev/sourcebot/pull/1353) +- [EE] Added mermaid diagram rendering to Ask Sourcebot answers, with pan/zoom, copy/export, in-thread deep links, and an interleaved right-panel view. [#1369](https://github.com/sourcebot-dev/sourcebot/pull/1369) ### Fixed - Send anonymous server-side PostHog events as personless so unauthenticated requests don't inflate person counts. [#1367](https://github.com/sourcebot-dev/sourcebot/pull/1367) From 13e906b90ec214727a8545cbcf46ab6bd417f638 Mon Sep 17 00:00:00 2001 From: whoisthey Date: Thu, 25 Jun 2026 15:35:30 -0700 Subject: [PATCH 03/15] chore(web): tidy comments (fix stale tab reference, drop historical notes) --- .../features/chat/components/chatThread/mermaidDiagram.tsx | 6 +++--- .../components/chatThread/referencedSourcesListView.tsx | 3 +-- packages/web/src/ee/features/chat/diagramPanelContext.tsx | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/web/src/ee/features/chat/components/chatThread/mermaidDiagram.tsx b/packages/web/src/ee/features/chat/components/chatThread/mermaidDiagram.tsx index 54d939789..f64a2fdff 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/mermaidDiagram.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/mermaidDiagram.tsx @@ -94,7 +94,7 @@ const getSvgBaseSize = (svgEl: Element): { width: number; height: number } => { * Rasterize a mermaid SVG to a high-resolution PNG. We bake explicit pixel * dimensions (scaled up from the SVG's viewBox) into the SVG before drawing so * the browser rasterizes the vector at full resolution instead of upscaling a - * small default bitmap (which produced blurry exports). + * small default bitmap. */ const svgToPngBlob = (svg: string, background: string): Promise => { return new Promise((resolve) => { @@ -393,7 +393,7 @@ export const MermaidDiagram = ({ // the panel sizes to the diagram's full height and lets the right pane scroll. const viewportSizeClass = variant === 'panel' ? '' : 'max-h-[480px]'; - // On-hover controls overlaid on the diagram (no separate header bar). + // On-hover controls overlaid on the diagram. const actions = ( <> {variant === 'inline' && diagramPanel && ( @@ -479,7 +479,7 @@ export const MermaidDiagram = ({ // Invalid / mid-stream source: show it as code until it renders cleanly. ) : ( - // Initial render pending: a neutral placeholder avoids a code-to-diagram pop-in. + // Initial render in flight: show a neutral placeholder rather than the source.
diff --git a/packages/web/src/ee/features/chat/components/chatThread/referencedSourcesListView.tsx b/packages/web/src/ee/features/chat/components/chatThread/referencedSourcesListView.tsx index 46b3fc436..fac30ac3e 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/referencedSourcesListView.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/referencedSourcesListView.tsx @@ -49,8 +49,7 @@ const ReferencedSourcesListViewComponent = ({ const [collapsedFileIds, setCollapsedFileIds] = useState([]); // Diagrams render pre-collapsed; expand by id (or via reveal-from-answer). const [expandedDiagramIds, setExpandedDiagramIds] = useState([]); - // Transient highlight applied when a diagram is revealed from the answer - // (cleared after a moment so the panel item isn't permanently outlined). + // Transient highlight applied when a diagram is revealed from the answer. const [highlightedDiagramId, setHighlightedDiagramId] = useState(undefined); // When a diagram is revealed from the answer, expand it, scroll it into diff --git a/packages/web/src/ee/features/chat/diagramPanelContext.tsx b/packages/web/src/ee/features/chat/diagramPanelContext.tsx index 7a22d7ec1..a4e048ce6 100644 --- a/packages/web/src/ee/features/chat/diagramPanelContext.tsx +++ b/packages/web/src/ee/features/chat/diagramPanelContext.tsx @@ -3,8 +3,8 @@ import { createContext, useContext } from "react"; export interface DiagramPanelContextValue { - // Reveal the right-panel mirror of the given diagram (switch to the - // Diagrams tab and scroll it into view). + // Reveal the right-panel mirror of the given diagram (expand it and scroll + // it into view). revealInPanel: (diagramId: string) => void; } From ee54b415775a446560c85a84d3c1e303a57a908b Mon Sep 17 00:00:00 2001 From: whoisthey Date: Thu, 25 Jun 2026 17:16:39 -0700 Subject: [PATCH 04/15] feat(web): inline diagram references, shimmer, and fullscreen fit Make the right "evidence" panel the canonical, default-expanded render for mermaid diagrams and turn the inline answer occurrence into a compact "Diagram"/title reference button that scrolls to and focuses its panel counterpart on click, with hover-sync, mirroring the file-reference chips. The raw mermaid fence in the answer text is untouched, so copy stays valid. Also: - Let the model name diagrams via a mermaid frontmatter title (used as the chip/panel label, falling back to "Diagram N"), with prompt guidance to quote titles containing special characters. - Show a shine-sweep shimmer on the in-progress diagram chip while streaming. - Fix fullscreen zoom to fit-to-surface on open (measured from the rendered SVG), add cursor-anchored scroll-to-zoom, and make the readout relative so the fitted view reads 100%. - Fix the right panel hover ring being clipped at the top edge. Co-authored-by: Cursor --- .../src/components/ui/animatedShinyText.tsx | 39 +++++ packages/web/src/ee/features/chat/agent.ts | 9 ++ .../chatThread/chatThreadListItem.tsx | 19 ++- .../chatThread/diagramPanelListItem.tsx | 12 +- .../chatThread/diagramReferenceChip.tsx | 89 ++++++++++++ .../chatThread/markdownRenderer.tsx | 6 +- .../components/chatThread/mermaidDiagram.tsx | 136 +++++++++++++++++- .../chatThread/referencedSourcesListView.tsx | 22 +-- .../ee/features/chat/diagramPanelContext.tsx | 9 ++ .../web/src/ee/features/chat/diagramUtils.ts | 36 +++++ packages/web/tailwind.config.ts | 11 +- 11 files changed, 368 insertions(+), 20 deletions(-) create mode 100644 packages/web/src/components/ui/animatedShinyText.tsx create mode 100644 packages/web/src/ee/features/chat/components/chatThread/diagramReferenceChip.tsx diff --git a/packages/web/src/components/ui/animatedShinyText.tsx b/packages/web/src/components/ui/animatedShinyText.tsx new file mode 100644 index 000000000..f3027446d --- /dev/null +++ b/packages/web/src/components/ui/animatedShinyText.tsx @@ -0,0 +1,39 @@ +import { cn } from "@/lib/utils"; +import { ComponentPropsWithoutRef, CSSProperties, FC } from "react"; + +interface AnimatedShinyTextProps extends ComponentPropsWithoutRef<"span"> { + shimmerWidth?: number; +} + +// Classic "shine sweep" animated text (magicui AnimatedShinyText). A bright +// band moves across the text via an animated background-position on a +// background-clipped gradient. See the `shiny-text` keyframes in +// tailwind.config.ts. +export const AnimatedShinyText: FC = ({ + children, + className, + shimmerWidth = 100, + ...props +}) => { + return ( + + {children} + + ); +}; diff --git a/packages/web/src/ee/features/chat/agent.ts b/packages/web/src/ee/features/chat/agent.ts index 265d756e8..afd00aeef 100644 --- a/packages/web/src/ee/features/chat/agent.ts +++ b/packages/web/src/ee/features/chat/agent.ts @@ -658,7 +658,16 @@ const createPrompt = ({ **Diagrams:** - Proactively include a diagram when a visual communicates the answer better than prose, e.g. architecture overviews, control/data flow, sequences of interactions, state machines, or entity relationships. Use your judgement, do not force a diagram for simple answers. - Render diagrams as a \`\`\`mermaid fenced code block. This is an explicit exception to the rule above: it is OK to use a \`\`\`mermaid block even though you otherwise prefer \`${FILE_REFERENCE_PREFIX}\` over code blocks. Continue to use \`${FILE_REFERENCE_PREFIX}\` for code references in your prose. + - Give every diagram a short, descriptive, human-readable name via a mermaid YAML frontmatter \`title\` placed at the very top of the \`\`\`mermaid block, before the diagram type declaration. This name is shown as the diagram's label in the answer and the side panel (it falls back to a generic "Diagram N" if omitted). Keep the title plain text; if it must contain special characters such as a colon, wrap the value in double quotes so the frontmatter stays valid YAML (e.g. \`title: "Auth: login flow"\`). Invalid frontmatter will prevent the diagram from rendering. For example: + \`\`\`mermaid + --- + title: Authentication Flow + --- + flowchart TD + ... + \`\`\` - Mermaid syntax rules: do NOT put spaces or special characters in node IDs (use camelCase or underscores), wrap node and edge labels that contain special characters (parentheses, commas, colons) in double quotes, avoid reserved keywords (\`end\`, \`graph\`, \`subgraph\`) as node IDs, and do NOT use \`click\` events or custom colors/styling (the theme is applied automatically). + - You can group related nodes into a subgraph. Open it with the exact form \`subgraph someId["Label"]\` (the literal keyword \`subgraph\`, then a unique camelCase id, then the quoted label) and close it with \`end\`; the keyword and id are both required or the diagram will not render. **Example answer structure:** \`\`\`markdown diff --git a/packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx b/packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx index 7761482d6..47a7d856d 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx @@ -331,6 +331,13 @@ const ChatThreadListItemComponent = forwardRef(undefined); + const [hoveredDiagramId, setHoveredDiagramId] = useState(undefined); + + // Maps a diagram id to its position in order of appearance (matches the + // index the right panel assigns), used for the "Diagram N" label fallback. + const diagramIndexById = useMemo(() => { + return new Map(diagrams.map((diagram, i) => [diagram.id, i])); + }, [diagrams]); // Reveal a diagram in the right panel: the panel list expands it and scrolls // it into view when `selectedDiagramId` changes. @@ -343,7 +350,16 @@ const ChatThreadListItemComponent = forwardRef ({ revealInPanel: revealDiagramInPanel }), [revealDiagramInPanel]); + const getDiagramIndex = useCallback((diagramId: string) => { + return diagramIndexById.get(diagramId) ?? -1; + }, [diagramIndexById]); + + const diagramPanelContextValue = useMemo(() => ({ + revealInPanel: revealDiagramInPanel, + getDiagramIndex, + onHoverDiagram: setHoveredDiagramId, + isStreaming: isNetworkActive, + }), [revealDiagramInPanel, getDiagramIndex, isNetworkActive]); // Extract the file sources that are referenced by the answer part. const referencedFileSources = useMemo(() => { @@ -434,6 +450,7 @@ const ChatThreadListItemComponent = forwardRef ); diff --git a/packages/web/src/ee/features/chat/components/chatThread/diagramPanelListItem.tsx b/packages/web/src/ee/features/chat/components/chatThread/diagramPanelListItem.tsx index 05659f2b7..f877bd619 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/diagramPanelListItem.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/diagramPanelListItem.tsx @@ -5,12 +5,14 @@ import { cn } from "@/lib/utils"; import { ExtractedDiagram } from "@/ee/features/chat/useExtractDiagrams"; import { ChevronDown, ChevronRight, CornerUpLeft, Workflow } from "lucide-react"; import { MermaidDiagram } from "./mermaidDiagram"; +import { getDiagramTitle } from "@/ee/features/chat/diagramUtils"; interface DiagramPanelListItemProps { diagram: ExtractedDiagram; index: number; isExpanded: boolean; isHighlighted: boolean; + isHovered: boolean; onToggle: () => void; onJumpToInline: () => void; } @@ -20,13 +22,19 @@ export const DiagramPanelListItem = ({ index, isExpanded, isHighlighted, + isHovered, onToggle, onJumpToInline, }: DiagramPanelListItemProps) => { + const label = getDiagramTitle(diagram.code) ?? `Diagram ${index + 1}`; + return (
+ ); +}; diff --git a/packages/web/src/ee/features/chat/components/chatThread/markdownRenderer.tsx b/packages/web/src/ee/features/chat/components/chatThread/markdownRenderer.tsx index 1444f81ef..012e1e682 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/markdownRenderer.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/markdownRenderer.tsx @@ -18,7 +18,7 @@ import type { PluggableList, Plugin } from "unified"; import { visit } from 'unist-util-visit'; import { CodeBlock } from './codeBlock'; import { LinearIssueCard } from './linearIssueCard'; -import { MermaidDiagram } from './mermaidDiagram'; +import { DiagramReferenceChip } from './diagramReferenceChip'; import { FILE_REFERENCE_REGEX } from '@/features/chat/constants'; import { createFileReference } from '@/features/chat/utils'; import isEqual from "fast-deep-equal/react"; @@ -211,8 +211,10 @@ const MarkdownRendererComponent = forwardRef + ) } diff --git a/packages/web/src/ee/features/chat/components/chatThread/mermaidDiagram.tsx b/packages/web/src/ee/features/chat/components/chatThread/mermaidDiagram.tsx index f64a2fdff..5ac943613 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/mermaidDiagram.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/mermaidDiagram.tsx @@ -150,7 +150,7 @@ const svgToPngBlob = (svg: string, background: string): Promise => }; const ZOOM_MIN = 0.25; -const ZOOM_MAX = 4; +const ZOOM_MAX = 8; const ZOOM_STEP = 0.25; /** @@ -161,8 +161,55 @@ const ZOOM_STEP = 0.25; const DiagramViewport = ({ svg, className, controlsClassName, actions, fill, forceControlsVisible }: { svg: string; className?: string; controlsClassName?: string; actions?: ReactNode; fill?: boolean; forceControlsVisible?: boolean }) => { const [zoom, setZoom] = useState(1); const [offset, setOffset] = useState({ x: 0, y: 0 }); + // The scale treated as the "0%" baseline for the zoom readout. In + // fullscreen this is the fit scale (so the awkward absolute fit percentage + // reads as a clean 0%); inline it stays 1 (1:1). + const [baseScale, setBaseScale] = useState(1); const draggingRef = useRef(false); const startRef = useRef({ x: 0, y: 0 }); + const surfaceRef = useRef(null); + const contentRef = useRef(null); + const zoomRef = useRef(1); + useEffect(() => { + zoomRef.current = zoom; + }, [zoom]); + const offsetRef = useRef(offset); + useEffect(() => { + offsetRef.current = offset; + }, [offset]); + + // Scale the diagram to fill the available surface (with a small margin), + // so a small diagram doesn't open tiny in fullscreen and a large one is + // brought fully into view. Used for the fullscreen (`fill`) viewport. + const fitToSurface = useCallback(() => { + const surface = surfaceRef.current; + const svgEl = contentRef.current?.querySelector('svg'); + if (!surface || !svgEl) { + return false; + } + + const availWidth = surface.clientWidth; + const availHeight = surface.clientHeight; + // Measure the SVG as currently rendered, then divide out the active + // zoom to recover its unscaled (1:1) size. Mermaid sizes the SVG via an + // inline max-width that is typically far smaller than its viewBox, so + // the rendered size — not the viewBox — is what we must fit against. + const rect = svgEl.getBoundingClientRect(); + const currentZoom = zoomRef.current || 1; + const baseWidth = rect.width / currentZoom; + const baseHeight = rect.height / currentZoom; + if (!availWidth || !availHeight || !baseWidth || !baseHeight) { + return false; + } + + const next = Math.min(availWidth / baseWidth, availHeight / baseHeight) * 0.95; + const clamped = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, next)); + setZoom(clamped); + // Anchor the zoom readout's 0% to the fitted scale. + setBaseScale(clamped); + setOffset({ x: 0, y: 0 }); + return true; + }, []); const onPointerDown = useCallback((e: React.PointerEvent) => { draggingRef.current = true; @@ -187,13 +234,91 @@ const DiagramViewport = ({ svg, className, controlsClassName, actions, fill, for }, []); const reset = useCallback(() => { + // In fullscreen, "reset" returns to the fitted view rather than 1:1. + if (fill && fitToSurface()) { + return; + } setZoom(1); setOffset({ x: 0, y: 0 }); - }, []); + }, [fill, fitToSurface]); + + // Auto fit-to-surface once the fullscreen viewport has a measurable size + // and the SVG is in the DOM. We fit a single time (the user can pan/zoom + // freely afterwards, and "reset" re-fits on demand). + useEffect(() => { + if (!fill) { + return; + } + let fitted = false; + const tryFit = () => { + if (!fitted && fitToSurface()) { + fitted = true; + observer.disconnect(); + } + }; + const observer = new ResizeObserver(tryFit); + if (surfaceRef.current) { + observer.observe(surfaceRef.current); + } + const handle = requestAnimationFrame(tryFit); + return () => { + observer.disconnect(); + cancelAnimationFrame(handle); + }; + }, [fill, svg, fitToSurface]); + + // Fullscreen: scroll/trackpad wheel zooms toward the cursor. A non-passive + // native listener is required so we can preventDefault the page/scroll-area + // from scrolling underneath. + useEffect(() => { + if (!fill) { + return; + } + const surface = surfaceRef.current; + if (!surface) { + return; + } + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + const rect = surface.getBoundingClientRect(); + // Cursor position relative to the surface center (the transform origin). + const pointerX = e.clientX - (rect.left + rect.width / 2); + const pointerY = e.clientY - (rect.top + rect.height / 2); + + const prevZoom = zoomRef.current; + const nextZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, prevZoom * Math.exp(-e.deltaY * 0.0015))); + if (nextZoom === prevZoom) { + return; + } + + // Keep the content point under the cursor stationary while zooming. + const ratio = nextZoom / prevZoom; + const prevOffset = offsetRef.current; + setZoom(nextZoom); + setOffset({ + x: pointerX - ratio * (pointerX - prevOffset.x), + y: pointerY - ratio * (pointerY - prevOffset.y), + }); + }; + + surface.addEventListener('wheel', handleWheel, { passive: false }); + return () => surface.removeEventListener('wheel', handleWheel); + }, [fill]); + + // In fullscreen, zoom in clean 10%-of-fit increments so the relative + // readout stays round; inline keeps the absolute 0.25 step. + const zoomStep = fill ? Math.max(0.01, baseScale * 0.1) : ZOOM_STEP; + const zoomOut = () => setZoom((z) => Math.max(ZOOM_MIN, z - zoomStep)); + const zoomIn = () => setZoom((z) => Math.min(ZOOM_MAX, z + zoomStep)); + // The readout is relative to the baseline scale, so the fitted fullscreen + // view reads 100% (matching the inline 1:1 baseline, whose baseScale is 1). + const zoomLabel = `${Math.round((zoom / baseScale) * 100)}%`; return (
setZoom((z) => Math.max(ZOOM_MIN, z - ZOOM_STEP))} + onClick={zoomOut} aria-label="Zoom out" > - {Math.round(zoom * 100)}% + {zoomLabel} - )} - {onJump && ( - - )}
diff --git a/packages/web/src/ee/features/chat/components/chatThread/diagramReferenceChip.tsx b/packages/web/src/ee/features/chat/components/chatThread/diagramReferenceChip.tsx index 0110a6254..27c65aa0f 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/diagramReferenceChip.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/diagramReferenceChip.tsx @@ -5,7 +5,7 @@ import { Workflow } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import { getDiagramAnchorId, getDiagramId, getDiagramTitle } from '@/ee/features/chat/diagramUtils'; import { useDiagramPanel } from '@/ee/features/chat/diagramPanelContext'; -import { AnimatedShinyText } from '@/components/ui/animatedShinyText'; +import { TextShimmer } from '@/components/ui/textShimmer'; import useCaptureEvent from '@/hooks/useCaptureEvent'; interface DiagramReferenceChipProps { @@ -85,7 +85,7 @@ export const DiagramReferenceChip = ({ code }: DiagramReferenceChipProps) => { > {isGenerating ? ( - {label} + {label} ) : ( {label} )} diff --git a/packages/web/src/ee/features/chat/components/chatThread/referencedFileSourceListItem.tsx b/packages/web/src/ee/features/chat/components/chatThread/referencedFileSourceListItem.tsx index e2e4f68fc..4d7664827 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/referencedFileSourceListItem.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/referencedFileSourceListItem.tsx @@ -125,12 +125,12 @@ const ReferencedFileSourceListItemComponent = ({ }, [references, selectedReference?.id, selectedReference?.range]); return ( -
+
{/* Sentinel element to scroll to when collapsing a file */}
{/* Sticky header outside the bordered container */} -
onExpandedChanged(!isExpanded)} /> @@ -149,7 +149,7 @@ const ReferencedFileSourceListItemComponent = ({ {/* Code container */} {/* @note: don't conditionally render here since we want to maintain state */} -
diff --git a/packages/web/tailwind.config.ts b/packages/web/tailwind.config.ts index eb6ee21be..9ab2ba665 100644 --- a/packages/web/tailwind.config.ts +++ b/packages/web/tailwind.config.ts @@ -145,14 +145,6 @@ const config = { to: { height: '0' } - }, - 'shiny-text': { - '0%, 90%, 100%': { - 'background-position': 'calc(-100% - var(--shiny-width)) 0' - }, - '30%, 60%': { - 'background-position': 'calc(100% + var(--shiny-width)) 0' - } } }, animation: { @@ -160,8 +152,7 @@ const config = { 'accordion-up': 'accordion-up 0.2s ease-out', 'spin-slow': 'spin 1.5s linear infinite', 'bounce-slow': 'bounce 1.5s linear infinite', - 'ping-slow': 'ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite', - 'shiny-text': 'shiny-text 3s infinite' + 'ping-slow': 'ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite' } } }, diff --git a/yarn.lock b/yarn.lock index 3b06a9e60..ce21f594b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9492,6 +9492,7 @@ __metadata: mermaid: "npm:^11.16.0" micromatch: "npm:^4.0.8" minidenticons: "npm:^4.2.1" + motion: "npm:^12.42.0" next: "npm:^16.2.6" next-auth: "npm:^5.0.0-beta.30" next-navigation-guard: "npm:^0.2.0" @@ -15249,6 +15250,28 @@ __metadata: languageName: node linkType: hard +"framer-motion@npm:^12.42.0": + version: 12.42.0 + resolution: "framer-motion@npm:12.42.0" + dependencies: + motion-dom: "npm:^12.42.0" + motion-utils: "npm:^12.39.0" + tslib: "npm:^2.4.0" + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 10c0/c425fa953615f3a54a0a3bb1dd7030d68613436aec1357bdb8b4752e9c46e0c9afe3c6d7ec2c2e730ff1978900575060480cb57ac1957baecd35093108e711bc + languageName: node + linkType: hard + "fresh@npm:0.5.2": version: 0.5.2 resolution: "fresh@npm:0.5.2" @@ -18539,6 +18562,43 @@ __metadata: languageName: node linkType: hard +"motion-dom@npm:^12.42.0": + version: 12.42.0 + resolution: "motion-dom@npm:12.42.0" + dependencies: + motion-utils: "npm:^12.39.0" + checksum: 10c0/c6cfe46ecce0bf2ffb4dcbc5d8b92777f0507b273bddc0a7da8cf2e043ee3db3082034138d801bbd581d1111a8d12465df9d7bb21ab0ea3e35cb2aac43e08d4e + languageName: node + linkType: hard + +"motion-utils@npm:^12.39.0": + version: 12.39.0 + resolution: "motion-utils@npm:12.39.0" + checksum: 10c0/6d7a2a2cc0797b72410a666a9cc1c201c8e39bf9669670464e433fe1e72af5f0217154c869867b34fbadf3664cf222c0d022bbc4eed7927f201ae971918e7440 + languageName: node + linkType: hard + +"motion@npm:^12.42.0": + version: 12.42.0 + resolution: "motion@npm:12.42.0" + dependencies: + framer-motion: "npm:^12.42.0" + tslib: "npm:^2.4.0" + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 10c0/5589172986a3a0bd3c11295d29190a01ef82a3a8b995b3db5cfe10a95f83a57ca72d5180034082c7ffee6c1389514342c330b147369ef6570b71d02cb482825c + languageName: node + linkType: hard + "ms@npm:2.0.0": version: 2.0.0 resolution: "ms@npm:2.0.0" From 61ad22c6f0ad4744f6dd1fe3963466c8cb68d890 Mon Sep 17 00:00:00 2001 From: whoisthey Date: Fri, 26 Jun 2026 13:11:02 -0700 Subject: [PATCH 14/15] remove dupey handling of diagrams and coalesce diagram and references into common panel items --- .../chatThread/chatThreadListItem.tsx | 154 +++++------------- .../chatThread/diagramPanelListItem.tsx | 2 +- .../chatThread/diagramReferenceChip.tsx | 24 +-- .../components/chatThread/mermaidDiagram.tsx | 8 +- .../chatThread/referencedSourcesListView.tsx | 44 +++-- .../ee/features/chat/diagramPanelContext.tsx | 24 --- .../web/src/ee/features/chat/panelContext.tsx | 41 +++++ .../ee/features/chat/useExtractDiagrams.ts | 48 ------ .../ee/features/chat/useExtractPanelItems.ts | 114 +++++++++++++ 9 files changed, 238 insertions(+), 221 deletions(-) delete mode 100644 packages/web/src/ee/features/chat/diagramPanelContext.tsx create mode 100644 packages/web/src/ee/features/chat/panelContext.tsx delete mode 100644 packages/web/src/ee/features/chat/useExtractDiagrams.ts create mode 100644 packages/web/src/ee/features/chat/useExtractPanelItems.ts diff --git a/packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx b/packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx index b0b890f45..da9e1c835 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx @@ -6,19 +6,18 @@ import { Skeleton } from '@/components/ui/skeleton'; import { CheckCircle, Loader2 } from 'lucide-react'; import { CSSProperties, forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import scrollIntoView from 'scroll-into-view-if-needed'; -import { FileSource, Reference, referenceSchema, SBChatMessage, Source } from "@/features/chat/types"; +import { Reference, referenceSchema, SBChatMessage, Source } from "@/features/chat/types"; import { useExtractReferences } from '../../useExtractReferences'; -import { createFileReference, getAnswerPartFromAssistantMessage, getLastStepParts, groupMessageIntoSteps, isSBChatToolPart, repairReferences, tryResolveFileReference } from '@/features/chat/utils'; +import { getAnswerPartFromAssistantMessage, getLastStepParts, groupMessageIntoSteps, isSBChatToolPart, repairReferences } from '@/features/chat/utils'; import { AnswerCard } from './answerCard'; import { DetailsCard } from './detailsCard'; import { ApprovalRequestedToolPart, ToolApprovalBanner } from './toolApprovalBanner'; import { MarkdownRenderer, REFERENCE_PAYLOAD_ATTRIBUTE } from './markdownRenderer'; -import { PanelItem, ReferencedSourcesListView } from './referencedSourcesListView'; -import { useExtractDiagrams } from '../../useExtractDiagrams'; -import { DiagramPanelContext } from '../../diagramPanelContext'; -import { getDiagramId, MERMAID_BLOCK_REGEX } from '../../diagramUtils'; +import { ReferencedSourcesListView } from './referencedSourcesListView'; +import { useExtractPanelItems } from '../../useExtractPanelItems'; +import { PanelContext, PanelContextValue, PanelSelection } from '../../panelContext'; import isEqual from "fast-deep-equal/react"; -import { ANSWER_TAG, FILE_REFERENCE_REGEX } from '@/features/chat/constants'; +import { ANSWER_TAG } from '@/features/chat/constants'; interface ChatThreadListItemProps { userMessage: SBChatMessage; @@ -45,8 +44,22 @@ const ChatThreadListItemComponent = forwardRef(null); const answerRef = useRef(null); - const [hoveredReference, setHoveredReference] = useState(undefined); - const [selectedReference, setSelectedReference] = useState(undefined); + // Unified panel selection/hover: a single selection model shared by inline + // file-reference citations and diagrams (see panelContext.ts). Only one + // thing is selected/hovered at a time. + const [selected, setSelected] = useState(undefined); + const [hovered, setHovered] = useState(undefined); + + const selectedReference = useMemo(() => (selected?.kind === 'reference' ? selected.reference : undefined), [selected]); + const hoveredReference = useMemo(() => (hovered?.kind === 'reference' ? hovered.reference : undefined), [hovered]); + + const setSelectedReference = useCallback((reference?: Reference) => { + setSelected(reference ? { kind: 'reference', reference } : undefined); + }, []); + const setHoveredReference = useCallback((reference?: Reference) => { + setHovered(reference ? { kind: 'reference', reference } : undefined); + }, []); + const [isDetailsPanelExpanded, _setIsDetailsPanelExpanded] = useState(isNetworkActive); const hasAutoCollapsed = useRef(false); const userHasManuallyExpanded = useRef(false); @@ -328,10 +341,7 @@ const ChatThreadListItemComponent = forwardRef(undefined); - const [hoveredDiagramId, setHoveredDiagramId] = useState(undefined); + const { diagrams, referencedFileSources, orderedItems } = useExtractPanelItems(answerPart, references, sources); // Maps a diagram id to its position in order of appearance (matches the // index the right panel assigns), used for the "Diagram N" label fallback. @@ -340,10 +350,15 @@ const ChatThreadListItemComponent = forwardRef { - setSelectedDiagramId(undefined); - requestAnimationFrame(() => setSelectedDiagramId(diagramId)); + // it into view when the selection changes. Clearing first lets the same + // diagram be re-revealed (re-clicking a chip re-scrolls). + const revealDiagram = useCallback((diagramId: string) => { + setSelected(undefined); + requestAnimationFrame(() => setSelected({ kind: 'diagram', diagramId })); + }, []); + + const setHoveredDiagram = useCallback((diagramId?: string) => { + setHovered(diagramId ? { kind: 'diagram', diagramId } : undefined); }, []); const jumpToInlineDiagram = useCallback((diagramId: string) => { @@ -354,110 +369,31 @@ const ChatThreadListItemComponent = forwardRef ({ + const panelContextValue = useMemo(() => ({ chatId, - revealInPanel: revealDiagramInPanel, - getDiagramIndex, - onHoverDiagram: setHoveredDiagramId, isStreaming: isNetworkActive, - }), [chatId, revealDiagramInPanel, getDiagramIndex, isNetworkActive]); - - // Extract the file sources that are referenced by the answer part. - const referencedFileSources = useMemo(() => { - const fileSources = sources.filter((source) => source.type === 'file'); - - return references - .filter((reference) => reference.type === 'file') - .map((reference) => tryResolveFileReference(reference, fileSources)) - .filter((file) => file !== undefined) - // de-duplicate files - .filter((file, index, self) => - index === self.findIndex((t) => - t?.path === file?.path - && t?.repo === file?.repo - && t?.revision === file?.revision - ) - ); - }, [references, sources]); - - // Interleave file sources and diagrams by their order of appearance in the - // answer, via a single combined scan over the answer text. - const orderedPanelItems = useMemo(() => { - const text = answerPart?.text ?? ''; - const items: PanelItem[] = []; - const seenSources = new Set(); - const seenDiagrams = new Set(); - const diagramById = new Map(diagrams.map((diagram) => [diagram.id, diagram])); - const diagramIndexById = new Map(diagrams.map((diagram, i) => [diagram.id, i])); - const sourceKey = (source: FileSource) => `${source.repo}::${source.path}::${source.revision}`; - - const combined = new RegExp(`${MERMAID_BLOCK_REGEX.source}|${FILE_REFERENCE_REGEX.source}`, 'g'); - let match: RegExpExecArray | null; - while ((match = combined.exec(text)) !== null) { - if (match[1] !== undefined) { - const code = match[1].trim(); - if (!code) { - continue; - } - const id = getDiagramId(code); - const diagram = diagramById.get(id); - if (!diagram || seenDiagrams.has(id)) { - continue; - } - seenDiagrams.add(id); - items.push({ kind: 'diagram', diagram, diagramIndex: diagramIndexById.get(id) ?? 0 }); - } else if (match[2] !== undefined && match[3] !== undefined) { - const reference = createFileReference({ repo: match[2], path: match[3], startLine: match[4], endLine: match[5] }); - const source = tryResolveFileReference(reference, referencedFileSources); - if (!source) { - continue; - } - const key = sourceKey(source); - if (seenSources.has(key)) { - continue; - } - seenSources.add(key); - items.push({ kind: 'source', source }); - } - } - - // Safety net: append anything resolved but not matched in the scan. - for (const source of referencedFileSources) { - const key = sourceKey(source); - if (!seenSources.has(key)) { - seenSources.add(key); - items.push({ kind: 'source', source }); - } - } - for (const diagram of diagrams) { - if (!seenDiagrams.has(diagram.id)) { - seenDiagrams.add(diagram.id); - items.push({ kind: 'diagram', diagram, diagramIndex: diagramIndexById.get(diagram.id) ?? 0 }); - } - } - - return items; - }, [answerPart, referencedFileSources, diagrams]); + setSelectedReference, + setHoveredReference, + revealDiagram, + setHoveredDiagram, + getDiagramIndex, + jumpToInlineDiagram, + }), [chatId, isNetworkActive, setSelectedReference, setHoveredReference, revealDiagram, setHoveredDiagram, getDiagramIndex, jumpToInlineDiagram]); const sourcesView = ( ); return ( - +
-
+ ) }); diff --git a/packages/web/src/ee/features/chat/components/chatThread/diagramPanelListItem.tsx b/packages/web/src/ee/features/chat/components/chatThread/diagramPanelListItem.tsx index 60721d665..621c2c6ef 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/diagramPanelListItem.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/diagramPanelListItem.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import { ExtractedDiagram } from "@/ee/features/chat/useExtractDiagrams"; +import { ExtractedDiagram } from "@/ee/features/chat/useExtractPanelItems"; import { ChevronDown, ChevronRight, CornerUpLeft, Workflow } from "lucide-react"; import { MermaidDiagram } from "./mermaidDiagram"; import { getDiagramTitle } from "@/ee/features/chat/diagramUtils"; diff --git a/packages/web/src/ee/features/chat/components/chatThread/diagramReferenceChip.tsx b/packages/web/src/ee/features/chat/components/chatThread/diagramReferenceChip.tsx index 27c65aa0f..c3ae4d88e 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/diagramReferenceChip.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/diagramReferenceChip.tsx @@ -4,7 +4,7 @@ import { cn } from '@/lib/utils'; import { Workflow } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import { getDiagramAnchorId, getDiagramId, getDiagramTitle } from '@/ee/features/chat/diagramUtils'; -import { useDiagramPanel } from '@/ee/features/chat/diagramPanelContext'; +import { usePanelContext } from '@/ee/features/chat/panelContext'; import { TextShimmer } from '@/components/ui/textShimmer'; import useCaptureEvent from '@/hooks/useCaptureEvent'; @@ -20,19 +20,19 @@ interface DiagramReferenceChipProps { * untouched, so copying the answer still yields a valid mermaid code block. */ export const DiagramReferenceChip = ({ code }: DiagramReferenceChipProps) => { - const diagramPanel = useDiagramPanel(); + const panel = usePanelContext(); const captureEvent = useCaptureEvent(); const containerRef = useRef(null); const diagramId = useMemo(() => getDiagramId(code), [code]); const anchorId = useMemo(() => getDiagramAnchorId(code), [code]); - const index = diagramPanel?.getDiagramIndex(diagramId) ?? -1; + const index = panel?.getDiagramIndex(diagramId) ?? -1; // While streaming, a chip whose source has not yet resolved to a (closed) // panel diagram is the one currently being written: show a shimmer so the // turn doesn't look stalled. - const isGenerating = (diagramPanel?.isStreaming ?? false) && index < 0; + const isGenerating = (panel?.isStreaming ?? false) && index < 0; const label = useMemo(() => { if (isGenerating) { @@ -46,11 +46,11 @@ export const DiagramReferenceChip = ({ code }: DiagramReferenceChipProps) => { }, [code, index, isGenerating]); const reveal = useCallback(() => { - if (diagramPanel?.chatId) { - captureEvent('wa_chat_diagram_reference_clicked', { chatId: diagramPanel.chatId, diagramId }); + if (panel?.chatId) { + captureEvent('wa_chat_diagram_reference_clicked', { chatId: panel.chatId, diagramId }); } - diagramPanel?.revealInPanel(diagramId); - }, [diagramPanel, diagramId, captureEvent]); + panel?.revealDiagram(diagramId); + }, [panel, diagramId, captureEvent]); // Shared in-thread deep links target the inline anchor (`#diagram-`). // When the hash matches, scroll the chip into view and reveal the full @@ -61,13 +61,13 @@ export const DiagramReferenceChip = ({ code }: DiagramReferenceChipProps) => { return; } containerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - diagramPanel?.revealInPanel(diagramId); + panel?.revealDiagram(diagramId); }; checkHash(); window.addEventListener('hashchange', checkHash); return () => window.removeEventListener('hashchange', checkHash); - }, [anchorId, diagramId, diagramPanel]); + }, [anchorId, diagramId, panel]); return (