mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
[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:
@@ -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 });
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = ``;
|
||||
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 = ``;
|
||||
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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user