Skip to content

Commit 18ff55e

Browse files
committed
Merge branch 'main' into whoisthey/language-model-input-modalities
2 parents ed307ed + 1f289e4 commit 18ff55e

20 files changed

Lines changed: 2369 additions & 67 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
### Added
1515
- 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)
16+
- [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)
1617
- [EE] Added a context-window usage gauge to the Ask Sourcebot chat details, showing how much of the selected model's context window each turn occupies. Window sizes are resolved from the models.dev catalog. [#1370](https://github.com/sourcebot-dev/sourcebot/pull/1370)
1718
- Added optional `inputModalities` and `supportedDocumentTypes` configuration for language models, exposing model input-modality and document capabilities (defaults to text-only, no documents). [#1372](https://github.com/sourcebot-dev/sourcebot/pull/1372)
1819

docs/docs/features/ask/ask-sourcebot.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ title: Ask Sourcebot
55
Ask Sourcebot gives you the ability to ask complex questions about your entire codebase in natural language.
66

77
It uses Sourcebot’s existing [code search](/docs/features/search/code-search) and [navigation](/docs/features/code-navigation) tools to allow reasoning models to search your code,
8-
follow code nav references, and provide an answer that’s rich with inline citations and navigable code snippets.
8+
follow code nav references, and provide an answer that’s rich with inline citations, diagrams, and navigable code snippets.
99

1010
Ask Sourcebot **uses an LLM provider you configure with Sourcebot**, ensuring you have full control over where your data is sent.
1111

@@ -45,6 +45,7 @@ In this domain, these tools fall short:
4545
We built Ask Sourcebot to address these problems. With Ask Sourcebot, you can:
4646
- Ask questions about your teams entire codebase (even on repos you don't have locally)
4747
- Easily parse the response with side-by-side citations and code navigation
48+
- Visualize architecture and flows with diagrams the model generates inline
4849
- Share answers with your team to spread the knowledge
4950

5051
Being a web app is less convenient than being in your IDE, but it allows Sourcebot to provide responses in a richer UI that isn't constrained by the IDE.

packages/web/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,10 @@
157157
"langfuse-vercel": "^3.38.4",
158158
"linguist-languages": "^9.3.1",
159159
"lucide-react": "^1.7.0",
160+
"mermaid": "^11.16.0",
160161
"micromatch": "^4.0.8",
161162
"minidenticons": "^4.2.1",
163+
"motion": "^12.42.0",
162164
"next": "^16.2.6",
163165
"next-auth": "^5.0.0-beta.30",
164166
"next-navigation-guard": "^0.2.0",
@@ -180,6 +182,7 @@
180182
"react-icons": "^5.6.0",
181183
"react-markdown": "^10.1.0",
182184
"react-resizable-panels": "^2.1.1",
185+
"react-zoom-pan-pinch": "^4.0.3",
183186
"recharts": "^2.15.3",
184187
"rehype-raw": "^7.0.0",
185188
"rehype-sanitize": "^6.0.0",
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
'use client';
2+
3+
import { cn } from '@/lib/utils';
4+
import { motion } from 'motion/react';
5+
import React, { useMemo, type JSX } from 'react';
6+
7+
// Vendored from motion-primitives (https://motion-primitives.com/docs/text-shimmer).
8+
// A shimmer sweeps across the text via an animated background-position on a
9+
// background-clipped gradient; the spread scales with the content length.
10+
export type TextShimmerProps = {
11+
children: string;
12+
as?: React.ElementType;
13+
className?: string;
14+
duration?: number;
15+
spread?: number;
16+
};
17+
18+
function TextShimmerComponent({
19+
children,
20+
as: Component = 'p',
21+
className,
22+
duration = 2,
23+
spread = 2,
24+
}: TextShimmerProps) {
25+
const MotionComponent = motion.create(
26+
Component as keyof JSX.IntrinsicElements
27+
);
28+
29+
const dynamicSpread = useMemo(() => {
30+
return children.length * spread;
31+
}, [children, spread]);
32+
33+
return (
34+
<MotionComponent
35+
className={cn(
36+
'relative inline-block bg-[length:250%_100%,auto] bg-clip-text',
37+
'text-transparent [--base-color:#a1a1aa] [--base-gradient-color:#000]',
38+
'[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--base-gradient-color),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]',
39+
'dark:[--base-color:#71717a] dark:[--base-gradient-color:#ffffff] dark:[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--base-gradient-color),#0000_calc(50%+var(--spread)))]',
40+
className
41+
)}
42+
initial={{ backgroundPosition: '100% center' }}
43+
animate={{ backgroundPosition: '0% center' }}
44+
transition={{
45+
repeat: Infinity,
46+
duration,
47+
ease: 'linear',
48+
}}
49+
style={
50+
{
51+
'--spread': `${dynamicSpread}px`,
52+
backgroundImage: `var(--bg), linear-gradient(var(--base-color), var(--base-color))`,
53+
} as React.CSSProperties
54+
}
55+
>
56+
{children}
57+
</MotionComponent>
58+
);
59+
}
60+
61+
export const TextShimmer = React.memo(TextShimmerComponent);

packages/web/src/ee/features/chat/agent.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,22 @@ const createPrompt = ({
658658
- If you cannot provide a code reference for something you're discussing, do not mention that specific code element
659659
- Always prefer to use \`${FILE_REFERENCE_PREFIX}\` over \`\`\`code\`\`\` blocks.
660660
661+
**Diagrams:**
662+
- 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.
663+
- 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.
664+
- 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:
665+
\`\`\`mermaid
666+
---
667+
title: Authentication Flow
668+
---
669+
flowchart TD
670+
...
671+
\`\`\`
672+
- 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 (e.g. \`style\`, \`classDef\`, \`linkStyle\` lines — the theme is applied automatically and these directives are stripped before rendering).
673+
- Do NOT use \`<br>\`/\`<br/>\` tags or \`\\n\` for line breaks inside node or edge labels — they do not render reliably. Keep each label to a single short phrase; if you need more detail, split it into multiple connected nodes rather than wrapping text.
674+
- 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.
675+
- Before emitting a \`\`\`mermaid block, self-check it once: every label containing a special character is double-quoted, no node ID is a reserved keyword, there are no \`<br/>\`/\`\\n\` line breaks in labels, and there are no \`style\`/\`classDef\`/\`linkStyle\` directives.
676+
661677
**Example answer structure:**
662678
\`\`\`markdown
663679
${ANSWER_TAG}

packages/web/src/ee/features/chat/components/chatThread/answerCard.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ const AnswerCardComponent = forwardRef<HTMLDivElement, AnswerCardProps>(({
155155
<MarkdownRenderer
156156
ref={markdownRendererRef}
157157
content={answerText}
158+
enableDiagrams={true}
158159
// scroll-mt offsets the scroll position for headings to take account
159160
// of the sticky "answer" header.
160161
className="prose prose-sm max-w-none prose-headings:scroll-mt-14"

packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx

Lines changed: 71 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import { CSSProperties, forwardRef, memo, useCallback, useEffect, useMemo, useRe
88
import scrollIntoView from 'scroll-into-view-if-needed';
99
import { Reference, referenceSchema, SBChatMessage, Source } from "@/features/chat/types";
1010
import { useExtractReferences } from '../../useExtractReferences';
11-
import { getAnswerPartFromAssistantMessage, getLastStepParts, getUserMessageText, groupMessageIntoSteps, isSBChatToolPart, repairReferences, tryResolveFileReference } from '@/features/chat/utils';
11+
import { getAnswerPartFromAssistantMessage, getLastStepParts, getUserMessageText, groupMessageIntoSteps, isSBChatToolPart, repairReferences } from '@/features/chat/utils';
1212
import { AnswerCard } from './answerCard';
1313
import { DetailsCard } from './detailsCard';
1414
import { ApprovalRequestedToolPart, ToolApprovalBanner } from './toolApprovalBanner';
1515
import { MarkdownRenderer, REFERENCE_PAYLOAD_ATTRIBUTE } from './markdownRenderer';
1616
import { ReferencedSourcesListView } from './referencedSourcesListView';
17+
import { useExtractPanelItems } from '../../useExtractPanelItems';
18+
import { PanelContext, PanelContextValue, PanelSelection } from '../../panelContext';
1719
import isEqual from "fast-deep-equal/react";
1820
import { ANSWER_TAG } from '@/features/chat/constants';
1921

@@ -42,8 +44,22 @@ const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThreadListIte
4244
const [leftPanelHeight, setLeftPanelHeight] = useState<number | null>(null);
4345
const answerRef = useRef<HTMLDivElement>(null);
4446

45-
const [hoveredReference, setHoveredReference] = useState<Reference | undefined>(undefined);
46-
const [selectedReference, setSelectedReference] = useState<Reference | undefined>(undefined);
47+
// Unified panel selection/hover: a single selection model shared by inline
48+
// file-reference citations and diagrams (see panelContext.ts). Only one
49+
// thing is selected/hovered at a time.
50+
const [selected, setSelected] = useState<PanelSelection | undefined>(undefined);
51+
const [hovered, setHovered] = useState<PanelSelection | undefined>(undefined);
52+
53+
const selectedReference = useMemo(() => (selected?.kind === 'reference' ? selected.reference : undefined), [selected]);
54+
const hoveredReference = useMemo(() => (hovered?.kind === 'reference' ? hovered.reference : undefined), [hovered]);
55+
56+
const setSelectedReference = useCallback((reference?: Reference) => {
57+
setSelected(reference ? { kind: 'reference', reference } : undefined);
58+
}, []);
59+
const setHoveredReference = useCallback((reference?: Reference) => {
60+
setHovered(reference ? { kind: 'reference', reference } : undefined);
61+
}, []);
62+
4763
const [isDetailsPanelExpanded, _setIsDetailsPanelExpanded] = useState(isNetworkActive);
4864
const hasAutoCollapsed = useRef(false);
4965
const userHasManuallyExpanded = useRef(false);
@@ -325,27 +341,59 @@ const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThreadListIte
325341
}, [hoveredReference]);
326342

327343
const references = useExtractReferences(answerPart);
344+
const { diagrams, referencedFileSources, orderedItems } = useExtractPanelItems(answerPart, references, sources);
345+
346+
// Maps a diagram id to its position in order of appearance (matches the
347+
// index the right panel assigns), used for the "Diagram N" label fallback.
348+
const diagramIndexById = useMemo(() => {
349+
return new Map(diagrams.map((diagram, i) => [diagram.id, i]));
350+
}, [diagrams]);
351+
352+
// Reveal a diagram in the right panel: the panel list expands it and scrolls
353+
// it into view when the selection changes. Clearing first lets the same
354+
// diagram be re-revealed (re-clicking a chip re-scrolls).
355+
const revealDiagram = useCallback((diagramId: string) => {
356+
setSelected(undefined);
357+
requestAnimationFrame(() => setSelected({ kind: 'diagram', diagramId }));
358+
}, []);
328359

329-
// Extract the file sources that are referenced by the answer part.
330-
const referencedFileSources = useMemo(() => {
331-
const fileSources = sources.filter((source) => source.type === 'file');
332-
333-
return references
334-
.filter((reference) => reference.type === 'file')
335-
.map((reference) => tryResolveFileReference(reference, fileSources))
336-
.filter((file) => file !== undefined)
337-
// de-duplicate files
338-
.filter((file, index, self) =>
339-
index === self.findIndex((t) =>
340-
t?.path === file?.path
341-
&& t?.repo === file?.repo
342-
&& t?.revision === file?.revision
343-
)
344-
);
345-
}, [references, sources]);
360+
const setHoveredDiagram = useCallback((diagramId?: string) => {
361+
setHovered(diagramId ? { kind: 'diagram', diagramId } : undefined);
362+
}, []);
363+
364+
const jumpToInlineDiagram = useCallback((diagramId: string) => {
365+
document.getElementById(`diagram-${diagramId}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
366+
}, []);
346367

368+
const getDiagramIndex = useCallback((diagramId: string) => {
369+
return diagramIndexById.get(diagramId) ?? -1;
370+
}, [diagramIndexById]);
371+
372+
const panelContextValue = useMemo<PanelContextValue>(() => ({
373+
chatId,
374+
isStreaming: isNetworkActive,
375+
setSelectedReference,
376+
setHoveredReference,
377+
revealDiagram,
378+
setHoveredDiagram,
379+
getDiagramIndex,
380+
jumpToInlineDiagram,
381+
}), [chatId, isNetworkActive, setSelectedReference, setHoveredReference, revealDiagram, setHoveredDiagram, getDiagramIndex, jumpToInlineDiagram]);
382+
383+
const sourcesView = (
384+
<ReferencedSourcesListView
385+
index={index}
386+
references={references}
387+
sources={referencedFileSources}
388+
style={rightPanelStyle}
389+
orderedItems={orderedItems}
390+
selected={selected}
391+
hovered={hovered}
392+
/>
393+
);
347394

348395
return (
396+
<PanelContext.Provider value={panelContextValue}>
349397
<div
350398
className="flex flex-col md:flex-row relative min-h-[calc(100vh-250px-var(--banner-height,0px))]"
351399
ref={ref}
@@ -440,17 +488,8 @@ const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThreadListIte
440488
<div
441489
className="sticky top-0"
442490
>
443-
{referencedFileSources.length > 0 ? (
444-
<ReferencedSourcesListView
445-
index={index}
446-
references={references}
447-
sources={referencedFileSources}
448-
hoveredReference={hoveredReference}
449-
selectedReference={selectedReference}
450-
onSelectedReferenceChanged={setSelectedReference}
451-
onHoveredReferenceChanged={setHoveredReference}
452-
style={rightPanelStyle}
453-
/>
491+
{(referencedFileSources.length > 0 || diagrams.length > 0) ? (
492+
sourcesView
454493
) : isNetworkActive ? (
455494
<div className="space-y-4">
456495
{Array.from({ length: 3 }).map((_, index) => (
@@ -466,6 +505,7 @@ const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThreadListIte
466505
</ResizablePanel>
467506
</ResizablePanelGroup>
468507
</div>
508+
</PanelContext.Provider>
469509
)
470510
});
471511

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
'use client';
2+
3+
import { Button } from "@/components/ui/button";
4+
import { cn } from "@/lib/utils";
5+
import { ExtractedDiagram } from "@/ee/features/chat/useExtractPanelItems";
6+
import { ChevronDown, ChevronRight, CornerUpLeft, Workflow } from "lucide-react";
7+
import { MermaidDiagram } from "./mermaidDiagram";
8+
import { getDiagramTitle } from "@/ee/features/chat/diagramUtils";
9+
10+
interface DiagramPanelListItemProps {
11+
diagram: ExtractedDiagram;
12+
index: number;
13+
isExpanded: boolean;
14+
isHighlighted: boolean;
15+
isHovered: boolean;
16+
onToggle: () => void;
17+
onJumpToInline: () => void;
18+
}
19+
20+
export const DiagramPanelListItem = ({
21+
diagram,
22+
index,
23+
isExpanded,
24+
isHighlighted,
25+
isHovered,
26+
onToggle,
27+
onJumpToInline,
28+
}: DiagramPanelListItemProps) => {
29+
const label = getDiagramTitle(diagram.code) ?? `Diagram ${index + 1}`;
30+
31+
return (
32+
<div
33+
id={`diagram-panel-${diagram.id}`}
34+
className={cn(
35+
'relative rounded-md overflow-clip scroll-mt-4 transition-shadow',
36+
isHighlighted ? 'ring-2 ring-primary' : isHovered && 'ring-1 ring-primary/50',
37+
)}
38+
>
39+
<div className={cn(
40+
'sticky top-0 z-10 flex flex-row items-center bg-accent py-1 px-3 gap-1.5 border-l border-r border-t',
41+
{ 'border-b': !isExpanded },
42+
)}>
43+
<button
44+
className="flex flex-1 min-w-0 flex-row items-center gap-1.5 text-left"
45+
onClick={onToggle}
46+
aria-expanded={isExpanded}
47+
>
48+
{isExpanded ? (
49+
<ChevronDown className="h-3 w-3 shrink-0 cursor-pointer" />
50+
) : (
51+
<ChevronRight className="h-3 w-3 shrink-0 cursor-pointer" />
52+
)}
53+
<Workflow className="h-4 w-4 shrink-0 text-muted-foreground" />
54+
<span className="text-sm truncate">{label}</span>
55+
</button>
56+
<Button
57+
variant="ghost"
58+
size="sm"
59+
className="h-6 w-6 text-muted-foreground"
60+
onClick={onJumpToInline}
61+
aria-label="Jump to answer"
62+
>
63+
<CornerUpLeft className="h-3 w-3" />
64+
</Button>
65+
</div>
66+
67+
{isExpanded && (
68+
<MermaidDiagram
69+
code={diagram.code}
70+
className="my-0 rounded-t-none border-t-0"
71+
/>
72+
)}
73+
</div>
74+
);
75+
};

0 commit comments

Comments
 (0)