Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,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)
- [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)

### Fixed
Expand Down
3 changes: 2 additions & 1 deletion docs/docs/features/ask/ask-sourcebot.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ title: Ask Sourcebot
Ask Sourcebot gives you the ability to ask complex questions about your entire codebase in natural language.

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,
follow code nav references, and provide an answer that’s rich with inline citations and navigable code snippets.
follow code nav references, and provide an answer that’s rich with inline citations, diagrams, and navigable code snippets.

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

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

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.
Expand Down
3 changes: 3 additions & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,10 @@
"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",
"motion": "^12.42.0",
"next": "^16.2.6",
"next-auth": "^5.0.0-beta.30",
"next-navigation-guard": "^0.2.0",
Expand All @@ -180,6 +182,7 @@
"react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.1",
"react-zoom-pan-pinch": "^4.0.3",
"recharts": "^2.15.3",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
Expand Down
61 changes: 61 additions & 0 deletions packages/web/src/components/ui/textShimmer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'use client';

import { cn } from '@/lib/utils';
import { motion } from 'motion/react';
import React, { useMemo, type JSX } from 'react';

// Vendored from motion-primitives (https://motion-primitives.com/docs/text-shimmer).
// A shimmer sweeps across the text via an animated background-position on a
// background-clipped gradient; the spread scales with the content length.
export type TextShimmerProps = {
children: string;
as?: React.ElementType;
className?: string;
duration?: number;
spread?: number;
};

function TextShimmerComponent({
children,
as: Component = 'p',
className,
duration = 2,
spread = 2,
}: TextShimmerProps) {
const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements
);

const dynamicSpread = useMemo(() => {
return children.length * spread;
}, [children, spread]);

return (
<MotionComponent
className={cn(
'relative inline-block bg-[length:250%_100%,auto] bg-clip-text',
'text-transparent [--base-color:#a1a1aa] [--base-gradient-color:#000]',
'[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--base-gradient-color),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]',
'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)))]',
className
)}
initial={{ backgroundPosition: '100% center' }}
animate={{ backgroundPosition: '0% center' }}
transition={{
repeat: Infinity,
duration,
ease: 'linear',
}}
style={
{
'--spread': `${dynamicSpread}px`,
backgroundImage: `var(--bg), linear-gradient(var(--base-color), var(--base-color))`,
} as React.CSSProperties
}
>
{children}
</MotionComponent>
);
}

export const TextShimmer = React.memo(TextShimmerComponent);
16 changes: 16 additions & 0 deletions packages/web/src/ee/features/chat/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,22 @@ 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.
- 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 (e.g. \`style\`, \`classDef\`, \`linkStyle\` lines — the theme is applied automatically and these directives are stripped before rendering).
- 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.
- 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.
- 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.

**Example answer structure:**
\`\`\`markdown
${ANSWER_TAG}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ const AnswerCardComponent = forwardRef<HTMLDivElement, AnswerCardProps>(({
<MarkdownRenderer
ref={markdownRendererRef}
content={answerText}
enableDiagrams={true}
// scroll-mt offsets the scroll position for headings to take account
// of the sticky "answer" header.
className="prose prose-sm max-w-none prose-headings:scroll-mt-14"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
import scrollIntoView from 'scroll-into-view-if-needed';
import { Reference, referenceSchema, SBChatMessage, Source } from "@/features/chat/types";
import { useExtractReferences } from '../../useExtractReferences';
import { getAnswerPartFromAssistantMessage, getLastStepParts, getUserMessageText, groupMessageIntoSteps, isSBChatToolPart, repairReferences, tryResolveFileReference } from '@/features/chat/utils';
import { getAnswerPartFromAssistantMessage, getLastStepParts, getUserMessageText, 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 { ReferencedSourcesListView } from './referencedSourcesListView';
import { useExtractPanelItems } from '../../useExtractPanelItems';
import { PanelContext, PanelContextValue, PanelSelection } from '../../panelContext';
import isEqual from "fast-deep-equal/react";
import { ANSWER_TAG } from '@/features/chat/constants';

Expand Down Expand Up @@ -42,8 +44,22 @@
const [leftPanelHeight, setLeftPanelHeight] = useState<number | null>(null);
const answerRef = useRef<HTMLDivElement>(null);

const [hoveredReference, setHoveredReference] = useState<Reference | undefined>(undefined);
const [selectedReference, setSelectedReference] = useState<Reference | undefined>(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<PanelSelection | undefined>(undefined);
const [hovered, setHovered] = useState<PanelSelection | undefined>(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);
Expand Down Expand Up @@ -256,7 +272,7 @@
markdownRenderer.removeEventListener('mouseout', handleMouseOut);
markdownRenderer.removeEventListener('click', handleClick);
};
}, [answerPart, selectedReference?.id]); // Re-run when answerPart changes to ensure we catch new content

Check warning on line 275 in packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has missing dependencies: 'setHoveredReference' and 'setSelectedReference'. Either include them or remove the dependency array

// When the selected reference changes, highlight all associated reference elements
// and scroll to the nearest one, if needed.
Expand Down Expand Up @@ -325,27 +341,59 @@
}, [hoveredReference]);

const references = useExtractReferences(answerPart);
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.
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 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 }));
}, []);

// 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]);
const setHoveredDiagram = useCallback((diagramId?: string) => {
setHovered(diagramId ? { kind: 'diagram', diagramId } : undefined);
}, []);

const jumpToInlineDiagram = useCallback((diagramId: string) => {
document.getElementById(`diagram-${diagramId}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, []);

const getDiagramIndex = useCallback((diagramId: string) => {
return diagramIndexById.get(diagramId) ?? -1;
}, [diagramIndexById]);

const panelContextValue = useMemo<PanelContextValue>(() => ({
chatId,
isStreaming: isNetworkActive,
setSelectedReference,
setHoveredReference,
revealDiagram,
setHoveredDiagram,
getDiagramIndex,
jumpToInlineDiagram,
}), [chatId, isNetworkActive, setSelectedReference, setHoveredReference, revealDiagram, setHoveredDiagram, getDiagramIndex, jumpToInlineDiagram]);

const sourcesView = (
<ReferencedSourcesListView
index={index}
references={references}
sources={referencedFileSources}
style={rightPanelStyle}
orderedItems={orderedItems}
selected={selected}
hovered={hovered}
/>
);

return (
<PanelContext.Provider value={panelContextValue}>
<div
className="flex flex-col md:flex-row relative min-h-[calc(100vh-250px-var(--banner-height,0px))]"
ref={ref}
Expand Down Expand Up @@ -440,17 +488,8 @@
<div
className="sticky top-0"
>
{referencedFileSources.length > 0 ? (
<ReferencedSourcesListView
index={index}
references={references}
sources={referencedFileSources}
hoveredReference={hoveredReference}
selectedReference={selectedReference}
onSelectedReferenceChanged={setSelectedReference}
onHoveredReferenceChanged={setHoveredReference}
style={rightPanelStyle}
/>
{(referencedFileSources.length > 0 || diagrams.length > 0) ? (
sourcesView
) : isNetworkActive ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
Expand All @@ -466,6 +505,7 @@
</ResizablePanel>
</ResizablePanelGroup>
</div>
</PanelContext.Provider>
)
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use client';

import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
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";

interface DiagramPanelListItemProps {
diagram: ExtractedDiagram;
index: number;
isExpanded: boolean;
isHighlighted: boolean;
isHovered: boolean;
onToggle: () => void;
onJumpToInline: () => void;
}

export const DiagramPanelListItem = ({
diagram,
index,
isExpanded,
isHighlighted,
isHovered,
onToggle,
onJumpToInline,
}: DiagramPanelListItemProps) => {
const label = getDiagramTitle(diagram.code) ?? `Diagram ${index + 1}`;

return (
<div
id={`diagram-panel-${diagram.id}`}
className={cn(
'relative rounded-md overflow-clip scroll-mt-4 transition-shadow',
isHighlighted ? 'ring-2 ring-primary' : isHovered && 'ring-1 ring-primary/50',
)}
>
<div className={cn(
'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',
{ 'border-b': !isExpanded },
)}>
<button
className="flex flex-1 min-w-0 flex-row items-center gap-1.5 text-left"
onClick={onToggle}
aria-expanded={isExpanded}
>
{isExpanded ? (
<ChevronDown className="h-3 w-3 shrink-0 cursor-pointer" />
) : (
<ChevronRight className="h-3 w-3 shrink-0 cursor-pointer" />
)}
<Workflow className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="text-sm truncate">{label}</span>
</button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 text-muted-foreground"
onClick={onJumpToInline}
aria-label="Jump to answer"
>
<CornerUpLeft className="h-3 w-3" />
</Button>
</div>

{isExpanded && (
<MermaidDiagram
code={diagram.code}
className="my-0 rounded-t-none border-t-0"
/>
)}
</div>
);
};
Loading
Loading