mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09:00
Merge public-gh/master into pap-979-runtime-workspaces
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
4d61dbfd34
46 changed files with 3635 additions and 297 deletions
|
|
@ -6,10 +6,13 @@ import {
|
|||
computeInboxBadgeData,
|
||||
getApprovalsForTab,
|
||||
getInboxWorkItems,
|
||||
getInboxKeyboardSelectionIndex,
|
||||
getRecentTouchedIssues,
|
||||
getUnreadTouchedIssues,
|
||||
isMineInboxTab,
|
||||
loadLastInboxTab,
|
||||
RECENT_ISSUES_LIMIT,
|
||||
resolveInboxSelectionIndex,
|
||||
saveLastInboxTab,
|
||||
shouldShowInboxSection,
|
||||
} from "./inbox";
|
||||
|
|
@ -400,4 +403,24 @@ describe("inbox helpers", () => {
|
|||
localStorage.setItem("paperclip:inbox:last-tab", "new");
|
||||
expect(loadLastInboxTab()).toBe("mine");
|
||||
});
|
||||
|
||||
it("enables swipe archive only on the mine tab", () => {
|
||||
expect(isMineInboxTab("mine")).toBe(true);
|
||||
expect(isMineInboxTab("recent")).toBe(false);
|
||||
expect(isMineInboxTab("unread")).toBe(false);
|
||||
expect(isMineInboxTab("all")).toBe(false);
|
||||
});
|
||||
|
||||
it("anchors Mine selection to the first available inbox row", () => {
|
||||
expect(resolveInboxSelectionIndex(-1, 3)).toBe(-1);
|
||||
expect(resolveInboxSelectionIndex(5, 3)).toBe(2);
|
||||
expect(resolveInboxSelectionIndex(1, 0)).toBe(-1);
|
||||
});
|
||||
|
||||
it("selects the first row only after keyboard navigation starts", () => {
|
||||
expect(getInboxKeyboardSelectionIndex(-1, 3, "next")).toBe(0);
|
||||
expect(getInboxKeyboardSelectionIndex(-1, 3, "previous")).toBe(0);
|
||||
expect(getInboxKeyboardSelectionIndex(0, 3, "next")).toBe(1);
|
||||
expect(getInboxKeyboardSelectionIndex(0, 3, "previous")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -98,6 +98,31 @@ export function saveLastInboxTab(tab: InboxTab) {
|
|||
}
|
||||
}
|
||||
|
||||
export function isMineInboxTab(tab: InboxTab): boolean {
|
||||
return tab === "mine";
|
||||
}
|
||||
|
||||
export function resolveInboxSelectionIndex(
|
||||
previousIndex: number,
|
||||
itemCount: number,
|
||||
): number {
|
||||
if (itemCount === 0) return -1;
|
||||
if (previousIndex < 0) return -1;
|
||||
return Math.min(previousIndex, itemCount - 1);
|
||||
}
|
||||
|
||||
export function getInboxKeyboardSelectionIndex(
|
||||
previousIndex: number,
|
||||
itemCount: number,
|
||||
direction: "next" | "previous",
|
||||
): number {
|
||||
if (itemCount === 0) return -1;
|
||||
if (previousIndex < 0) return 0;
|
||||
return direction === "next"
|
||||
? Math.min(previousIndex + 1, itemCount - 1)
|
||||
: Math.max(previousIndex - 1, 0);
|
||||
}
|
||||
|
||||
export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
|
||||
const sorted = [...runs].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
|
|
|
|||
34
ui/src/lib/issueDetailBreadcrumb.test.ts
Normal file
34
ui/src/lib/issueDetailBreadcrumb.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createIssueDetailLocationState,
|
||||
createIssueDetailPath,
|
||||
readIssueDetailBreadcrumb,
|
||||
} from "./issueDetailBreadcrumb";
|
||||
|
||||
describe("issueDetailBreadcrumb", () => {
|
||||
it("prefers the full breadcrumb from route state", () => {
|
||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||
|
||||
expect(readIssueDetailBreadcrumb(state, "?from=issues")).toEqual({
|
||||
label: "Inbox",
|
||||
href: "/inbox/mine",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the source query param when route state is unavailable", () => {
|
||||
expect(readIssueDetailBreadcrumb(null, "?from=inbox")).toEqual({
|
||||
label: "Inbox",
|
||||
href: "/inbox",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds the source query param when building an issue detail path", () => {
|
||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||
|
||||
expect(createIssueDetailPath("PAP-465", state)).toBe("/issues/PAP-465?from=inbox");
|
||||
});
|
||||
|
||||
it("reuses the current source query param when state has been dropped", () => {
|
||||
expect(createIssueDetailPath("PAP-465", null, "?from=issues")).toBe("/issues/PAP-465?from=issues");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
type IssueDetailSource = "issues" | "inbox";
|
||||
|
||||
type IssueDetailBreadcrumb = {
|
||||
label: string;
|
||||
href: string;
|
||||
|
|
@ -5,20 +7,64 @@ type IssueDetailBreadcrumb = {
|
|||
|
||||
type IssueDetailLocationState = {
|
||||
issueDetailBreadcrumb?: IssueDetailBreadcrumb;
|
||||
issueDetailSource?: IssueDetailSource;
|
||||
};
|
||||
|
||||
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
|
||||
|
||||
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const candidate = value as Partial<IssueDetailBreadcrumb>;
|
||||
return typeof candidate.label === "string" && typeof candidate.href === "string";
|
||||
}
|
||||
|
||||
export function createIssueDetailLocationState(label: string, href: string): IssueDetailLocationState {
|
||||
return { issueDetailBreadcrumb: { label, href } };
|
||||
function isIssueDetailSource(value: unknown): value is IssueDetailSource {
|
||||
return value === "issues" || value === "inbox";
|
||||
}
|
||||
|
||||
export function readIssueDetailBreadcrumb(state: unknown): IssueDetailBreadcrumb | null {
|
||||
function readIssueDetailSource(state: unknown): IssueDetailSource | null {
|
||||
if (typeof state !== "object" || state === null) return null;
|
||||
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
|
||||
return isIssueDetailBreadcrumb(candidate) ? candidate : null;
|
||||
const source = (state as IssueDetailLocationState).issueDetailSource;
|
||||
return isIssueDetailSource(source) ? source : null;
|
||||
}
|
||||
|
||||
function readIssueDetailSourceFromSearch(search?: string): IssueDetailSource | null {
|
||||
if (!search) return null;
|
||||
const params = new URLSearchParams(search);
|
||||
const source = params.get(ISSUE_DETAIL_SOURCE_QUERY_PARAM);
|
||||
return isIssueDetailSource(source) ? source : null;
|
||||
}
|
||||
|
||||
function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb {
|
||||
if (source === "inbox") return { label: "Inbox", href: "/inbox" };
|
||||
return { label: "Issues", href: "/issues" };
|
||||
}
|
||||
|
||||
export function createIssueDetailLocationState(
|
||||
label: string,
|
||||
href: string,
|
||||
source?: IssueDetailSource,
|
||||
): IssueDetailLocationState {
|
||||
return {
|
||||
issueDetailBreadcrumb: { label, href },
|
||||
issueDetailSource: source,
|
||||
};
|
||||
}
|
||||
|
||||
export function createIssueDetailPath(issuePathId: string, state?: unknown, search?: string): string {
|
||||
const source = readIssueDetailSource(state) ?? readIssueDetailSourceFromSearch(search);
|
||||
if (!source) return `/issues/${issuePathId}`;
|
||||
const params = new URLSearchParams();
|
||||
params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source);
|
||||
return `/issues/${issuePathId}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function readIssueDetailBreadcrumb(state: unknown, search?: string): IssueDetailBreadcrumb | null {
|
||||
if (typeof state === "object" && state !== null) {
|
||||
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
|
||||
if (isIssueDetailBreadcrumb(candidate)) return candidate;
|
||||
}
|
||||
|
||||
const source = readIssueDetailSourceFromSearch(search);
|
||||
return source ? breadcrumbForSource(source) : null;
|
||||
}
|
||||
|
|
|
|||
215
ui/src/lib/optimistic-issue-comments.test.ts
Normal file
215
ui/src/lib/optimistic-issue-comments.test.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyOptimisticIssueCommentUpdate,
|
||||
createOptimisticIssueComment,
|
||||
isQueuedIssueComment,
|
||||
mergeIssueComments,
|
||||
upsertIssueComment,
|
||||
} from "./optimistic-issue-comments";
|
||||
|
||||
describe("optimistic issue comments", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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("falls back when crypto.randomUUID is unavailable", () => {
|
||||
vi.stubGlobal("crypto", {});
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_746_000_000_000);
|
||||
const mathSpy = vi.spyOn(Math, "random").mockReturnValue(0.123456789);
|
||||
|
||||
const comment = createOptimisticIssueComment({
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
body: "Working on it",
|
||||
authorUserId: "board-1",
|
||||
});
|
||||
|
||||
expect(comment.id).toBe("optimistic-1746000000000-4fzzzxjy");
|
||||
expect(comment.clientId).toBe(comment.id);
|
||||
|
||||
nowSpy.mockRestore();
|
||||
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(
|
||||
[
|
||||
{
|
||||
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");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
123
ui/src/lib/optimistic-issue-comments.ts
Normal file
123
ui/src/lib/optimistic-issue-comments.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
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" | "queued";
|
||||
queueTargetRunId?: string | null;
|
||||
}
|
||||
|
||||
export type IssueTimelineComment = IssueComment | OptimisticIssueComment;
|
||||
|
||||
function toTimestamp(value: Date | string) {
|
||||
return new Date(value).getTime();
|
||||
}
|
||||
|
||||
function createOptimisticCommentId() {
|
||||
const randomUuid = globalThis.crypto?.randomUUID?.();
|
||||
if (randomUuid) {
|
||||
return `optimistic-${randomUuid}`;
|
||||
}
|
||||
return `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function sortIssueComments<T extends { createdAt: Date | string; id: string }>(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;
|
||||
clientStatus?: OptimisticIssueComment["clientStatus"];
|
||||
queueTargetRunId?: string | null;
|
||||
}): OptimisticIssueComment {
|
||||
const now = new Date();
|
||||
const clientId = createOptimisticCommentId();
|
||||
return {
|
||||
id: clientId,
|
||||
clientId,
|
||||
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[],
|
||||
): 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue