Speed up issue detail comments and refreshes

This commit is contained in:
dotta 2026-04-08 17:22:52 -05:00
parent a4b05d8831
commit 9e8cd28f81
4 changed files with 276 additions and 77 deletions

View file

@ -82,7 +82,21 @@ export const issuesApi = {
expectedStatuses: ["todo", "backlog", "blocked", "in_review"], expectedStatuses: ["todo", "backlog", "blocked", "in_review"],
}), }),
release: (id: string) => api.post<Issue>(`/issues/${id}/release`, {}), release: (id: string) => api.post<Issue>(`/issues/${id}/release`, {}),
listComments: (id: string) => api.get<IssueComment[]>(`/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<IssueComment[]>(`/issues/${id}/comments${qs ? `?${qs}` : ""}`);
},
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`), listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => { listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
const params = new URLSearchParams(); const params = new URLSearchParams();

View file

@ -5,10 +5,12 @@ import {
applyOptimisticIssueFieldUpdateToCollection, applyOptimisticIssueFieldUpdateToCollection,
applyOptimisticIssueCommentUpdate, applyOptimisticIssueCommentUpdate,
createOptimisticIssueComment, createOptimisticIssueComment,
flattenIssueCommentPages,
isQueuedIssueComment, isQueuedIssueComment,
matchesIssueRef, matchesIssueRef,
mergeIssueComments, mergeIssueComments,
upsertIssueComment, upsertIssueComment,
upsertIssueCommentInPages,
} from "./optimistic-issue-comments"; } from "./optimistic-issue-comments";
describe("optimistic issue comments", () => { describe("optimistic issue comments", () => {
@ -128,6 +130,91 @@ describe("optimistic issue comments", () => {
expect(next[0]?.body).toBe("Updated"); 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", () => { it("applies optimistic reopen and reassignment updates to the issue cache", () => {
const next = applyOptimisticIssueCommentUpdate( const next = applyOptimisticIssueCommentUpdate(
{ {

View file

@ -33,6 +33,10 @@ export function sortIssueComments<T extends { createdAt: Date | string; id: stri
}); });
} }
function sortIssueCommentsDesc<T extends { createdAt: Date | string; id: string }>(comments: T[]) {
return sortIssueComments(comments).reverse();
}
export function createOptimisticIssueComment(params: { export function createOptimisticIssueComment(params: {
companyId: string; companyId: string;
issueId: string; issueId: string;
@ -92,6 +96,12 @@ export function mergeIssueComments(
return sortIssueComments(merged); return sortIssueComments(merged);
} }
export function flattenIssueCommentPages(
pages: ReadonlyArray<ReadonlyArray<IssueComment>> | undefined,
): IssueComment[] {
return sortIssueComments((pages ?? []).flatMap((page) => page));
}
export function upsertIssueComment( export function upsertIssueComment(
comments: IssueComment[] | undefined, comments: IssueComment[] | undefined,
nextComment: IssueComment, nextComment: IssueComment,
@ -210,3 +220,24 @@ export function applyOptimisticIssueFieldUpdateToCollection(
return changed ? nextIssues : issues; return changed ? nextIssues : issues;
} }
export function upsertIssueCommentInPages(
pages: ReadonlyArray<ReadonlyArray<IssueComment>> | 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;
}

View file

@ -1,7 +1,7 @@
import { useCallback, 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 { pickTextColorForPillBg } from "@/lib/color-contrast";
import { Link, useLocation, useNavigate, useParams } from "@/lib/router"; 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 { issuesApi } from "../api/issues";
import { approvalsApi } from "../api/approvals"; import { approvalsApi } from "../api/approvals";
import { activityApi, type RunForIssue } from "../api/activity"; import { activityApi, type RunForIssue } from "../api/activity";
@ -35,10 +35,11 @@ import {
applyOptimisticIssueFieldUpdateToCollection, applyOptimisticIssueFieldUpdateToCollection,
applyOptimisticIssueCommentUpdate, applyOptimisticIssueCommentUpdate,
createOptimisticIssueComment, createOptimisticIssueComment,
flattenIssueCommentPages,
isQueuedIssueComment, isQueuedIssueComment,
matchesIssueRef, matchesIssueRef,
mergeIssueComments, mergeIssueComments,
upsertIssueComment, upsertIssueCommentInPages,
type IssueCommentReassignment, type IssueCommentReassignment,
type OptimisticIssueComment, type OptimisticIssueComment,
} from "../lib/optimistic-issue-comments"; } from "../lib/optimistic-issue-comments";
@ -134,6 +135,11 @@ const ACTION_LABELS: Record<string, string> = {
}; };
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos"; const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
const ISSUE_COMMENT_PAGE_SIZE = 50;
function keepPreviousData<T>(previousData: T | undefined) {
return previousData;
}
function humanizeValue(value: unknown): string { function humanizeValue(value: unknown): string {
if (typeof value !== "string") return String(value ?? "none"); if (typeof value !== "string") return String(value ?? "none");
@ -393,28 +399,50 @@ export function IssueDetail() {
return getClosedIsolatedExecutionWorkspaceMessage(issue.currentExecutionWorkspace); return getClosedIsolatedExecutionWorkspaceMessage(issue.currentExecutionWorkspace);
}, [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!), 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, 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({ const { data: activity, isLoading: activityLoading } = useQuery({
queryKey: queryKeys.issues.activity(issueId!), queryKey: queryKeys.issues.activity(issueId!),
queryFn: () => activityApi.forIssue(issueId!), queryFn: () => activityApi.forIssue(issueId!),
enabled: !!issueId, enabled: !!issueId,
placeholderData: keepPreviousData,
}); });
const { data: linkedApprovals } = useQuery({ const { data: linkedApprovals } = useQuery({
queryKey: queryKeys.issues.approvals(issueId!), queryKey: queryKeys.issues.approvals(issueId!),
queryFn: () => issuesApi.listApprovals(issueId!), queryFn: () => issuesApi.listApprovals(issueId!),
enabled: !!issueId, enabled: !!issueId,
placeholderData: keepPreviousData,
}); });
const { data: attachments, isLoading: attachmentsLoading } = useQuery({ const { data: attachments, isLoading: attachmentsLoading } = useQuery({
queryKey: queryKeys.issues.attachments(issueId!), queryKey: queryKeys.issues.attachments(issueId!),
queryFn: () => issuesApi.listAttachments(issueId!), queryFn: () => issuesApi.listAttachments(issueId!),
enabled: !!issueId, enabled: !!issueId,
placeholderData: keepPreviousData,
}); });
const { data: liveRuns, isLoading: liveRunsLoading } = useQuery({ const { data: liveRuns, isLoading: liveRunsLoading } = useQuery({
@ -427,26 +455,31 @@ export function IssueDetail() {
? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS ? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS
: IDLE_ISSUE_RUN_POLL_INTERVAL_MS; : IDLE_ISSUE_RUN_POLL_INTERVAL_MS;
}, },
placeholderData: keepPreviousData,
}); });
const { data: activeRun, isLoading: activeRunLoading } = useQuery({ const { data: activeRun, isLoading: activeRunLoading } = useQuery({
queryKey: queryKeys.issues.activeRun(issueId!), queryKey: queryKeys.issues.activeRun(issueId!),
queryFn: () => heartbeatsApi.activeRunForIssue(issueId!), queryFn: () => heartbeatsApi.activeRunForIssue(issueId!),
enabled: !!issueId, enabled: !!issueId && (!!issue?.executionRunId || issue?.status === "in_progress"),
refetchInterval: (query) => refetchInterval: (query) =>
query.state.data (liveRuns?.length ?? 0) > 0
? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS ? false
: IDLE_ISSUE_RUN_POLL_INTERVAL_MS, : query.state.data
? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS
: IDLE_ISSUE_RUN_POLL_INTERVAL_MS,
placeholderData: keepPreviousData,
}); });
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun; const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
const { data: linkedRuns } = useQuery({ const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({
queryKey: queryKeys.issues.runs(issueId!), queryKey: queryKeys.issues.runs(issueId!),
queryFn: () => activityApi.runsForIssue(issueId!), queryFn: () => activityApi.runsForIssue(issueId!),
enabled: !!issueId, enabled: !!issueId,
refetchInterval: hasLiveRuns refetchInterval: hasLiveRuns
? ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS ? ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS
: IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS, : IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS,
placeholderData: keepPreviousData,
}); });
const runningIssueRun = useMemo( const runningIssueRun = useMemo(
() => ( () => (
@ -481,6 +514,7 @@ export function IssueDetail() {
: ["issues", "parent", "pending"], : ["issues", "parent", "pending"],
queryFn: () => issuesApi.list(resolvedCompanyId!, { parentId: issue!.id }), queryFn: () => issuesApi.list(resolvedCompanyId!, { parentId: issue!.id }),
enabled: !!resolvedCompanyId && !!issue?.id, enabled: !!resolvedCompanyId && !!issue?.id,
placeholderData: keepPreviousData,
}); });
const { data: agents } = useQuery({ const { data: agents } = useQuery({
@ -734,26 +768,18 @@ export function IssueDetail() {
}; };
}, [linkedRuns]); }, [linkedRuns]);
const invalidateIssue = () => { const invalidateIssueDetail = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(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.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.liveRuns(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
if (selectedCompanyId) { }, [issueId, queryClient]);
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) });
}
};
const invalidateIssueCollections = () => { const invalidateIssueCollections = useCallback(() => {
if (selectedCompanyId) { if (selectedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(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.issues.listUnreadTouchedByMe(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
} }
}; }, [queryClient, selectedCompanyId]);
const applyOptimisticIssueCacheUpdate = useCallback((refs: Iterable<string>, data: Record<string, unknown>) => { const applyOptimisticIssueCacheUpdate = useCallback((refs: Iterable<string>, data: Record<string, unknown>) => {
queryClient.setQueriesData<Issue>( queryClient.setQueriesData<Issue>(
@ -867,7 +893,9 @@ export function IssueDetail() {
setPendingApprovalAction({ approvalId, action }); setPendingApprovalAction({ approvalId, action });
}, },
onSuccess: (_approval, variables) => { onSuccess: (_approval, variables) => {
invalidateIssue(); invalidateIssueDetail();
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) });
invalidateIssueCollections();
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.detail(variables.approvalId) }); queryClient.invalidateQueries({ queryKey: queryKeys.approvals.detail(variables.approvalId) });
if (resolvedCompanyId) { if (resolvedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(resolvedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(resolvedCompanyId) });
@ -930,9 +958,15 @@ export function IssueDetail() {
current.filter((entry) => entry.clientId !== context.optimisticCommentId), current.filter((entry) => entry.clientId !== context.optimisticCommentId),
); );
} }
queryClient.setQueryData<IssueComment[]>( queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
queryKeys.issues.comments(issueId!), 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) => { onError: (err, _variables, context) => {
@ -950,9 +984,14 @@ export function IssueDetail() {
tone: "error", tone: "error",
}); });
}, },
onSettled: () => { onSettled: (_result, _error, variables) => {
invalidateIssue(); invalidateIssueDetail();
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); if (variables.interrupt) {
invalidateIssueRunState();
}
if (variables.reopen) {
invalidateIssueCollections();
}
}, },
}); });
@ -1017,9 +1056,15 @@ export function IssueDetail() {
const { comment, ...nextIssue } = result; const { comment, ...nextIssue } = result;
queryClient.setQueryData(queryKeys.issues.detail(issueId!), nextIssue); queryClient.setQueryData(queryKeys.issues.detail(issueId!), nextIssue);
if (comment) { if (comment) {
queryClient.setQueryData<IssueComment[]>( queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
queryKeys.issues.comments(issueId!), 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", tone: "error",
}); });
}, },
onSettled: () => { onSettled: (_result, _error, variables) => {
invalidateIssue(); invalidateIssueDetail();
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); if (variables.interrupt) {
invalidateIssueRunState();
}
invalidateIssueCollections();
}, },
}); });
@ -1085,7 +1133,8 @@ export function IssueDetail() {
}; };
}, },
onSuccess: () => { onSuccess: () => {
invalidateIssue(); invalidateIssueDetail();
invalidateIssueRunState();
pushToast({ pushToast({
title: "Interrupt requested", title: "Interrupt requested",
body: "The active run is stopping so queued comments can continue next.", body: "The active run is stopping so queued comments can continue next.",
@ -1177,7 +1226,7 @@ export function IssueDetail() {
onSuccess: () => { onSuccess: () => {
setAttachmentError(null); setAttachmentError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
invalidateIssue(); invalidateIssueDetail();
}, },
onError: (err) => { onError: (err) => {
setAttachmentError(err instanceof Error ? err.message : "Upload failed"); setAttachmentError(err instanceof Error ? err.message : "Upload failed");
@ -1201,7 +1250,8 @@ export function IssueDetail() {
}, },
onSuccess: () => { onSuccess: () => {
setAttachmentError(null); setAttachmentError(null);
invalidateIssue(); invalidateIssueDetail();
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issueId!) });
}, },
onError: (err) => { onError: (err) => {
setAttachmentError(err instanceof Error ? err.message : "Document import failed"); setAttachmentError(err instanceof Error ? err.message : "Document import failed");
@ -1213,7 +1263,7 @@ export function IssueDetail() {
onSuccess: () => { onSuccess: () => {
setAttachmentError(null); setAttachmentError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
invalidateIssue(); invalidateIssueDetail();
}, },
onError: (err) => { onError: (err) => {
setAttachmentError(err instanceof Error ? err.message : "Delete failed"); setAttachmentError(err instanceof Error ? err.message : "Delete failed");
@ -1223,7 +1273,7 @@ export function IssueDetail() {
const archiveFromInbox = useMutation({ const archiveFromInbox = useMutation({
mutationFn: (id: string) => issuesApi.archiveFromInbox(id), mutationFn: (id: string) => issuesApi.archiveFromInbox(id),
onSuccess: () => { onSuccess: () => {
invalidateIssue(); invalidateIssueCollections();
navigate(sourceBreadcrumb.href.startsWith("/inbox") ? sourceBreadcrumb.href : "/inbox", { replace: true }); navigate(sourceBreadcrumb.href.startsWith("/inbox") ? sourceBreadcrumb.href : "/inbox", { replace: true });
pushToast({ title: "Issue archived from inbox", tone: "success" }); pushToast({ title: "Issue archived from inbox", tone: "success" });
}, },
@ -1504,7 +1554,7 @@ export function IssueDetail() {
}; };
const issueChatInitialLoading = const issueChatInitialLoading =
(commentsLoading && comments === undefined) (commentsLoading && commentPages === undefined)
|| (activityLoading && activity === undefined) || (activityLoading && activity === undefined)
|| (linkedRunsLoading && linkedRuns === undefined) || (linkedRunsLoading && linkedRuns === undefined)
|| (liveRunsLoading && liveRuns === undefined) || (liveRunsLoading && liveRuns === undefined)
@ -2067,41 +2117,58 @@ export function IssueDetail() {
{issueChatInitialLoading ? ( {issueChatInitialLoading ? (
<IssueChatSkeleton /> <IssueChatSkeleton />
) : ( ) : (
<IssueChatThread <div className="space-y-3">
composerRef={commentComposerRef} {hasOlderComments ? (
comments={commentsWithRunMeta} <div className="flex justify-center">
feedbackVotes={feedbackVotes} <Button
feedbackDataSharingPreference={feedbackDataSharingPreference} type="button"
feedbackTermsUrl={FEEDBACK_TERMS_URL} variant="outline"
linkedRuns={timelineRuns} size="sm"
timelineEvents={timelineEvents} disabled={commentsLoadingOlder}
liveRuns={liveRuns} onClick={() => {
activeRun={activeRun} void fetchOlderComments();
companyId={issue.companyId} }}
projectId={issue.projectId} >
issueStatus={issue.status} {commentsLoadingOlder ? "Loading earlier comments..." : "Load earlier comments"}
agentMap={agentMap} </Button>
currentUserId={currentUserId} </div>
draftKey={`paperclip:issue-comment-draft:${issue.id}`} ) : null}
enableReassign <IssueChatThread
reassignOptions={commentReassignOptions} composerRef={commentComposerRef}
currentAssigneeValue={actualAssigneeValue} comments={commentsWithRunMeta}
suggestedAssigneeValue={suggestedAssigneeValue} feedbackVotes={feedbackVotes}
mentions={mentionOptions} feedbackDataSharingPreference={feedbackDataSharingPreference}
onInterruptQueued={handleInterruptQueued} feedbackTermsUrl={FEEDBACK_TERMS_URL}
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null} linkedRuns={timelineRuns}
composerDisabledReason={commentComposerDisabledReason} timelineEvents={timelineEvents}
onVote={handleCommentVote} liveRuns={liveRuns}
onAdd={handleCommentAdd} activeRun={activeRun}
imageUploadHandler={handleCommentImageUpload} companyId={issue.companyId}
onAttachImage={handleCommentAttachImage} projectId={issue.projectId}
onCancelRun={runningIssueRun issueStatus={issue.status}
? async () => { agentMap={agentMap}
await interruptQueuedComment.mutateAsync(runningIssueRun.id); currentUserId={currentUserId}
} draftKey={`paperclip:issue-comment-draft:${issue.id}`}
: undefined} enableReassign
onImageClick={handleChatImageClick} reassignOptions={commentReassignOptions}
/> currentAssigneeValue={actualAssigneeValue}
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentionOptions}
onInterruptQueued={handleInterruptQueued}
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
composerDisabledReason={commentComposerDisabledReason}
onVote={handleCommentVote}
onAdd={handleCommentAdd}
imageUploadHandler={handleCommentImageUpload}
onAttachImage={handleCommentAttachImage}
onCancelRun={runningIssueRun
? async () => {
await interruptQueuedComment.mutateAsync(runningIssueRun.id);
}
: undefined}
onImageClick={handleChatImageClick}
/>
</div>
)} )}
</TabsContent> </TabsContent>