Merge upstream/master into dev (13 commits — includes #5922, #5938, blocked inbox, recovery actions)

This commit is contained in:
2026-05-13 22:35:18 -04:00
180 changed files with 31626 additions and 545 deletions
+185 -2
View File
@@ -3,11 +3,124 @@
import { act } from "react";
import type { ComponentProps } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CompanyJoinRequest } from "../api/access";
const routerMock = vi.hoisted(() => ({
location: { pathname: "/", search: "", hash: "" },
navigate: vi.fn(),
}));
const apiMocks = vi.hoisted(() => ({
approvalsList: vi.fn(),
joinRequestsList: vi.fn(),
userDirectoryList: vi.fn(),
authSession: vi.fn(),
dashboardSummary: vi.fn(),
executionWorkspaceSummaries: vi.fn(),
issuesList: vi.fn(),
issuesCount: vi.fn(),
issueLabels: vi.fn(),
agentsList: vi.fn(),
heartbeatRunsList: vi.fn(),
liveRunsForCompany: vi.fn(),
experimentalSettings: vi.fn(),
projectsList: vi.fn(),
}));
vi.mock("../api/approvals", () => ({
approvalsApi: { list: apiMocks.approvalsList },
}));
vi.mock("../api/access", async () => {
const actual = await vi.importActual<typeof import("../api/access")>("../api/access");
return {
...actual,
accessApi: {
listJoinRequests: apiMocks.joinRequestsList,
listUserDirectory: apiMocks.userDirectoryList,
},
};
});
vi.mock("../api/auth", () => ({
authApi: { getSession: apiMocks.authSession },
}));
vi.mock("../api/dashboard", () => ({
dashboardApi: { summary: apiMocks.dashboardSummary },
}));
vi.mock("../api/execution-workspaces", () => ({
executionWorkspacesApi: { listSummaries: apiMocks.executionWorkspaceSummaries },
}));
vi.mock("../api/issues", () => ({
issuesApi: {
list: apiMocks.issuesList,
count: apiMocks.issuesCount,
listLabels: apiMocks.issueLabels,
markRead: vi.fn(),
markUnread: vi.fn(),
archiveFromInbox: vi.fn(),
unarchiveFromInbox: vi.fn(),
},
}));
vi.mock("../api/agents", () => ({
agentsApi: { list: apiMocks.agentsList },
}));
vi.mock("../api/heartbeats", () => ({
heartbeatsApi: {
list: apiMocks.heartbeatRunsList,
liveRunsForCompany: apiMocks.liveRunsForCompany,
},
}));
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: { getExperimental: apiMocks.experimentalSettings },
}));
vi.mock("../api/projects", () => ({
projectsApi: { list: apiMocks.projectsList },
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
vi.mock("../context/DialogContext", () => ({
useDialogActions: () => ({ openNewIssue: vi.fn() }),
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({ isMobile: false }),
}));
vi.mock("../context/GeneralSettingsContext", () => ({
useGeneralSettings: () => ({ keyboardShortcutsEnabled: false }),
}));
vi.mock("../hooks/useInboxBadge", () => ({
useDismissedInboxAlerts: () => ({ dismissed: new Set(), dismiss: vi.fn() }),
useInboxDismissals: () => ({ dismissedAtByKey: new Map(), dismiss: vi.fn() }),
useReadInboxItems: () => ({
readItems: new Set(),
markRead: vi.fn(),
markUnread: vi.fn(),
}),
}));
import {
FailedRunInboxRow,
Inbox,
InboxGroupHeader,
InboxIssueMetaLeading,
InboxIssueTrailingColumns,
@@ -18,8 +131,8 @@ vi.mock("@/lib/router", () => ({
Link: ({ children, className, ...props }: ComponentProps<"a">) => (
<a className={className} {...props}>{children}</a>
),
useLocation: () => ({ pathname: "/", search: "", hash: "" }),
useNavigate: () => () => {},
useLocation: () => routerMock.location,
useNavigate: () => routerMock.navigate,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -108,6 +221,76 @@ function createJoinRequest(
};
}
function resetInboxApiMocks() {
routerMock.location.pathname = "/";
routerMock.location.search = "";
routerMock.location.hash = "";
routerMock.navigate.mockReset();
apiMocks.approvalsList.mockResolvedValue([]);
apiMocks.joinRequestsList.mockResolvedValue([]);
apiMocks.userDirectoryList.mockResolvedValue({ users: [] });
apiMocks.authSession.mockResolvedValue({
user: { id: "local-board" },
session: { userId: "local-board" },
});
apiMocks.dashboardSummary.mockResolvedValue({
agents: { error: 0 },
costs: { monthBudgetCents: 0, monthUtilizationPercent: 0 },
});
apiMocks.executionWorkspaceSummaries.mockResolvedValue([]);
apiMocks.issuesList.mockResolvedValue([]);
apiMocks.issuesCount.mockResolvedValue({ count: 0 });
apiMocks.issueLabels.mockResolvedValue([]);
apiMocks.agentsList.mockResolvedValue([]);
apiMocks.heartbeatRunsList.mockResolvedValue([]);
apiMocks.liveRunsForCompany.mockResolvedValue([]);
apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: false });
apiMocks.projectsList.mockResolvedValue([]);
}
describe("Inbox toolbar", () => {
let container: HTMLDivElement;
beforeEach(() => {
resetInboxApiMocks();
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("shows blocked toolbar controls on the Blocked tab", async () => {
routerMock.location.pathname = "/inbox/blocked";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
});
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Inbox />
</QueryClientProvider>,
);
});
expect(container.querySelector('input[placeholder="Search inbox…"]')).not.toBeNull();
expect(container.querySelector('[data-testid="inbox-blocked-tab-badge"]')).toBeNull();
expect(container.querySelector('button[title="Filter"]')).not.toBeNull();
expect(container.querySelector('button[title="Group"]')).not.toBeNull();
expect(container.querySelector('button[title="Columns"]')).not.toBeNull();
expect(container.querySelector('button[title="Sort"]')).not.toBeNull();
expect(container.querySelector('button[title="Enable parent-child nesting"]')).toBeNull();
expect(container.textContent).not.toContain("Mark all as read");
act(() => {
root.unmount();
});
});
});
describe("FailedRunInboxRow", () => {
let container: HTMLDivElement;
+227 -102
View File
@@ -13,6 +13,12 @@ import { agentsApi } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { projectsApi } from "../api/projects";
import {
BLOCKED_GROUP_OPTIONS,
BLOCKED_SORT_OPTIONS,
type BlockedInboxGroupBy,
type BlockedInboxSort,
} from "../lib/blockedInbox";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useGeneralSettings } from "../context/GeneralSettingsContext";
@@ -54,6 +60,7 @@ import {
} from "../components/IssueColumns";
import { IssueFiltersPopover } from "../components/IssueFiltersPopover";
import { IssueRow } from "../components/IssueRow";
import { BlockedInboxView } from "../components/BlockedInboxView";
import { SwipeToArchive } from "../components/SwipeToArchive";
import { StatusIcon } from "../components/StatusIcon";
@@ -85,6 +92,7 @@ import {
AlertTriangle,
Check,
ChevronRight,
ArrowUpDown,
Layers,
Plus,
XCircle,
@@ -674,6 +682,8 @@ export function Inbox() {
() => loadInboxFilterPreferences(selectedCompanyId),
);
const [groupBy, setGroupBy] = useState<InboxWorkItemGroupBy>(() => loadInboxWorkItemGroupBy());
const [blockedGroupBy, setBlockedGroupBy] = useState<BlockedInboxGroupBy>("none");
const [blockedSortBy, setBlockedSortBy] = useState<BlockedInboxSort>("most_recent");
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts();
const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId);
@@ -682,7 +692,11 @@ export function Inbox() {
const pathSegment = location.pathname.split("/").pop() ?? "mine";
const tab: InboxTab =
pathSegment === "mine" || pathSegment === "recent" || pathSegment === "all" || pathSegment === "unread"
pathSegment === "mine"
|| pathSegment === "recent"
|| pathSegment === "all"
|| pathSegment === "unread"
|| pathSegment === "blocked"
? pathSegment
: "mine";
const canArchiveFromTab = isMineInboxTab(tab);
@@ -824,7 +838,6 @@ export function Inbox() {
queryFn: () => heartbeatsApi.list(selectedCompanyId!, undefined, INBOX_HEARTBEAT_RUN_LIMIT),
enabled: !!selectedCompanyId,
});
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(selectedCompanyId!),
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
@@ -1902,6 +1915,7 @@ export function Inbox() {
.map((issue) => issue.id);
const canMarkAllRead = unreadIssueIds.length > 0;
const activeIssueFilterCount = countActiveIssueFilters(issueFilters, true);
const showGeneralIssueToolbarControls = tab !== "blocked";
return (
<div className="space-y-6">
<div className="space-y-2">
@@ -1947,6 +1961,7 @@ export function Inbox() {
label: "Recent",
},
{ value: "unread", label: "Unread" },
{ value: "blocked", label: "Blocked" },
{ value: "all", label: "All" },
]}
/>
@@ -1981,112 +1996,203 @@ export function Inbox() {
data-page-search-target="true"
/>
</div>
<Button
type="button"
variant="outline"
size="icon"
className={cn("hidden h-8 w-8 shrink-0 sm:inline-flex", nestingEnabled && "bg-accent")}
onClick={toggleNesting}
title={nestingEnabled ? "Disable parent-child nesting" : "Enable parent-child nesting"}
>
<ListTree className="h-3.5 w-3.5" />
</Button>
<IssueFiltersPopover
state={issueFilters}
onChange={updateIssueFilters}
activeFilterCount={activeIssueFilterCount}
agents={agents}
creators={creatorOptions}
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
currentUserId={currentUserId}
enableRoutineVisibilityFilter
buttonVariant="outline"
iconOnly
workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined}
/>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className={cn("h-8 w-8 shrink-0", groupBy !== "none" && "bg-accent")}
title="Group"
>
<Layers className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-40 p-2">
<div className="space-y-0.5">
{([
["none", "None"],
["type", "Type"],
["assignee", "Assignee"],
["project", "Project"],
...(isolatedWorkspacesEnabled ? ([["workspace", "Workspace"]] as const) : []),
] as const).map(([value, label]) => (
<button
key={value}
{tab === "blocked" ? (
<>
<IssueFiltersPopover
state={issueFilters}
onChange={updateIssueFilters}
activeFilterCount={activeIssueFilterCount}
agents={agents}
creators={creatorOptions}
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
currentUserId={currentUserId}
enableRoutineVisibilityFilter
buttonVariant="outline"
iconOnly
workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined}
/>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
className={cn(
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
groupBy === value ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
)}
onClick={() => updateGroupBy(value)}
variant="outline"
size="icon"
className={cn("h-8 w-8 shrink-0", blockedGroupBy !== "none" && "bg-accent")}
title="Group"
>
<span>{label}</span>
{groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
<IssueColumnPicker
availableColumns={availableIssueColumns}
visibleColumnSet={visibleIssueColumnSet}
onToggleColumn={toggleIssueColumn}
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
title="Choose which inbox columns stay visible"
iconOnly
/>
{canMarkAllRead && (
<Layers className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0">
<div className="space-y-0.5 p-2">
{BLOCKED_GROUP_OPTIONS.map(([value, label]) => (
<button
key={value}
type="button"
className={cn(
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
blockedGroupBy === value ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
)}
onClick={() => setBlockedGroupBy(value)}
>
<span>{label}</span>
{blockedGroupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
<IssueColumnPicker
availableColumns={availableIssueColumns}
visibleColumnSet={visibleIssueColumnSet}
onToggleColumn={toggleIssueColumn}
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
title="Choose which inbox columns stay visible"
iconOnly
/>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
title="Sort"
>
<ArrowUpDown className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-48 p-0">
<div className="space-y-0.5 p-2">
{BLOCKED_SORT_OPTIONS.map(([value, label]) => (
<button
key={value}
type="button"
className={cn(
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
blockedSortBy === value ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
)}
onClick={() => setBlockedSortBy(value)}
>
<span>{label}</span>
{blockedSortBy === value ? <Check className="h-3.5 w-3.5" /> : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
</>
) : showGeneralIssueToolbarControls ? (
<>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0"
onClick={() => setShowMarkAllReadConfirm(true)}
disabled={markAllReadMutation.isPending}
size="icon"
className={cn("hidden h-8 w-8 shrink-0 sm:inline-flex", nestingEnabled && "bg-accent")}
onClick={toggleNesting}
title={nestingEnabled ? "Disable parent-child nesting" : "Enable parent-child nesting"}
>
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
<ListTree className="h-3.5 w-3.5" />
</Button>
<Dialog open={showMarkAllReadConfirm} onOpenChange={setShowMarkAllReadConfirm}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Mark all as read?</DialogTitle>
<DialogDescription>
This will mark {unreadIssueIds.length} unread {unreadIssueIds.length === 1 ? "item" : "items"} as read.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowMarkAllReadConfirm(false)}>
Cancel
</Button>
<Button
onClick={() => {
setShowMarkAllReadConfirm(false);
markAllReadMutation.mutate(unreadIssueIds);
}}
>
Mark all as read
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<IssueFiltersPopover
state={issueFilters}
onChange={updateIssueFilters}
activeFilterCount={activeIssueFilterCount}
agents={agents}
creators={creatorOptions}
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
currentUserId={currentUserId}
enableRoutineVisibilityFilter
buttonVariant="outline"
iconOnly
workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined}
/>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className={cn("h-8 w-8 shrink-0", groupBy !== "none" && "bg-accent")}
title="Group"
>
<Layers className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-40 p-2">
<div className="space-y-0.5">
{([
["none", "None"],
["type", "Type"],
["assignee", "Assignee"],
["project", "Project"],
...(isolatedWorkspacesEnabled ? ([["workspace", "Workspace"]] as const) : []),
] as const).map(([value, label]) => (
<button
key={value}
type="button"
className={cn(
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
groupBy === value ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
)}
onClick={() => updateGroupBy(value)}
>
<span>{label}</span>
{groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
<IssueColumnPicker
availableColumns={availableIssueColumns}
visibleColumnSet={visibleIssueColumnSet}
onToggleColumn={toggleIssueColumn}
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
title="Choose which inbox columns stay visible"
iconOnly
/>
{canMarkAllRead && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0"
onClick={() => setShowMarkAllReadConfirm(true)}
disabled={markAllReadMutation.isPending}
>
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
</Button>
<Dialog open={showMarkAllReadConfirm} onOpenChange={setShowMarkAllReadConfirm}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Mark all as read?</DialogTitle>
<DialogDescription>
This will mark {unreadIssueIds.length} unread {unreadIssueIds.length === 1 ? "item" : "items"} as read.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowMarkAllReadConfirm(false)}>
Cancel
</Button>
<Button
onClick={() => {
setShowMarkAllReadConfirm(false);
markAllReadMutation.mutate(unreadIssueIds);
}}
>
Mark all as read
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
</>
)}
) : null}
</div>
</div>
</div>
@@ -2131,11 +2237,30 @@ export function Inbox() {
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
{!allLoaded && visibleSections.length === 0 && (
{tab === "blocked" ? (
<BlockedInboxView
companyId={selectedCompanyId!}
searchQuery={searchQuery}
agentNameById={agentById}
userLabelById={companyUserLabelMap}
issueLinkState={issueLinkState}
groupBy={blockedGroupBy}
sortBy={blockedSortBy}
issueFilters={issueFilters}
currentUserId={currentUserId}
liveIssueIds={liveIssueIds}
workspaceFilterContext={inboxWorkspaceGrouping}
showStatusColumn={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
showIdentifierColumn={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
showUpdatedColumn={visibleIssueColumnSet.has("updated") && availableIssueColumnSet.has("updated")}
/>
) : null}
{tab !== "blocked" && !allLoaded && visibleSections.length === 0 && (
<PageSkeleton variant="inbox" />
)}
{allLoaded && visibleSections.length === 0 && (
{tab !== "blocked" && allLoaded && visibleSections.length === 0 && (
<EmptyState
icon={searchQuery.trim() ? Search : InboxIcon}
message={
@@ -2152,7 +2277,7 @@ export function Inbox() {
/>
)}
{showWorkItemsSection && (
{tab !== "blocked" && showWorkItemsSection && (
<>
{showSeparatorBefore("work_items") && <Separator />}
<div>
+168 -3
View File
@@ -2,10 +2,10 @@
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 { IssueDetail } from "./IssueDetail";
import { canBoardResolveRecoveryAction, IssueDetail } from "./IssueDetail";
const mockIssuesApi = vi.hoisted(() => ({
get: vi.fn(),
@@ -110,7 +110,24 @@ vi.mock("../api/instanceSettings", () => ({
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to }: { children?: ReactNode; to: string }) => <a href={to}>{children}</a>,
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<HTMLAnchorElement>) => (
<a href={to} {...props}>{children}</a>
),
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<void>;
stopRunLabel?: string;
stoppingRunLabel?: string;
footer?: ReactNode;
}) => {
mockIssueChatThreadRender(props);
return (
@@ -207,6 +225,7 @@ vi.mock("../components/IssueChatThread", () => ({
{props.stopRunLabel ?? "Stop run"}
</button>
) : null}
{props.footer}
</div>
);
},
@@ -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(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
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",
@@ -1447,3 +1576,39 @@ describe("IssueDetail", () => {
expect(footer?.className).toContain("bg-background");
});
});
describe("canBoardResolveRecoveryAction", () => {
it("falls back to companyIds when memberships are not populated", () => {
expect(
canBoardResolveRecoveryAction("company-1", {
companyIds: ["company-1"],
memberships: [],
isInstanceAdmin: false,
source: "session",
keyId: null,
user: null,
userId: "user-1",
}),
).toBe(true);
});
it("uses populated memberships as the authoritative board access source", () => {
expect(
canBoardResolveRecoveryAction("company-1", {
companyIds: ["company-1"],
memberships: [
{
companyId: "company-1",
membershipRole: "viewer",
status: "active",
},
],
isInstanceAdmin: false,
source: "session",
keyId: null,
user: null,
userId: "user-1",
}),
).toBe(false);
});
});
+138 -3
View File
@@ -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";
@@ -8,7 +8,7 @@ import { approvalsApi } from "../api/approvals";
import { activityApi, type RunForIssue } from "../api/activity";
import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { accessApi } from "../api/access";
import { accessApi, type CurrentBoardAccess } from "../api/access";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { projectsApi } from "../api/projects";
@@ -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 {
@@ -157,6 +158,7 @@ import {
type CommentReassignment = IssueCommentReassignment;
type ActionableIssueThreadInteraction = SuggestTasksInteraction | RequestConfirmationInteraction;
type ResolveRecoveryActionOutcome = "restored" | "false_positive" | "blocked" | "cancelled";
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
runId?: string | null;
runAgentId?: string | null;
@@ -211,6 +213,23 @@ function treeControlPreviewErrorCopy(error: unknown): string {
return error instanceof Error ? error.message : "Unable to load preview.";
}
export function canBoardResolveRecoveryAction(
companyId: string | null | undefined,
boardAccess: CurrentBoardAccess | undefined,
) {
if (!companyId || !boardAccess) return false;
if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true;
if (!boardAccess.memberships || boardAccess.memberships.length === 0) {
return boardAccess.companyIds.includes(companyId);
}
const membership = boardAccess.memberships.find(
(item) => item.companyId === companyId && item.status === "active",
);
if (!membership) return false;
return membership.membershipRole !== "viewer" && membership.membershipRole !== null;
}
function resolveRunningIssueRun(
activeRun: ActiveRunForIssue | null | undefined,
liveRuns: readonly LiveRunForIssue[] | undefined,
@@ -598,6 +617,14 @@ type IssueDetailChatTabProps = {
blockedBy: Issue["blockedBy"];
blockerAttention: Issue["blockerAttention"] | null;
successfulRunHandoff: Issue["successfulRunHandoff"] | null;
recoveryAction: Issue["activeRecoveryAction"];
onResolveRecoveryAction?: (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => void;
canFalsePositiveRecoveryAction?: boolean;
legacyRecoverySourceIssue?: {
identifier: string | null;
href: string;
title?: string | null;
} | null;
comments: IssueDetailComment[];
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
interactions: IssueThreadInteraction[];
@@ -607,6 +634,7 @@ type IssueDetailChatTabProps = {
onRefreshLatestComments: () => Promise<unknown> | void;
onWorkModeChange?: (workMode: IssueWorkMode) => Promise<void> | void;
composerRef: Ref<IssueChatComposerHandle>;
footer?: ReactNode;
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt";
feedbackTermsUrl: string | null;
@@ -661,6 +689,10 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
blockedBy,
blockerAttention,
successfulRunHandoff,
recoveryAction,
onResolveRecoveryAction,
canFalsePositiveRecoveryAction,
legacyRecoverySourceIssue,
comments,
locallyQueuedCommentRunIds,
interactions,
@@ -670,6 +702,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onRefreshLatestComments,
onWorkModeChange,
composerRef,
footer,
feedbackVotes,
feedbackDataSharingPreference,
feedbackTermsUrl,
@@ -867,6 +900,10 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
blockedBy={blockedBy ?? []}
blockerAttention={blockerAttention}
successfulRunHandoff={successfulRunHandoff}
recoveryAction={recoveryAction ?? null}
onResolveRecoveryAction={onResolveRecoveryAction}
canFalsePositiveRecoveryAction={canFalsePositiveRecoveryAction}
legacyRecoverySourceIssue={legacyRecoverySourceIssue ?? null}
companyId={companyId}
projectId={projectId}
issueStatus={issueStatus}
@@ -912,6 +949,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
assigneeUserId={assigneeUserId}
onResumeFromBacklog={onResumeFromBacklog}
resumeFromBacklogPending={resumeFromBacklogPending}
footer={footer}
/>
</div>
);
@@ -1334,6 +1372,18 @@ export function IssueDetail() {
enabled: !!resolvedCompanyId && !!issue?.id,
placeholderData: keepPreviousDataForSameQueryTail<Issue[]>(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!),
@@ -1374,6 +1424,7 @@ export function IssueDetail() {
selectedCompanyId
&& boardAccess?.companyIds?.includes(selectedCompanyId),
);
const canResolveBoardRecoveryAction = canBoardResolveRecoveryAction(selectedCompanyId, boardAccess);
const { data: feedbackVotes } = useQuery({
queryKey: queryKeys.issues.feedbackVotes(issueId!),
queryFn: () => issuesApi.listFeedbackVotes(issueId!),
@@ -1502,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));
@@ -1709,6 +1766,34 @@ export function IssueDetail() {
}
},
});
const resolveRecoveryAction = useMutation({
mutationFn: (data: {
actionId?: string;
outcome: ResolveRecoveryActionOutcome;
sourceIssueStatus: "done" | "in_review" | "blocked";
resolutionNote?: string | null;
}) => issuesApi.resolveRecoveryAction(issueId!, data),
onSuccess: ({ issue: nextIssue }) => {
const issueRefs = new Set<string>([issueId!, nextIssue.id]);
if (nextIssue.identifier) issueRefs.add(nextIssue.identifier);
mergeIssueResponseIntoCaches(issueRefs, nextIssue);
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
invalidateIssueCollections();
},
onError: (err) => {
pushToast({
title: "Recovery resolution failed",
body: err instanceof Error ? err.message : "Unable to resolve recovery action",
tone: "error",
});
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
if (selectedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
}
},
});
const executeTreeControl = useMutation({
mutationFn: async () => {
if (treeControlMode === "resume") {
@@ -2909,6 +2994,28 @@ export function IssueDetail() {
const handleResumeFromBacklog = useCallback(async () => {
await updateIssue.mutateAsync({ status: "todo" });
}, [updateIssue.mutateAsync]);
const activeRecoveryActionId = issue?.activeRecoveryAction?.id;
const handleResolveRecoveryAction = useCallback(
(outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => {
const actionId = activeRecoveryActionId;
if (!actionId) return;
switch (outcome) {
case "done":
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "done" });
return;
case "in_review":
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "in_review" });
return;
case "false_positive_done":
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "false_positive", sourceIssueStatus: "done" });
return;
case "false_positive_in_review":
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "false_positive", sourceIssueStatus: "in_review" });
return;
}
},
[activeRecoveryActionId, resolveRecoveryAction.mutateAsync],
);
const treePreviewAffectedIssues = useMemo(
() => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped),
@@ -2970,6 +3077,22 @@ export function IssueDetail() {
// Ancestors are returned oldest-first from the server (root at end, immediate parent at start)
const ancestors = issue.ancestors ?? [];
const legacyRecoverySourceIssue = (() => {
if (
issue.originKind !== "stranded_issue_recovery" &&
issue.originKind !== "stale_active_run_evaluation"
) {
return null;
}
const parent = ancestors.length > 0 ? ancestors[0] : null;
if (!parent) return null;
const ref = parent.identifier ?? parent.id;
return {
identifier: parent.identifier ?? null,
title: parent.title ?? null,
href: createIssueDetailPath(ref),
};
})();
const handleFilePicked = async (evt: ChangeEvent<HTMLInputElement>) => {
const files = evt.target.files;
if (!files || files.length === 0) return;
@@ -3787,6 +3910,10 @@ export function IssueDetail() {
blockedBy={issue.blockedBy ?? []}
blockerAttention={issue.blockerAttention ?? null}
successfulRunHandoff={issue.successfulRunHandoff ?? null}
recoveryAction={issue.activeRecoveryAction ?? null}
onResolveRecoveryAction={handleResolveRecoveryAction}
canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction}
legacyRecoverySourceIssue={legacyRecoverySourceIssue}
comments={threadComments}
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
interactions={interactions}
@@ -3795,6 +3922,14 @@ export function IssueDetail() {
onLoadOlderComments={loadOlderComments}
onRefreshLatestComments={refetchLatestComments}
composerRef={commentComposerRef}
footer={
siblingNavigation ? (
<IssueSiblingNavigation
navigation={siblingNavigation}
linkState={resolvedIssueDetailState ?? location.state}
/>
) : null
}
feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={FEEDBACK_TERMS_URL}