mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
[codex] Improve issue detail and issue-list UX (#3678)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - A core part of that is the operator experience around reading issue state, agent chat, and sub-task structure > - The current branch had a long run of issue-detail and issue-list UX fixes that all improve how humans follow and steer active work > - Those changes mostly live in the UI/chat surface and should be reviewed together instead of mixed with workspace/runtime work > - This pull request packages the issue-detail, chat, markdown, and sub-issue list improvements into one standalone change > - The benefit is a cleaner, less jumpy, more reliable issue workflow on desktop and mobile without coupling it to unrelated server/runtime refactors ## What Changed - Stabilized issue chat runtime wiring, optimistic comment handling, queued-comment cancellation, and composer anchoring during live updates - Fixed several issue-detail rendering and navigation regressions including placeholder bleed, local polling scope, mobile inbox-to-issue transitions, and visible refresh resets - Improved markdown and rich-content handling with advisory image normalization, editor fallback behavior, touch mention recovery, and `issue:` quicklook links - Refined sub-issue behavior with parent-derived defaults, current-user inheritance fixes, empty-state cleanup, and a reusable issue-list presentation for sub-issues - Added targeted UI tests for the new issue-detail, chat scroll/message, placeholder-data, markdown, and issue-list behaviors ## Verification - `pnpm vitest run ui/src/components/IssueChatThread.test.tsx ui/src/components/MarkdownEditor.test.tsx ui/src/components/IssuesList.test.tsx ui/src/context/LiveUpdatesProvider.test.tsx ui/src/lib/issue-chat-messages.test.ts ui/src/lib/issue-chat-scroll.test.ts ui/src/lib/issue-detail-subissues.test.ts ui/src/lib/query-placeholder-data.test.tsx ui/src/hooks/usePaperclipIssueRuntime.test.tsx` ## Risks - Medium: this branch touches the highest-traffic issue-detail UI paths, so regressions would show up as chat/thread or sub-issue UX glitches - The changes are UI-heavy and would benefit from reviewer screenshots or a quick manual browser pass before merge ## Model Used - OpenAI Codex coding agent (GPT-5-class runtime in Codex CLI; exact deployed model ID is not exposed in this environment), reasoning enabled, tool use and local code execution enabled ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
5d1ed71779
commit
6e6f538630
41 changed files with 4141 additions and 590 deletions
|
|
@ -25,6 +25,7 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
|
|||
"issue.checked_out": "checked out",
|
||||
"issue.released": "released",
|
||||
"issue.comment_added": "commented on",
|
||||
"issue.comment_cancelled": "cancelled a queued comment on",
|
||||
"issue.attachment_added": "attached file to",
|
||||
"issue.attachment_removed": "removed attachment from",
|
||||
"issue.document_created": "created document for",
|
||||
|
|
@ -65,6 +66,7 @@ const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
|
|||
"issue.checked_out": "checked out the issue",
|
||||
"issue.released": "released the issue",
|
||||
"issue.comment_added": "added a comment",
|
||||
"issue.comment_cancelled": "cancelled a queued comment",
|
||||
"issue.feedback_vote_saved": "saved feedback on an AI output",
|
||||
"issue.attachment_added": "added an attachment",
|
||||
"issue.attachment_removed": "removed an attachment",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Agent } from "@paperclipai/shared";
|
|||
import {
|
||||
buildAssistantPartsFromTranscript,
|
||||
buildIssueChatMessages,
|
||||
stabilizeThreadMessages,
|
||||
type IssueChatComment,
|
||||
type IssueChatLinkedRun,
|
||||
} from "./issue-chat-messages";
|
||||
|
|
@ -527,3 +528,69 @@ describe("buildIssueChatMessages", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("stabilizeThreadMessages", () => {
|
||||
it("reuses unchanged message objects across rebuilds", () => {
|
||||
const firstPass = buildIssueChatMessages({
|
||||
comments: [createComment()],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [],
|
||||
liveRuns: [],
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
const firstStable = stabilizeThreadMessages(firstPass, [], new Map());
|
||||
const secondPass = buildIssueChatMessages({
|
||||
comments: [
|
||||
createComment(),
|
||||
createComment({
|
||||
id: "comment-2",
|
||||
body: "New message",
|
||||
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
}),
|
||||
],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [],
|
||||
liveRuns: [],
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
const secondStable = stabilizeThreadMessages(
|
||||
secondPass,
|
||||
firstStable.messages,
|
||||
firstStable.cache,
|
||||
);
|
||||
|
||||
expect(secondStable.messages).toHaveLength(2);
|
||||
expect(secondStable.messages[0]).toBe(firstStable.messages[0]);
|
||||
expect(secondStable.messages[1]?.id).toBe("comment-2");
|
||||
});
|
||||
|
||||
it("reuses the previous array when nothing semantically changed", () => {
|
||||
const firstPass = buildIssueChatMessages({
|
||||
comments: [createComment()],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [],
|
||||
liveRuns: [],
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
const firstStable = stabilizeThreadMessages(firstPass, [], new Map());
|
||||
const secondPass = buildIssueChatMessages({
|
||||
comments: [createComment()],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [],
|
||||
liveRuns: [],
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
const secondStable = stabilizeThreadMessages(
|
||||
secondPass,
|
||||
firstStable.messages,
|
||||
firstStable.cache,
|
||||
);
|
||||
|
||||
expect(secondStable.messages).toBe(firstStable.messages);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -81,6 +81,11 @@ type MessageWithOrder = {
|
|||
message: ThreadMessage;
|
||||
};
|
||||
|
||||
export interface StableThreadMessageCacheEntry {
|
||||
fingerprint: string;
|
||||
message: ThreadMessage;
|
||||
}
|
||||
|
||||
function toDate(value: Date | string | null | undefined) {
|
||||
return value instanceof Date ? value : new Date(value ?? Date.now());
|
||||
}
|
||||
|
|
@ -89,6 +94,41 @@ function toTimestamp(value: Date | string | null | undefined) {
|
|||
return toDate(value).getTime();
|
||||
}
|
||||
|
||||
function fingerprintThreadMessage(message: ThreadMessage) {
|
||||
return JSON.stringify(message);
|
||||
}
|
||||
|
||||
export function stabilizeThreadMessages(
|
||||
messages: readonly ThreadMessage[],
|
||||
previousMessages: readonly ThreadMessage[],
|
||||
previousById: ReadonlyMap<string, StableThreadMessageCacheEntry>,
|
||||
) {
|
||||
const nextById = new Map<string, StableThreadMessageCacheEntry>();
|
||||
let sameSequence = previousMessages.length === messages.length;
|
||||
|
||||
const stabilizedMessages = messages.map((message, index) => {
|
||||
const fingerprint = fingerprintThreadMessage(message);
|
||||
const cached = previousById.get(message.id);
|
||||
const stableMessage =
|
||||
cached && cached.fingerprint === fingerprint
|
||||
? cached.message
|
||||
: message;
|
||||
nextById.set(message.id, {
|
||||
fingerprint,
|
||||
message: stableMessage,
|
||||
});
|
||||
if (sameSequence && previousMessages[index] !== stableMessage) {
|
||||
sameSequence = false;
|
||||
}
|
||||
return stableMessage;
|
||||
});
|
||||
|
||||
return {
|
||||
messages: sameSequence ? previousMessages : stabilizedMessages,
|
||||
cache: nextById,
|
||||
};
|
||||
}
|
||||
|
||||
function sortByCreated<T extends { createdAt: Date | string; id: string }>(items: readonly T[]) {
|
||||
return [...items].sort((a, b) => {
|
||||
const diff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt);
|
||||
|
|
|
|||
98
ui/src/lib/issue-chat-scroll.test.ts
Normal file
98
ui/src/lib/issue-chat-scroll.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
captureComposerViewportSnapshot,
|
||||
restoreComposerViewportSnapshot,
|
||||
shouldPreserveComposerViewport,
|
||||
} from "./issue-chat-scroll";
|
||||
|
||||
function mockTop(element: HTMLElement, top: number) {
|
||||
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
|
||||
top,
|
||||
bottom: top + 48,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: 0,
|
||||
height: 48,
|
||||
x: 0,
|
||||
y: top,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
}
|
||||
|
||||
describe("issue-chat-scroll", () => {
|
||||
it("restores page scroll when the composer shifts in the viewport", () => {
|
||||
const composer = document.createElement("div");
|
||||
document.body.appendChild(composer);
|
||||
const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
|
||||
|
||||
mockTop(composer, 420);
|
||||
const snapshot = captureComposerViewportSnapshot(composer);
|
||||
|
||||
mockTop(composer, 560);
|
||||
restoreComposerViewportSnapshot(snapshot, composer);
|
||||
|
||||
expect(scrollByMock).toHaveBeenCalledWith({ top: 140, left: 0, behavior: "auto" });
|
||||
|
||||
scrollByMock.mockRestore();
|
||||
composer.remove();
|
||||
});
|
||||
|
||||
it("restores main-content scroll when the layout uses an internal scroller", () => {
|
||||
const mainContent = document.createElement("main");
|
||||
mainContent.id = "main-content";
|
||||
mainContent.style.overflowY = "auto";
|
||||
Object.defineProperty(mainContent, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 1800,
|
||||
});
|
||||
Object.defineProperty(mainContent, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 900,
|
||||
});
|
||||
mainContent.scrollTop = 240;
|
||||
document.body.appendChild(mainContent);
|
||||
|
||||
const composer = document.createElement("div");
|
||||
document.body.appendChild(composer);
|
||||
const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
|
||||
|
||||
mockTop(composer, 300);
|
||||
const snapshot = captureComposerViewportSnapshot(composer);
|
||||
|
||||
mockTop(composer, 380);
|
||||
restoreComposerViewportSnapshot(snapshot, composer);
|
||||
|
||||
expect(mainContent.scrollTop).toBe(320);
|
||||
expect(scrollByMock).not.toHaveBeenCalled();
|
||||
|
||||
scrollByMock.mockRestore();
|
||||
composer.remove();
|
||||
mainContent.remove();
|
||||
});
|
||||
|
||||
it("does not preserve the composer viewport just because the composer is visible", () => {
|
||||
const composer = document.createElement("div");
|
||||
document.body.appendChild(composer);
|
||||
mockTop(composer, 540);
|
||||
|
||||
expect(shouldPreserveComposerViewport(composer)).toBe(false);
|
||||
|
||||
composer.remove();
|
||||
});
|
||||
|
||||
it("preserves the composer viewport when focus stays inside the composer", () => {
|
||||
const composer = document.createElement("div");
|
||||
const input = document.createElement("textarea");
|
||||
composer.appendChild(input);
|
||||
document.body.appendChild(composer);
|
||||
mockTop(composer, 1200);
|
||||
|
||||
input.focus();
|
||||
|
||||
expect(shouldPreserveComposerViewport(composer)).toBe(true);
|
||||
|
||||
composer.remove();
|
||||
});
|
||||
});
|
||||
70
ui/src/lib/issue-chat-scroll.ts
Normal file
70
ui/src/lib/issue-chat-scroll.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
export type IssueChatScrollTarget =
|
||||
| { type: "element"; element: HTMLElement }
|
||||
| { type: "window" };
|
||||
|
||||
export interface ComposerViewportSnapshot {
|
||||
composerViewportTop: number;
|
||||
}
|
||||
|
||||
export function resolveIssueChatScrollTarget(
|
||||
doc: Document = document,
|
||||
win: Window = window,
|
||||
): IssueChatScrollTarget {
|
||||
const mainContent = doc.getElementById("main-content");
|
||||
|
||||
if (mainContent instanceof HTMLElement) {
|
||||
const overflowY = win.getComputedStyle(mainContent).overflowY;
|
||||
const usesOwnScroll =
|
||||
(overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay")
|
||||
&& mainContent.scrollHeight > mainContent.clientHeight + 1;
|
||||
|
||||
if (usesOwnScroll) {
|
||||
return { type: "element", element: mainContent };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "window" };
|
||||
}
|
||||
|
||||
export function captureComposerViewportSnapshot(
|
||||
composerElement: HTMLElement | null,
|
||||
): ComposerViewportSnapshot | null {
|
||||
if (!composerElement) return null;
|
||||
|
||||
return {
|
||||
composerViewportTop: composerElement.getBoundingClientRect().top,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldPreserveComposerViewport(
|
||||
composerElement: HTMLElement | null,
|
||||
doc: Document = document,
|
||||
) {
|
||||
if (!composerElement) return false;
|
||||
|
||||
const activeElement = doc.activeElement;
|
||||
if (activeElement instanceof Node && composerElement.contains(activeElement)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function restoreComposerViewportSnapshot(
|
||||
snapshot: ComposerViewportSnapshot | null,
|
||||
composerElement: HTMLElement | null,
|
||||
doc: Document = document,
|
||||
win: Window = window,
|
||||
) {
|
||||
if (!snapshot || !composerElement) return;
|
||||
|
||||
const delta = composerElement.getBoundingClientRect().top - snapshot.composerViewportTop;
|
||||
if (!Number.isFinite(delta) || Math.abs(delta) < 1) return;
|
||||
|
||||
const target = resolveIssueChatScrollTarget(doc, win);
|
||||
if (target.type === "element") {
|
||||
target.element.scrollTop += delta;
|
||||
return;
|
||||
}
|
||||
|
||||
win.scrollBy({ top: delta, left: 0, behavior: "auto" });
|
||||
}
|
||||
18
ui/src/lib/issue-detail-subissues.test.ts
Normal file
18
ui/src/lib/issue-detail-subissues.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldRenderRichSubIssuesSection } from "./issue-detail-subissues";
|
||||
|
||||
describe("shouldRenderRichSubIssuesSection", () => {
|
||||
it("shows the rich sub-issues section while child issues are loading", () => {
|
||||
expect(shouldRenderRichSubIssuesSection(true, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("shows the rich sub-issues section when at least one child issue exists", () => {
|
||||
expect(shouldRenderRichSubIssuesSection(false, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the rich sub-issues section when there are no child issues", () => {
|
||||
expect(shouldRenderRichSubIssuesSection(false, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
3
ui/src/lib/issue-detail-subissues.ts
Normal file
3
ui/src/lib/issue-detail-subissues.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function shouldRenderRichSubIssuesSection(childIssuesLoading: boolean, childIssueCount: number): boolean {
|
||||
return childIssuesLoading || childIssueCount > 0;
|
||||
}
|
||||
47
ui/src/lib/issue-properties-panel-key.test.ts
Normal file
47
ui/src/lib/issue-properties-panel-key.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { buildIssuePropertiesPanelKey } from "./issue-properties-panel-key";
|
||||
|
||||
function createIssue(overrides: Partial<Issue> = {}) {
|
||||
return {
|
||||
id: "issue-1",
|
||||
status: "in_progress" as const,
|
||||
priority: "medium" as const,
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
projectId: "project-1",
|
||||
parentId: null,
|
||||
createdByUserId: "user-1",
|
||||
hiddenAt: null,
|
||||
labelIds: ["label-1"],
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
ancestors: [],
|
||||
updatedAt: new Date("2026-04-12T12:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildIssuePropertiesPanelKey", () => {
|
||||
it("ignores plain updatedAt churn", () => {
|
||||
const first = buildIssuePropertiesPanelKey(createIssue(), []);
|
||||
const second = buildIssuePropertiesPanelKey(
|
||||
createIssue({ updatedAt: new Date("2026-04-12T12:05:00.000Z") }),
|
||||
[],
|
||||
);
|
||||
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("changes when a displayed property changes", () => {
|
||||
const first = buildIssuePropertiesPanelKey(createIssue(), []);
|
||||
const second = buildIssuePropertiesPanelKey(
|
||||
createIssue({ assigneeAgentId: "agent-2" }),
|
||||
[],
|
||||
);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
});
|
||||
76
ui/src/lib/issue-properties-panel-key.ts
Normal file
76
ui/src/lib/issue-properties-panel-key.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import type { Issue } from "@paperclipai/shared";
|
||||
|
||||
type IssuePropertiesPanelKeyIssue = Pick<
|
||||
Issue,
|
||||
| "id"
|
||||
| "status"
|
||||
| "priority"
|
||||
| "assigneeAgentId"
|
||||
| "assigneeUserId"
|
||||
| "projectId"
|
||||
| "parentId"
|
||||
| "createdByUserId"
|
||||
| "hiddenAt"
|
||||
| "labelIds"
|
||||
| "executionPolicy"
|
||||
| "executionState"
|
||||
| "blocks"
|
||||
| "blockedBy"
|
||||
| "ancestors"
|
||||
>;
|
||||
|
||||
type IssuePropertiesPanelKeyChild = Pick<Issue, "id" | "updatedAt" | "identifier" | "title">;
|
||||
|
||||
export function buildIssuePropertiesPanelKey(
|
||||
issue: IssuePropertiesPanelKeyIssue | null | undefined,
|
||||
childIssues: readonly IssuePropertiesPanelKeyChild[],
|
||||
) {
|
||||
if (!issue) return "";
|
||||
|
||||
return JSON.stringify({
|
||||
id: issue.id,
|
||||
status: issue.status,
|
||||
priority: issue.priority,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
assigneeUserId: issue.assigneeUserId,
|
||||
projectId: issue.projectId,
|
||||
parentId: issue.parentId,
|
||||
createdByUserId: issue.createdByUserId,
|
||||
hiddenAt: issue.hiddenAt,
|
||||
labelIds: issue.labelIds ?? [],
|
||||
executionPolicy: issue.executionPolicy ?? null,
|
||||
executionState: issue.executionState
|
||||
? {
|
||||
status: issue.executionState.status,
|
||||
currentStageType: issue.executionState.currentStageType,
|
||||
currentParticipant: issue.executionState.currentParticipant,
|
||||
returnAssignee: issue.executionState.returnAssignee,
|
||||
}
|
||||
: null,
|
||||
blocks: (issue.blocks ?? []).map((relation) => ({
|
||||
id: relation.id,
|
||||
identifier: relation.identifier ?? null,
|
||||
title: relation.title,
|
||||
status: relation.status,
|
||||
})),
|
||||
blockedBy: (issue.blockedBy ?? []).map((relation) => ({
|
||||
id: relation.id,
|
||||
identifier: relation.identifier ?? null,
|
||||
title: relation.title,
|
||||
status: relation.status,
|
||||
})),
|
||||
parentSummary: issue.ancestors?.[0]
|
||||
? {
|
||||
id: issue.ancestors[0].id,
|
||||
identifier: issue.ancestors[0].identifier ?? null,
|
||||
title: issue.ancestors[0].title,
|
||||
}
|
||||
: null,
|
||||
childIssues: childIssues.map((child) => ({
|
||||
id: child.id,
|
||||
updatedAt: String(child.updatedAt),
|
||||
identifier: child.identifier ?? null,
|
||||
title: child.title,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ describe("issue-reference", () => {
|
|||
expect(parseIssuePathIdFromPath("http://localhost:3100/PAP/issues/PAP-1179")).toBe("PAP-1179");
|
||||
});
|
||||
|
||||
it("normalizes bare identifiers and issue URLs into internal links", () => {
|
||||
it("normalizes bare identifiers, issue URLs, and issue scheme links into internal links", () => {
|
||||
expect(parseIssueReferenceFromHref("pap-1271")).toEqual({
|
||||
issuePathId: "PAP-1271",
|
||||
href: "/issues/PAP-1271",
|
||||
|
|
@ -20,6 +20,14 @@ describe("issue-reference", () => {
|
|||
issuePathId: "PAP-1179",
|
||||
href: "/issues/PAP-1179",
|
||||
});
|
||||
expect(parseIssueReferenceFromHref("issue://PAP-1310")).toEqual({
|
||||
issuePathId: "PAP-1310",
|
||||
href: "/issues/PAP-1310",
|
||||
});
|
||||
expect(parseIssueReferenceFromHref("issue://:PAP-1311")).toEqual({
|
||||
issuePathId: "PAP-1311",
|
||||
href: "/issues/PAP-1311",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes exact inline-code-like issue identifiers", () => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ type MarkdownNode = {
|
|||
};
|
||||
|
||||
const BARE_ISSUE_IDENTIFIER_RE = /^[A-Z][A-Z0-9]+-\d+$/i;
|
||||
const ISSUE_REFERENCE_TOKEN_RE = /https?:\/\/[^\s<>()]+|\b[A-Z][A-Z0-9]+-\d+\b/gi;
|
||||
const ISSUE_SCHEME_RE = /^issue:\/\/:?([^?#\s]+)(?:[?#].*)?$/i;
|
||||
const ISSUE_REFERENCE_TOKEN_RE = /issue:\/\/:?[^\s<>()]+|https?:\/\/[^\s<>()]+|\b[A-Z][A-Z0-9]+-\d+\b/gi;
|
||||
|
||||
export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined): string | null {
|
||||
if (!pathOrUrl) return null;
|
||||
|
|
@ -29,6 +30,16 @@ export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined):
|
|||
|
||||
export function parseIssueReferenceFromHref(href: string | null | undefined) {
|
||||
if (!href) return null;
|
||||
const trimmed = href.trim();
|
||||
const issueSchemeMatch = trimmed.match(ISSUE_SCHEME_RE);
|
||||
if (issueSchemeMatch?.[1]) {
|
||||
const issuePathId = decodeURIComponent(issueSchemeMatch[1]);
|
||||
return {
|
||||
issuePathId,
|
||||
href: `/issues/${encodeURIComponent(issuePathId)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const pathId = parseIssuePathIdFromPath(href);
|
||||
if (pathId) {
|
||||
return {
|
||||
|
|
@ -37,7 +48,6 @@ export function parseIssueReferenceFromHref(href: string | null | undefined) {
|
|||
};
|
||||
}
|
||||
|
||||
const trimmed = href.trim();
|
||||
if (!BARE_ISSUE_IDENTIFIER_RE.test(trimmed)) return null;
|
||||
const normalized = trimmed.toUpperCase();
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import {
|
|||
isQueuedIssueComment,
|
||||
matchesIssueRef,
|
||||
mergeIssueComments,
|
||||
removeIssueCommentFromPages,
|
||||
takeOptimisticIssueComment,
|
||||
upsertIssueComment,
|
||||
upsertIssueCommentInPages,
|
||||
} from "./optimistic-issue-comments";
|
||||
|
|
@ -101,6 +103,30 @@ describe("optimistic issue comments", () => {
|
|||
expect(merged.map((comment) => comment.id)).toEqual(["optimistic-1", "comment-2"]);
|
||||
});
|
||||
|
||||
it("can take one optimistic queued comment back out of the queue", () => {
|
||||
const first = createOptimisticIssueComment({
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
body: "First",
|
||||
authorUserId: "board-1",
|
||||
clientStatus: "queued",
|
||||
queueTargetRunId: "run-1",
|
||||
});
|
||||
const second = createOptimisticIssueComment({
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
body: "Second",
|
||||
authorUserId: "board-1",
|
||||
clientStatus: "queued",
|
||||
queueTargetRunId: "run-1",
|
||||
});
|
||||
|
||||
const result = takeOptimisticIssueComment([first, second], first.clientId);
|
||||
|
||||
expect(result.comment?.body).toBe("First");
|
||||
expect(result.comments.map((comment) => comment.clientId)).toEqual([second.clientId]);
|
||||
});
|
||||
|
||||
it("upserts confirmed comments without creating duplicates", () => {
|
||||
const next = upsertIssueComment(
|
||||
[
|
||||
|
|
@ -250,6 +276,52 @@ describe("optimistic issue comments", () => {
|
|||
expect(nextPages[1]?.map((comment) => comment.id)).toEqual(["comment-1"]);
|
||||
});
|
||||
|
||||
it("removes a confirmed queued comment from paged caches", () => {
|
||||
const nextPages = removeIssueCommentFromPages(
|
||||
[
|
||||
[
|
||||
{
|
||||
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-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"),
|
||||
},
|
||||
{
|
||||
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"),
|
||||
},
|
||||
],
|
||||
],
|
||||
"comment-2",
|
||||
);
|
||||
|
||||
expect(nextPages).toHaveLength(2);
|
||||
expect(nextPages[0]?.map((comment) => comment.id)).toEqual(["comment-3"]);
|
||||
expect(nextPages[1]?.map((comment) => comment.id)).toEqual(["comment-1"]);
|
||||
});
|
||||
|
||||
it("applies optimistic reopen and reassignment updates to the issue cache", () => {
|
||||
const next = applyOptimisticIssueCommentUpdate(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -96,6 +96,21 @@ export function mergeIssueComments(
|
|||
return sortIssueComments(merged);
|
||||
}
|
||||
|
||||
export function takeOptimisticIssueComment(
|
||||
comments: OptimisticIssueComment[],
|
||||
clientId: string,
|
||||
): { comments: OptimisticIssueComment[]; comment: OptimisticIssueComment | null } {
|
||||
const index = comments.findIndex((comment) => comment.clientId === clientId);
|
||||
if (index === -1) {
|
||||
return { comments, comment: null };
|
||||
}
|
||||
|
||||
return {
|
||||
comments: comments.filter((comment) => comment.clientId !== clientId),
|
||||
comment: comments[index] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function flattenIssueCommentPages(
|
||||
pages: ReadonlyArray<ReadonlyArray<IssueComment>> | undefined,
|
||||
): IssueComment[] {
|
||||
|
|
@ -254,3 +269,16 @@ export function upsertIssueCommentInPages(
|
|||
nextPages[0] = sortIssueCommentsDesc([...nextPages[0]!, nextComment]);
|
||||
return nextPages;
|
||||
}
|
||||
|
||||
export function removeIssueCommentFromPages(
|
||||
pages: ReadonlyArray<ReadonlyArray<IssueComment>> | undefined,
|
||||
commentId: string,
|
||||
): IssueComment[][] {
|
||||
if (!pages || pages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return pages
|
||||
.map((page) => page.filter((comment) => comment.id !== commentId))
|
||||
.filter((page) => page.length > 0);
|
||||
}
|
||||
|
|
|
|||
111
ui/src/lib/query-placeholder-data.test.tsx
Normal file
111
ui/src/lib/query-placeholder-data.test.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { keepPreviousDataForSameQueryTail } from "./query-placeholder-data";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
resolve = nextResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function Harness({
|
||||
issueId,
|
||||
fetchIssueRuns,
|
||||
}: {
|
||||
issueId: string;
|
||||
fetchIssueRuns: (issueId: string) => Promise<string[]>;
|
||||
}) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["issues", "live-runs", issueId],
|
||||
queryFn: () => fetchIssueRuns(issueId),
|
||||
placeholderData: keepPreviousDataForSameQueryTail(issueId),
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-testid="query-state">
|
||||
{JSON.stringify({
|
||||
issueId,
|
||||
runs: data ?? null,
|
||||
isLoading,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("keepPreviousDataForSameQueryTail", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("clears issue-scoped placeholder data when the query tail changes", async () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
},
|
||||
});
|
||||
const root = createRoot(container);
|
||||
const issueBRuns = createDeferred<string[]>();
|
||||
|
||||
queryClient.setQueryData(["issues", "live-runs", "issue-a"], ["run-a"]);
|
||||
|
||||
const fetchIssueRuns = (issueId: string) => {
|
||||
if (issueId === "issue-a") return Promise.resolve(["run-a"]);
|
||||
if (issueId === "issue-b") return issueBRuns.promise;
|
||||
return Promise.resolve([]);
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness issueId="issue-a" fetchIssueRuns={fetchIssueRuns} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe(JSON.stringify({
|
||||
issueId: "issue-a",
|
||||
runs: ["run-a"],
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness issueId="issue-b" fetchIssueRuns={fetchIssueRuns} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe(JSON.stringify({
|
||||
issueId: "issue-b",
|
||||
runs: null,
|
||||
isLoading: true,
|
||||
}));
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
queryClient.clear();
|
||||
});
|
||||
});
|
||||
10
ui/src/lib/query-placeholder-data.ts
Normal file
10
ui/src/lib/query-placeholder-data.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { PlaceholderDataFunction, QueryKey } from "@tanstack/react-query";
|
||||
|
||||
export function keepPreviousDataForSameQueryTail<TQueryData, TQueryKey extends QueryKey = QueryKey>(
|
||||
tail: unknown,
|
||||
): PlaceholderDataFunction<TQueryData, Error, TQueryData, TQueryKey> {
|
||||
return (previousData, previousQuery) => {
|
||||
const previousKey = Array.isArray(previousQuery?.queryKey) ? previousQuery.queryKey : [];
|
||||
return previousKey.at(-1) === tail ? previousData : undefined;
|
||||
};
|
||||
}
|
||||
136
ui/src/lib/subIssueDefaults.test.ts
Normal file
136
ui/src/lib/subIssueDefaults.test.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ExecutionWorkspace, Issue } from "@paperclipai/shared";
|
||||
import { buildSubIssueDefaults, buildSubIssueDefaultsForViewer } from "./subIssueDefaults";
|
||||
|
||||
function makeExecutionWorkspace(overrides: Partial<ExecutionWorkspace> = {}): ExecutionWorkspace {
|
||||
return {
|
||||
id: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-1",
|
||||
sourceIssueId: null,
|
||||
status: "active",
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Parent workspace",
|
||||
cwd: "/tmp/workspace-1",
|
||||
repoUrl: null,
|
||||
baseRef: null,
|
||||
branchName: "feature/pap-1",
|
||||
providerType: "git_worktree",
|
||||
providerRef: null,
|
||||
derivedFromExecutionWorkspaceId: null,
|
||||
openedAt: new Date("2026-04-07T00:00:00.000Z"),
|
||||
closedAt: null,
|
||||
cleanupEligibleAt: null,
|
||||
cleanupReason: null,
|
||||
config: null,
|
||||
metadata: null,
|
||||
lastUsedAt: new Date("2026-04-07T00:00:00.000Z"),
|
||||
createdAt: new Date("2026-04-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-07T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-1",
|
||||
goalId: "goal-1",
|
||||
parentId: null,
|
||||
title: "Parent issue",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: 1,
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: "shared_workspace",
|
||||
executionWorkspaceSettings: null,
|
||||
currentExecutionWorkspace: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
createdAt: new Date("2026-04-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-07T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildSubIssueDefaults", () => {
|
||||
it("inherits the parent agent assignee and workspace context", () => {
|
||||
const defaults = buildSubIssueDefaults(
|
||||
makeIssue({
|
||||
assigneeAgentId: "agent-1",
|
||||
executionWorkspaceId: "workspace-1",
|
||||
currentExecutionWorkspace: makeExecutionWorkspace(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(defaults).toEqual({
|
||||
parentId: "issue-1",
|
||||
parentIdentifier: "PAP-1",
|
||||
parentTitle: "Parent issue",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-1",
|
||||
goalId: "goal-1",
|
||||
executionWorkspaceId: "workspace-1",
|
||||
executionWorkspaceMode: "reuse_existing",
|
||||
parentExecutionWorkspaceLabel: "Parent workspace",
|
||||
assigneeAgentId: "agent-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("inherits a user assignee when the parent is assigned to a user", () => {
|
||||
const defaults = buildSubIssueDefaults(
|
||||
makeIssue({
|
||||
assigneeUserId: "user-1",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(defaults).toEqual({
|
||||
parentId: "issue-1",
|
||||
parentIdentifier: "PAP-1",
|
||||
parentTitle: "Parent issue",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-1",
|
||||
goalId: "goal-1",
|
||||
executionWorkspaceMode: "shared_workspace",
|
||||
assigneeUserId: "user-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the sub-issue unassigned when the parent assignee is the current user", () => {
|
||||
const defaults = buildSubIssueDefaultsForViewer(
|
||||
makeIssue({
|
||||
assigneeUserId: "user-1",
|
||||
}),
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(defaults).toEqual({
|
||||
parentId: "issue-1",
|
||||
parentIdentifier: "PAP-1",
|
||||
parentTitle: "Parent issue",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-1",
|
||||
goalId: "goal-1",
|
||||
executionWorkspaceMode: "shared_workspace",
|
||||
});
|
||||
});
|
||||
});
|
||||
52
ui/src/lib/subIssueDefaults.ts
Normal file
52
ui/src/lib/subIssueDefaults.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { Issue } from "@paperclipai/shared";
|
||||
|
||||
type SubIssueDefaultSource = Pick<
|
||||
Issue,
|
||||
| "id"
|
||||
| "identifier"
|
||||
| "title"
|
||||
| "projectId"
|
||||
| "projectWorkspaceId"
|
||||
| "goalId"
|
||||
| "executionWorkspaceId"
|
||||
| "executionWorkspacePreference"
|
||||
| "currentExecutionWorkspace"
|
||||
| "assigneeAgentId"
|
||||
| "assigneeUserId"
|
||||
>;
|
||||
|
||||
export function buildSubIssueDefaults(issue: SubIssueDefaultSource) {
|
||||
return buildSubIssueDefaultsForViewer(issue);
|
||||
}
|
||||
|
||||
export function buildSubIssueDefaultsForViewer(
|
||||
issue: SubIssueDefaultSource,
|
||||
currentUserId?: string | null,
|
||||
) {
|
||||
const parentExecutionWorkspaceLabel =
|
||||
issue.currentExecutionWorkspace?.name
|
||||
?? issue.currentExecutionWorkspace?.branchName
|
||||
?? issue.currentExecutionWorkspace?.cwd
|
||||
?? issue.executionWorkspaceId
|
||||
?? null;
|
||||
const shouldInheritUserAssignee = Boolean(issue.assigneeUserId && issue.assigneeUserId !== currentUserId);
|
||||
const inheritedAssigneeUserId = shouldInheritUserAssignee ? issue.assigneeUserId ?? undefined : undefined;
|
||||
|
||||
return {
|
||||
parentId: issue.id,
|
||||
parentIdentifier: issue.identifier ?? undefined,
|
||||
parentTitle: issue.title,
|
||||
...(issue.projectId ? { projectId: issue.projectId } : {}),
|
||||
...(issue.projectWorkspaceId ? { projectWorkspaceId: issue.projectWorkspaceId } : {}),
|
||||
...(issue.goalId ? { goalId: issue.goalId } : {}),
|
||||
...(issue.executionWorkspaceId ? { executionWorkspaceId: issue.executionWorkspaceId } : {}),
|
||||
...(issue.executionWorkspaceId
|
||||
? { executionWorkspaceMode: "reuse_existing" }
|
||||
: issue.executionWorkspacePreference
|
||||
? { executionWorkspaceMode: issue.executionWorkspacePreference }
|
||||
: {}),
|
||||
...(parentExecutionWorkspaceLabel ? { parentExecutionWorkspaceLabel } : {}),
|
||||
...(issue.assigneeAgentId ? { assigneeAgentId: issue.assigneeAgentId } : {}),
|
||||
...(inheritedAssigneeUserId ? { assigneeUserId: inheritedAssigneeUserId } : {}),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue