Improve queued comment thread UX

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-28 11:25:25 -05:00
parent 4226e15128
commit 74687553f3
4 changed files with 331 additions and 107 deletions

View file

@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
applyOptimisticIssueCommentUpdate,
createOptimisticIssueComment,
isQueuedIssueComment,
mergeIssueComments,
upsertIssueComment,
} from "./optimistic-issue-comments";
@ -46,6 +47,20 @@ describe("optimistic issue comments", () => {
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);
});
});

View file

@ -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<IssueTimelineComment, "createdAt"> & Partial<Pick<OptimisticIssueComment, "clientStatus">>;
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[],