[codex] Improve issue thread review flow (#4381)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Issue detail is where operators coordinate review, approvals, and
follow-up work with active runs
> - That thread UI needs to surface blockers, descendants, review
handoffs, and reply ergonomics clearly enough for humans to guide agent
work
> - Several small gaps in the issue-thread flow were making review and
navigation clunkier than necessary
> - This pull request improves the reply composer, descendant/blocker
presentation, interaction folding, and review-request handoff plumbing
together as one cohesive issue-thread workflow slice
> - The benefit is a cleaner operator review loop without changing the
broader task model

## What Changed

- restored and refined the floating reply composer behavior in the issue
thread
- folded expired confirmation interactions and improved post-submit
thread scrolling behavior
- surfaced descendant issue context and inline blocker/paused-assignee
notices on the issue detail view
- tightened large-board first paint behavior in `IssuesList`
- added loose review-request handoffs through the issue
execution-policy/update path and covered them with tests

## Verification

- `pnpm vitest run ui/src/pages/IssueDetail.test.tsx`
- `pnpm vitest run server/src/__tests__/issues-service.test.ts
server/src/__tests__/issue-execution-policy.test.ts`
- `pnpm exec vitest run --project @paperclipai/ui
ui/src/components/IssueChatThread.test.tsx
ui/src/components/IssueProperties.test.tsx
ui/src/components/IssuesList.test.tsx ui/src/lib/issue-tree.test.ts
ui/src/api/issues.test.ts`
- `pnpm exec vitest run --project @paperclipai/adapter-utils
packages/adapter-utils/src/server-utils.test.ts`
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/issue-comment-reopen-routes.test.ts -t "coerces
executor handoff patches into workflow-controlled review wakes|wakes the
return assignee with execution_changes_requested"`
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/issue-execution-policy.test.ts
server/src/__tests__/issues-service.test.ts`

## Visual Evidence

- UI layout changes are covered by the focused issue-thread component
and issue-detail tests listed above. Browser screenshots were not
attachable from this automated greploop environment, so reviewers should
use the running preview for final visual confirmation.

## Risks

- Moderate UI-flow risk: these changes touch the issue detail experience
in multiple spots, so regressions would most likely show up as
thread-layout quirks or incorrect review-handoff behavior

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex GPT-5-based coding agent with tool use and code execution
in the Codex CLI environment

## 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 checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots or documented the visual verification path
- [ ] 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-24 08:02:45 -05:00
committed by GitHub
parent 35a9dc37b0
commit 7ad225a198
25 changed files with 1046 additions and 44 deletions

View File

@@ -24,6 +24,14 @@ describe("issuesApi.list", () => {
);
});
it("passes descendantOf through to the company issues endpoint", async () => {
await issuesApi.list("company-1", { descendantOf: "issue-root-1", limit: 25 });
expect(mockApi.get).toHaveBeenCalledWith(
"/companies/company-1/issues?descendantOf=issue-root-1&limit=25",
);
});
it("passes generic workspaceId filters through to the company issues endpoint", async () => {
await issuesApi.list("company-1", { workspaceId: "workspace-1", limit: 1000 });

View File

@@ -43,6 +43,7 @@ export const issuesApi = {
executionWorkspaceId?: string;
originKind?: string;
originId?: string;
descendantOf?: string;
includeRoutineExecutions?: boolean;
q?: string;
limit?: number;
@@ -63,6 +64,7 @@ export const issuesApi = {
if (filters?.executionWorkspaceId) params.set("executionWorkspaceId", filters.executionWorkspaceId);
if (filters?.originKind) params.set("originKind", filters.originKind);
if (filters?.originId) params.set("originId", filters.originId);
if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf);
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
if (filters?.q) params.set("q", filters.q);
if (filters?.limit) params.set("limit", String(filters.limit));

View File

@@ -14,6 +14,7 @@ import {
} from "./IssueChatThread";
import type {
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "../lib/issue-thread-interactions";
@@ -215,6 +216,39 @@ function createQuestionInteraction(
};
}
function createExpiredRequestConfirmationInteraction(
overrides: Partial<RequestConfirmationInteraction> = {},
): RequestConfirmationInteraction {
return {
id: "interaction-confirmation-expired",
companyId: "company-1",
issueId: "issue-1",
kind: "request_confirmation",
title: "Approve the plan",
status: "expired",
continuationPolicy: "wake_assignee_on_accept",
createdByAgentId: "agent-1",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: "user-1",
createdAt: new Date("2026-04-06T12:04:00.000Z"),
updatedAt: new Date("2026-04-06T12:05:00.000Z"),
resolvedAt: new Date("2026-04-06T12:05:00.000Z"),
payload: {
version: 1,
prompt: "Approve the plan and let the assignee start implementation?",
acceptLabel: "Approve plan",
rejectLabel: "Request revisions",
},
result: {
version: 1,
outcome: "superseded_by_comment",
commentId: "comment-1",
},
...overrides,
};
}
describe("IssueChatThread", () => {
let container: HTMLDivElement;
@@ -535,6 +569,50 @@ describe("IssueChatThread", () => {
});
});
it("folds expired request confirmations into an activity row by default", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
interactions={[createExpiredRequestConfirmationInteraction()]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
currentUserId="user-1"
userLabelMap={new Map([["user-1", "Dotta"]])}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
expect(container.textContent).toContain("Dotta");
expect(container.textContent).toContain("updated this task");
expect(container.textContent).toContain("Expired confirmation");
expect(container.textContent).not.toContain("Approve the plan");
const toggleButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Expired confirmation"),
);
expect(toggleButton).toBeTruthy();
await act(async () => {
toggleButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("Approve the plan");
expect(container.textContent).toContain("Confirmation expired after comment");
act(() => {
root.unmount();
});
});
it("renders the transcript directly from stable Paperclip messages", () => {
const root = createRoot(container);
@@ -706,7 +784,7 @@ describe("IssueChatThread", () => {
});
});
it("keeps the composer inline with bottom breathing room and a capped editor height", () => {
it("keeps the composer floating with a capped editor height", () => {
const root = createRoot(container);
act(() => {
@@ -724,15 +802,85 @@ describe("IssueChatThread", () => {
);
});
const dock = container.querySelector('[data-testid="issue-chat-composer-dock"]') as HTMLDivElement | null;
expect(dock).not.toBeNull();
expect(dock?.className).toContain("sticky");
expect(dock?.className).toContain("bottom-[calc(env(safe-area-inset-bottom)+20px)]");
expect(dock?.className).toContain("z-20");
const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null;
expect(composer).not.toBeNull();
expect(composer?.className).not.toContain("sticky");
expect(composer?.className).not.toContain("bottom-0");
expect(composer?.className).toContain("pb-[calc(env(safe-area-inset-bottom)+1.5rem)]");
expect(composer?.className).toContain("rounded-md");
expect(composer?.className).not.toContain("rounded-lg");
expect(composer?.className).toContain("p-[15px]");
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
expect(editor?.dataset.contentClassName).toContain("max-h-[28dvh]");
expect(editor?.dataset.contentClassName).toContain("overflow-y-auto");
expect(editor?.dataset.contentClassName).not.toContain("min-h-[72px]");
act(() => {
root.unmount();
});
});
it("renders the bottom spacer with zero height until the user has submitted", () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[{
id: "comment-spacer-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "user-1",
body: "hello",
createdAt: new Date("2026-04-22T12:00:00.000Z"),
updatedAt: new Date("2026-04-22T12:00:00.000Z"),
}]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
const spacer = container.querySelector('[data-testid="issue-chat-bottom-spacer"]') as HTMLDivElement | null;
expect(spacer).not.toBeNull();
expect(spacer?.style.height).toBe("0px");
act(() => {
root.unmount();
});
});
it("omits the bottom spacer when the composer is hidden", () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
const spacer = container.querySelector('[data-testid="issue-chat-bottom-spacer"]');
expect(spacer).toBeNull();
act(() => {
root.unmount();

View File

@@ -20,6 +20,7 @@ import {
useRef,
useState,
type ChangeEvent,
type DragEvent as ReactDragEvent,
type ErrorInfo,
type Ref,
type ReactNode,
@@ -52,7 +53,7 @@ import type {
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "../lib/issue-thread-interactions";
import { isIssueThreadInteraction } from "../lib/issue-thread-interactions";
import { buildIssueThreadInteractionSummary, isIssueThreadInteraction } from "../lib/issue-thread-interactions";
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
import { Button } from "@/components/ui/button";
@@ -505,6 +506,11 @@ function IssueChatFallbackThread({
const DRAFT_DEBOUNCE_MS = 800;
const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96;
const SUBMIT_SCROLL_RESERVE_VH = 0.4;
function hasFilePayload(evt: ReactDragEvent<HTMLDivElement>) {
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
}
function toIsoString(value: string | Date | null | undefined): string | null {
if (!value) return null;
@@ -610,6 +616,23 @@ function initialsForName(name: string) {
return name.slice(0, 2).toUpperCase();
}
function formatInteractionActorLabel(args: {
agentId?: string | null;
userId?: string | null;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
}) {
const { agentId, userId, agentMap, currentUserId, userLabelMap } = args;
if (agentId) return agentMap?.get(agentId)?.name ?? agentId.slice(0, 8);
if (userId) {
return userLabelMap?.get(userId)
?? formatAssigneeUserLabel(userId, currentUserId, userLabelMap)
?? "Board";
}
return "System";
}
export function resolveIssueChatHumanAuthor(args: {
authorName?: string | null;
authorUserId?: string | null;
@@ -1735,6 +1758,106 @@ function IssueChatFeedbackButtons({
);
}
function ExpiredRequestConfirmationActivity({
message,
anchorId,
interaction,
}: {
message: ThreadMessage;
anchorId?: string;
interaction: RequestConfirmationInteraction;
}) {
const {
agentMap,
currentUserId,
userLabelMap,
onAcceptInteraction,
onRejectInteraction,
} = useContext(IssueChatCtx);
const [expanded, setExpanded] = useState(false);
const hasResolvedActor = Boolean(interaction.resolvedByAgentId || interaction.resolvedByUserId);
const actorAgentId = hasResolvedActor
? interaction.resolvedByAgentId ?? null
: interaction.createdByAgentId ?? null;
const actorUserId = hasResolvedActor
? interaction.resolvedByUserId ?? null
: interaction.createdByUserId ?? null;
const actorName = formatInteractionActorLabel({
agentId: actorAgentId,
userId: actorUserId,
agentMap,
currentUserId,
userLabelMap,
});
const actorIcon = actorAgentId ? agentMap?.get(actorAgentId)?.icon : undefined;
const isCurrentUser = Boolean(actorUserId && currentUserId && actorUserId === currentUserId);
const detailsId = anchorId ? `${anchorId}-details` : `${interaction.id}-details`;
const summary = buildIssueThreadInteractionSummary(interaction);
const rowContent = (
<div className="min-w-0 flex-1">
<div className={cn("flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs", isCurrentUser && "justify-end")}>
<span className="font-medium text-foreground">{actorName}</span>
<span className="text-muted-foreground">updated this task</span>
<a
href={anchorId ? `#${anchorId}` : undefined}
className="text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
>
{timeAgo(message.createdAt)}
</a>
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border border-border/70 bg-background/70 px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground transition-colors hover:border-border hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
aria-expanded={expanded}
aria-controls={detailsId}
onClick={() => setExpanded((current) => !current)}
>
<ChevronDown className={cn("h-3 w-3 transition-transform", expanded && "rotate-180")} />
{expanded ? "Hide confirmation" : "Expired confirmation"}
</button>
</div>
{expanded ? (
<p className={cn("mt-1 text-xs text-muted-foreground", isCurrentUser && "text-right")}>
{summary}
</p>
) : null}
</div>
);
return (
<div id={anchorId}>
{isCurrentUser ? (
<div className="flex items-start justify-end gap-2 py-1">
{rowContent}
</div>
) : (
<div className="flex items-start gap-2.5 py-1">
<Avatar size="sm" className="mt-0.5">
{actorIcon ? (
<AvatarFallback><AgentIcon icon={actorIcon} className="h-3.5 w-3.5" /></AvatarFallback>
) : (
<AvatarFallback>{initialsForName(actorName)}</AvatarFallback>
)}
</Avatar>
{rowContent}
</div>
)}
{expanded ? (
<div id={detailsId} className="mt-2">
<IssueThreadInteractionCard
interaction={interaction}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
/>
</div>
) : null}
</div>
);
}
function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
const {
agentMap,
@@ -1767,6 +1890,16 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
: null;
if (custom.kind === "interaction" && interaction) {
if (interaction.kind === "request_confirmation" && interaction.status === "expired") {
return (
<ExpiredRequestConfirmationActivity
message={message}
anchorId={anchorId}
interaction={interaction}
/>
);
}
return (
<div id={anchorId}>
<div className="py-1.5">
@@ -1921,12 +2054,15 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
const [body, setBody] = useState("");
const [submitting, setSubmitting] = useState(false);
const [attaching, setAttaching] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const dragDepthRef = useRef(0);
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
const attachInputRef = useRef<HTMLInputElement | null>(null);
const editorRef = useRef<MarkdownEditorRef>(null);
const composerContainerRef = useRef<HTMLDivElement | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const canAcceptFiles = Boolean(onImageUpload || onAttachImage);
function queueViewportRestore(snapshot: ReturnType<typeof captureComposerViewportSnapshot>) {
if (!snapshot) return;
@@ -2026,25 +2162,46 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
}
}
async function attachFile(file: File) {
if (onImageUpload && file.type.startsWith("image/")) {
const url = await onImageUpload(file);
const safeName = file.name.replace(/[[\]]/g, "\\$&");
const markdown = `![${safeName}](${url})`;
setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown);
} else if (onAttachImage) {
await onAttachImage(file);
}
}
async function handleAttachFile(evt: ChangeEvent<HTMLInputElement>) {
const file = evt.target.files?.[0];
if (!file) return;
setAttaching(true);
try {
if (onImageUpload) {
const url = await onImageUpload(file);
const safeName = file.name.replace(/[[\]]/g, "\\$&");
const markdown = `![${safeName}](${url})`;
setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown);
} else if (onAttachImage) {
await onAttachImage(file);
}
await attachFile(file);
} finally {
setAttaching(false);
if (attachInputRef.current) attachInputRef.current.value = "";
}
}
async function handleDroppedFiles(files: FileList | null | undefined) {
if (!files || files.length === 0) return;
setAttaching(true);
try {
for (const file of Array.from(files)) {
await attachFile(file);
}
} finally {
setAttaching(false);
}
}
function resetDragState() {
dragDepthRef.current = 0;
setIsDragOver(false);
}
const canSubmit = !submitting && !!body.trim();
if (composerDisabledReason) {
@@ -2059,7 +2216,35 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
<div
ref={composerContainerRef}
data-testid="issue-chat-composer"
className="space-y-3 pt-4 pb-[calc(env(safe-area-inset-bottom)+1.5rem)]"
className={cn(
"relative rounded-md border border-border/70 bg-background/95 p-[15px] shadow-[0_-12px_28px_rgba(15,23,42,0.08)] backdrop-blur supports-[backdrop-filter]:bg-background/85 dark:shadow-[0_-12px_28px_rgba(0,0,0,0.28)]",
isDragOver && "ring-2 ring-primary/60 bg-accent/10",
)}
onDragEnter={(evt) => {
if (!canAcceptFiles || !hasFilePayload(evt)) return;
dragDepthRef.current += 1;
setIsDragOver(true);
}}
onDragOver={(evt) => {
if (!canAcceptFiles || !hasFilePayload(evt)) return;
evt.preventDefault();
evt.dataTransfer.dropEffect = "copy";
}}
onDragLeave={() => {
if (!canAcceptFiles) return;
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsDragOver(false);
}}
onDrop={(evt) => {
if (!canAcceptFiles) return;
if (evt.defaultPrevented) {
resetDragState();
return;
}
evt.preventDefault();
resetDragState();
void handleDroppedFiles(evt.dataTransfer?.files);
}}
>
<MarkdownEditor
ref={editorRef}
@@ -2069,8 +2254,8 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
mentions={mentions}
onSubmit={handleSubmit}
imageUploadHandler={onImageUpload}
bordered
contentClassName="min-h-[72px] max-h-[28dvh] overflow-y-auto pr-1 text-sm scrollbar-auto-hide"
bordered={false}
contentClassName="max-h-[28dvh] overflow-y-auto pr-1 text-sm scrollbar-auto-hide"
/>
{composerHint ? (
@@ -2204,6 +2389,11 @@ export function IssueChatThread({
const composerViewportAnchorRef = useRef<HTMLDivElement | null>(null);
const composerViewportSnapshotRef = useRef<ReturnType<typeof captureComposerViewportSnapshot>>(null);
const preserveComposerViewportRef = useRef(false);
const pendingSubmitScrollRef = useRef(false);
const lastUserMessageIdRef = useRef<string | null>(null);
const spacerBaselineAnchorRef = useRef<string | null>(null);
const spacerInitialReserveRef = useRef(0);
const [bottomSpacerHeight, setBottomSpacerHeight] = useState(0);
const displayLiveRuns = useMemo(() => {
const deduped = new Map<string, LiveRunForIssue>();
for (const run of liveRuns) {
@@ -2317,10 +2507,57 @@ export function IssueChatThread({
const runtime = usePaperclipIssueRuntime({
messages,
isRunning,
onSend: ({ body, reopen, reassignment }) => onAdd(body, reopen, reassignment),
onSend: ({ body, reopen, reassignment }) => {
pendingSubmitScrollRef.current = true;
return onAdd(body, reopen, reassignment);
},
onCancel: onCancelRun,
});
useEffect(() => {
const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
const lastUserId = lastUserMessage?.id ?? null;
if (
pendingSubmitScrollRef.current
&& lastUserId
&& lastUserId !== lastUserMessageIdRef.current
) {
pendingSubmitScrollRef.current = false;
const custom = lastUserMessage?.metadata.custom as { anchorId?: unknown } | undefined;
const anchorId = typeof custom?.anchorId === "string" ? custom.anchorId : null;
if (anchorId) {
const reserve = Math.round(window.innerHeight * SUBMIT_SCROLL_RESERVE_VH);
spacerBaselineAnchorRef.current = anchorId;
spacerInitialReserveRef.current = reserve;
setBottomSpacerHeight(reserve);
requestAnimationFrame(() => {
const el = document.getElementById(anchorId);
el?.scrollIntoView({ behavior: "smooth", block: "start" });
});
}
}
lastUserMessageIdRef.current = lastUserId;
}, [messages]);
useLayoutEffect(() => {
const anchorId = spacerBaselineAnchorRef.current;
if (!anchorId || spacerInitialReserveRef.current <= 0) return;
const userEl = document.getElementById(anchorId);
const bottomEl = bottomAnchorRef.current;
if (!userEl || !bottomEl) return;
const contentBelow = Math.max(
0,
bottomEl.getBoundingClientRect().top - userEl.getBoundingClientRect().bottom,
);
const next = Math.max(0, spacerInitialReserveRef.current - contentBelow);
setBottomSpacerHeight((prev) => (prev === next ? prev : next));
if (next === 0) {
spacerBaselineAnchorRef.current = null;
spacerInitialReserveRef.current = 0;
}
}, [messages]);
useLayoutEffect(() => {
const composerElement = composerViewportAnchorRef.current;
if (preserveComposerViewportRef.current) {
@@ -2459,15 +2696,30 @@ export function IssueChatThread({
return <IssueChatSystemMessage key={message.id} message={message} />;
})
)}
{showComposer ? (
<div data-testid="issue-chat-thread-notices" className="space-y-2">
<IssueBlockedNotice issueStatus={issueStatus} blockers={unresolvedBlockers} />
<IssueAssigneePausedNotice agent={assignedAgent} />
</div>
) : null}
<div ref={bottomAnchorRef} />
{showComposer ? (
<div
aria-hidden
data-testid="issue-chat-bottom-spacer"
style={{ height: bottomSpacerHeight }}
/>
) : null}
</div>
</div>
</IssueChatErrorBoundary>
{showComposer ? (
<div ref={composerViewportAnchorRef}>
<IssueBlockedNotice issueStatus={issueStatus} blockers={unresolvedBlockers} />
<IssueAssigneePausedNotice agent={assignedAgent} />
<div
ref={composerViewportAnchorRef}
data-testid="issue-chat-composer-dock"
className="sticky bottom-[calc(env(safe-area-inset-bottom)+20px)] z-20 space-y-2 bg-gradient-to-t from-background via-background/95 to-background/0 pt-6"
>
<IssueChatComposer
ref={composerRef}
onImageUpload={imageUploadHandler}

View File

@@ -318,6 +318,7 @@ function createExecutionState(overrides: Partial<IssueExecutionState> = {}): Iss
currentStageType: "review",
currentParticipant: { type: "agent", agentId: "agent-1", userId: null },
returnAssignee: { type: "agent", agentId: "agent-2", userId: null },
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: "changes_requested",

View File

@@ -22,6 +22,8 @@ const mockIssuesApi = vi.hoisted(() => ({
listLabels: vi.fn(),
}));
const mockKanbanBoard = vi.hoisted(() => vi.fn());
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
@@ -87,7 +89,16 @@ vi.mock("./IssueRow", () => ({
}));
vi.mock("./KanbanBoard", () => ({
KanbanBoard: () => null,
KanbanBoard: (props: { issues: Issue[] }) => {
mockKanbanBoard(props);
return (
<div data-testid="kanban-board">
{props.issues.map((issue) => (
<span key={issue.id}>{issue.title}</span>
))}
</div>
);
},
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -189,6 +200,7 @@ describe("IssuesList", () => {
container = document.createElement("div");
document.body.appendChild(container);
dialogState.openNewIssue.mockReset();
mockKanbanBoard.mockReset();
mockIssuesApi.list.mockReset();
mockIssuesApi.listLabels.mockReset();
mockAuthApi.getSession.mockReset();
@@ -404,6 +416,113 @@ describe("IssuesList", () => {
});
});
it("loads board issues with a separate result limit for each status column", async () => {
localStorage.setItem(
"paperclip:test-issues:company-1",
JSON.stringify({ viewMode: "board" }),
);
const parentIssue = createIssue({
id: "issue-parent-total-limit",
title: "Parent total-limited issue",
status: "todo",
});
const backlogIssue = createIssue({
id: "issue-backlog",
title: "Backlog column issue",
status: "backlog",
});
const doneIssue = createIssue({
id: "issue-done",
title: "Done column issue",
status: "done",
});
mockIssuesApi.list.mockImplementation((_companyId, filters) => {
if (filters?.status === "backlog") return Promise.resolve([backlogIssue]);
if (filters?.status === "done") return Promise.resolve([doneIssue]);
return Promise.resolve([]);
});
const { root } = renderWithQueryClient(
<IssuesList
issues={[parentIssue]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
enableRoutineVisibilityFilter
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({
status: "backlog",
limit: 200,
includeRoutineExecutions: true,
}));
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({
status: "done",
limit: 200,
includeRoutineExecutions: true,
}));
expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({
issues: expect.arrayContaining([
expect.objectContaining({ id: "issue-backlog" }),
expect.objectContaining({ id: "issue-done" }),
]),
}));
expect(container.textContent).toContain("Backlog column issue");
expect(container.textContent).toContain("Done column issue");
expect(container.textContent).not.toContain("Parent total-limited issue");
});
act(() => {
root.unmount();
});
});
it("shows a refinement hint when a board column hits its server cap", async () => {
localStorage.setItem(
"paperclip:test-issues:company-1",
JSON.stringify({ viewMode: "board" }),
);
const cappedBacklogIssues = Array.from({ length: 200 }, (_, index) =>
createIssue({
id: `issue-backlog-${index + 1}`,
identifier: `PAP-${index + 1}`,
title: `Backlog issue ${index + 1}`,
status: "backlog",
}),
);
mockIssuesApi.list.mockImplementation((_companyId, filters) => {
if (filters?.status === "backlog") return Promise.resolve(cappedBacklogIssues);
return Promise.resolve([]);
});
const { root } = renderWithQueryClient(
<IssuesList
issues={[]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(container.textContent).toContain("Some board columns are showing up to 200 issues. Refine filters or search to reveal the rest.");
});
act(() => {
root.unmount();
});
});
it("caps the first paint for large issue lists", async () => {
const manyIssues = Array.from({ length: 220 }, (_, index) =>
createIssue({

View File

@@ -1,5 +1,5 @@
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQueries, useQuery } from "@tanstack/react-query";
import { accessApi } from "../api/access";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
@@ -57,12 +57,14 @@ import { KanbanBoard } from "./KanbanBoard";
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
import { statusBadge } from "../lib/status-colors";
import type { Issue, Project } from "@paperclipai/shared";
import { ISSUE_STATUSES, type Issue, type Project } from "@paperclipai/shared";
const ISSUE_SEARCH_DEBOUNCE_MS = 250;
const ISSUE_SEARCH_RESULT_LIMIT = 200;
const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200;
const INITIAL_ISSUE_ROW_RENDER_LIMIT = 150;
const ISSUE_ROW_RENDER_BATCH_SIZE = 150;
const ISSUE_ROW_RENDER_BATCH_DELAY_MS = 0;
const boardIssueStatuses = ISSUE_STATUSES;
/* ── View state ── */
@@ -175,6 +177,15 @@ function sortIssues(issues: Issue[], state: IssueViewState): Issue[] {
return sorted;
}
function issueMatchesLocalSearch(issue: Issue, normalizedSearch: string): boolean {
if (!normalizedSearch) return true;
return [
issue.identifier,
issue.title,
issue.description,
].some((value) => value?.toLowerCase().includes(normalizedSearch));
}
/* ── Component ── */
interface Agent {
@@ -206,6 +217,7 @@ interface IssuesListProps {
initialWorkspaces?: string[];
initialSearch?: string;
searchFilters?: Omit<IssueListRequestFilters, "q" | "projectId" | "limit" | "includeRoutineExecutions">;
searchWithinLoadedIssues?: boolean;
baseCreateIssueDefaults?: Record<string, unknown>;
createIssueLabel?: string;
enableRoutineVisibilityFilter?: boolean;
@@ -291,6 +303,7 @@ export function IssuesList({
initialWorkspaces,
initialSearch,
searchFilters,
searchWithinLoadedIssues = false,
baseCreateIssueDefaults,
createIssueLabel,
enableRoutineVisibilityFilter = false,
@@ -391,9 +404,34 @@ export function IssuesList({
...searchFilters,
...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}),
}),
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0 && !searchWithinLoadedIssues,
placeholderData: (previousData) => previousData,
});
const boardIssueQueries = useQueries({
queries: boardIssueStatuses.map((status) => ({
queryKey: [
...queryKeys.issues.list(selectedCompanyId ?? "__no-company__"),
"board-column",
status,
normalizedIssueSearch,
projectId ?? "__all-projects__",
searchFilters ?? {},
ISSUE_BOARD_COLUMN_RESULT_LIMIT,
enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions",
],
queryFn: () =>
issuesApi.list(selectedCompanyId!, {
...searchFilters,
...(normalizedIssueSearch.length > 0 ? { q: normalizedIssueSearch } : {}),
projectId,
status,
limit: ISSUE_BOARD_COLUMN_RESULT_LIMIT,
...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}),
}),
enabled: !!selectedCompanyId && viewState.viewMode === "board" && !searchWithinLoadedIssues,
placeholderData: (previousData: Issue[] | undefined) => previousData,
})),
});
const { data: executionWorkspaces = [] } = useQuery({
queryKey: selectedCompanyId
? queryKeys.executionWorkspaces.summaryList(selectedCompanyId)
@@ -570,11 +608,36 @@ export function IssuesList({
return map;
}, [issues]);
const boardIssues = useMemo(() => {
if (viewState.viewMode !== "board" || searchWithinLoadedIssues) return null;
const merged = new Map<string, Issue>();
let isPending = false;
for (const query of boardIssueQueries) {
isPending ||= query.isPending;
for (const issue of query.data ?? []) {
merged.set(issue.id, issue);
}
}
if (merged.size > 0) return [...merged.values()];
return isPending ? issues : [];
}, [boardIssueQueries, issues, searchWithinLoadedIssues, viewState.viewMode]);
const boardColumnLimitReached = useMemo(
() =>
viewState.viewMode === "board" &&
!searchWithinLoadedIssues &&
boardIssueQueries.some((query) => (query.data?.length ?? 0) === ISSUE_BOARD_COLUMN_RESULT_LIMIT),
[boardIssueQueries, searchWithinLoadedIssues, viewState.viewMode],
);
const filtered = useMemo(() => {
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
const filteredByControls = applyIssueFilters(sourceIssues, viewState, currentUserId, enableRoutineVisibilityFilter);
const useRemoteSearch = normalizedIssueSearch.length > 0 && !searchWithinLoadedIssues;
const sourceIssues = boardIssues ?? (useRemoteSearch ? searchedIssues : issues);
const searchScopedIssues = normalizedIssueSearch.length > 0 && searchWithinLoadedIssues
? sourceIssues.filter((issue) => issueMatchesLocalSearch(issue, normalizedIssueSearch))
: sourceIssues;
const filteredByControls = applyIssueFilters(searchScopedIssues, viewState, currentUserId, enableRoutineVisibilityFilter);
return sortIssues(filteredByControls, viewState);
}, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter]);
}, [boardIssues, issues, searchedIssues, searchWithinLoadedIssues, viewState, normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter]);
const { data: labels } = useQuery({
queryKey: queryKeys.issues.labels(selectedCompanyId!),
@@ -872,11 +935,16 @@ export function IssuesList({
{isLoading && <PageSkeleton variant="issues-list" />}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{normalizedIssueSearch.length > 0 && searchedIssues.length === ISSUE_SEARCH_RESULT_LIMIT && (
{!searchWithinLoadedIssues && normalizedIssueSearch.length > 0 && searchedIssues.length === ISSUE_SEARCH_RESULT_LIMIT && (
<p className="text-xs text-muted-foreground">
Showing up to {ISSUE_SEARCH_RESULT_LIMIT} matches. Refine the search to narrow further.
</p>
)}
{boardColumnLimitReached && (
<p className="text-xs text-muted-foreground">
Some board columns are showing up to {ISSUE_BOARD_COLUMN_RESULT_LIMIT} issues. Refine filters or search to reveal the rest.
</p>
)}
{!isLoading && filtered.length === 0 && viewState.viewMode === "list" && (
<EmptyState
icon={CircleDot}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { Issue } from "@paperclipai/shared";
import { buildIssueTree, countDescendants } from "./issue-tree";
import { buildIssueTree, countDescendants, filterIssueDescendants } from "./issue-tree";
function makeIssue(id: string, parentId: string | null = null): Issue {
return {
@@ -128,3 +128,33 @@ describe("countDescendants", () => {
expect(countDescendants("nonexistent", childMap)).toBe(0);
});
});
describe("filterIssueDescendants", () => {
it("returns only children and deeper descendants of the requested root", () => {
const root = makeIssue("root");
const child = makeIssue("child", "root");
const grandchild = makeIssue("grandchild", "child");
const unrelatedParent = makeIssue("other");
const unrelatedChild = makeIssue("other-child", "other");
expect(filterIssueDescendants("root", [
root,
child,
grandchild,
unrelatedParent,
unrelatedChild,
]).map((issue) => issue.id)).toEqual(["child", "grandchild"]);
});
it("handles stale broad issue-list responses without requiring the root in the list", () => {
const child = makeIssue("child", "root");
const grandchild = makeIssue("grandchild", "child");
const globalIssue = makeIssue("global");
expect(filterIssueDescendants("root", [
globalIssue,
child,
grandchild,
]).map((issue) => issue.id)).toEqual(["child", "grandchild"]);
});
});

View File

@@ -34,3 +34,39 @@ export function countDescendants(id: string, childMap: Map<string, Issue[]>): nu
const children = childMap.get(id) ?? [];
return children.reduce((sum, c) => sum + 1 + countDescendants(c.id, childMap), 0);
}
/**
* Filters a flat issue list to only descendants of `rootId`.
*
* This is intentionally useful even when the list contains unrelated issues:
* stale servers may ignore newer descendant-scoped query params, and the UI
* must still avoid rendering global issue data in a sub-issue panel.
*/
export function filterIssueDescendants(rootId: string, items: Issue[]): Issue[] {
const childrenByParentId = new Map<string, Issue[]>();
for (const item of items) {
if (!item.parentId) continue;
const siblings = childrenByParentId.get(item.parentId) ?? [];
siblings.push(item);
childrenByParentId.set(item.parentId, siblings);
}
const descendants: Issue[] = [];
const seen = new Set<string>([rootId]);
let frontier = [rootId];
while (frontier.length > 0) {
const nextFrontier: string[] = [];
for (const parentId of frontier) {
for (const child of childrenByParentId.get(parentId) ?? []) {
if (seen.has(child.id)) continue;
seen.add(child.id);
descendants.push(child);
nextFrontier.push(child.id);
}
}
frontier = nextFrontier;
}
return descendants;
}

View File

@@ -41,6 +41,8 @@ export const queryKeys = {
["issues", companyId, "project", projectId] as const,
listByParent: (companyId: string, parentId: string) =>
["issues", companyId, "parent", parentId] as const,
listByDescendantRoot: (companyId: string, rootIssueId: string) =>
["issues", companyId, "descendants", rootIssueId] as const,
listByExecutionWorkspace: (companyId: string, executionWorkspaceId: string) =>
["issues", companyId, "execution-workspace", executionWorkspaceId] as const,
detail: (id: string) => ["issues", "detail", id] as const,

View File

@@ -850,8 +850,8 @@ describe("IssueDetail", () => {
};
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockImplementation((_companyId, filters?: { parentId?: string }) =>
Promise.resolve(filters?.parentId === "issue-1" ? [childIssue] : []),
mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string }) =>
Promise.resolve(filters?.descendantOf === "issue-1" ? [childIssue] : []),
);
mockIssuesApi.getTreeControlState.mockImplementation(() =>
Promise.resolve({ activePauseHold: activePauseHoldState }),
@@ -937,8 +937,8 @@ describe("IssueDetail", () => {
});
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockImplementation((_companyId, filters?: { parentId?: string }) =>
Promise.resolve(filters?.parentId === "issue-1" ? [childIssue] : []),
mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string }) =>
Promise.resolve(filters?.descendantOf === "issue-1" ? [childIssue] : []),
);
mockIssuesApi.previewTreeControl.mockResolvedValue(pausePreview);
mockIssuesApi.createTreeHold.mockResolvedValue({ hold: pauseHold, preview: pausePreview });
@@ -1031,8 +1031,8 @@ describe("IssueDetail", () => {
});
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockImplementation((_companyId, filters?: { parentId?: string }) =>
Promise.resolve(filters?.parentId === "issue-1" ? [childIssue] : []),
mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string }) =>
Promise.resolve(filters?.descendantOf === "issue-1" ? [childIssue] : []),
);
mockIssuesApi.listTreeHolds.mockImplementation((_issueId, filters?: { mode?: string }) =>
Promise.resolve(filters?.mode === "cancel" ? [cancelHold] : []),
@@ -1106,8 +1106,8 @@ describe("IssueDetail", () => {
});
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockImplementation((_companyId, filters?: { parentId?: string }) =>
Promise.resolve(filters?.parentId === "issue-1" ? [childIssue] : []),
mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string }) =>
Promise.resolve(filters?.descendantOf === "issue-1" ? [childIssue] : []),
);
mockIssuesApi.previewTreeControl.mockResolvedValue(createCancelPreview(24));
mockAuthApi.getSession.mockResolvedValue({

View File

@@ -98,6 +98,7 @@ import { Textarea } from "@/components/ui/textarea";
import { formatIssueActivityAction } from "@/lib/activity-format";
import { buildIssuePropertiesPanelKey } from "../lib/issue-properties-panel-key";
import { shouldRenderRichSubIssuesSection } from "../lib/issue-detail-subissues";
import { filterIssueDescendants } from "../lib/issue-tree";
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
import {
Activity as ActivityIcon,
@@ -1132,9 +1133,9 @@ export function IssueDetail() {
const { data: rawChildIssues = [], isLoading: childIssuesLoading } = useQuery({
queryKey:
issue?.id && resolvedCompanyId
? queryKeys.issues.listByParent(resolvedCompanyId, issue.id)
? queryKeys.issues.listByDescendantRoot(resolvedCompanyId, issue.id)
: ["issues", "parent", "pending"],
queryFn: () => issuesApi.list(resolvedCompanyId!, { parentId: issue!.id }),
queryFn: () => issuesApi.list(resolvedCompanyId!, { descendantOf: issue!.id }),
enabled: !!resolvedCompanyId && !!issue?.id,
placeholderData: keepPreviousDataForSameQueryTail<Issue[]>(issue?.id ?? "pending"),
});
@@ -1286,8 +1287,11 @@ export function IssueDetail() {
[issue?.project, issue?.projectId, orderedProjects],
);
const childIssues = useMemo(
() => [...rawChildIssues].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
[rawChildIssues],
() => {
const descendants = issue?.id ? filterIssueDescendants(issue.id, rawChildIssues) : rawChildIssues;
return [...descendants].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
},
[issue?.id, rawChildIssues],
);
const liveIssueIds = useMemo(() => collectLiveIssueIds(companyLiveRuns), [companyLiveRuns]);
const issuePanelKey = useMemo(
@@ -3133,7 +3137,8 @@ export function IssueDetail() {
projectId={issue.projectId ?? undefined}
viewStateKey={`paperclip:issue-detail:${issue.id}:subissues-view`}
issueLinkState={resolvedIssueDetailState ?? location.state}
searchFilters={{ parentId: issue.id }}
searchFilters={{ descendantOf: issue.id }}
searchWithinLoadedIssues
baseCreateIssueDefaults={buildSubIssueDefaultsForViewer(issue, currentUserId)}
createIssueLabel="Sub-issue"
onUpdateIssue={handleChildIssueUpdate}