diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 9b19baa4..33ba63be 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -11,6 +11,10 @@ import type { } from "@paperclipai/shared"; import { api } from "./client"; +export type IssueUpdateResponse = Issue & { + comment?: IssueComment | null; +}; + export const issuesApi = { list: ( companyId: string, @@ -60,7 +64,8 @@ export const issuesApi = { api.delete<{ id: string; archivedAt: Date } | { ok: true }>(`/issues/${id}/inbox-archive`), create: (companyId: string, data: Record) => api.post(`/companies/${companyId}/issues`, data), - update: (id: string, data: Record) => api.patch(`/issues/${id}`, data), + update: (id: string, data: Record) => + api.patch(`/issues/${id}`, data), remove: (id: string) => api.delete(`/issues/${id}`), checkout: (id: string, agentId: string) => api.post(`/issues/${id}/checkout`, { diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index cdf0ddd2..3595883e 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -15,6 +15,8 @@ import { PluginSlotOutlet } from "@/plugins/slots"; interface CommentWithRunMeta extends IssueComment { runId?: string | null; runAgentId?: string | null; + clientId?: string; + clientStatus?: "pending"; } interface LinkedRunItem { @@ -169,11 +171,14 @@ const TimelineList = memo(function TimelineList({ const comment = item.comment; const isHighlighted = highlightCommentId === comment.id; + const isPending = comment.clientStatus === "pending"; return (
{comment.authorAgentId ? ( @@ -187,7 +192,7 @@ const TimelineList = memo(function TimelineList({ )} - {companyId ? ( + {companyId && !isPending ? ( ) : null} - - {formatDateTime(comment.createdAt)} - + {isPending ? ( + Sending... + ) : ( + + {formatDateTime(comment.createdAt)} + + )}
{comment.body} - {companyId ? ( + {companyId && !isPending ? (
) : null} - {comment.runId && ( + {comment.runId && !isPending && (
{comment.runAgentId ? ( { + it("creates a pending optimistic comment for the current user", () => { + const comment = createOptimisticIssueComment({ + companyId: "company-1", + issueId: "issue-1", + body: "Working on it", + authorUserId: "board-1", + }); + + expect(comment.id).toMatch(/^optimistic-/); + expect(comment.clientId).toBe(comment.id); + expect(comment.clientStatus).toBe("pending"); + expect(comment.authorUserId).toBe("board-1"); + expect(comment.authorAgentId).toBeNull(); + }); + + it("merges optimistic comments into the server thread in chronological order", () => { + const merged = mergeIssueComments( + [ + { + id: "comment-2", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Second", + createdAt: new Date("2026-03-28T14:00:02.000Z"), + updatedAt: new Date("2026-03-28T14:00:02.000Z"), + }, + ], + [ + { + id: "optimistic-1", + clientId: "optimistic-1", + clientStatus: "pending", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "First", + createdAt: new Date("2026-03-28T14:00:01.000Z"), + updatedAt: new Date("2026-03-28T14:00:01.000Z"), + }, + ], + ); + + expect(merged.map((comment) => comment.id)).toEqual(["optimistic-1", "comment-2"]); + }); + + it("upserts confirmed comments without creating duplicates", () => { + const next = upsertIssueComment( + [ + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Original", + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:00.000Z"), + }, + ], + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Updated", + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:05.000Z"), + }, + ); + + expect(next).toHaveLength(1); + expect(next[0]?.body).toBe("Updated"); + }); + + it("applies optimistic reopen and reassignment updates to the issue cache", () => { + const next = applyOptimisticIssueCommentUpdate( + { + id: "issue-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Fix comment flow", + description: null, + status: "done", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: "board-1", + issueNumber: 1, + identifier: "PAP-1", + originKind: "manual", + originId: null, + originRunId: null, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:00.000Z"), + }, + { + reopen: true, + reassignment: { + assigneeAgentId: null, + assigneeUserId: "board-2", + }, + }, + ); + + expect(next?.status).toBe("todo"); + expect(next?.assigneeAgentId).toBeNull(); + expect(next?.assigneeUserId).toBe("board-2"); + }); +}); diff --git a/ui/src/lib/optimistic-issue-comments.ts b/ui/src/lib/optimistic-issue-comments.ts new file mode 100644 index 00000000..20bc1bfe --- /dev/null +++ b/ui/src/lib/optimistic-issue-comments.ts @@ -0,0 +1,98 @@ +import type { Issue, IssueComment } from "@paperclipai/shared"; + +export interface IssueCommentReassignment { + assigneeAgentId: string | null; + assigneeUserId: string | null; +} + +export interface OptimisticIssueComment extends IssueComment { + clientId: string; + clientStatus: "pending"; +} + +export type IssueTimelineComment = IssueComment | OptimisticIssueComment; + +function toTimestamp(value: Date | string) { + return new Date(value).getTime(); +} + +export function sortIssueComments(comments: T[]) { + return [...comments].sort((a, b) => { + const createdAtDiff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt); + if (createdAtDiff !== 0) return createdAtDiff; + return a.id.localeCompare(b.id); + }); +} + +export function createOptimisticIssueComment(params: { + companyId: string; + issueId: string; + body: string; + authorUserId: string | null; +}): OptimisticIssueComment { + const now = new Date(); + const clientId = `optimistic-${crypto.randomUUID()}`; + return { + id: clientId, + clientId, + clientStatus: "pending", + companyId: params.companyId, + issueId: params.issueId, + authorAgentId: null, + authorUserId: params.authorUserId, + body: params.body, + createdAt: now, + updatedAt: now, + }; +} + +export function mergeIssueComments( + comments: IssueComment[] | undefined, + optimisticComments: OptimisticIssueComment[], +): IssueTimelineComment[] { + const merged = [...(comments ?? [])]; + const existingIds = new Set(merged.map((comment) => comment.id)); + for (const comment of optimisticComments) { + if (!existingIds.has(comment.id)) { + merged.push(comment); + } + } + return sortIssueComments(merged); +} + +export function upsertIssueComment( + comments: IssueComment[] | undefined, + nextComment: IssueComment, +): IssueComment[] { + const current = comments ?? []; + const existingIndex = current.findIndex((comment) => comment.id === nextComment.id); + if (existingIndex === -1) { + return sortIssueComments([...current, nextComment]); + } + + const updated = [...current]; + updated[existingIndex] = nextComment; + return sortIssueComments(updated); +} + +export function applyOptimisticIssueCommentUpdate( + issue: Issue | undefined, + params: { + reopen?: boolean; + reassignment?: IssueCommentReassignment; + }, +) { + if (!issue) return issue; + const nextIssue: Issue = { ...issue }; + + if (params.reopen === true && (issue.status === "done" || issue.status === "cancelled")) { + nextIssue.status = "todo"; + } + + if (params.reassignment) { + nextIssue.assigneeAgentId = params.reassignment.assigneeAgentId; + nextIssue.assigneeUserId = params.reassignment.assigneeUserId; + } + + return nextIssue; +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 7bb616ce..5cf552b2 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -15,6 +15,14 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees"; import { queryKeys } from "../lib/queryKeys"; import { createIssueDetailPath, readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb"; +import { + applyOptimisticIssueCommentUpdate, + createOptimisticIssueComment, + mergeIssueComments, + upsertIssueComment, + type IssueCommentReassignment, + type OptimisticIssueComment, +} from "../lib/optimistic-issue-comments"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { InlineEditor } from "../components/InlineEditor"; @@ -55,12 +63,9 @@ import { Trash2, } from "lucide-react"; import type { ActivityEvent } from "@paperclipai/shared"; -import type { Agent, IssueAttachment } from "@paperclipai/shared"; +import type { Agent, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared"; -type CommentReassignment = { - assigneeAgentId: string | null; - assigneeUserId: string | null; -}; +type CommentReassignment = IssueCommentReassignment; const ACTION_LABELS: Record = { "issue.created": "created the issue", @@ -213,6 +218,7 @@ export function IssueDetail() { }); const [attachmentError, setAttachmentError] = useState(null); const [attachmentDragActive, setAttachmentDragActive] = useState(false); + const [optimisticComments, setOptimisticComments] = useState([]); const fileInputRef = useRef(null); const lastMarkedReadIssueIdRef = useRef(null); @@ -386,8 +392,18 @@ export function IssueDetail() { ); const suggestedAssigneeValue = useMemo( - () => suggestedCommentAssigneeValue(issue ?? {}, comments, currentUserId), - [issue, comments, currentUserId], + () => + suggestedCommentAssigneeValue( + issue ?? {}, + mergeIssueComments(comments ?? [], optimisticComments), + currentUserId, + ), + [issue, comments, optimisticComments, currentUserId], + ); + + const threadComments = useMemo( + () => mergeIssueComments(comments ?? [], optimisticComments), + [comments, optimisticComments], ); const commentsWithRunMeta = useMemo(() => { @@ -406,11 +422,11 @@ export function IssueDetail() { runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null, }); } - return (comments ?? []).map((comment) => { + return threadComments.map((comment) => { const meta = runMetaByCommentId.get(comment.id); return meta ? { ...comment, ...meta } : comment; }); - }, [activity, comments, linkedRuns]); + }, [activity, threadComments, linkedRuns]); const issueCostSummary = useMemo(() => { let input = 0; @@ -491,7 +507,62 @@ export function IssueDetail() { const addComment = useMutation({ mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) => issuesApi.addComment(issueId!, body, reopen), - onSuccess: () => { + onMutate: async ({ body, reopen }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) }); + await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) }); + + const previousIssue = queryClient.getQueryData(queryKeys.issues.detail(issueId!)); + const optimisticComment = issue + ? createOptimisticIssueComment({ + companyId: issue.companyId, + issueId: issue.id, + body, + authorUserId: currentUserId, + }) + : null; + + if (optimisticComment) { + setOptimisticComments((current) => [...current, optimisticComment]); + } + if (previousIssue) { + queryClient.setQueryData( + queryKeys.issues.detail(issueId!), + applyOptimisticIssueCommentUpdate(previousIssue, { reopen }), + ); + } + + return { + optimisticCommentId: optimisticComment?.clientId ?? null, + previousIssue, + }; + }, + onSuccess: (comment, _variables, context) => { + if (context?.optimisticCommentId) { + setOptimisticComments((current) => + current.filter((entry) => entry.clientId !== context.optimisticCommentId), + ); + } + queryClient.setQueryData( + queryKeys.issues.comments(issueId!), + (current) => upsertIssueComment(current, comment), + ); + }, + onError: (err, _variables, context) => { + if (context?.optimisticCommentId) { + setOptimisticComments((current) => + current.filter((entry) => entry.clientId !== context.optimisticCommentId), + ); + } + if (context?.previousIssue) { + queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue); + } + pushToast({ + title: "Comment failed", + body: err instanceof Error ? err.message : "Unable to post comment", + tone: "error", + }); + }, + onSettled: () => { invalidateIssue(); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); }, @@ -513,7 +584,67 @@ export function IssueDetail() { assigneeUserId: reassignment.assigneeUserId, ...(reopen ? { status: "todo" } : {}), }), - onSuccess: () => { + onMutate: async ({ body, reopen, reassignment }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) }); + await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) }); + + const previousIssue = queryClient.getQueryData(queryKeys.issues.detail(issueId!)); + const optimisticComment = issue + ? createOptimisticIssueComment({ + companyId: issue.companyId, + issueId: issue.id, + body, + authorUserId: currentUserId, + }) + : null; + + if (optimisticComment) { + setOptimisticComments((current) => [...current, optimisticComment]); + } + if (previousIssue) { + queryClient.setQueryData( + queryKeys.issues.detail(issueId!), + applyOptimisticIssueCommentUpdate(previousIssue, { reopen, reassignment }), + ); + } + + return { + optimisticCommentId: optimisticComment?.clientId ?? null, + previousIssue, + }; + }, + onSuccess: (result, _variables, context) => { + if (context?.optimisticCommentId) { + setOptimisticComments((current) => + current.filter((entry) => entry.clientId !== context.optimisticCommentId), + ); + } + + const { comment, ...nextIssue } = result; + queryClient.setQueryData(queryKeys.issues.detail(issueId!), nextIssue); + if (comment) { + queryClient.setQueryData( + queryKeys.issues.comments(issueId!), + (current) => upsertIssueComment(current, comment), + ); + } + }, + onError: (err, _variables, context) => { + if (context?.optimisticCommentId) { + setOptimisticComments((current) => + current.filter((entry) => entry.clientId !== context.optimisticCommentId), + ); + } + if (context?.previousIssue) { + queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue); + } + pushToast({ + title: "Comment failed", + body: err instanceof Error ? err.message : "Unable to post comment", + tone: "error", + }); + }, + onSettled: () => { invalidateIssue(); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); },