diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 3918b39b..3ad29546 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -346,6 +346,39 @@ describe("IssueChatThread", () => { }); }); + it("renders footer content inside the thread viewport before the bottom anchor", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + showComposer={false} + enableLiveTranscriptPolling={false} + footer={
Sibling footer
} + /> +
, + ); + }); + + const viewport = container.querySelector('[data-testid="thread-viewport"]'); + const footer = container.querySelector('[data-testid="issue-chat-thread-footer"]'); + expect(viewport).not.toBeNull(); + expect(footer).not.toBeNull(); + expect(footer?.textContent).toBe("Sibling footer"); + expect(footer?.parentElement).toBe(viewport); + expect(footer?.nextElementSibling?.textContent).toBe(""); + + act(() => { + root.unmount(); + }); + }); + it("renders the composer in planning mode when the issue is in planning mode", () => { const root = createRoot(container); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 511f79a8..b25eb39f 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -342,6 +342,7 @@ interface IssueChatThreadProps { showComposer?: boolean; showJumpToLatest?: boolean; emptyMessage?: string; + footer?: ReactNode; variant?: "full" | "embedded"; enableLiveTranscriptPolling?: boolean; transcriptsByRunId?: ReadonlyMap; @@ -3650,6 +3651,7 @@ export function IssueChatThread({ showComposer = true, showJumpToLatest, emptyMessage, + footer, variant = "full", enableLiveTranscriptPolling = true, transcriptsByRunId, @@ -4310,6 +4312,7 @@ export function IssueChatThread({ ) : null} + {footer ?
{footer}
: null}
{showComposer ? (
vi.fn()); + +vi.mock("@/api/issues", () => ({ + issuesApi: { + get: mockIssuesApiGet, + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function createIssue(overrides: Partial = {}): Issue { + return { + id: "issue-1", + identifier: "PAP-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Quicklook title", + description: "Quicklook description", + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-05-01T00:00:00.000Z"), + updatedAt: new Date("2026-05-01T00:00:00.000Z"), + labels: [], + labelIds: [], + myLastTouchAt: null, + lastExternalCommentAt: null, + isUnreadForMe: false, + workMode: "standard", + ...overrides, + }; +} + +describe("IssueLinkQuicklook", () => { + let container: HTMLDivElement; + let root: Root; + let queryClient: QueryClient; + + beforeEach(() => { + vi.useFakeTimers(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + mockIssuesApiGet.mockResolvedValue(createIssue()); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + queryClient.clear(); + container.remove(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("keeps portaled quicklook links mounted until after blur click handling", () => { + const issue = createIssue(); + + act(() => { + root.render( + + + + PAP-1 + + + , + ); + }); + + const trigger = container.querySelector("a") as HTMLAnchorElement | null; + expect(trigger).not.toBeNull(); + + act(() => { + trigger?.focus(); + }); + + expect(document.body.textContent).toContain("Quicklook title"); + + act(() => { + trigger?.blur(); + }); + + expect(document.body.textContent).toContain("Quicklook title"); + + act(() => { + vi.runOnlyPendingTimers(); + }); + + expect(document.body.textContent).not.toContain("Quicklook title"); + }); +}); diff --git a/ui/src/components/IssueLinkQuicklook.tsx b/ui/src/components/IssueLinkQuicklook.tsx index 16b16822..65a07b1f 100644 --- a/ui/src/components/IssueLinkQuicklook.tsx +++ b/ui/src/components/IssueLinkQuicklook.tsx @@ -75,6 +75,8 @@ export const IssueLinkQuicklook = React.forwardRef< issuePathId: string; disableIssueQuicklook?: boolean; issuePrefetch?: Issue | null; + issueQuicklookSide?: React.ComponentProps["side"]; + issueQuicklookAlign?: React.ComponentProps["align"]; } >(function IssueLinkQuicklookImpl( { @@ -85,10 +87,13 @@ export const IssueLinkQuicklook = React.forwardRef< state, disableIssueQuicklook = false, issuePrefetch = null, + issueQuicklookSide = "top", + issueQuicklookAlign = "start", onClick, onClickCapture, onMouseEnter, onFocus, + onBlur, onTouchStart, ...props }, @@ -119,8 +124,14 @@ export const IssueLinkQuicklook = React.forwardRef< }} onFocus={(event) => { handlePrefetch(); + setOpen(true); onFocus?.(event); }} + onBlur={(event) => { + // Let clicks inside the portaled quicklook content finish before closing. + setTimeout(() => setOpen(false), 0); + onBlur?.(event); + }} onTouchStart={(event) => { handlePrefetch(); onTouchStart?.(event); @@ -157,8 +168,8 @@ export const IssueLinkQuicklook = React.forwardRef< setOpen(true)} onMouseLeave={() => setOpen(false)} onOpenAutoFocus={(event) => event.preventDefault()} diff --git a/ui/src/components/IssueSiblingNavigation.test.tsx b/ui/src/components/IssueSiblingNavigation.test.tsx new file mode 100644 index 00000000..d356fcfb --- /dev/null +++ b/ui/src/components/IssueSiblingNavigation.test.tsx @@ -0,0 +1,130 @@ +// @vitest-environment jsdom + +import { act, type AnchorHTMLAttributes, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import type { Issue } from "@paperclipai/shared"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { IssueSiblingNavigation } from "./IssueSiblingNavigation"; + +vi.mock("@/lib/router", () => ({ + Link: ({ + children, + to, + issueQuicklookAlign, + issueQuicklookSide, + issuePrefetch: _issuePrefetch, + state: _state, + ...props + }: AnchorHTMLAttributes & { + to: string; + issueQuicklookAlign?: string; + issueQuicklookSide?: string; + issuePrefetch?: unknown; + state?: unknown; + children?: ReactNode; + }) => ( + + {children} + + ), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function issue(id: string, overrides: Partial = {}): Issue { + return { + id, + identifier: `PAP-${id}`, + title: `Sibling ${id}`, + status: "todo", + blockerAttention: null, + createdAt: new Date("2026-05-01T00:00:00.000Z"), + updatedAt: new Date("2026-05-01T00:00:00.000Z"), + ...overrides, + } as Issue; +} + +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +afterEach(() => { + if (root) { + act(() => root?.unmount()); + } + root = null; + container?.remove(); + container = null; +}); + +function render(node: ReactNode) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => root?.render(node)); + return container; +} + +describe("IssueSiblingNavigation", () => { + it("renders the locked card anatomy for previous and next siblings", () => { + const node = render( + , + ); + + const nav = node.querySelector("nav"); + expect(nav?.getAttribute("aria-label")).toBe("Sub-issue navigation"); + expect(nav?.className).toContain("sm:grid-cols-2"); + expect(nav?.className).not.toContain("border-t"); + + const links = Array.from(node.querySelectorAll("a")); + expect(links).toHaveLength(2); + expect(links[0].textContent).toContain("Previous"); + expect(links[0].textContent).toContain("PAP-1"); + expect(links[0].textContent).toContain("Previous sibling title"); + expect(links[0].getAttribute("aria-label")).toBe("Previous sub-issue: PAP-1 - Previous sibling title"); + expect(links[0].getAttribute("data-quicklook-align")).toBe("start"); + + expect(links[1].textContent).toContain("Next"); + expect(links[1].textContent).toContain("PAP-3"); + expect(links[1].textContent).toContain("Next sibling title"); + expect(links[1].getAttribute("aria-label")).toBe("Next sub-issue: PAP-3 - Next sibling title"); + expect(links[1].getAttribute("data-quicklook-align")).toBe("end"); + expect(links[1].className).toContain("sm:text-right"); + + expect(links[0].className).toContain("rounded-lg"); + expect(links[0].className).toContain("hover:bg-accent/50"); + expect(links[0].className).toContain("focus-visible:ring-[3px]"); + expect(node.querySelector(".truncate")?.textContent).toBe("Previous sibling title"); + }); + + it("keeps a lone next card in the right desktop column", () => { + const node = render( + , + ); + + const links = Array.from(node.querySelectorAll("a")); + expect(links).toHaveLength(1); + expect(links[0].textContent).toContain("Next"); + expect(links[0].className).toContain("sm:col-start-2"); + expect(node.textContent).not.toContain("Previous"); + }); +}); diff --git a/ui/src/components/IssueSiblingNavigation.tsx b/ui/src/components/IssueSiblingNavigation.tsx new file mode 100644 index 00000000..213dfe4d --- /dev/null +++ b/ui/src/components/IssueSiblingNavigation.tsx @@ -0,0 +1,90 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import type { Issue } from "@paperclipai/shared"; +import type { IssueSiblingNavigation as IssueSiblingNavigationState } from "@/lib/issue-detail-subissues"; +import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDetailBreadcrumb"; +import { cn } from "@/lib/utils"; +import { Link } from "@/lib/router"; +import { StatusIcon } from "./StatusIcon"; + +type IssueSiblingNavigationProps = { + navigation: IssueSiblingNavigationState | null; + linkState?: unknown; +}; + +export function IssueSiblingNavigation({ navigation, linkState }: IssueSiblingNavigationProps) { + if (!navigation) return null; + + return ( + + ); +} + +function SiblingLink({ + direction, + issue, + linkState, + className, +}: { + direction: "previous" | "next"; + issue: Issue; + linkState?: unknown; + className?: string; +}) { + const issuePathId = issue.identifier ?? issue.id; + const label = direction === "previous" ? "Previous" : "Next"; + const ariaDirection = direction === "previous" ? "Previous sub-issue" : "Next sub-issue"; + const identifier = issue.identifier ?? issue.id.slice(0, 8); + const Icon = direction === "previous" ? ChevronLeft : ChevronRight; + + return ( + +
+
+ {direction === "previous" ? : null} + {label} + {direction === "next" ? : null} +
+
+ + {identifier} +
+
+ {issue.title} +
+
+ + ); +} diff --git a/ui/src/lib/issue-detail-subissues.test.ts b/ui/src/lib/issue-detail-subissues.test.ts index 611e677b..53b3ead5 100644 --- a/ui/src/lib/issue-detail-subissues.test.ts +++ b/ui/src/lib/issue-detail-subissues.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import type { Issue } from "@paperclipai/shared"; import { + buildIssueSiblingNavigation, buildSubIssueProgressSummary, shouldRenderRichSubIssuesSection, shouldRenderSubIssueProgressSummary, @@ -24,6 +25,21 @@ function issue( } as Issue; } +function siblingIssue( + id: string, + createdAt: string, + blockedByIds: string[] = [], + overrides: Partial = {}, +): Issue { + return { + ...issue(id, "todo", createdAt, blockedByIds), + parentId: "parent-1", + title: `Sibling ${id}`, + hiddenAt: null, + ...overrides, + } as Issue; +} + describe("shouldRenderRichSubIssuesSection", () => { it("shows the rich sub-issues section while child issues are loading", () => { expect(shouldRenderRichSubIssuesSection(true, 0)).toBe(true); @@ -78,3 +94,112 @@ describe("buildSubIssueProgressSummary", () => { expect(summary.target?.issue.id).toBe("2"); }); }); + +describe("buildIssueSiblingNavigation", () => { + it("orders linear blocker chains before selecting previous and next siblings", () => { + const current = siblingIssue("2", "2026-04-02T00:00:00.000Z", ["1"]); + const navigation = buildIssueSiblingNavigation(current, [ + siblingIssue("3", "2026-04-03T00:00:00.000Z", ["2"]), + siblingIssue("1", "2026-04-01T00:00:00.000Z"), + current, + ]); + + expect(navigation?.previous?.id).toBe("1"); + expect(navigation?.next?.id).toBe("3"); + expect(navigation?.currentIndex).toBe(1); + expect(navigation?.totalCount).toBe(3); + }); + + it("degrades branch and merge graphs to stable workflow order", () => { + const current = siblingIssue("3", "2026-04-03T00:00:00.000Z", ["1"]); + const navigation = buildIssueSiblingNavigation(current, [ + siblingIssue("4", "2026-04-04T00:00:00.000Z", ["2", "3"]), + siblingIssue("2", "2026-04-02T00:00:00.000Z", ["1"]), + current, + siblingIssue("1", "2026-04-01T00:00:00.000Z"), + ]); + + expect(navigation?.previous?.id).toBe("2"); + expect(navigation?.next?.id).toBe("4"); + }); + + it("falls back to created time and id when siblings have no direct blocker hints", () => { + const current = siblingIssue("2", "2026-04-01T00:00:00.000Z"); + const navigation = buildIssueSiblingNavigation(current, [ + siblingIssue("3", "2026-04-02T00:00:00.000Z"), + siblingIssue("1", "2026-04-01T00:00:00.000Z"), + current, + ]); + + expect(navigation?.previous?.id).toBe("1"); + expect(navigation?.next?.id).toBe("3"); + }); + + it("hides navigation for root issues without children or hidden current issues", () => { + expect(buildIssueSiblingNavigation(siblingIssue("1", "2026-04-01T00:00:00.000Z", [], { parentId: null }), [])) + .toBeNull(); + expect(buildIssueSiblingNavigation(siblingIssue("1", "2026-04-01T00:00:00.000Z", [], { parentId: null }), [ + siblingIssue("2", "2026-04-02T00:00:00.000Z", [], { parentId: null }), + ])).toBeNull(); + expect(buildIssueSiblingNavigation(siblingIssue("1", "2026-04-01T00:00:00.000Z", [], { hiddenAt: new Date() }), [])) + .toBeNull(); + }); + + it("hides navigation when the current issue is the only visible child", () => { + const current = siblingIssue("1", "2026-04-01T00:00:00.000Z"); + const navigation = buildIssueSiblingNavigation(current, [ + current, + siblingIssue("2", "2026-04-02T00:00:00.000Z", [], { hiddenAt: new Date() }), + ]); + + expect(navigation).toBeNull(); + }); + + it("returns only next for the first sibling and only previous for the last sibling", () => { + const first = siblingIssue("1", "2026-04-01T00:00:00.000Z"); + const last = siblingIssue("3", "2026-04-03T00:00:00.000Z"); + const siblings = [ + siblingIssue("2", "2026-04-02T00:00:00.000Z"), + last, + first, + ]; + + expect(buildIssueSiblingNavigation(first, siblings)).toMatchObject({ + previous: null, + next: { id: "2" }, + }); + expect(buildIssueSiblingNavigation(last, siblings)).toMatchObject({ + previous: { id: "2" }, + next: null, + }); + }); + + it("uses the first direct child as next when a root issue has no sibling next", () => { + const current = siblingIssue("1", "2026-04-01T00:00:00.000Z", [], { parentId: null }); + const navigation = buildIssueSiblingNavigation(current, [], [ + siblingIssue("3", "2026-04-03T00:00:00.000Z", ["2"], { parentId: "1" }), + siblingIssue("2", "2026-04-02T00:00:00.000Z", [], { parentId: "1" }), + ]); + + expect(navigation).toMatchObject({ + previous: null, + next: { id: "2" }, + }); + }); + + it("uses the first direct child as next when the current sibling is last", () => { + const current = siblingIssue("2", "2026-04-02T00:00:00.000Z"); + const navigation = buildIssueSiblingNavigation(current, [ + siblingIssue("1", "2026-04-01T00:00:00.000Z"), + current, + ], [ + siblingIssue("4", "2026-04-04T00:00:00.000Z", ["3"], { parentId: "2" }), + siblingIssue("3", "2026-04-03T00:00:00.000Z", [], { parentId: "2" }), + ]); + + expect(navigation).toMatchObject({ + previous: { id: "1" }, + next: { id: "3" }, + }); + }); +}); diff --git a/ui/src/lib/issue-detail-subissues.ts b/ui/src/lib/issue-detail-subissues.ts index 379ca8c1..40274675 100644 --- a/ui/src/lib/issue-detail-subissues.ts +++ b/ui/src/lib/issue-detail-subissues.ts @@ -17,6 +17,13 @@ export type SubIssueProgressSummary = { target: SubIssueProgressTarget | null; }; +export type IssueSiblingNavigation = { + previous: Issue | null; + next: Issue | null; + currentIndex: number; + totalCount: number; +}; + export function shouldRenderRichSubIssuesSection(childIssuesLoading: boolean, childIssueCount: number): boolean { return childIssuesLoading || childIssueCount > 0; } @@ -56,6 +63,57 @@ export function buildSubIssueProgressSummary(issues: Issue[]): SubIssueProgressS }; } +export function buildIssueSiblingNavigation( + currentIssue: Issue, + siblingIssues: Issue[], + childIssues: Issue[] = [], +): IssueSiblingNavigation | null { + if (currentIssue.hiddenAt) return null; + + const byId = new Map(); + if (currentIssue.parentId) { + for (const issue of siblingIssues) { + if (issue.parentId !== currentIssue.parentId || issue.hiddenAt) continue; + byId.set( + issue.id, + issue.id === currentIssue.id + ? { ...issue, ...currentIssue, blockedBy: currentIssue.blockedBy ?? issue.blockedBy } + : issue, + ); + } + if (!byId.has(currentIssue.id)) byId.set(currentIssue.id, currentIssue); + } + + const ordered = workflowSort(Array.from(byId.values())); + const currentIndex = ordered.findIndex((issue) => issue.id === currentIssue.id); + const directChildren = workflowSort( + childIssues.filter((issue) => issue.parentId === currentIssue.id && !issue.hiddenAt), + ); + const firstChild = directChildren[0] ?? null; + + if (currentIndex < 0) { + return firstChild + ? { + previous: null, + next: firstChild, + currentIndex: 0, + totalCount: directChildren.length + 1, + } + : null; + } + + const previous = currentIndex > 0 ? ordered[currentIndex - 1] : null; + const next = currentIndex < ordered.length - 1 ? ordered[currentIndex + 1] : firstChild; + if (!previous && !next) return null; + + return { + previous, + next, + currentIndex, + totalCount: ordered.length, + }; +} + function isActionableStatus(status: IssueStatus): boolean { return status !== "done" && status !== "cancelled" && status !== "blocked"; } diff --git a/ui/src/lib/router.tsx b/ui/src/lib/router.tsx index 0ec23ede..48891144 100644 --- a/ui/src/lib/router.tsx +++ b/ui/src/lib/router.tsx @@ -46,10 +46,19 @@ export * from "react-router-dom"; type CompanyLinkProps = React.ComponentProps & { disableIssueQuicklook?: boolean; issuePrefetch?: Issue | null; + issueQuicklookSide?: React.ComponentProps["issueQuicklookSide"]; + issueQuicklookAlign?: React.ComponentProps["issueQuicklookAlign"]; }; export const Link = React.forwardRef( - function CompanyLink({ to, disableIssueQuicklook = false, issuePrefetch = null, ...props }, ref) { + function CompanyLink({ + to, + disableIssueQuicklook = false, + issuePrefetch = null, + issueQuicklookSide, + issueQuicklookAlign, + ...props + }, ref) { const companyPrefix = useActiveCompanyPrefix(); const resolvedTo = resolveTo(to, companyPrefix); const issuePathId = parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname); @@ -62,6 +71,8 @@ export const Link = React.forwardRef( issuePathId={issuePathId} disableIssueQuicklook={disableIssueQuicklook} issuePrefetch={issuePrefetch} + issueQuicklookSide={issueQuicklookSide} + issueQuicklookAlign={issueQuicklookAlign} {...props} /> ); diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index e4dd4c37..98f611d1 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Agent, Issue, IssueTreeControlPreview, IssueTreeHold } from "@paperclipai/shared"; -import { act, type ButtonHTMLAttributes, type ReactNode } from "react"; +import { act, type AnchorHTMLAttributes, type ButtonHTMLAttributes, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { canBoardResolveRecoveryAction, IssueDetail } from "./IssueDetail"; @@ -110,7 +110,24 @@ vi.mock("../api/instanceSettings", () => ({ })); vi.mock("@/lib/router", () => ({ - Link: ({ children, to }: { children?: ReactNode; to: string }) => {children}, + Link: ({ + children, + to, + state: _state, + issuePrefetch: _issuePrefetch, + issueQuicklookSide: _issueQuicklookSide, + issueQuicklookAlign: _issueQuicklookAlign, + ...props + }: { + children?: ReactNode; + to: string; + state?: unknown; + issuePrefetch?: unknown; + issueQuicklookSide?: unknown; + issueQuicklookAlign?: unknown; + } & AnchorHTMLAttributes) => ( + {children} + ), useLocation: () => ({ pathname: "/issues/PAP-1", search: "", hash: "", state: null }), useNavigate: () => mockNavigate, useNavigationType: () => "PUSH", @@ -197,6 +214,7 @@ vi.mock("../components/IssueChatThread", () => ({ onStopRun?: (runId: string) => Promise; stopRunLabel?: string; stoppingRunLabel?: string; + footer?: ReactNode; }) => { mockIssueChatThreadRender(props); return ( @@ -207,6 +225,7 @@ vi.mock("../components/IssueChatThread", () => ({ {props.stopRunLabel ?? "Stop run"} ) : null} + {props.footer}
); }, @@ -839,6 +858,116 @@ describe("IssueDetail", () => { expect(consoleErrorSpy).not.toHaveBeenCalled(); }); + it("renders sibling previous and next navigation at the chat footer", async () => { + const issue = createIssue({ + id: "issue-2", + identifier: "PAP-2", + issueNumber: 2, + parentId: "parent-1", + title: "Current sibling", + createdAt: new Date("2026-04-02T00:00:00.000Z"), + }); + const previous = createIssue({ + id: "issue-1", + identifier: "PAP-1", + issueNumber: 1, + parentId: "parent-1", + title: "Previous sibling", + status: "done", + createdAt: new Date("2026-04-01T00:00:00.000Z"), + }); + const next = createIssue({ + id: "issue-3", + identifier: "PAP-3", + issueNumber: 3, + parentId: "parent-1", + title: "Next sibling", + blockedBy: [{ id: "issue-2" }] as Issue["blockedBy"], + createdAt: new Date("2026-04-03T00:00:00.000Z"), + }); + + mockIssuesApi.get.mockResolvedValue(issue); + mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string; parentId?: string }) => { + if (filters?.parentId === "parent-1") return Promise.resolve([next, previous, issue]); + return Promise.resolve([]); + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { + parentId: "parent-1", + includeBlockedBy: true, + }); + expect(container.querySelector('a[aria-label="Previous sub-issue: PAP-1 - Previous sibling"]')).toBeTruthy(); + expect(container.querySelector('a[aria-label="Next sub-issue: PAP-3 - Next sibling"]')).toBeTruthy(); + expect(container.textContent).toContain("Previous"); + expect(container.textContent).toContain("Previous sibling"); + expect(container.textContent).toContain("Next"); + expect(container.textContent).toContain("Next sibling"); + expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].footer).toBeTruthy(); + }); + + it("uses the first child issue as next navigation for parent issues without a sibling next", async () => { + const parent = createIssue({ + id: "issue-parent", + identifier: "PAP-10", + issueNumber: 10, + parentId: null, + title: "Plan parent", + createdAt: new Date("2026-04-01T00:00:00.000Z"), + }); + const firstChild = createIssue({ + id: "issue-child-1", + identifier: "PAP-11", + issueNumber: 11, + parentId: "issue-parent", + title: "First child", + createdAt: new Date("2026-04-02T00:00:00.000Z"), + }); + const secondChild = createIssue({ + id: "issue-child-2", + identifier: "PAP-12", + issueNumber: 12, + parentId: "issue-parent", + title: "Second child", + blockedBy: [{ id: "issue-child-1" }] as Issue["blockedBy"], + createdAt: new Date("2026-04-03T00:00:00.000Z"), + }); + + mockIssuesApi.get.mockResolvedValue(parent); + mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string; parentId?: string }) => { + if (filters?.descendantOf === "issue-parent") return Promise.resolve([secondChild, firstChild]); + return Promise.resolve([]); + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { + descendantOf: "issue-parent", + includeBlockedBy: true, + }); + expect(container.querySelector('a[aria-label="Next sub-issue: PAP-11 - First child"]')).toBeTruthy(); + expect(container.textContent).toContain("Next"); + expect(container.textContent).toContain("First child"); + expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].footer).toBeTruthy(); + }); + it("passes blocker attention to the issue detail header status icon", async () => { mockIssuesApi.get.mockResolvedValue(createIssue({ status: "blocked", diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index a3d875b7..95156440 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type Ref } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode, type Ref } from "react"; import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { Link, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router"; import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query"; @@ -66,6 +66,7 @@ import { InlineEditor } from "../components/InlineEditor"; import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread"; import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff"; import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; +import { IssueSiblingNavigation } from "../components/IssueSiblingNavigation"; import { IssuesList } from "../components/IssuesList"; import { AgentIcon } from "../components/AgentIconPicker"; import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary"; @@ -102,7 +103,7 @@ import { 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 { buildIssueSiblingNavigation, shouldRenderRichSubIssuesSection } from "../lib/issue-detail-subissues"; import { filterIssueDescendants } from "../lib/issue-tree"; import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults"; import { @@ -633,6 +634,7 @@ type IssueDetailChatTabProps = { onRefreshLatestComments: () => Promise | void; onWorkModeChange?: (workMode: IssueWorkMode) => Promise | void; composerRef: Ref; + footer?: ReactNode; feedbackVotes?: FeedbackVote[]; feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt"; feedbackTermsUrl: string | null; @@ -700,6 +702,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ onRefreshLatestComments, onWorkModeChange, composerRef, + footer, feedbackVotes, feedbackDataSharingPreference, feedbackTermsUrl, @@ -946,6 +949,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ assigneeUserId={assigneeUserId} onResumeFromBacklog={onResumeFromBacklog} resumeFromBacklogPending={resumeFromBacklogPending} + footer={footer} />
); @@ -1368,6 +1372,18 @@ export function IssueDetail() { enabled: !!resolvedCompanyId && !!issue?.id, placeholderData: keepPreviousDataForSameQueryTail(issue?.id ?? "pending"), }); + const { + data: rawSiblingIssues = [], + isLoading: siblingIssuesLoading, + isError: siblingIssuesError, + } = useQuery({ + queryKey: + issue?.parentId && resolvedCompanyId + ? queryKeys.issues.listByParent(resolvedCompanyId, issue.parentId) + : ["issues", "siblings", "pending"], + queryFn: () => issuesApi.list(resolvedCompanyId!, { parentId: issue!.parentId!, includeBlockedBy: true }), + enabled: !!resolvedCompanyId && !!issue?.parentId, + }); const { data: companyLiveRuns } = useQuery({ queryKey: resolvedCompanyId ? queryKeys.liveRuns(resolvedCompanyId) : ["live-runs", "pending"], queryFn: () => heartbeatsApi.liveRunsForCompany(resolvedCompanyId!), @@ -1537,6 +1553,12 @@ export function IssueDetail() { [issuePanelKey], ); const showRichSubIssuesSection = shouldRenderRichSubIssuesSection(childIssuesLoading, childIssues.length); + const siblingNavigation = useMemo( + () => issue && !childIssuesLoading && !siblingIssuesLoading && !siblingIssuesError + ? buildIssueSiblingNavigation(issue, rawSiblingIssues, childIssues) + : null, + [childIssues, childIssuesLoading, issue, rawSiblingIssues, siblingIssuesError, siblingIssuesLoading], + ); const openNewSubIssue = useCallback(() => { if (!issue) return; openNewIssue(buildSubIssueDefaultsForViewer(issue, currentUserId)); @@ -3900,6 +3922,14 @@ export function IssueDetail() { onLoadOlderComments={loadOlderComments} onRefreshLatestComments={refetchLatestComments} composerRef={commentComposerRef} + footer={ + siblingNavigation ? ( + + ) : null + } feedbackVotes={feedbackVotes} feedbackDataSharingPreference={feedbackDataSharingPreference} feedbackTermsUrl={FEEDBACK_TERMS_URL}