diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index a628bde4..bd604af9 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -82,7 +82,21 @@ export const issuesApi = { expectedStatuses: ["todo", "backlog", "blocked", "in_review"], }), release: (id: string) => api.post(`/issues/${id}/release`, {}), - listComments: (id: string) => api.get(`/issues/${id}/comments`), + listComments: ( + id: string, + filters?: { + after?: string; + order?: "asc" | "desc"; + limit?: number; + }, + ) => { + const params = new URLSearchParams(); + if (filters?.after) params.set("after", filters.after); + if (filters?.order) params.set("order", filters.order); + if (filters?.limit) params.set("limit", String(filters.limit)); + const qs = params.toString(); + return api.get(`/issues/${id}/comments${qs ? `?${qs}` : ""}`); + }, listFeedbackVotes: (id: string) => api.get(`/issues/${id}/feedback-votes`), listFeedbackTraces: (id: string, filters?: Record) => { const params = new URLSearchParams(); diff --git a/ui/src/lib/optimistic-issue-comments.test.ts b/ui/src/lib/optimistic-issue-comments.test.ts index 12659f61..2499ce9b 100644 --- a/ui/src/lib/optimistic-issue-comments.test.ts +++ b/ui/src/lib/optimistic-issue-comments.test.ts @@ -5,10 +5,12 @@ import { applyOptimisticIssueFieldUpdateToCollection, applyOptimisticIssueCommentUpdate, createOptimisticIssueComment, + flattenIssueCommentPages, isQueuedIssueComment, matchesIssueRef, mergeIssueComments, upsertIssueComment, + upsertIssueCommentInPages, } from "./optimistic-issue-comments"; describe("optimistic issue comments", () => { @@ -128,6 +130,91 @@ describe("optimistic issue comments", () => { expect(next[0]?.body).toBe("Updated"); }); + it("flattens paged comments into one chronological thread", () => { + const flattened = flattenIssueCommentPages([ + [ + { + id: "comment-3", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Newest", + createdAt: new Date("2026-03-28T14:00:03.000Z"), + updatedAt: new Date("2026-03-28T14:00:03.000Z"), + }, + ], + [ + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Oldest", + createdAt: new Date("2026-03-28T14:00:01.000Z"), + updatedAt: new Date("2026-03-28T14:00:01.000Z"), + }, + { + id: "comment-2", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Middle", + createdAt: new Date("2026-03-28T14:00:02.000Z"), + updatedAt: new Date("2026-03-28T14:00:02.000Z"), + }, + ], + ]); + + expect(flattened.map((comment) => comment.id)).toEqual(["comment-1", "comment-2", "comment-3"]); + }); + + it("upserts paged comments without dropping older pages", () => { + const nextPages = upsertIssueCommentInPages( + [ + [ + { + id: "comment-3", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Newest", + createdAt: new Date("2026-03-28T14:00:03.000Z"), + updatedAt: new Date("2026-03-28T14:00:03.000Z"), + }, + ], + [ + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Oldest", + createdAt: new Date("2026-03-28T14:00:01.000Z"), + updatedAt: new Date("2026-03-28T14:00:01.000Z"), + }, + ], + ], + { + id: "comment-4", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Brand new", + createdAt: new Date("2026-03-28T14:00:04.000Z"), + updatedAt: new Date("2026-03-28T14:00:04.000Z"), + }, + ); + + expect(nextPages[0]?.map((comment) => comment.id)).toEqual(["comment-4", "comment-3"]); + expect(nextPages[1]?.map((comment) => comment.id)).toEqual(["comment-1"]); + }); + it("applies optimistic reopen and reassignment updates to the issue cache", () => { const next = applyOptimisticIssueCommentUpdate( { diff --git a/ui/src/lib/optimistic-issue-comments.ts b/ui/src/lib/optimistic-issue-comments.ts index be35ebb8..c7237bf8 100644 --- a/ui/src/lib/optimistic-issue-comments.ts +++ b/ui/src/lib/optimistic-issue-comments.ts @@ -33,6 +33,10 @@ export function sortIssueComments(comments: T[]) { + return sortIssueComments(comments).reverse(); +} + export function createOptimisticIssueComment(params: { companyId: string; issueId: string; @@ -92,6 +96,12 @@ export function mergeIssueComments( return sortIssueComments(merged); } +export function flattenIssueCommentPages( + pages: ReadonlyArray> | undefined, +): IssueComment[] { + return sortIssueComments((pages ?? []).flatMap((page) => page)); +} + export function upsertIssueComment( comments: IssueComment[] | undefined, nextComment: IssueComment, @@ -210,3 +220,24 @@ export function applyOptimisticIssueFieldUpdateToCollection( return changed ? nextIssues : issues; } + +export function upsertIssueCommentInPages( + pages: ReadonlyArray> | undefined, + nextComment: IssueComment, +): IssueComment[][] { + if (!pages || pages.length === 0) { + return [[nextComment]]; + } + + const nextPages = pages.map((page) => [...page]); + for (let pageIndex = 0; pageIndex < nextPages.length; pageIndex += 1) { + const existingIndex = nextPages[pageIndex]!.findIndex((comment) => comment.id === nextComment.id); + if (existingIndex === -1) continue; + nextPages[pageIndex]![existingIndex] = nextComment; + nextPages[pageIndex] = sortIssueCommentsDesc(nextPages[pageIndex]!); + return nextPages; + } + + nextPages[0] = sortIssueCommentsDesc([...nextPages[0]!, nextComment]); + return nextPages; +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 98c8be4c..5741b40b 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1,7 +1,7 @@ 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"; +import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type InfiniteData } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; import { approvalsApi } from "../api/approvals"; import { activityApi, type RunForIssue } from "../api/activity"; @@ -35,10 +35,11 @@ import { applyOptimisticIssueFieldUpdateToCollection, applyOptimisticIssueCommentUpdate, createOptimisticIssueComment, + flattenIssueCommentPages, isQueuedIssueComment, matchesIssueRef, mergeIssueComments, - upsertIssueComment, + upsertIssueCommentInPages, type IssueCommentReassignment, type OptimisticIssueComment, } from "../lib/optimistic-issue-comments"; @@ -134,6 +135,11 @@ const ACTION_LABELS: Record = { }; const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos"; +const ISSUE_COMMENT_PAGE_SIZE = 50; + +function keepPreviousData(previousData: T | undefined) { + return previousData; +} function humanizeValue(value: unknown): string { if (typeof value !== "string") return String(value ?? "none"); @@ -393,28 +399,50 @@ export function IssueDetail() { return getClosedIsolatedExecutionWorkspaceMessage(issue.currentExecutionWorkspace); }, [issue?.currentExecutionWorkspace]); - const { data: comments, isLoading: commentsLoading } = useQuery({ + const { + data: commentPages, + isLoading: commentsLoading, + isFetchingNextPage: commentsLoadingOlder, + hasNextPage: hasOlderComments, + fetchNextPage: fetchOlderComments, + } = useInfiniteQuery({ queryKey: queryKeys.issues.comments(issueId!), - queryFn: () => issuesApi.listComments(issueId!), + queryFn: ({ pageParam }) => + issuesApi.listComments(issueId!, { + order: "desc", + limit: ISSUE_COMMENT_PAGE_SIZE, + ...(pageParam ? { after: pageParam } : {}), + }), enabled: !!issueId, + initialPageParam: null as string | null, + getNextPageParam: (lastPage) => + lastPage.length === ISSUE_COMMENT_PAGE_SIZE ? lastPage[lastPage.length - 1]?.id : undefined, + placeholderData: keepPreviousData, }); + const comments = useMemo( + () => flattenIssueCommentPages(commentPages?.pages), + [commentPages?.pages], + ); const { data: activity, isLoading: activityLoading } = useQuery({ queryKey: queryKeys.issues.activity(issueId!), queryFn: () => activityApi.forIssue(issueId!), enabled: !!issueId, + placeholderData: keepPreviousData, }); const { data: linkedApprovals } = useQuery({ queryKey: queryKeys.issues.approvals(issueId!), queryFn: () => issuesApi.listApprovals(issueId!), enabled: !!issueId, + placeholderData: keepPreviousData, }); const { data: attachments, isLoading: attachmentsLoading } = useQuery({ queryKey: queryKeys.issues.attachments(issueId!), queryFn: () => issuesApi.listAttachments(issueId!), enabled: !!issueId, + placeholderData: keepPreviousData, }); const { data: liveRuns, isLoading: liveRunsLoading } = useQuery({ @@ -427,26 +455,31 @@ export function IssueDetail() { ? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS : IDLE_ISSUE_RUN_POLL_INTERVAL_MS; }, + placeholderData: keepPreviousData, }); const { data: activeRun, isLoading: activeRunLoading } = useQuery({ queryKey: queryKeys.issues.activeRun(issueId!), queryFn: () => heartbeatsApi.activeRunForIssue(issueId!), - enabled: !!issueId, + enabled: !!issueId && (!!issue?.executionRunId || issue?.status === "in_progress"), refetchInterval: (query) => - query.state.data - ? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS - : IDLE_ISSUE_RUN_POLL_INTERVAL_MS, + (liveRuns?.length ?? 0) > 0 + ? false + : query.state.data + ? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS + : IDLE_ISSUE_RUN_POLL_INTERVAL_MS, + placeholderData: keepPreviousData, }); const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun; - const { data: linkedRuns } = useQuery({ + const { data: linkedRuns, isLoading: linkedRunsLoading } = 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, + placeholderData: keepPreviousData, }); const runningIssueRun = useMemo( () => ( @@ -481,6 +514,7 @@ export function IssueDetail() { : ["issues", "parent", "pending"], queryFn: () => issuesApi.list(resolvedCompanyId!, { parentId: issue!.id }), enabled: !!resolvedCompanyId && !!issue?.id, + placeholderData: keepPreviousData, }); const { data: agents } = useQuery({ @@ -734,26 +768,18 @@ export function IssueDetail() { }; }, [linkedRuns]); - const invalidateIssue = () => { + const invalidateIssueDetail = useCallback(() => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }); + }, [issueId, queryClient]); + + const invalidateIssueRunState = useCallback(() => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.feedbackVotes(issueId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }); - if (selectedCompanyId) { - queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(selectedCompanyId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) }); - } - }; + }, [issueId, queryClient]); - const invalidateIssueCollections = () => { + const invalidateIssueCollections = useCallback(() => { if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(selectedCompanyId) }); @@ -761,7 +787,7 @@ export function IssueDetail() { queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) }); } - }; + }, [queryClient, selectedCompanyId]); const applyOptimisticIssueCacheUpdate = useCallback((refs: Iterable, data: Record) => { queryClient.setQueriesData( @@ -867,7 +893,9 @@ export function IssueDetail() { setPendingApprovalAction({ approvalId, action }); }, onSuccess: (_approval, variables) => { - invalidateIssue(); + invalidateIssueDetail(); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) }); + invalidateIssueCollections(); queryClient.invalidateQueries({ queryKey: queryKeys.approvals.detail(variables.approvalId) }); if (resolvedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(resolvedCompanyId) }); @@ -930,9 +958,15 @@ export function IssueDetail() { current.filter((entry) => entry.clientId !== context.optimisticCommentId), ); } - queryClient.setQueryData( + queryClient.setQueryData>( queryKeys.issues.comments(issueId!), - (current) => upsertIssueComment(current, comment), + (current) => current ? { + ...current, + pages: upsertIssueCommentInPages(current.pages, comment), + } : { + pageParams: [null], + pages: upsertIssueCommentInPages(undefined, comment), + }, ); }, onError: (err, _variables, context) => { @@ -950,9 +984,14 @@ export function IssueDetail() { tone: "error", }); }, - onSettled: () => { - invalidateIssue(); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); + onSettled: (_result, _error, variables) => { + invalidateIssueDetail(); + if (variables.interrupt) { + invalidateIssueRunState(); + } + if (variables.reopen) { + invalidateIssueCollections(); + } }, }); @@ -1017,9 +1056,15 @@ export function IssueDetail() { const { comment, ...nextIssue } = result; queryClient.setQueryData(queryKeys.issues.detail(issueId!), nextIssue); if (comment) { - queryClient.setQueryData( + queryClient.setQueryData>( queryKeys.issues.comments(issueId!), - (current) => upsertIssueComment(current, comment), + (current) => current ? { + ...current, + pages: upsertIssueCommentInPages(current.pages, comment), + } : { + pageParams: [null], + pages: upsertIssueCommentInPages(undefined, comment), + }, ); } }, @@ -1038,9 +1083,12 @@ export function IssueDetail() { tone: "error", }); }, - onSettled: () => { - invalidateIssue(); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); + onSettled: (_result, _error, variables) => { + invalidateIssueDetail(); + if (variables.interrupt) { + invalidateIssueRunState(); + } + invalidateIssueCollections(); }, }); @@ -1085,7 +1133,8 @@ export function IssueDetail() { }; }, onSuccess: () => { - invalidateIssue(); + invalidateIssueDetail(); + invalidateIssueRunState(); pushToast({ title: "Interrupt requested", body: "The active run is stopping so queued comments can continue next.", @@ -1177,7 +1226,7 @@ export function IssueDetail() { onSuccess: () => { setAttachmentError(null); queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) }); - invalidateIssue(); + invalidateIssueDetail(); }, onError: (err) => { setAttachmentError(err instanceof Error ? err.message : "Upload failed"); @@ -1201,7 +1250,8 @@ export function IssueDetail() { }, onSuccess: () => { setAttachmentError(null); - invalidateIssue(); + invalidateIssueDetail(); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issueId!) }); }, onError: (err) => { setAttachmentError(err instanceof Error ? err.message : "Document import failed"); @@ -1213,7 +1263,7 @@ export function IssueDetail() { onSuccess: () => { setAttachmentError(null); queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) }); - invalidateIssue(); + invalidateIssueDetail(); }, onError: (err) => { setAttachmentError(err instanceof Error ? err.message : "Delete failed"); @@ -1223,7 +1273,7 @@ export function IssueDetail() { const archiveFromInbox = useMutation({ mutationFn: (id: string) => issuesApi.archiveFromInbox(id), onSuccess: () => { - invalidateIssue(); + invalidateIssueCollections(); navigate(sourceBreadcrumb.href.startsWith("/inbox") ? sourceBreadcrumb.href : "/inbox", { replace: true }); pushToast({ title: "Issue archived from inbox", tone: "success" }); }, @@ -1504,7 +1554,7 @@ export function IssueDetail() { }; const issueChatInitialLoading = - (commentsLoading && comments === undefined) + (commentsLoading && commentPages === undefined) || (activityLoading && activity === undefined) || (linkedRunsLoading && linkedRuns === undefined) || (liveRunsLoading && liveRuns === undefined) @@ -2067,41 +2117,58 @@ export function IssueDetail() { {issueChatInitialLoading ? ( ) : ( - { - await interruptQueuedComment.mutateAsync(runningIssueRun.id); - } - : undefined} - onImageClick={handleChatImageClick} - /> +
+ {hasOlderComments ? ( +
+ +
+ ) : null} + { + await interruptQueuedComment.mutateAsync(runningIssueRun.id); + } + : undefined} + onImageClick={handleChatImageClick} + /> +
)}