Skip to content

Commit 596df83

Browse files
fix(web): reduce unnecessary re-renders in chat reference panel (#1042)
* fix(web): reduce unnecessary re-renders in chat reference panel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: update CHANGELOG for #1042 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * changelog * refactor(web): move ReferencedFileSourceListItemContainer into its own file Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * nit * feedbacl --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a795a34 commit 596df83

File tree

7 files changed

+314
-205
lines changed

7 files changed

+314
-205
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- Fixed line numbers being selectable in Safari in the lightweight code highlighter. [#1037](https://github.com/sourcebot-dev/sourcebot/pull/1037)
1212
- 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)
1313
- 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)
14+
- Fixed rendering performance for ask threads, especially when hovering or selecting citations. [#1042](https://github.com/sourcebot-dev/sourcebot/pull/1042)
1415

1516
### Added
1617
- Added optional copy button to the lightweight code highlighter (`isCopyButtonVisible` prop), shown on hover. [#1037](https://github.com/sourcebot-dev/sourcebot/pull/1037)

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { DuplicateChatDialog } from '@/app/[domain]/chat/components/duplicateCha
3131
import { LoginModal } from '@/app/components/loginModal';
3232
import type { IdentityProviderMetadata } from '@/lib/identityProviders';
3333
import { getAskGhLoginWallData } from '../../actions';
34-
import { useParams } from 'next/navigation';
34+
import { SINGLE_TENANT_ORG_DOMAIN } from '@/lib/constants';
3535

3636
type ChatHistoryState = {
3737
scrollOffset?: number;
@@ -71,7 +71,6 @@ export const ChatThread = ({
7171
const { scrollRef, contentRef, scrollToBottom, isAtBottom } = useStickToBottom({ initial: false });
7272
const { toast } = useToast();
7373
const router = useRouter();
74-
const params = useParams<{ domain: string }>();
7574
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
7675
const [isDuplicateDialogOpen, setIsDuplicateDialogOpen] = useState(false);
7776
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
@@ -341,9 +340,9 @@ export const ChatThread = ({
341340
}
342341

343342
captureEvent('wa_chat_duplicated', { chatId: defaultChatId });
344-
router.push(`/${params.domain}/chat/${result.id}`);
343+
router.push(`/${SINGLE_TENANT_ORG_DOMAIN}/chat/${result.id}`);
345344
return result.id;
346-
}, [defaultChatId, toast, router, params.domain, captureEvent]);
345+
}, [defaultChatId, toast, router, captureEvent]);
347346

348347
return (
349348
<>

packages/web/src/features/chat/components/chatThread/referencedFileSourceListItem.tsx

Lines changed: 32 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,25 @@ import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/sy
66
import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
77
import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension";
88
import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme";
9+
import { useExtensionWithDependency } from "@/hooks/useExtensionWithDependency";
910
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
1011
import { cn } from "@/lib/utils";
11-
import { Range } from "@codemirror/state";
12-
import { Decoration, DecorationSet, EditorView } from '@codemirror/view';
12+
import { EditorView } from '@codemirror/view';
1313
import { CodeHostType } from "@sourcebot/db";
14-
import CodeMirror, { ReactCodeMirrorRef, StateField } from '@uiw/react-codemirror';
14+
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
1515
import isEqual from "fast-deep-equal/react";
1616
import { ChevronDown, ChevronRight } from "lucide-react";
17-
import { forwardRef, memo, Ref, useCallback, useImperativeHandle, useMemo, useState } from "react";
17+
import { forwardRef, memo, Ref, useEffect, useImperativeHandle, useMemo, useState } from "react";
1818
import { FileReference } from "../../types";
1919
import { createCodeFoldingExtension } from "./codeFoldingExtension";
20+
import { createReferencesHighlightExtension, setHoveredIdEffect, setSelectedIdEffect } from "./referencesHighlightExtension";
2021

21-
const lineDecoration = Decoration.line({
22-
attributes: { class: "cm-range-border-radius chat-lineHighlight" },
23-
});
24-
25-
const selectedLineDecoration = Decoration.line({
26-
attributes: { class: "cm-range-border-radius cm-range-border-shadow chat-lineHighlight-selected" },
27-
});
28-
29-
const hoverLineDecoration = Decoration.line({
30-
attributes: { class: "chat-lineHighlight-hover" },
31-
});
32-
22+
const CODEMIRROR_BASIC_SETUP = {
23+
highlightActiveLine: false,
24+
highlightActiveLineGutter: false,
25+
foldGutter: false,
26+
foldKeymap: false,
27+
} as const;
3328

3429
interface ReferencedFileSourceListItemProps {
3530
id: string;
@@ -75,47 +70,32 @@ const ReferencedFileSourceListItemComponent = ({
7570
forwardedRef,
7671
() => editorRef as ReactCodeMirrorRef
7772
);
73+
7874
const keymapExtension = useKeymapExtension(editorRef?.view);
7975
const hasCodeNavEntitlement = useHasEntitlement("code-nav");
80-
8176
const languageExtension = useCodeMirrorLanguageExtension(language, editorRef?.view);
8277

83-
const getReferenceAtPos = useCallback((x: number, y: number, view: EditorView): FileReference | undefined => {
84-
const pos = view.posAtCoords({ x, y });
85-
if (pos === null) return undefined;
86-
87-
// Check if position is within the main editor content area
88-
const rect = view.contentDOM.getBoundingClientRect();
89-
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
90-
return undefined;
91-
}
92-
93-
const line = view.state.doc.lineAt(pos);
94-
const lineNumber = line.number;
95-
96-
// Check if this line is part of any highlighted range
97-
const matchingRanges = references.filter(({ range }) =>
98-
range && lineNumber >= range.startLine && lineNumber <= range.endLine
99-
);
78+
const codeFoldingExtension = useMemo(() => {
79+
return createCodeFoldingExtension(references, 3);
80+
}, [references]);
10081

101-
// Sort by the length of the range.
102-
// Shorter ranges are more specific, so we want to prioritize them.
103-
matchingRanges.sort((a, b) => {
104-
const aLength = (a.range!.endLine) - (a.range!.startLine);
105-
const bLength = (b.range!.endLine) - (b.range!.startLine);
106-
return aLength - bLength;
107-
});
82+
const referencesHighlightExtension = useExtensionWithDependency(
83+
editorRef?.view ?? null,
84+
() => createReferencesHighlightExtension(references, onHoveredReferenceChanged, onSelectedReferenceChanged),
85+
[references],
86+
);
10887

109-
if (matchingRanges.length > 0) {
110-
return matchingRanges[0];
88+
useEffect(() => {
89+
if (editorRef?.view) {
90+
editorRef.view.dispatch({ effects: setHoveredIdEffect.of(hoveredReference?.id) });
11191
}
92+
}, [hoveredReference?.id, editorRef?.view]);
11293

113-
return undefined;
114-
}, [references]);
115-
116-
const codeFoldingExtension = useMemo(() => {
117-
return createCodeFoldingExtension(references, 3);
118-
}, [references]);
94+
useEffect(() => {
95+
if (editorRef?.view) {
96+
editorRef.view.dispatch({ effects: setSelectedIdEffect.of(selectedReference?.id) });
97+
}
98+
}, [selectedReference?.id, editorRef?.view]);
11999

120100
const extensions = useMemo(() => {
121101
return [
@@ -126,88 +106,14 @@ const ReferencedFileSourceListItemComponent = ({
126106
symbolHoverTargetsExtension,
127107
] : []),
128108
codeFoldingExtension,
129-
StateField.define<DecorationSet>({
130-
create(state) {
131-
const decorations: Range<Decoration>[] = [];
132-
133-
for (const { range, id } of references) {
134-
if (!range) {
135-
continue;
136-
}
137-
138-
const isHovered = id === hoveredReference?.id;
139-
const isSelected = id === selectedReference?.id;
140-
141-
for (let line = range.startLine; line <= range.endLine; line++) {
142-
// Skip lines that are outside the document bounds.
143-
if (line > state.doc.lines) {
144-
continue;
145-
}
146-
147-
if (isSelected) {
148-
decorations.push(selectedLineDecoration.range(state.doc.line(line).from));
149-
} else {
150-
decorations.push(lineDecoration.range(state.doc.line(line).from));
151-
if (isHovered) {
152-
decorations.push(hoverLineDecoration.range(state.doc.line(line).from));
153-
}
154-
}
155-
156-
}
157-
}
158-
159-
return Decoration.set(decorations, /* sort = */ true);
160-
},
161-
update(deco, tr) {
162-
return deco.map(tr.changes);
163-
},
164-
provide: (field) => EditorView.decorations.from(field),
165-
}),
166-
EditorView.domEventHandlers({
167-
click: (event, view) => {
168-
const reference = getReferenceAtPos(event.clientX, event.clientY, view);
169-
170-
if (reference) {
171-
onSelectedReferenceChanged(reference.id === selectedReference?.id ? undefined : reference);
172-
return true; // prevent default handling
173-
}
174-
return false;
175-
},
176-
mouseover: (event, view) => {
177-
const reference = getReferenceAtPos(event.clientX, event.clientY, view);
178-
if (!reference) {
179-
return false;
180-
}
181-
182-
if (reference.id === selectedReference?.id || reference.id === hoveredReference?.id) {
183-
return false;
184-
}
185-
186-
onHoveredReferenceChanged(reference);
187-
return true;
188-
},
189-
mouseout: (event, view) => {
190-
const reference = getReferenceAtPos(event.clientX, event.clientY, view);
191-
if (reference) {
192-
return false;
193-
}
194-
195-
onHoveredReferenceChanged(undefined);
196-
return true;
197-
}
198-
})
109+
referencesHighlightExtension,
199110
];
200111
}, [
201112
languageExtension,
202113
keymapExtension,
203114
hasCodeNavEntitlement,
204-
references,
205-
hoveredReference?.id,
206-
selectedReference?.id,
207-
getReferenceAtPos,
208-
onSelectedReferenceChanged,
209-
onHoveredReferenceChanged,
210115
codeFoldingExtension,
116+
referencesHighlightExtension,
211117
]);
212118

213119
const ExpandCollapseIcon = useMemo(() => {
@@ -253,12 +159,7 @@ const ReferencedFileSourceListItemComponent = ({
253159
extensions={extensions}
254160
readOnly={true}
255161
theme={theme}
256-
basicSetup={{
257-
highlightActiveLine: false,
258-
highlightActiveLineGutter: false,
259-
foldGutter: false,
260-
foldKeymap: false,
261-
}}
162+
basicSetup={CODEMIRROR_BASIC_SETUP}
262163
>
263164
{editorRef && hasCodeNavEntitlement && (
264165
<SymbolHoverPopup
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
'use client';
2+
3+
import { getFileSource } from "@/app/api/(client)/client";
4+
import { VscodeFileIcon } from "@/app/components/vscodeFileIcon";
5+
import { Skeleton } from "@/components/ui/skeleton";
6+
import { isServiceError, unwrapServiceError } from "@/lib/utils";
7+
import { useQuery } from "@tanstack/react-query";
8+
import { ReactCodeMirrorRef } from '@uiw/react-codemirror';
9+
import { memo, useCallback } from "react";
10+
import { FileReference, FileSource, Reference } from "../../types";
11+
import { ReferencedFileSourceListItem } from "./referencedFileSourceListItem";
12+
import isEqual from 'fast-deep-equal/react';
13+
14+
export interface ReferencedFileSourceListItemContainerProps {
15+
fileId: string;
16+
fileSource: FileSource;
17+
references: FileReference[];
18+
hoveredReference?: Reference;
19+
selectedReference?: Reference;
20+
onHoveredReferenceChanged: (reference?: Reference) => void;
21+
onSelectedReferenceChanged: (reference?: Reference) => void;
22+
isExpanded: boolean;
23+
onExpandedChanged: (fileId: string, isExpanded: boolean) => void;
24+
onEditorRef: (fileId: string, ref: ReactCodeMirrorRef | null) => void;
25+
}
26+
27+
const ReferencedFileSourceListItemContainerComponent = ({
28+
fileId,
29+
fileSource,
30+
references,
31+
hoveredReference,
32+
selectedReference,
33+
onHoveredReferenceChanged,
34+
onSelectedReferenceChanged,
35+
isExpanded,
36+
onExpandedChanged,
37+
onEditorRef,
38+
}: ReferencedFileSourceListItemContainerProps) => {
39+
const fileName = fileSource.path.split('/').pop() ?? fileSource.path;
40+
41+
const { data, isLoading, isError, error } = useQuery({
42+
queryKey: ['fileSource', fileSource.path, fileSource.repo, fileSource.revision],
43+
queryFn: () => unwrapServiceError(getFileSource({
44+
path: fileSource.path,
45+
repo: fileSource.repo,
46+
ref: fileSource.revision,
47+
})),
48+
staleTime: Infinity,
49+
});
50+
51+
const handleRef = useCallback((ref: ReactCodeMirrorRef | null) => {
52+
onEditorRef(fileId, ref);
53+
}, [fileId, onEditorRef]);
54+
55+
const handleExpandedChanged = useCallback((isExpanded: boolean) => {
56+
onExpandedChanged(fileId, isExpanded);
57+
}, [fileId, onExpandedChanged]);
58+
59+
if (isLoading) {
60+
return (
61+
<div className="space-y-2">
62+
<div className="flex items-center gap-2 p-2">
63+
<VscodeFileIcon fileName={fileName} className="w-4 h-4" />
64+
<span className="text-sm font-medium">{fileName}</span>
65+
</div>
66+
<Skeleton className="h-48 w-full" />
67+
</div>
68+
);
69+
}
70+
71+
if (isError || isServiceError(data) || !data) {
72+
return (
73+
<div className="space-y-2">
74+
<div className="flex items-center gap-2 p-2">
75+
<VscodeFileIcon fileName={fileName} className="w-4 h-4" />
76+
<span className="text-sm font-medium">{fileName}</span>
77+
</div>
78+
<div className="p-4 text-sm text-destructive bg-destructive/10 rounded border">
79+
Failed to load file: {isServiceError(data) ? data.message : error?.message ?? 'Unknown error'}
80+
</div>
81+
</div>
82+
);
83+
}
84+
85+
return (
86+
<ReferencedFileSourceListItem
87+
id={fileId}
88+
code={data.source}
89+
language={data.language}
90+
revision={fileSource.revision}
91+
repoName={fileSource.repo}
92+
repoCodeHostType={data.repoCodeHostType}
93+
repoDisplayName={data.repoDisplayName}
94+
repoWebUrl={data.repoExternalWebUrl}
95+
fileName={data.path}
96+
references={references}
97+
ref={handleRef}
98+
onSelectedReferenceChanged={onSelectedReferenceChanged}
99+
onHoveredReferenceChanged={onHoveredReferenceChanged}
100+
selectedReference={selectedReference}
101+
hoveredReference={hoveredReference}
102+
isExpanded={isExpanded}
103+
onExpandedChanged={handleExpandedChanged}
104+
/>
105+
);
106+
};
107+
108+
export const ReferencedFileSourceListItemContainer = memo(ReferencedFileSourceListItemContainerComponent, isEqual);

0 commit comments

Comments
 (0)