diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a0af6bf1..2f0e47e66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed line numbers being selectable in Safari in the lightweight code highlighter. [#1037](https://github.com/sourcebot-dev/sourcebot/pull/1037) - Fixed GitLab sync deleting repos when the API returns a non-404 error (e.g. 500) during group/user/project fetch. [#1039](https://github.com/sourcebot-dev/sourcebot/pull/1039) - Fixed React hydration mismatch in `KeyboardShortcutHint` caused by platform detection running at module load time during SSR. [#1041](https://github.com/sourcebot-dev/sourcebot/pull/1041) +- Fixed rendering performance for ask threads, especially when hovering or selecting citations. [#1042](https://github.com/sourcebot-dev/sourcebot/pull/1042) ### Added - Added optional copy button to the lightweight code highlighter (`isCopyButtonVisible` prop), shown on hover. [#1037](https://github.com/sourcebot-dev/sourcebot/pull/1037) diff --git a/packages/web/src/features/chat/components/chatThread/chatThread.tsx b/packages/web/src/features/chat/components/chatThread/chatThread.tsx index cff328e4d..b817d6cc8 100644 --- a/packages/web/src/features/chat/components/chatThread/chatThread.tsx +++ b/packages/web/src/features/chat/components/chatThread/chatThread.tsx @@ -31,7 +31,7 @@ import { DuplicateChatDialog } from '@/app/[domain]/chat/components/duplicateCha import { LoginModal } from '@/app/components/loginModal'; import type { IdentityProviderMetadata } from '@/lib/identityProviders'; import { getAskGhLoginWallData } from '../../actions'; -import { useParams } from 'next/navigation'; +import { SINGLE_TENANT_ORG_DOMAIN } from '@/lib/constants'; type ChatHistoryState = { scrollOffset?: number; @@ -71,7 +71,6 @@ export const ChatThread = ({ const { scrollRef, contentRef, scrollToBottom, isAtBottom } = useStickToBottom({ initial: false }); const { toast } = useToast(); const router = useRouter(); - const params = useParams<{ domain: string }>(); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); const [isDuplicateDialogOpen, setIsDuplicateDialogOpen] = useState(false); const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); @@ -341,9 +340,9 @@ export const ChatThread = ({ } captureEvent('wa_chat_duplicated', { chatId: defaultChatId }); - router.push(`/${params.domain}/chat/${result.id}`); + router.push(`/${SINGLE_TENANT_ORG_DOMAIN}/chat/${result.id}`); return result.id; - }, [defaultChatId, toast, router, params.domain, captureEvent]); + }, [defaultChatId, toast, router, captureEvent]); return ( <> diff --git a/packages/web/src/features/chat/components/chatThread/referencedFileSourceListItem.tsx b/packages/web/src/features/chat/components/chatThread/referencedFileSourceListItem.tsx index 15b59e988..75ecdd63d 100644 --- a/packages/web/src/features/chat/components/chatThread/referencedFileSourceListItem.tsx +++ b/packages/web/src/features/chat/components/chatThread/referencedFileSourceListItem.tsx @@ -6,30 +6,25 @@ import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/sy import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension"; import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; +import { useExtensionWithDependency } from "@/hooks/useExtensionWithDependency"; import { useKeymapExtension } from "@/hooks/useKeymapExtension"; import { cn } from "@/lib/utils"; -import { Range } from "@codemirror/state"; -import { Decoration, DecorationSet, EditorView } from '@codemirror/view'; +import { EditorView } from '@codemirror/view'; import { CodeHostType } from "@sourcebot/db"; -import CodeMirror, { ReactCodeMirrorRef, StateField } from '@uiw/react-codemirror'; +import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror'; import isEqual from "fast-deep-equal/react"; import { ChevronDown, ChevronRight } from "lucide-react"; -import { forwardRef, memo, Ref, useCallback, useImperativeHandle, useMemo, useState } from "react"; +import { forwardRef, memo, Ref, useEffect, useImperativeHandle, useMemo, useState } from "react"; import { FileReference } from "../../types"; import { createCodeFoldingExtension } from "./codeFoldingExtension"; +import { createReferencesHighlightExtension, setHoveredIdEffect, setSelectedIdEffect } from "./referencesHighlightExtension"; -const lineDecoration = Decoration.line({ - attributes: { class: "cm-range-border-radius chat-lineHighlight" }, -}); - -const selectedLineDecoration = Decoration.line({ - attributes: { class: "cm-range-border-radius cm-range-border-shadow chat-lineHighlight-selected" }, -}); - -const hoverLineDecoration = Decoration.line({ - attributes: { class: "chat-lineHighlight-hover" }, -}); - +const CODEMIRROR_BASIC_SETUP = { + highlightActiveLine: false, + highlightActiveLineGutter: false, + foldGutter: false, + foldKeymap: false, +} as const; interface ReferencedFileSourceListItemProps { id: string; @@ -75,47 +70,32 @@ const ReferencedFileSourceListItemComponent = ({ forwardedRef, () => editorRef as ReactCodeMirrorRef ); + const keymapExtension = useKeymapExtension(editorRef?.view); const hasCodeNavEntitlement = useHasEntitlement("code-nav"); - const languageExtension = useCodeMirrorLanguageExtension(language, editorRef?.view); - const getReferenceAtPos = useCallback((x: number, y: number, view: EditorView): FileReference | undefined => { - const pos = view.posAtCoords({ x, y }); - if (pos === null) return undefined; - - // Check if position is within the main editor content area - const rect = view.contentDOM.getBoundingClientRect(); - if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { - return undefined; - } - - const line = view.state.doc.lineAt(pos); - const lineNumber = line.number; - - // Check if this line is part of any highlighted range - const matchingRanges = references.filter(({ range }) => - range && lineNumber >= range.startLine && lineNumber <= range.endLine - ); + const codeFoldingExtension = useMemo(() => { + return createCodeFoldingExtension(references, 3); + }, [references]); - // Sort by the length of the range. - // Shorter ranges are more specific, so we want to prioritize them. - matchingRanges.sort((a, b) => { - const aLength = (a.range!.endLine) - (a.range!.startLine); - const bLength = (b.range!.endLine) - (b.range!.startLine); - return aLength - bLength; - }); + const referencesHighlightExtension = useExtensionWithDependency( + editorRef?.view ?? null, + () => createReferencesHighlightExtension(references, onHoveredReferenceChanged, onSelectedReferenceChanged), + [references], + ); - if (matchingRanges.length > 0) { - return matchingRanges[0]; + useEffect(() => { + if (editorRef?.view) { + editorRef.view.dispatch({ effects: setHoveredIdEffect.of(hoveredReference?.id) }); } + }, [hoveredReference?.id, editorRef?.view]); - return undefined; - }, [references]); - - const codeFoldingExtension = useMemo(() => { - return createCodeFoldingExtension(references, 3); - }, [references]); + useEffect(() => { + if (editorRef?.view) { + editorRef.view.dispatch({ effects: setSelectedIdEffect.of(selectedReference?.id) }); + } + }, [selectedReference?.id, editorRef?.view]); const extensions = useMemo(() => { return [ @@ -126,88 +106,14 @@ const ReferencedFileSourceListItemComponent = ({ symbolHoverTargetsExtension, ] : []), codeFoldingExtension, - StateField.define({ - create(state) { - const decorations: Range[] = []; - - for (const { range, id } of references) { - if (!range) { - continue; - } - - const isHovered = id === hoveredReference?.id; - const isSelected = id === selectedReference?.id; - - for (let line = range.startLine; line <= range.endLine; line++) { - // Skip lines that are outside the document bounds. - if (line > state.doc.lines) { - continue; - } - - if (isSelected) { - decorations.push(selectedLineDecoration.range(state.doc.line(line).from)); - } else { - decorations.push(lineDecoration.range(state.doc.line(line).from)); - if (isHovered) { - decorations.push(hoverLineDecoration.range(state.doc.line(line).from)); - } - } - - } - } - - return Decoration.set(decorations, /* sort = */ true); - }, - update(deco, tr) { - return deco.map(tr.changes); - }, - provide: (field) => EditorView.decorations.from(field), - }), - EditorView.domEventHandlers({ - click: (event, view) => { - const reference = getReferenceAtPos(event.clientX, event.clientY, view); - - if (reference) { - onSelectedReferenceChanged(reference.id === selectedReference?.id ? undefined : reference); - return true; // prevent default handling - } - return false; - }, - mouseover: (event, view) => { - const reference = getReferenceAtPos(event.clientX, event.clientY, view); - if (!reference) { - return false; - } - - if (reference.id === selectedReference?.id || reference.id === hoveredReference?.id) { - return false; - } - - onHoveredReferenceChanged(reference); - return true; - }, - mouseout: (event, view) => { - const reference = getReferenceAtPos(event.clientX, event.clientY, view); - if (reference) { - return false; - } - - onHoveredReferenceChanged(undefined); - return true; - } - }) + referencesHighlightExtension, ]; }, [ languageExtension, keymapExtension, hasCodeNavEntitlement, - references, - hoveredReference?.id, - selectedReference?.id, - getReferenceAtPos, - onSelectedReferenceChanged, - onHoveredReferenceChanged, codeFoldingExtension, + referencesHighlightExtension, ]); const ExpandCollapseIcon = useMemo(() => { @@ -253,12 +159,7 @@ const ReferencedFileSourceListItemComponent = ({ extensions={extensions} readOnly={true} theme={theme} - basicSetup={{ - highlightActiveLine: false, - highlightActiveLineGutter: false, - foldGutter: false, - foldKeymap: false, - }} + basicSetup={CODEMIRROR_BASIC_SETUP} > {editorRef && hasCodeNavEntitlement && ( void; + onSelectedReferenceChanged: (reference?: Reference) => void; + isExpanded: boolean; + onExpandedChanged: (fileId: string, isExpanded: boolean) => void; + onEditorRef: (fileId: string, ref: ReactCodeMirrorRef | null) => void; +} + +const ReferencedFileSourceListItemContainerComponent = ({ + fileId, + fileSource, + references, + hoveredReference, + selectedReference, + onHoveredReferenceChanged, + onSelectedReferenceChanged, + isExpanded, + onExpandedChanged, + onEditorRef, +}: ReferencedFileSourceListItemContainerProps) => { + const fileName = fileSource.path.split('/').pop() ?? fileSource.path; + + const { data, isLoading, isError, error } = useQuery({ + queryKey: ['fileSource', fileSource.path, fileSource.repo, fileSource.revision], + queryFn: () => unwrapServiceError(getFileSource({ + path: fileSource.path, + repo: fileSource.repo, + ref: fileSource.revision, + })), + staleTime: Infinity, + }); + + const handleRef = useCallback((ref: ReactCodeMirrorRef | null) => { + onEditorRef(fileId, ref); + }, [fileId, onEditorRef]); + + const handleExpandedChanged = useCallback((isExpanded: boolean) => { + onExpandedChanged(fileId, isExpanded); + }, [fileId, onExpandedChanged]); + + if (isLoading) { + return ( +
+
+ + {fileName} +
+ +
+ ); + } + + if (isError || isServiceError(data) || !data) { + return ( +
+
+ + {fileName} +
+
+ Failed to load file: {isServiceError(data) ? data.message : error?.message ?? 'Unknown error'} +
+
+ ); + } + + return ( + + ); +}; + +export const ReferencedFileSourceListItemContainer = memo(ReferencedFileSourceListItemContainerComponent, isEqual); diff --git a/packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx b/packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx index 5f12c6f54..3197338ba 100644 --- a/packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx +++ b/packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx @@ -1,17 +1,12 @@ 'use client'; -import { getFileSource } from "@/app/api/(client)/client"; -import { VscodeFileIcon } from "@/app/components/vscodeFileIcon"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { Skeleton } from "@/components/ui/skeleton"; -import { isServiceError, unwrapServiceError } from "@/lib/utils"; -import { useQueries } from "@tanstack/react-query"; -import { ReactCodeMirrorRef } from '@uiw/react-codemirror'; +import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import scrollIntoView from 'scroll-into-view-if-needed'; import { FileReference, FileSource, Reference } from "../../types"; import { tryResolveFileReference } from '../../utils'; -import { ReferencedFileSourceListItem } from "./referencedFileSourceListItem"; +import { ReferencedFileSourceListItemContainer } from "./referencedFileSourceListItemContainer"; import isEqual from 'fast-deep-equal/react'; interface ReferencedSourcesListViewProps { @@ -72,19 +67,6 @@ const ReferencedSourcesListViewComponent = ({ return groupedReferences; }, [references, sources, getFileId]); - const fileSourceQueries = useQueries({ - queries: sources.map((file) => ({ - queryKey: ['fileSource', file.path, file.repo, file.revision], - queryFn: () => unwrapServiceError(getFileSource({ - path: file.path, - repo: file.repo, - ref: file.revision, - })), - staleTime: Infinity, - })), - }); - - useEffect(() => { if (!selectedReference || selectedReference.type !== 'file') { return; @@ -206,63 +188,25 @@ const ReferencedSourcesListViewComponent = ({ style={style} >
- {fileSourceQueries.map((query, index) => { - const fileSource = sources[index]; - const fileName = fileSource.path.split('/').pop() ?? fileSource.path; - - if (query.isLoading) { - return ( -
-
- - {fileName} -
- -
- ); - } - - if (query.isError || isServiceError(query.data)) { - return ( -
-
- - {fileName} -
-
- Failed to load file: {isServiceError(query.data) ? query.data.message : query.error?.message ?? 'Unknown error'} -
-
- ); - } - - const fileData = query.data!; - + {sources.map((fileSource) => { const fileId = getFileId(fileSource); const referencesInFile = referencesGroupedByFile.get(fileId) || []; + const hoveredReferenceInFile = referencesInFile.some(r => r.id === hoveredReference?.id) ? hoveredReference : undefined; + const selectedReferenceInFile = referencesInFile.some(r => r.id === selectedReference?.id) ? selectedReference : undefined; return ( - { - setEditorRef(fileId, ref); - }} - onSelectedReferenceChanged={onSelectedReferenceChanged} + hoveredReference={hoveredReferenceInFile} + selectedReference={selectedReferenceInFile} onHoveredReferenceChanged={onHoveredReferenceChanged} - selectedReference={selectedReference} - hoveredReference={hoveredReference} + onSelectedReferenceChanged={onSelectedReferenceChanged} isExpanded={!collapsedFileIds.includes(fileId)} - onExpandedChanged={(isExpanded) => onExpandedChanged(fileId, isExpanded)} + onExpandedChanged={onExpandedChanged} + onEditorRef={setEditorRef} /> ); })} diff --git a/packages/web/src/features/chat/components/chatThread/referencesHighlightExtension.ts b/packages/web/src/features/chat/components/chatThread/referencesHighlightExtension.ts new file mode 100644 index 000000000..c2467bbe4 --- /dev/null +++ b/packages/web/src/features/chat/components/chatThread/referencesHighlightExtension.ts @@ -0,0 +1,152 @@ +import { EditorState, Range, StateEffect, StateField } from "@codemirror/state"; +import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; +import { FileReference } from "../../types"; + +const lineDecoration = Decoration.line({ + attributes: { class: "cm-range-border-radius chat-lineHighlight" }, +}); + +const selectedLineDecoration = Decoration.line({ + attributes: { class: "cm-range-border-radius cm-range-border-shadow chat-lineHighlight-selected" }, +}); + +const hoverLineDecoration = Decoration.line({ + attributes: { class: "chat-lineHighlight-hover" }, +}); + +export const setHoveredIdEffect = StateEffect.define(); +export const setSelectedIdEffect = StateEffect.define(); + +const hoveredSelectedField = StateField.define<{ hoveredId?: string; selectedId?: string }>({ + create: () => ({ hoveredId: undefined, selectedId: undefined }), + update(state, tr) { + let next = state; + for (const effect of tr.effects) { + if (effect.is(setHoveredIdEffect)) { + next = { ...next, hoveredId: effect.value }; + } + if (effect.is(setSelectedIdEffect)) { + next = { ...next, selectedId: effect.value }; + } + } + return next; + }, +}); + +function getReferenceAtPos(references: FileReference[], x: number, y: number, view: EditorView): FileReference | undefined { + const pos = view.posAtCoords({ x, y }); + if (pos === null) { + return undefined; + } + + // Check if position is within the main editor content area + const rect = view.contentDOM.getBoundingClientRect(); + if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { + return undefined; + } + + const line = view.state.doc.lineAt(pos); + const lineNumber = line.number; + + // Check if this line is part of any highlighted range + const matchingRanges = references.filter(({ range }) => + range && lineNumber >= range.startLine && lineNumber <= range.endLine + ); + + // Sort by range length — shorter ranges are more specific and take priority. + matchingRanges.sort((a, b) => { + const aLength = (a.range!.endLine) - (a.range!.startLine); + const bLength = (b.range!.endLine) - (b.range!.startLine); + return aLength - bLength; + }); + + return matchingRanges[0]; +} + +function buildDecorations(state: EditorState, references: FileReference[], hoveredId: string | undefined, selectedId: string | undefined): DecorationSet { + const decorations: Range[] = []; + + for (const { range, id } of references) { + if (!range) { + continue; + } + + const isHovered = id === hoveredId; + const isSelected = id === selectedId; + + for (let line = range.startLine; line <= range.endLine; line++) { + // Skip lines that are outside the document bounds. + if (line < 1 || line > state.doc.lines) { + continue; + } + + if (isSelected) { + decorations.push(selectedLineDecoration.range(state.doc.line(line).from)); + } else { + decorations.push(lineDecoration.range(state.doc.line(line).from)); + if (isHovered) { + decorations.push(hoverLineDecoration.range(state.doc.line(line).from)); + } + } + } + } + + return Decoration.set(decorations, /* sort = */ true); +} + +export function createReferencesHighlightExtension( + references: FileReference[], + onHoveredReferenceChanged: (reference?: FileReference) => void, + onSelectedReferenceChanged: (reference?: FileReference) => void, +) { + const decorationField = StateField.define({ + create(state) { + const { hoveredId, selectedId } = state.field(hoveredSelectedField); + return buildDecorations(state, references, hoveredId, selectedId); + }, + update(deco, tr) { + if (tr.effects.some(e => e.is(setHoveredIdEffect) || e.is(setSelectedIdEffect))) { + const { hoveredId, selectedId } = tr.state.field(hoveredSelectedField); + return buildDecorations(tr.state, references, hoveredId, selectedId); + } + return deco.map(tr.changes); + }, + provide: (field) => EditorView.decorations.from(field), + }); + + return [ + hoveredSelectedField, + decorationField, + EditorView.domEventHandlers({ + click: (event, view) => { + const reference = getReferenceAtPos(references, event.clientX, event.clientY, view); + if (reference) { + const { selectedId } = view.state.field(hoveredSelectedField); + onSelectedReferenceChanged(reference.id === selectedId ? undefined : reference); + return true; + } + return false; + }, + mouseover: (event, view) => { + const reference = getReferenceAtPos(references, event.clientX, event.clientY, view); + if (!reference) { + return false; + } + const { selectedId, hoveredId } = view.state.field(hoveredSelectedField); + if (reference.id === selectedId || reference.id === hoveredId) { + return false; + } + onHoveredReferenceChanged(reference); + return true; + }, + mouseout: (event, view) => { + const reference = getReferenceAtPos(references, event.clientX, event.clientY, view); + if (reference) { + return false; + } + onHoveredReferenceChanged(undefined); + return true; + }, + }), + ]; +} diff --git a/packages/web/src/hooks/useExtensionWithDependency.ts b/packages/web/src/hooks/useExtensionWithDependency.ts index d29a521d2..f9aca9b76 100644 --- a/packages/web/src/hooks/useExtensionWithDependency.ts +++ b/packages/web/src/hooks/useExtensionWithDependency.ts @@ -13,7 +13,11 @@ export function useExtensionWithDependency( deps: unknown[], ) { const compartment = useMemo(() => new Compartment(), []); - const extension = useMemo(() => compartment.of(extensionFactory()), [compartment, extensionFactory]); + // extensionFactory is intentionally excluded from deps — it only computes the + // initial value. Subsequent updates are handled by compartment.reconfigure() in + // the effect below, keeping the returned extension reference stable across renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + const extension = useMemo(() => compartment.of(extensionFactory()), [compartment]); useEffect(() => { if (view) {