Fix issue thread typing lag

Reduce idle issue-detail polling, avoid duplicate live-run polling inside the embedded widget, and memoize the comment thread so long timelines do not rerender during unrelated page updates.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip committed 2026-04-06 17:34:21 -07:00
1 parent 08fea10ce1
commit 2779701aba
4 files changed
+94 -37

No files matched your search

+4 -2
View File
@@ -545,7 +545,7 @@ const TimelineList = memo(function TimelineList({
);
});
export function CommentThread({
export const CommentThread = memo(function CommentThread({
comments,
queuedComments = [],
feedbackVotes = [],
@@ -891,4 +891,6 @@ export function CommentThread({
</div>
);
}
});
CommentThread.displayName = "CommentThread";
+18 -6
View File
@@ -1,7 +1,7 @@
import { useMemo, useState } from "react";
import { Link } from "@/lib/router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue } from "../api/heartbeats";
import { queryKeys } from "../lib/queryKeys";
import { formatDateTime } from "../lib/utils";
import { ExternalLink, Square } from "lucide-react";
@@ -13,6 +13,8 @@ import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
interface LiveRunWidgetProps {
issueId: string;
companyId?: string | null;
liveRunsData?: LiveRunForIssue[];
activeRunData?: ActiveRunForIssue | null;
}
function toIsoString(value: string | Date | null | undefined): string | null {
@@ -24,24 +26,34 @@ function isRunActive(status: string): boolean {
return status === "queued" || status === "running";
}
export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
export function LiveRunWidget({
issueId,
companyId,
liveRunsData,
activeRunData,
}: LiveRunWidgetProps) {
const queryClient = useQueryClient();
const [cancellingRunIds, setCancellingRunIds] = useState(new Set<string>());
const shouldFetchLiveRuns = liveRunsData === undefined;
const shouldFetchActiveRun = activeRunData === undefined;
const { data: liveRuns } = useQuery({
const { data: fetchedLiveRuns } = useQuery({
queryKey: queryKeys.issues.liveRuns(issueId),
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
enabled: !!issueId,
enabled: !!issueId && shouldFetchLiveRuns,
refetchInterval: 3000,
});
const { data: activeRun } = useQuery({
const { data: fetchedActiveRun } = useQuery({
queryKey: queryKeys.issues.activeRun(issueId),
queryFn: () => heartbeatsApi.activeRunForIssue(issueId),
enabled: !!issueId,
enabled: !!issueId && shouldFetchActiveRun,
refetchInterval: 3000,
});
const liveRuns = liveRunsData ?? fetchedLiveRuns;
const activeRun = activeRunData ?? fetchedActiveRun;
const runs = useMemo(() => {
const deduped = new Map<string, LiveRunForIssue>();
for (const run of liveRuns ?? []) {
+1 -1
View File
@@ -549,7 +549,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
characterData: true,
});
return () => observer.disconnect();
}, [decorateProjectMentions, value]);
}, [decorateProjectMentions]);
const selectMention = useCallback(
(option: AutocompleteOption) => {
+71 -28
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -91,6 +91,11 @@ type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
queueTargetRunId?: string | null;
};
const ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS = 3000;
const IDLE_ISSUE_RUN_POLL_INTERVAL_MS = 30000;
const ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS = 5000;
const IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS = 30000;
const ACTION_LABELS: Record<string, string> = {
"issue.created": "created the issue",
"issue.updated": "updated the issue",
@@ -333,13 +338,6 @@ export function IssueDetail() {
enabled: !!issueId,
});
const { data: linkedRuns } = useQuery({
queryKey: queryKeys.issues.runs(issueId!),
queryFn: () => activityApi.runsForIssue(issueId!),
enabled: !!issueId,
refetchInterval: 5000,
});
const { data: linkedApprovals } = useQuery({
queryKey: queryKeys.issues.approvals(issueId!),
queryFn: () => issuesApi.listApprovals(issueId!),
@@ -356,17 +354,33 @@ export function IssueDetail() {
queryKey: queryKeys.issues.liveRuns(issueId!),
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!),
enabled: !!issueId,
refetchInterval: 3000,
refetchInterval: (query) => {
const data = query.state.data as Array<unknown> | undefined;
return data && data.length > 0
? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS
: IDLE_ISSUE_RUN_POLL_INTERVAL_MS;
},
});
const { data: activeRun } = useQuery({
queryKey: queryKeys.issues.activeRun(issueId!),
queryFn: () => heartbeatsApi.activeRunForIssue(issueId!),
enabled: !!issueId,
refetchInterval: 3000,
refetchInterval: (query) =>
query.state.data
? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS
: IDLE_ISSUE_RUN_POLL_INTERVAL_MS,
});
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
const { data: linkedRuns } = useQuery({
queryKey: queryKeys.issues.runs(issueId!),
queryFn: () => activityApi.runsForIssue(issueId!),
enabled: !!issueId,
refetchInterval: hasLiveRuns
? ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS
: IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS,
});
const runningIssueRun = useMemo(
() => (
activeRun?.status === "running"
@@ -957,6 +971,39 @@ export function IssueDetail() {
},
});
const handleInterruptQueued = useCallback(
async (runId: string) => {
await interruptQueuedComment.mutateAsync(runId);
},
[interruptQueuedComment],
);
const handleCommentImageUpload = useCallback(
async (file: File) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
},
[uploadAttachment],
);
const handleCommentAttachImage = useCallback(
async (file: File) => {
await uploadAttachment.mutateAsync(file);
},
[uploadAttachment],
);
const handleCommentAdd = useCallback(
async (body: string, reopen?: boolean, reassignment?: CommentReassignment) => {
if (reassignment) {
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
return;
}
await addComment.mutateAsync({ body, reopen });
},
[addComment, addCommentAndReassign],
);
useEffect(() => {
const titleLabel = issue?.title ?? issueId ?? "Issue";
setBreadcrumbs([
@@ -1532,9 +1579,7 @@ export function IssueDetail() {
currentAssigneeValue={actualAssigneeValue}
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentionOptions}
onInterruptQueued={async (runId) => {
await interruptQueuedComment.mutateAsync(runId);
}}
onInterruptQueued={handleInterruptQueued}
interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null}
composerDisabledReason={commentComposerDisabledReason}
onVote={async (commentId, vote, options) => {
@@ -1547,21 +1592,19 @@ export function IssueDetail() {
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
});
}}
onAdd={async (body, reopen, reassignment) => {
if (reassignment) {
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
return;
}
await addComment.mutateAsync({ body, reopen });
}}
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
}}
onAttachImage={async (file) => {
await uploadAttachment.mutateAsync(file);
}}
liveRunSlot={<LiveRunWidget issueId={issueId!} companyId={issue.companyId} />}
onAdd={handleCommentAdd}
imageUploadHandler={handleCommentImageUpload}
onAttachImage={handleCommentAttachImage}
liveRunSlot={
hasLiveRuns ? (
<LiveRunWidget
issueId={issueId!}
companyId={issue.companyId}
liveRunsData={liveRuns ?? []}
activeRunData={activeRun ?? null}
/>
) : null
}
/>
</TabsContent>