From 74687553f37532ee4542754926088e01a70b71f0 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 11:25:25 -0500 Subject: [PATCH] Improve queued comment thread UX Co-Authored-By: Paperclip --- ui/src/components/CommentThread.tsx | 274 ++++++++++++------- ui/src/lib/optimistic-issue-comments.test.ts | 51 ++++ ui/src/lib/optimistic-issue-comments.ts | 21 +- ui/src/pages/IssueDetail.tsx | 92 ++++++- 4 files changed, 331 insertions(+), 107 deletions(-) diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index cb9933c4..0204b94d 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -16,7 +16,9 @@ interface CommentWithRunMeta extends IssueComment { runId?: string | null; runAgentId?: string | null; clientId?: string; - clientStatus?: "pending"; + clientStatus?: "pending" | "queued"; + queueState?: "queued"; + queueTargetRunId?: string | null; } interface LinkedRunItem { @@ -34,6 +36,7 @@ interface CommentReassignment { interface CommentThreadProps { comments: CommentWithRunMeta[]; + queuedComments?: CommentWithRunMeta[]; linkedRuns?: LinkedRunItem[]; companyId?: string | null; projectId?: string | null; @@ -56,6 +59,8 @@ interface CommentThreadProps { suggestedAssigneeValue?: string; mentions?: MentionOption[]; interruptAvailable?: boolean; + onInterruptQueued?: (runId: string) => Promise; + interruptingQueuedRunId?: string | null; } const DRAFT_DEBOUNCE_MS = 800; @@ -122,6 +127,122 @@ function CopyMarkdownButton({ text }: { text: string }) { ); } +function CommentCard({ + comment, + agentMap, + companyId, + projectId, + highlightCommentId, + queued = false, +}: { + comment: CommentWithRunMeta; + agentMap?: Map; + companyId?: string | null; + projectId?: string | null; + highlightCommentId?: string | null; + queued?: boolean; +}) { + const isHighlighted = highlightCommentId === comment.id; + const isPending = comment.clientStatus === "pending"; + const isQueued = queued || comment.queueState === "queued" || comment.clientStatus === "queued"; + + return ( +
+
+ {comment.authorAgentId ? ( + + + + ) : ( + + )} + + {isQueued ? ( + + Queued + + ) : null} + {companyId && !isPending ? ( + + ) : null} + {isPending ? ( + {isQueued ? "Queueing..." : "Sending..."} + ) : ( + + {formatDateTime(comment.createdAt)} + + )} + + +
+ {comment.body} + {companyId && !isPending ? ( +
+ +
+ ) : null} + {comment.runId && !isPending ? ( +
+ {comment.runAgentId ? ( + + run {comment.runId.slice(0, 8)} + + ) : ( + + run {comment.runId.slice(0, 8)} + + )} +
+ ) : null} +
+ ); +} + type TimelineItem = | { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta } | { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem }; @@ -176,93 +297,15 @@ const TimelineList = memo(function TimelineList({ } const comment = item.comment; - const isHighlighted = highlightCommentId === comment.id; - const isPending = comment.clientStatus === "pending"; return ( -
-
- {comment.authorAgentId ? ( - - - - ) : ( - - )} - - {companyId && !isPending ? ( - - ) : null} - {isPending ? ( - Sending... - ) : ( - - {formatDateTime(comment.createdAt)} - - )} - - -
- {comment.body} - {companyId && !isPending ? ( -
- -
- ) : null} - {comment.runId && !isPending && ( -
- {comment.runAgentId ? ( - - run {comment.runId.slice(0, 8)} - - ) : ( - - run {comment.runId.slice(0, 8)} - - )} -
- )} -
+ comment={comment} + agentMap={agentMap} + companyId={companyId} + projectId={projectId} + highlightCommentId={highlightCommentId} + /> ); })} @@ -271,6 +314,7 @@ const TimelineList = memo(function TimelineList({ export function CommentThread({ comments, + queuedComments = [], linkedRuns = [], companyId, projectId, @@ -286,6 +330,8 @@ export function CommentThread({ suggestedAssigneeValue, mentions: providedMentions, interruptAvailable = false, + onInterruptQueued, + interruptingQueuedRunId = null, }: CommentThreadProps) { const [body, setBody] = useState(""); const [reopen, setReopen] = useState(true); @@ -370,7 +416,7 @@ export function CommentThread({ // Scroll to comment when URL hash matches #comment-{id} useEffect(() => { const hash = location.hash; - if (!hash.startsWith("#comment-") || comments.length === 0) return; + if (!hash.startsWith("#comment-") || comments.length + queuedComments.length === 0) return; const commentId = hash.slice("#comment-".length); // Only scroll once per hash if (hasScrolledRef.current) return; @@ -383,7 +429,7 @@ export function CommentThread({ const timer = setTimeout(() => setHighlightCommentId(null), 3000); return () => clearTimeout(timer); } - }, [location.hash, comments]); + }, [location.hash, comments, queuedComments]); async function handleSubmit() { const trimmed = body.trim(); @@ -429,18 +475,54 @@ export function CommentThread({ return (
-

Comments & Runs ({timeline.length})

+

Comments & Runs ({timeline.length + queuedComments.length})

- + {timeline.length > 0 ? ( + + ) : null} {liveRunSlot} + {queuedComments.length > 0 && ( +
+
+

+ Queued Comments ({queuedComments.length}) +

+ {onInterruptQueued && queuedComments[0]?.queueTargetRunId ? ( + + ) : null} +
+
+ {queuedComments.map((comment) => ( + + ))} +
+
+ )} +
{ mathSpy.mockRestore(); }); + it("supports queued optimistic comments for active-run follow-ups", () => { + const comment = createOptimisticIssueComment({ + companyId: "company-1", + issueId: "issue-1", + body: "Queue this", + authorUserId: "board-1", + clientStatus: "queued", + queueTargetRunId: "run-1", + }); + + expect(comment.clientStatus).toBe("queued"); + expect(comment.queueTargetRunId).toBe("run-1"); + }); + it("merges optimistic comments into the server thread in chronological order", () => { const merged = mergeIssueComments( [ @@ -161,4 +176,40 @@ describe("optimistic issue comments", () => { expect(next?.assigneeAgentId).toBeNull(); expect(next?.assigneeUserId).toBe("board-2"); }); + + it("treats comments without a run id as queued when they arrive during an active run", () => { + expect( + isQueuedIssueComment({ + comment: { + createdAt: new Date("2026-03-28T16:20:05.000Z"), + }, + activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"), + runId: null, + }), + ).toBe(true); + }); + + it("does not mark comments with an associated run as queued", () => { + expect( + isQueuedIssueComment({ + comment: { + createdAt: new Date("2026-03-28T16:20:05.000Z"), + }, + activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"), + runId: "run-1", + }), + ).toBe(false); + }); + + it("does not mark interrupt comments as queued", () => { + expect( + isQueuedIssueComment({ + comment: { + createdAt: new Date("2026-03-28T16:20:05.000Z"), + }, + activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"), + interruptedRunId: "run-1", + }), + ).toBe(false); + }); }); diff --git a/ui/src/lib/optimistic-issue-comments.ts b/ui/src/lib/optimistic-issue-comments.ts index 07384d1a..44d85332 100644 --- a/ui/src/lib/optimistic-issue-comments.ts +++ b/ui/src/lib/optimistic-issue-comments.ts @@ -7,7 +7,8 @@ export interface IssueCommentReassignment { export interface OptimisticIssueComment extends IssueComment { clientId: string; - clientStatus: "pending"; + clientStatus: "pending" | "queued"; + queueTargetRunId?: string | null; } export type IssueTimelineComment = IssueComment | OptimisticIssueComment; @@ -37,23 +38,39 @@ export function createOptimisticIssueComment(params: { issueId: string; body: string; authorUserId: string | null; + clientStatus?: OptimisticIssueComment["clientStatus"]; + queueTargetRunId?: string | null; }): OptimisticIssueComment { const now = new Date(); const clientId = createOptimisticCommentId(); return { id: clientId, clientId, - clientStatus: "pending", companyId: params.companyId, issueId: params.issueId, authorAgentId: null, authorUserId: params.authorUserId, body: params.body, + clientStatus: params.clientStatus ?? "pending", + queueTargetRunId: params.queueTargetRunId ?? null, createdAt: now, updatedAt: now, }; } +export function isQueuedIssueComment(params: { + comment: Pick & Partial>; + activeRunStartedAt?: Date | string | null; + runId?: string | null; + interruptedRunId?: string | null; +}) { + if (params.runId) return false; + if (params.interruptedRunId) return false; + if (params.comment.clientStatus === "queued") return true; + if (!params.activeRunStartedAt) return false; + return toTimestamp(params.comment.createdAt) >= toTimestamp(params.activeRunStartedAt); +} + export function mergeIssueComments( comments: IssueComment[] | undefined, optimisticComments: OptimisticIssueComment[], diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 9bd42221..996bb8fa 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -18,6 +18,7 @@ import { createIssueDetailPath, readIssueDetailBreadcrumb } from "../lib/issueDe import { applyOptimisticIssueCommentUpdate, createOptimisticIssueComment, + isQueuedIssueComment, mergeIssueComments, upsertIssueComment, type IssueCommentReassignment, @@ -66,6 +67,13 @@ import type { ActivityEvent } from "@paperclipai/shared"; import type { Agent, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared"; type CommentReassignment = IssueCommentReassignment; +type IssueDetailComment = (IssueComment | OptimisticIssueComment) & { + runId?: string | null; + runAgentId?: string | null; + interruptedRunId?: string | null; + queueState?: "queued"; + queueTargetRunId?: string | null; +}; const ACTION_LABELS: Record = { "issue.created": "created the issue", @@ -275,8 +283,15 @@ export function IssueDetail() { }); const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun; - const hasRunningIssueRun = - activeRun?.status === "running" || (liveRuns ?? []).some((run) => run.status === "running"); + const runningIssueRun = useMemo( + () => ( + activeRun?.status === "running" + ? activeRun + : (liveRuns ?? []).find((run) => run.status === "running") ?? null + ), + [activeRun, liveRuns], + ); + const hasRunningIssueRun = Boolean(runningIssueRun); const sourceBreadcrumb = useMemo( () => readIssueDetailBreadcrumb(location.state, location.search) ?? { label: "Issues", href: "/issues" }, [location.state, location.search], @@ -408,8 +423,9 @@ export function IssueDetail() { [comments, optimisticComments], ); - const commentsWithRunMeta = useMemo(() => { - const runMetaByCommentId = new Map(); + const commentsWithRunMeta = useMemo(() => { + const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null; + const runMetaByCommentId = new Map(); const agentIdByRunId = new Map(); for (const run of linkedRuns ?? []) { agentIdByRunId.set(run.runId, run.agentId); @@ -419,16 +435,44 @@ export function IssueDetail() { const details = evt.details ?? {}; const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null; if (!commentId || runMetaByCommentId.has(commentId)) continue; + const interruptedRunId = + typeof details["interruptedRunId"] === "string" ? details["interruptedRunId"] : null; runMetaByCommentId.set(commentId, { runId: evt.runId, runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null, + interruptedRunId, }); } return threadComments.map((comment) => { const meta = runMetaByCommentId.get(comment.id); - return meta ? { ...comment, ...meta } : comment; + const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment }; + if ( + isQueuedIssueComment({ + comment: nextComment, + activeRunStartedAt, + runId: meta?.runId ?? nextComment.runId ?? null, + interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null, + }) + ) { + return { + ...nextComment, + queueState: "queued" as const, + queueTargetRunId: runningIssueRun?.id ?? nextComment.queueTargetRunId ?? null, + }; + } + return nextComment; }); - }, [activity, threadComments, linkedRuns]); + }, [activity, threadComments, linkedRuns, runningIssueRun]); + + const queuedComments = useMemo( + () => commentsWithRunMeta.filter((comment) => comment.queueState === "queued"), + [commentsWithRunMeta], + ); + + const timelineComments = useMemo( + () => commentsWithRunMeta.filter((comment) => comment.queueState !== "queued"), + [commentsWithRunMeta], + ); const issueCostSummary = useMemo(() => { let input = 0; @@ -509,17 +553,20 @@ export function IssueDetail() { const addComment = useMutation({ mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) => issuesApi.addComment(issueId!, body, reopen, interrupt), - onMutate: async ({ body, reopen }) => { + onMutate: async ({ body, reopen, interrupt }) => { 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 queuedComment = !interrupt && runningIssueRun; const optimisticComment = issue ? createOptimisticIssueComment({ companyId: issue.companyId, issueId: issue.id, body, authorUserId: currentUserId, + clientStatus: queuedComment ? "queued" : "pending", + queueTargetRunId: queuedComment ? runningIssueRun.id : null, }) : null; @@ -589,17 +636,20 @@ export function IssueDetail() { ...(reopen ? { status: "todo" } : {}), ...(interrupt ? { interrupt } : {}), }), - onMutate: async ({ body, reopen, reassignment }) => { + onMutate: async ({ body, reopen, reassignment, interrupt }) => { 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 queuedComment = !interrupt && runningIssueRun; const optimisticComment = issue ? createOptimisticIssueComment({ companyId: issue.companyId, issueId: issue.id, body, authorUserId: currentUserId, + clientStatus: queuedComment ? "queued" : "pending", + queueTargetRunId: queuedComment ? runningIssueRun.id : null, }) : null; @@ -655,6 +705,25 @@ export function IssueDetail() { }, }); + const interruptQueuedComment = useMutation({ + mutationFn: (runId: string) => heartbeatsApi.cancel(runId), + onSuccess: () => { + invalidateIssue(); + pushToast({ + title: "Interrupt requested", + body: "The active run is stopping so queued comments can continue next.", + tone: "success", + }); + }, + onError: (err) => { + pushToast({ + title: "Interrupt failed", + body: err instanceof Error ? err.message : "Unable to interrupt the active run", + tone: "error", + }); + }, + }); + const uploadAttachment = useMutation({ mutationFn: async (file: File) => { if (!selectedCompanyId) throw new Error("No company selected"); @@ -1164,7 +1233,8 @@ export function IssueDetail() { { + await interruptQueuedComment.mutateAsync(runId); + }} + interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null} onAdd={async (body, reopen, reassignment, interrupt) => { if (reassignment) { await addCommentAndReassign.mutateAsync({ body, reopen, reassignment, interrupt });