[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:
Dotta 2026-04-14 12:50:48 -05:00 committed by GitHub
parent 5d1ed71779
commit 6e6f538630
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 4141 additions and 590 deletions

View file

@ -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",

View file

@ -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);
});
});

View file

@ -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);

View 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();
});
});

View 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" });
}

View 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);
});
});

View file

@ -0,0 +1,3 @@
export function shouldRenderRichSubIssuesSection(childIssuesLoading: boolean, childIssueCount: number): boolean {
return childIssuesLoading || childIssueCount > 0;
}

View 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);
});
});

View 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,
})),
});
}

View file

@ -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", () => {

View file

@ -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 {

View file

@ -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(
{

View file

@ -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);
}

View 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();
});
});

View 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;
};
}

View 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",
});
});
});

View 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 } : {}),
};
}