From 9a8dddcabda8e9d5512463cafe742a6cda63b143 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Fri, 15 May 2026 12:51:02 -0700 Subject: [PATCH 1/2] fix(web): preserve source revisions in chat citation links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `convertLLMOutputToPortableMarkdown` was not passing `revisionName` to `getBrowsePath`, so every copied citation link silently resolved to the repo's default branch — even when the source was attached at a non-HEAD revision. Plumb file sources through both callsites so the conversion can resolve each reference to its source and use that source's revision. Also tighten `get_diff` source emission: one source per path at head for added/modified files, base for deletes, both sides for renames. The old behavior emitted a duplicate unreachable source at base for every modified file that the reference resolver would have picked first. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chat/components/chatThread/answerCard.tsx | 7 ++- .../chatThread/chatThreadListItem.tsx | 1 + packages/web/src/features/chat/utils.ts | 17 +++++-- packages/web/src/features/mcp/askCodebase.ts | 9 +++- packages/web/src/features/tools/getDiff.ts | 45 ++++++++++++++++++- 5 files changed, 72 insertions(+), 7 deletions(-) diff --git a/packages/web/src/features/chat/components/chatThread/answerCard.tsx b/packages/web/src/features/chat/components/chatThread/answerCard.tsx index 3f0a62a0d..8775c69be 100644 --- a/packages/web/src/features/chat/components/chatThread/answerCard.tsx +++ b/packages/web/src/features/chat/components/chatThread/answerCard.tsx @@ -18,12 +18,14 @@ import useCaptureEvent from "@/hooks/useCaptureEvent"; import { LangfuseWeb } from "langfuse"; import { env } from "@sourcebot/shared/client"; import isEqual from "fast-deep-equal/react"; +import { FileSource } from "../../types"; interface AnswerCardProps { answerText: string; messageId: string; chatId: string; traceId?: string; + sources: FileSource[]; } const langfuseWeb = env.NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY ? new LangfuseWeb({ @@ -36,6 +38,7 @@ const AnswerCardComponent = forwardRef(({ messageId, chatId, traceId, + sources, }, forwardedRef) => { const markdownRendererRef = useRef(null); // eslint-disable-next-line react-hooks/refs -- ref.current is passed to a custom hook, not used directly in render output @@ -53,14 +56,14 @@ const AnswerCardComponent = forwardRef(({ const onCopyAnswer = useCallback(() => { const baseUrl = typeof window !== 'undefined' ? window.location.origin : ''; - const markdownText = convertLLMOutputToPortableMarkdown(answerText, baseUrl); + const markdownText = convertLLMOutputToPortableMarkdown(answerText, baseUrl, sources); navigator.clipboard.writeText(markdownText); toast({ description: "✅ Copied to clipboard", }); captureEvent('wa_chat_copy_answer_pressed', { chatId }); return true; - }, [answerText, chatId, captureEvent, toast]); + }, [answerText, sources, chatId, captureEvent, toast]); const onFeedback = useCallback(async (feedbackType: 'like' | 'dislike') => { setIsSubmittingFeedback(true); diff --git a/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx b/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx index f84abe97b..0cbd4b264 100644 --- a/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx +++ b/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx @@ -371,6 +371,7 @@ const ChatThreadListItemComponent = forwardRef ) : !isStreaming && (

Error: No answer response was provided

diff --git a/packages/web/src/features/chat/utils.ts b/packages/web/src/features/chat/utils.ts index b4f80bec2..38dd784fd 100644 --- a/packages/web/src/features/chat/utils.ts +++ b/packages/web/src/features/chat/utils.ts @@ -244,13 +244,23 @@ export const createFileReference = ({ repo, path, startLine, endLine }: { repo: * Markdown format. Practically, this means converting references into Markdown * links and removing the answer tag. */ -export const convertLLMOutputToPortableMarkdown = (text: string, baseUrl: string): string => { +export const convertLLMOutputToPortableMarkdown = (text: string, baseUrl: string, sources: FileSource[]): string => { return text .replace(ANSWER_TAG, '') .replace(FILE_REFERENCE_REGEX, (_, repo, fileName, startLine, endLine) => { - const displayName = fileName.split('/').pop() || fileName; + const reference = createFileReference({ + repo, + path: fileName, + startLine, + endLine + }); + + const source = tryResolveFileReference(reference, sources); + if (!source) { + return fileName; + } - let linkText = displayName; + let linkText = source.name; if (startLine) { if (endLine && startLine !== endLine) { linkText += `:${startLine}-${endLine}`; @@ -268,6 +278,7 @@ export const convertLLMOutputToPortableMarkdown = (text: string, baseUrl: string // Construct full browse URL const browsePath = getBrowsePath({ repoName: repo, + revisionName: source.revision, path: fileName, pathType: 'blob', highlightRange, diff --git a/packages/web/src/features/mcp/askCodebase.ts b/packages/web/src/features/mcp/askCodebase.ts index 6451e680f..810010bcc 100644 --- a/packages/web/src/features/mcp/askCodebase.ts +++ b/packages/web/src/features/mcp/askCodebase.ts @@ -196,8 +196,15 @@ export const askCodebase = (params: AskCodebaseParams): Promise + message.parts + .filter((part) => part.type === 'data-source') + .map((part) => part.data) + .filter((source) => source.type === 'file') + ); + const baseUrl = env.AUTH_URL; - const portableAnswer = convertLLMOutputToPortableMarkdown(answerText, baseUrl); + const portableAnswer = convertLLMOutputToPortableMarkdown(answerText, baseUrl, fileSources); const chatUrl = `${baseUrl}/chat/${chat.id}`; logger.debug(`Completed blocking agent for chat ${chat.id}`, { chatId: chat.id }); diff --git a/packages/web/src/features/tools/getDiff.ts b/packages/web/src/features/tools/getDiff.ts index f3a3cf77e..ae1048e54 100644 --- a/packages/web/src/features/tools/getDiff.ts +++ b/packages/web/src/features/tools/getDiff.ts @@ -4,7 +4,7 @@ import { isServiceError } from '@/lib/utils'; import description from './getDiff.txt'; import { formatDiffAsGitDiff } from './utils'; import { logger } from './logger'; -import { ToolDefinition } from './types'; +import { Source, ToolDefinition } from './types'; import { CodeHostType } from '@sourcebot/db'; import { getRepoInfoByName } from '@/actions'; @@ -49,6 +49,48 @@ export const getDiffDefinition: ToolDefinition<'get_diff', typeof getDiffRequest const gitDiffOutput = formatDiffAsGitDiff(response); + const sources: Source[] = response.files.flatMap((file) => { + // Deleted: only the base side exists. + if (file.newPath === null) { + return file.oldPath ? [{ + type: 'file' as const, + repo: repoInfo.name, + path: file.oldPath, + name: file.oldPath.split('/').pop() ?? file.oldPath, + revision: base, + }] : []; + } + + // Renamed: emit both sides since they have distinct paths. + if (file.oldPath && file.oldPath !== file.newPath) { + return [ + { + type: 'file' as const, + repo: repoInfo.name, + path: file.oldPath, + name: file.oldPath.split('/').pop() ?? file.oldPath, + revision: base, + }, + { + type: 'file' as const, + repo: repoInfo.name, + path: file.newPath, + name: file.newPath.split('/').pop() ?? file.newPath, + revision: head, + }, + ]; + } + + // Added or modified: only the head side is citable. + return [{ + type: 'file' as const, + repo: repoInfo.name, + path: file.newPath, + name: file.newPath.split('/').pop() ?? file.newPath, + revision: head, + }]; + }); + return { output: gitDiffOutput, metadata: { @@ -58,6 +100,7 @@ export const getDiffDefinition: ToolDefinition<'get_diff', typeof getDiffRequest base, head, }, + sources, }; }, }; From e5ef1c5e2d0e886cd24c4922d460f9499b62f0e7 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Fri, 15 May 2026 12:53:02 -0700 Subject: [PATCH 2/2] docs: add CHANGELOG entry for #1205 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93ace9904..7c156846a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded `hono` to `^4.12.18` to address CVE-2026-44455, CVE-2026-44456, CVE-2026-44457, CVE-2026-44458. [#1186](https://github.com/sourcebot-dev/sourcebot/pull/1186) - Upgraded `ip-address` to `^10.2.0` to address CVE-2026-42338. [#1189](https://github.com/sourcebot-dev/sourcebot/pull/1189) - Upgraded `fast-xml-builder` to `^1.2.0` to address CVE-2026-44664, CVE-2026-44665. [#1184](https://github.com/sourcebot-dev/sourcebot/pull/1184) +- Fixed file citations from the `get_diff` tool not being reliably citable in chat answers. [#1205](https://github.com/sourcebot-dev/sourcebot/pull/1205) ### Changed - Reduced the log verbosity of the worker by changing various log messages from info to debug. [#1179](https://github.com/sourcebot-dev/sourcebot/pull/1179)