forked from farhoodlabs/paperclip
Merge upstream/master into dev (13 commits — includes #5922, #5938, blocked inbox, recovery actions)
This commit is contained in:
@@ -128,6 +128,7 @@ function boardRoutes() {
|
||||
<Route path="inbox/mine" element={<Inbox />} />
|
||||
<Route path="inbox/recent" element={<Inbox />} />
|
||||
<Route path="inbox/unread" element={<Inbox />} />
|
||||
<Route path="inbox/blocked" element={<Inbox />} />
|
||||
<Route path="inbox/all" element={<Inbox />} />
|
||||
<Route path="inbox/requests" element={<JoinRequestQueue />} />
|
||||
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./client", () => ({
|
||||
@@ -13,7 +14,9 @@ import { issuesApi } from "./issues";
|
||||
describe("issuesApi.list", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.get.mockReset();
|
||||
mockApi.post.mockReset();
|
||||
mockApi.get.mockResolvedValue([]);
|
||||
mockApi.post.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("passes parentId through to the company issues endpoint", async () => {
|
||||
@@ -47,4 +50,21 @@ describe("issuesApi.list", () => {
|
||||
"/companies/company-1/issues?limit=500&offset=1500",
|
||||
);
|
||||
});
|
||||
|
||||
it("posts recovery action resolution to the source issue endpoint", async () => {
|
||||
await issuesApi.resolveRecoveryAction("issue-1", {
|
||||
actionId: "00000000-0000-0000-0000-0000000000aa",
|
||||
outcome: "restored",
|
||||
sourceIssueStatus: "done",
|
||||
});
|
||||
|
||||
expect(mockApi.post).toHaveBeenCalledWith(
|
||||
"/issues/issue-1/recovery-actions/resolve",
|
||||
{
|
||||
actionId: "00000000-0000-0000-0000-0000000000aa",
|
||||
outcome: "restored",
|
||||
sourceIssueStatus: "done",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
IssueComment,
|
||||
IssueDocument,
|
||||
IssueLabel,
|
||||
IssueRecoveryAction,
|
||||
IssueRetryNowResponse,
|
||||
IssueThreadInteraction,
|
||||
IssueTreeControlPreview,
|
||||
@@ -27,10 +28,16 @@ export type IssueUpdateResponse = Issue & {
|
||||
comment?: IssueComment | null;
|
||||
};
|
||||
|
||||
export type ResolveRecoveryActionResponse = {
|
||||
issue: Issue;
|
||||
recoveryAction: IssueRecoveryAction;
|
||||
};
|
||||
|
||||
export const issuesApi = {
|
||||
list: (
|
||||
companyId: string,
|
||||
filters?: {
|
||||
attention?: "blocked";
|
||||
status?: string;
|
||||
projectId?: string;
|
||||
parentId?: string;
|
||||
@@ -49,12 +56,14 @@ export const issuesApi = {
|
||||
descendantOf?: string;
|
||||
includeRoutineExecutions?: boolean;
|
||||
includeBlockedBy?: boolean;
|
||||
includeBlockedInboxAttention?: boolean;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.attention) params.set("attention", filters.attention);
|
||||
if (filters?.status) params.set("status", filters.status);
|
||||
if (filters?.projectId) params.set("projectId", filters.projectId);
|
||||
if (filters?.parentId) params.set("parentId", filters.parentId);
|
||||
@@ -73,12 +82,35 @@ export const issuesApi = {
|
||||
if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf);
|
||||
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
||||
if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true");
|
||||
if (filters?.includeBlockedInboxAttention) params.set("includeBlockedInboxAttention", "true");
|
||||
if (filters?.q) params.set("q", filters.q);
|
||||
if (filters?.limit) params.set("limit", String(filters.limit));
|
||||
if (filters?.offset !== undefined) params.set("offset", String(filters.offset));
|
||||
const qs = params.toString();
|
||||
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
count: (
|
||||
companyId: string,
|
||||
filters: {
|
||||
attention: "blocked";
|
||||
status?: string;
|
||||
assigneeAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
projectId?: string;
|
||||
labelId?: string;
|
||||
q?: string;
|
||||
},
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("attention", filters.attention);
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId);
|
||||
if (filters.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId);
|
||||
if (filters.projectId) params.set("projectId", filters.projectId);
|
||||
if (filters.labelId) params.set("labelId", filters.labelId);
|
||||
if (filters.q) params.set("q", filters.q);
|
||||
return api.get<{ count: number }>(`/companies/${companyId}/issues/count?${params.toString()}`);
|
||||
},
|
||||
listLabels: (companyId: string) => api.get<IssueLabel[]>(`/companies/${companyId}/labels`),
|
||||
createLabel: (companyId: string, data: { name: string; color: string }) =>
|
||||
api.post<IssueLabel>(`/companies/${companyId}/labels`, data),
|
||||
@@ -94,6 +126,15 @@ export const issuesApi = {
|
||||
api.post<Issue>(`/companies/${companyId}/issues`, data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<IssueUpdateResponse>(`/issues/${id}`, data),
|
||||
resolveRecoveryAction: (
|
||||
id: string,
|
||||
data: {
|
||||
actionId?: string;
|
||||
outcome: "restored" | "false_positive" | "blocked" | "cancelled";
|
||||
sourceIssueStatus: "done" | "in_review" | "blocked";
|
||||
resolutionNote?: string | null;
|
||||
},
|
||||
) => api.post<ResolveRecoveryActionResponse>(`/issues/${id}/recovery-actions/resolve`, data),
|
||||
previewTreeControl: (id: string, data: PreviewIssueTreeControl) =>
|
||||
api.post<IssueTreeControlPreview>(`/issues/${id}/tree-control/preview`, data),
|
||||
createTreeHold: (id: string, data: CreateIssueTreeHold) =>
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import type { Issue, IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
|
||||
import type { TranscriptEntry } from "../adapters";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, relativeTime } from "../lib/utils";
|
||||
import {
|
||||
deriveActiveRecoveryDisplayState,
|
||||
RECOVERY_CHIP_DEFAULT_TONE,
|
||||
} from "../lib/recovery-display";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Identity } from "./Identity";
|
||||
import { RunChatSurface } from "./RunChatSurface";
|
||||
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
|
||||
|
||||
function RunCardRecoveryChip({ action }: { action: IssueRecoveryAction }) {
|
||||
const state = deriveActiveRecoveryDisplayState(action);
|
||||
if (!state) return null;
|
||||
const tone = RECOVERY_CHIP_DEFAULT_TONE[state];
|
||||
const Icon = tone.icon;
|
||||
return (
|
||||
<span
|
||||
data-testid="active-agent-run-recovery-indicator"
|
||||
data-recovery-state={state}
|
||||
role="status"
|
||||
aria-label={tone.label}
|
||||
title={`${tone.label} — open the source issue to act.`}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-[10px] font-medium",
|
||||
tone.className,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5" aria-hidden />
|
||||
{tone.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const MIN_DASHBOARD_RUNS = 4;
|
||||
const DASHBOARD_RUN_CARD_LIMIT = 4;
|
||||
const DASHBOARD_LOG_POLL_INTERVAL_MS = 15_000;
|
||||
@@ -189,6 +216,11 @@ const AgentRunCard = memo(function AgentRunCard({
|
||||
{issue?.identifier ?? run.issueId.slice(0, 8)}
|
||||
{issue?.title ? ` - ${issue.title}` : ""}
|
||||
</Link>
|
||||
{issue?.activeRecoveryAction ? (
|
||||
<div className="mt-1.5">
|
||||
<RunCardRecoveryChip action={issue.activeRecoveryAction} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue, IssueBlockedInboxAttention } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
count: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: mockIssuesApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
children,
|
||||
className,
|
||||
disableIssueQuicklook: _disableIssueQuicklook,
|
||||
issuePrefetch: _issuePrefetch,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & { disableIssueQuicklook?: boolean; issuePrefetch?: Issue | null }) => (
|
||||
<a className={className} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
import { BlockedInboxView } from "./BlockedInboxView";
|
||||
import { defaultIssueFilterState } from "../lib/issue-filters";
|
||||
|
||||
function attention(
|
||||
overrides: Partial<IssueBlockedInboxAttention> = {},
|
||||
): IssueBlockedInboxAttention {
|
||||
return {
|
||||
kind: "blocked",
|
||||
state: "needs_attention",
|
||||
reason: "blocked_chain_stalled",
|
||||
severity: "medium",
|
||||
stoppedSinceAt: "2026-05-08T10:00:00.000Z",
|
||||
owner: { type: "agent", agentId: "agent-1", userId: null, label: null },
|
||||
action: { label: "Resolve PAP-77", detail: null },
|
||||
sourceIssue: null,
|
||||
leafIssue: null,
|
||||
recoveryIssue: null,
|
||||
approvalId: null,
|
||||
interactionId: null,
|
||||
sampleIssueIdentifier: null,
|
||||
redaction: { externalDetailsRedacted: false, secretFieldsOmitted: true },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeIssue(
|
||||
id: string,
|
||||
identifier: string,
|
||||
title: string,
|
||||
attentionPayload: IssueBlockedInboxAttention,
|
||||
): Issue {
|
||||
return {
|
||||
id,
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
title,
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
workMode: "standard",
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: 1,
|
||||
identifier,
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
blockedInboxAttention: attentionPayload,
|
||||
createdAt: new Date("2026-05-09T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-09T00:00:00.000Z"),
|
||||
} as Issue;
|
||||
}
|
||||
|
||||
function renderWithClient(node: React.ReactNode, container: HTMLDivElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
||||
});
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(<QueryClientProvider client={queryClient}>{node}</QueryClientProvider>);
|
||||
});
|
||||
return { root, queryClient };
|
||||
}
|
||||
|
||||
const blockedViewProps = {
|
||||
companyId: "company-1",
|
||||
searchQuery: "",
|
||||
agentNameById: new Map<string, string>(),
|
||||
issueLinkState: null,
|
||||
groupBy: "none" as const,
|
||||
sortBy: "most_recent" as const,
|
||||
issueFilters: defaultIssueFilterState,
|
||||
currentUserId: "local-board",
|
||||
liveIssueIds: new Set<string>(),
|
||||
workspaceFilterContext: {},
|
||||
showStatusColumn: true,
|
||||
showIdentifierColumn: true,
|
||||
showUpdatedColumn: true,
|
||||
};
|
||||
|
||||
async function waitFor(predicate: () => boolean, attempts = 30): Promise<void> {
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
if (predicate()) return;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
});
|
||||
}
|
||||
throw new Error("waitFor predicate did not become true");
|
||||
}
|
||||
|
||||
describe("BlockedInboxView", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockIssuesApi.list.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("shows the empty state when no blocked issues are returned", async () => {
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
const { root } = renderWithClient(
|
||||
<BlockedInboxView
|
||||
{...blockedViewProps}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
await waitFor(() => container.querySelector('[data-testid="blocked-inbox-empty"]') !== null);
|
||||
expect(container.querySelector('[data-testid="blocked-inbox-empty"]')).not.toBeNull();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("defaults to no grouping and orders rows by most recent stopped item first", async () => {
|
||||
const issues: Issue[] = [
|
||||
makeIssue(
|
||||
"issue-low",
|
||||
"PAP-1",
|
||||
"External wait row",
|
||||
attention({ reason: "external_owner_action", severity: "low" }),
|
||||
),
|
||||
makeIssue(
|
||||
"issue-stalled-high",
|
||||
"PAP-2",
|
||||
"Stalled chain row",
|
||||
attention({
|
||||
reason: "blocked_chain_stalled",
|
||||
severity: "high",
|
||||
stoppedSinceAt: "2026-05-09T01:00:00.000Z",
|
||||
action: { label: "Resolve PAP-9", detail: null },
|
||||
}),
|
||||
),
|
||||
makeIssue(
|
||||
"issue-stalled-critical",
|
||||
"PAP-3",
|
||||
"Critical stalled row",
|
||||
attention({
|
||||
reason: "blocked_chain_stalled",
|
||||
severity: "critical",
|
||||
stoppedSinceAt: "2026-05-09T05:00:00.000Z",
|
||||
action: { label: "Resolve PAP-10", detail: null },
|
||||
}),
|
||||
),
|
||||
makeIssue(
|
||||
"issue-decision",
|
||||
"PAP-4",
|
||||
"Pending board decision",
|
||||
attention({
|
||||
reason: "pending_board_decision",
|
||||
severity: "medium",
|
||||
owner: { type: "board", agentId: null, userId: null, label: "Board" },
|
||||
action: { label: "Accept or reject", detail: null },
|
||||
}),
|
||||
),
|
||||
];
|
||||
mockIssuesApi.list.mockResolvedValue(issues);
|
||||
|
||||
const { root } = renderWithClient(
|
||||
<BlockedInboxView
|
||||
{...blockedViewProps}
|
||||
agentNameById={new Map([["agent-1", "ClaudeCoder"]])}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
await waitFor(() => container.querySelectorAll("a").length === 4);
|
||||
|
||||
expect(container.querySelectorAll('[data-testid^="blocked-inbox-group-"]')).toHaveLength(0);
|
||||
|
||||
const titles = Array.from(container.querySelectorAll("a")).map((a) => a.textContent ?? "");
|
||||
expect(titles[0]).toContain("Critical stalled row");
|
||||
expect(titles[1]).toContain("Stalled chain row");
|
||||
|
||||
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
attention: "blocked",
|
||||
includeBlockedInboxAttention: true,
|
||||
includeBlockedBy: true,
|
||||
}));
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("places blocker reason chips with the title before owner and timestamp metadata", async () => {
|
||||
mockIssuesApi.list.mockResolvedValue([
|
||||
makeIssue(
|
||||
"issue-decision",
|
||||
"PAP-4",
|
||||
"Pending board decision",
|
||||
attention({
|
||||
reason: "pending_board_decision",
|
||||
severity: "medium",
|
||||
owner: { type: "board", agentId: null, userId: null, label: "Board" },
|
||||
action: { label: "Accept or reject", detail: null },
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const { root } = renderWithClient(
|
||||
<BlockedInboxView
|
||||
{...blockedViewProps}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
await waitFor(() => container.querySelector("a") !== null);
|
||||
|
||||
const rowText = container.querySelector("a")?.textContent ?? "";
|
||||
expect(rowText.indexOf("Pending board decision")).toBeGreaterThanOrEqual(0);
|
||||
expect(rowText.indexOf("Needs decision")).toBeGreaterThan(rowText.indexOf("Pending board decision"));
|
||||
expect(rowText.indexOf("Board")).toBeGreaterThan(rowText.indexOf("Needs decision"));
|
||||
expect(rowText).not.toContain("Accept or reject");
|
||||
expect(container.querySelector('[data-testid="blocked-row-reason-column"]')?.textContent).toContain("Needs decision");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("filters rows by search query against title, identifier, owner and action", async () => {
|
||||
const issues: Issue[] = [
|
||||
makeIssue(
|
||||
"issue-1",
|
||||
"PAP-77",
|
||||
"Resume parked work",
|
||||
attention({
|
||||
reason: "blocked_by_assigned_backlog_issue",
|
||||
owner: { type: "agent", agentId: null, userId: null, label: "Charlie" },
|
||||
action: { label: "Resume parked blocker", detail: null },
|
||||
}),
|
||||
),
|
||||
makeIssue(
|
||||
"issue-2",
|
||||
"PAP-99",
|
||||
"Other unrelated thing",
|
||||
attention({
|
||||
reason: "external_owner_action",
|
||||
owner: { type: "external", agentId: null, userId: null, label: "Vendor" },
|
||||
action: { label: "Awaiting Vendor", detail: null },
|
||||
}),
|
||||
),
|
||||
];
|
||||
mockIssuesApi.list.mockResolvedValue(issues);
|
||||
|
||||
const { root } = renderWithClient(
|
||||
<BlockedInboxView
|
||||
{...blockedViewProps}
|
||||
searchQuery="charlie"
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
await waitFor(() => container.querySelectorAll("a").length > 0);
|
||||
|
||||
const links = container.querySelectorAll("a");
|
||||
const titles = Array.from(links).map((a) => a.textContent ?? "");
|
||||
expect(titles.some((t) => t.includes("Resume parked work"))).toBe(true);
|
||||
expect(titles.some((t) => t.includes("Other unrelated thing"))).toBe(false);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders the visible error banner with retry when the query fails", async () => {
|
||||
mockIssuesApi.list.mockRejectedValue(new Error("network down"));
|
||||
|
||||
const { root } = renderWithClient(
|
||||
<BlockedInboxView
|
||||
{...blockedViewProps}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
await waitFor(() =>
|
||||
container.querySelector('[data-testid="blocked-inbox-error"]') !== null,
|
||||
);
|
||||
|
||||
const banner = container.querySelector('[data-testid="blocked-inbox-error"]');
|
||||
expect(banner).not.toBeNull();
|
||||
expect(banner?.getAttribute("role")).toBe("alert");
|
||||
expect(banner?.textContent).toContain("Couldn't load the Blocked tab");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, CheckCircle2 } from "lucide-react";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import { applyIssueFilters, type IssueFilterState, type IssueFilterWorkspaceContext } from "../lib/issue-filters";
|
||||
import {
|
||||
blockedRowMatchesSearch,
|
||||
buildBlockedInboxRows,
|
||||
formatStoppedAge,
|
||||
groupBlockedInboxRows,
|
||||
sortBlockedInboxRows,
|
||||
type BlockedInboxGroupBy,
|
||||
type BlockedInboxIssueRow,
|
||||
type BlockedInboxSort,
|
||||
} from "../lib/blockedInbox";
|
||||
import { BlockedReasonChip } from "./BlockedReasonChip";
|
||||
import { IssueGroupHeader } from "./IssueGroupHeader";
|
||||
import { IssueRow } from "./IssueRow";
|
||||
import { Identity } from "./Identity";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface BlockedInboxViewProps {
|
||||
companyId: string;
|
||||
searchQuery: string;
|
||||
agentNameById: ReadonlyMap<string, string>;
|
||||
userLabelById?: ReadonlyMap<string, string>;
|
||||
issueLinkState: unknown;
|
||||
groupBy: BlockedInboxGroupBy;
|
||||
sortBy: BlockedInboxSort;
|
||||
issueFilters: IssueFilterState;
|
||||
currentUserId: string | null;
|
||||
liveIssueIds: ReadonlySet<string>;
|
||||
workspaceFilterContext: IssueFilterWorkspaceContext;
|
||||
showStatusColumn: boolean;
|
||||
showIdentifierColumn: boolean;
|
||||
showUpdatedColumn: boolean;
|
||||
}
|
||||
|
||||
const BLOCKED_LIST_LIMIT = 200;
|
||||
|
||||
export function BlockedInboxView({
|
||||
companyId,
|
||||
searchQuery,
|
||||
agentNameById,
|
||||
userLabelById,
|
||||
issueLinkState,
|
||||
groupBy,
|
||||
sortBy,
|
||||
issueFilters,
|
||||
currentUserId,
|
||||
liveIssueIds,
|
||||
workspaceFilterContext,
|
||||
showStatusColumn,
|
||||
showIdentifierColumn,
|
||||
showUpdatedColumn,
|
||||
}: BlockedInboxViewProps) {
|
||||
const [collapsedVariants, setCollapsedVariants] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const {
|
||||
data: issues = [] as Issue[],
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.issues.listBlockedAttention(companyId),
|
||||
queryFn: () =>
|
||||
issuesApi.list(companyId, {
|
||||
attention: "blocked",
|
||||
includeBlockedInboxAttention: true,
|
||||
includeBlockedBy: true,
|
||||
limit: BLOCKED_LIST_LIMIT,
|
||||
}),
|
||||
});
|
||||
|
||||
const allRows = useMemo(() => buildBlockedInboxRows(issues), [issues]);
|
||||
const filteredRows = useMemo(
|
||||
() => allRows.filter((row) => blockedRowMatchesSearch(row, searchQuery)),
|
||||
[allRows, searchQuery],
|
||||
);
|
||||
const issueFilteredRows = useMemo(() => {
|
||||
const visibleIssueIds = new Set(
|
||||
applyIssueFilters(
|
||||
filteredRows.map((row) => row.issue),
|
||||
issueFilters,
|
||||
currentUserId,
|
||||
true,
|
||||
liveIssueIds,
|
||||
workspaceFilterContext,
|
||||
).map((issue) => issue.id),
|
||||
);
|
||||
return filteredRows.filter((row) => visibleIssueIds.has(row.issue.id));
|
||||
}, [currentUserId, filteredRows, issueFilters, liveIssueIds, workspaceFilterContext]);
|
||||
const sortedRows = useMemo(() => sortBlockedInboxRows(issueFilteredRows, sortBy), [issueFilteredRows, sortBy]);
|
||||
const groups = useMemo(
|
||||
() => groupBlockedInboxRows(issueFilteredRows, sortBy),
|
||||
[issueFilteredRows, sortBy],
|
||||
);
|
||||
|
||||
const toggleVariant = (variant: string) => {
|
||||
setCollapsedVariants((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(variant)) next.delete(variant);
|
||||
else next.add(variant);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div data-testid="blocked-inbox-loading" className="space-y-3" aria-busy="true">
|
||||
{Array.from({ length: 3 }).map((_, groupIdx) => (
|
||||
<div key={groupIdx} className="space-y-1">
|
||||
<div className="h-4 w-40 animate-pulse rounded bg-muted/70" />
|
||||
{Array.from({ length: 2 }).map((__, rowIdx) => (
|
||||
<div
|
||||
key={rowIdx}
|
||||
className="flex items-center gap-3 border-b border-border/60 px-3 py-2.5 sm:px-4"
|
||||
>
|
||||
<div className="h-3.5 w-3.5 animate-pulse rounded-full bg-muted" />
|
||||
<div className="h-4 w-16 animate-pulse rounded bg-muted/70" />
|
||||
<div className="h-4 w-32 animate-pulse rounded-md bg-muted/70" />
|
||||
<div className="h-4 flex-1 animate-pulse rounded bg-muted/60" />
|
||||
<div className="hidden h-3 w-24 animate-pulse rounded bg-muted/60 sm:block" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Couldn't load the Blocked tab.";
|
||||
return (
|
||||
<div
|
||||
data-testid="blocked-inbox-error"
|
||||
role="alert"
|
||||
className="flex flex-col gap-2 rounded-md border border-amber-300/70 bg-amber-50/90 p-4 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-200"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium">Couldn't load the Blocked tab.</p>
|
||||
<p className="text-xs opacity-80">
|
||||
Other Inbox tabs still work. {message}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 border-amber-400/70 bg-white/40 text-amber-900 hover:bg-white/70 dark:bg-amber-500/20 dark:text-amber-100"
|
||||
onClick={() => void refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{isFetching ? "Trying…" : "Try again"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (allRows.length === 0) {
|
||||
return (
|
||||
<div
|
||||
data-testid="blocked-inbox-empty"
|
||||
className="flex flex-col items-center gap-3 rounded-lg border border-border/70 bg-card/40 px-6 py-10 text-center"
|
||||
>
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300">
|
||||
<CheckCircle2 className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">No work is stopped.</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Issues that need a decision, recovery, or external action will appear here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
data-testid="blocked-inbox-no-search-results"
|
||||
className="rounded-lg border border-border/70 bg-card/40 px-4 py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
No stopped items match your search.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="blocked-inbox" className="space-y-3">
|
||||
<div className="overflow-hidden rounded-xl">
|
||||
{groupBy === "none" ? (
|
||||
sortedRows.map((row) => (
|
||||
<BlockedInboxRow
|
||||
key={row.issue.id}
|
||||
row={row}
|
||||
issueLinkState={issueLinkState}
|
||||
agentNameById={agentNameById}
|
||||
userLabelById={userLabelById}
|
||||
showStatusColumn={showStatusColumn}
|
||||
showIdentifierColumn={showIdentifierColumn}
|
||||
showUpdatedColumn={showUpdatedColumn}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
groups.map((group) => {
|
||||
const isCollapsed = collapsedVariants.has(group.variant);
|
||||
return (
|
||||
<div key={group.variant} data-testid={`blocked-inbox-group-${group.variant}`}>
|
||||
<div className="px-3 sm:px-4">
|
||||
<IssueGroupHeader
|
||||
label={`${group.label} · ${group.rows.length}`}
|
||||
collapsible
|
||||
collapsed={isCollapsed}
|
||||
onToggle={() => toggleVariant(group.variant)}
|
||||
/>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div>
|
||||
{group.rows.map((row) => (
|
||||
<BlockedInboxRow
|
||||
key={row.issue.id}
|
||||
row={row}
|
||||
issueLinkState={issueLinkState}
|
||||
agentNameById={agentNameById}
|
||||
userLabelById={userLabelById}
|
||||
showStatusColumn={showStatusColumn}
|
||||
showIdentifierColumn={showIdentifierColumn}
|
||||
showUpdatedColumn={showUpdatedColumn}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BlockedInboxRowProps {
|
||||
row: BlockedInboxIssueRow;
|
||||
issueLinkState: unknown;
|
||||
agentNameById: ReadonlyMap<string, string>;
|
||||
userLabelById?: ReadonlyMap<string, string>;
|
||||
showStatusColumn: boolean;
|
||||
showIdentifierColumn: boolean;
|
||||
showUpdatedColumn: boolean;
|
||||
}
|
||||
|
||||
function resolveOwnerName(
|
||||
row: BlockedInboxIssueRow,
|
||||
agentNameById: ReadonlyMap<string, string>,
|
||||
userLabelById?: ReadonlyMap<string, string>,
|
||||
): { label: string | null; isAgent: boolean } {
|
||||
const owner = row.attention.owner;
|
||||
if (owner.label) return { label: owner.label, isAgent: owner.type === "agent" };
|
||||
if (owner.agentId) {
|
||||
return { label: agentNameById.get(owner.agentId) ?? null, isAgent: true };
|
||||
}
|
||||
if (owner.userId) {
|
||||
return { label: userLabelById?.get(owner.userId) ?? null, isAgent: false };
|
||||
}
|
||||
return { label: null, isAgent: false };
|
||||
}
|
||||
|
||||
function BlockedInboxRow({
|
||||
row,
|
||||
issueLinkState,
|
||||
agentNameById,
|
||||
userLabelById,
|
||||
showStatusColumn,
|
||||
showIdentifierColumn,
|
||||
showUpdatedColumn,
|
||||
}: BlockedInboxRowProps) {
|
||||
const { label: ownerName, isAgent } = resolveOwnerName(row, agentNameById, userLabelById);
|
||||
const stoppedAge = formatStoppedAge(row.attention.stoppedSinceAt);
|
||||
|
||||
const desktopTrailing = (
|
||||
<span className="flex shrink-0 items-center gap-3 text-xs">
|
||||
<span
|
||||
className="hidden w-[10.5rem] shrink-0 justify-start sm:inline-flex"
|
||||
data-testid="blocked-row-reason-column"
|
||||
>
|
||||
<BlockedReasonChip
|
||||
reason={row.attention.reason}
|
||||
severity={row.attention.severity}
|
||||
className="max-w-full"
|
||||
/>
|
||||
</span>
|
||||
{ownerName ? (
|
||||
<span className="hidden w-[150px] min-w-0 items-center text-muted-foreground sm:inline-flex">
|
||||
<Identity
|
||||
name={ownerName}
|
||||
size="xs"
|
||||
className="max-w-full"
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className="hidden w-[150px] shrink-0 sm:inline-flex" aria-hidden="true" />
|
||||
)}
|
||||
{showUpdatedColumn ? (
|
||||
<span className="hidden w-[5.75rem] text-right text-muted-foreground sm:inline" data-testid="blocked-row-age">
|
||||
{stoppedAge}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
const mobileMeta = (
|
||||
<span className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted-foreground">
|
||||
<span data-testid="blocked-row-age-mobile">{stoppedAge}</span>
|
||||
{ownerName ? (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span
|
||||
className={cn(isAgent ? "font-medium text-foreground/90" : null)}
|
||||
data-testid="blocked-row-owner-mobile"
|
||||
>
|
||||
{ownerName}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<IssueRow
|
||||
issue={row.issue}
|
||||
issueLinkState={issueLinkState}
|
||||
desktopMetaLeading={
|
||||
<BlockedRowDesktopMeta
|
||||
row={row}
|
||||
showStatusColumn={showStatusColumn}
|
||||
showIdentifierColumn={showIdentifierColumn}
|
||||
/>
|
||||
}
|
||||
mobileLeading={
|
||||
<span className="flex shrink-0 items-center gap-1.5 pt-px">
|
||||
<StatusIcon status={row.issue.status} blockerAttention={row.issue.blockerAttention} />
|
||||
</span>
|
||||
}
|
||||
titleSuffix={
|
||||
<BlockedReasonChip
|
||||
reason={row.attention.reason}
|
||||
severity={row.attention.severity}
|
||||
className="ml-2 max-w-[12rem] align-middle sm:hidden"
|
||||
/>
|
||||
}
|
||||
mobileMeta={mobileMeta}
|
||||
desktopTrailing={desktopTrailing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockedRowDesktopMeta({
|
||||
row,
|
||||
showStatusColumn,
|
||||
showIdentifierColumn,
|
||||
}: {
|
||||
row: BlockedInboxIssueRow;
|
||||
showStatusColumn: boolean;
|
||||
showIdentifierColumn: boolean;
|
||||
}) {
|
||||
const identifier = row.issue.identifier ?? row.issue.id.slice(0, 8);
|
||||
return (
|
||||
<span className="hidden shrink-0 items-center gap-2 sm:inline-flex">
|
||||
{showStatusColumn ? <StatusIcon status={row.issue.status} blockerAttention={row.issue.blockerAttention} /> : null}
|
||||
{showIdentifierColumn ? <span className="font-mono text-xs text-muted-foreground">{identifier}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { BlockedReasonChip } from "./BlockedReasonChip";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("BlockedReasonChip", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("renders the canonical group label and exposes severity via aria-label", () => {
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<BlockedReasonChip reason="pending_board_decision" severity="high" />,
|
||||
);
|
||||
});
|
||||
const chip = container.querySelector('[data-testid="blocked-reason-chip"]');
|
||||
expect(chip).not.toBeNull();
|
||||
expect(chip?.getAttribute("data-variant")).toBe("needs_decision");
|
||||
expect(chip?.getAttribute("data-severity")).toBe("high");
|
||||
expect(chip?.getAttribute("aria-label")).toBe("Reason: Needs decision, severity high");
|
||||
expect(chip?.textContent).toContain("Needs decision");
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("includes a severity dot for critical and high but not medium/low", () => {
|
||||
const cases: Array<["critical" | "high" | "medium" | "low", boolean]> = [
|
||||
["critical", true],
|
||||
["high", true],
|
||||
["medium", false],
|
||||
["low", false],
|
||||
];
|
||||
for (const [severity, hasDot] of cases) {
|
||||
const local = document.createElement("div");
|
||||
document.body.appendChild(local);
|
||||
const root = createRoot(local);
|
||||
act(() => {
|
||||
root.render(<BlockedReasonChip reason="blocked_chain_stalled" severity={severity} />);
|
||||
});
|
||||
const chip = local.querySelector('[data-testid="blocked-reason-chip"]');
|
||||
const dot = chip?.querySelector('[aria-hidden="true"]');
|
||||
if (hasDot) {
|
||||
expect(dot).not.toBeNull();
|
||||
} else {
|
||||
// The first inner span (icon) is always aria-hidden, but the dot is the first child.
|
||||
// Distinguish by class name presence of bg-red-500/bg-orange-500.
|
||||
const classy = chip?.querySelector('span[class*="bg-red-500"], span[class*="bg-orange-500"]');
|
||||
expect(classy).toBeNull();
|
||||
}
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
local.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("hides the icon when compact is true", () => {
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<BlockedReasonChip reason="external_owner_action" severity="low" compact />,
|
||||
);
|
||||
});
|
||||
const chip = container.querySelector('[data-testid="blocked-reason-chip"]');
|
||||
const svg = chip?.querySelector("svg");
|
||||
expect(svg).toBeNull();
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { AlertTriangle, Clock, Pause, User, Wrench } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
import type { IssueBlockedInboxSeverity } from "@paperclipai/shared";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
blockedReasonVariant,
|
||||
blockedVariantLabel,
|
||||
type BlockedReasonVariant,
|
||||
} from "../lib/blockedInbox";
|
||||
import type { IssueBlockedInboxReason } from "@paperclipai/shared";
|
||||
|
||||
interface BlockedReasonChipProps {
|
||||
reason: IssueBlockedInboxReason;
|
||||
severity: IssueBlockedInboxSeverity;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type IconComponent = ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }>;
|
||||
|
||||
const VARIANT_STYLES: Record<BlockedReasonVariant, string> = {
|
||||
needs_decision:
|
||||
"border-violet-300/70 bg-violet-50 text-violet-800 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300",
|
||||
recovery_required:
|
||||
"border-cyan-300/70 bg-cyan-50 text-cyan-800 dark:border-cyan-500/30 dark:bg-cyan-500/10 dark:text-cyan-300",
|
||||
stalled:
|
||||
"border-amber-400/70 bg-amber-100 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-200",
|
||||
needs_attention:
|
||||
"border-amber-300/70 bg-amber-50 text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300",
|
||||
external_wait:
|
||||
"border-slate-300 bg-slate-50 text-slate-700 dark:border-slate-500/30 dark:bg-slate-500/15 dark:text-slate-300",
|
||||
owner_paused:
|
||||
"border-red-300/70 bg-red-50 text-red-800 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-300",
|
||||
};
|
||||
|
||||
const VARIANT_ICONS: Record<BlockedReasonVariant, IconComponent> = {
|
||||
needs_decision: Clock,
|
||||
recovery_required: Wrench,
|
||||
stalled: AlertTriangle,
|
||||
needs_attention: AlertTriangle,
|
||||
external_wait: User,
|
||||
owner_paused: Pause,
|
||||
};
|
||||
|
||||
const SEVERITY_DOT: Partial<Record<IssueBlockedInboxSeverity, string>> = {
|
||||
critical: "bg-red-500",
|
||||
high: "bg-orange-500",
|
||||
};
|
||||
|
||||
export function BlockedReasonChip({
|
||||
reason,
|
||||
severity,
|
||||
compact = false,
|
||||
className,
|
||||
}: BlockedReasonChipProps) {
|
||||
const variant = blockedReasonVariant(reason);
|
||||
const label = blockedVariantLabel(variant);
|
||||
const Icon = VARIANT_ICONS[variant];
|
||||
const dotClass = SEVERITY_DOT[severity];
|
||||
return (
|
||||
<span
|
||||
data-testid="blocked-reason-chip"
|
||||
data-variant={variant}
|
||||
data-severity={severity}
|
||||
aria-label={`Reason: ${label}, severity ${severity}`}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1 rounded-md border px-2 py-0.5 text-[10px] font-medium leading-tight sm:text-[11px]",
|
||||
VARIANT_STYLES[variant],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{dotClass ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("inline-block h-1.5 w-1.5 shrink-0 rounded-full", dotClass)}
|
||||
/>
|
||||
) : null}
|
||||
{compact ? null : <Icon className="h-3 w-3 shrink-0" aria-hidden="true" />}
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AnchorHTMLAttributes, ReactElement } from "react";
|
||||
import type { AnchorHTMLAttributes, ReactElement, ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { IssueBlockedNotice } from "./IssueBlockedNotice";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
@@ -27,11 +29,20 @@ afterEach(() => {
|
||||
container = null;
|
||||
});
|
||||
|
||||
function withProviders(node: ReactNode) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } } });
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function render(element: ReactElement) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => root?.render(element));
|
||||
act(() => root?.render(withProviders(element)));
|
||||
return container;
|
||||
}
|
||||
|
||||
@@ -102,4 +113,58 @@ describe("IssueBlockedNotice", () => {
|
||||
|
||||
expect(node.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("renders a recovery indicator on a blocker chip when the blocker has an active recovery action", () => {
|
||||
const node = render(
|
||||
<IssueBlockedNotice
|
||||
issueStatus="blocked"
|
||||
blockers={[
|
||||
{
|
||||
id: "blocker-1",
|
||||
identifier: "PAP-123",
|
||||
title: "Build still red",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
activeRecoveryAction: {
|
||||
id: "rec-1",
|
||||
companyId: "co-1",
|
||||
sourceIssueId: "blocker-1",
|
||||
recoveryIssueId: null,
|
||||
kind: "missing_disposition",
|
||||
status: "active",
|
||||
ownerType: "agent",
|
||||
ownerAgentId: "agent-cto",
|
||||
ownerUserId: null,
|
||||
previousOwnerAgentId: null,
|
||||
returnOwnerAgentId: null,
|
||||
cause: "successful_run_missing_state",
|
||||
fingerprint: "fp-1",
|
||||
evidence: {},
|
||||
nextAction: "choose disposition",
|
||||
wakePolicy: { type: "wake_owner" },
|
||||
monitorPolicy: null,
|
||||
attemptCount: 1,
|
||||
maxAttempts: 3,
|
||||
timeoutAt: null,
|
||||
lastAttemptAt: null,
|
||||
outcome: null,
|
||||
resolutionNote: null,
|
||||
resolvedAt: null,
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
updatedAt: "2026-05-01T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const indicator = node.querySelector(
|
||||
'[data-testid="issue-blocked-notice-recovery-indicator"]',
|
||||
);
|
||||
expect(indicator).not.toBeNull();
|
||||
expect(indicator?.getAttribute("data-recovery-state")).toBe("needed");
|
||||
expect(indicator?.textContent).toContain("Recovery needed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,38 @@
|
||||
import type { IssueBlockerAttention, IssueRelationIssueSummary, SuccessfulRunHandoffState } from "@paperclipai/shared";
|
||||
import type {
|
||||
IssueBlockerAttention,
|
||||
IssueRecoveryAction,
|
||||
IssueRelationIssueSummary,
|
||||
SuccessfulRunHandoffState,
|
||||
} from "@paperclipai/shared";
|
||||
import { AlertTriangle, Flag } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
|
||||
import { isAssignedBacklogBlocker } from "../lib/issue-blockers";
|
||||
import {
|
||||
deriveActiveRecoveryDisplayState,
|
||||
RECOVERY_CHIP_DEFAULT_TONE,
|
||||
} from "../lib/recovery-display";
|
||||
|
||||
function BlockerRecoveryIndicator({ action }: { action: IssueRecoveryAction }) {
|
||||
const state = deriveActiveRecoveryDisplayState(action);
|
||||
if (!state) return null;
|
||||
const tone = RECOVERY_CHIP_DEFAULT_TONE[state];
|
||||
const Icon = tone.icon;
|
||||
return (
|
||||
<span
|
||||
data-testid="issue-blocked-notice-recovery-indicator"
|
||||
data-recovery-state={state}
|
||||
role="status"
|
||||
aria-label={tone.label}
|
||||
title={`${tone.label} — open the source issue to act.`}
|
||||
className={`inline-flex shrink-0 items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-[10px] font-medium ${tone.className}`}
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5" aria-hidden />
|
||||
{tone.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueBlockedNotice({
|
||||
issueStatus,
|
||||
@@ -69,6 +98,7 @@ export function IssueBlockedNotice({
|
||||
|
||||
const renderBlockerChip = (blocker: IssueRelationIssueSummary) => {
|
||||
const issuePathId = blocker.identifier ?? blocker.id;
|
||||
const recoveryAction = blocker.activeRecoveryAction ?? null;
|
||||
return (
|
||||
<IssueLinkQuicklook
|
||||
key={blocker.id}
|
||||
@@ -80,6 +110,7 @@ export function IssueBlockedNotice({
|
||||
<span className="max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200">
|
||||
{blocker.title}
|
||||
</span>
|
||||
{recoveryAction ? <BlockerRecoveryIndicator action={recoveryAction} /> : null}
|
||||
</IssueLinkQuicklook>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
showComposer={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
footer={<div>Sibling footer</div>}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
FeedbackVoteValue,
|
||||
IssueAttachment,
|
||||
IssueBlockerAttention,
|
||||
IssueRecoveryAction,
|
||||
IssueRelationIssueSummary,
|
||||
SuccessfulRunHandoffState,
|
||||
IssueWorkMode,
|
||||
@@ -134,6 +135,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, ClipboardList, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, PauseCircle, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import { IssueBlockedNotice } from "./IssueBlockedNotice";
|
||||
import { IssueAssignedBacklogNotice } from "./IssueAssignedBacklogNotice";
|
||||
import { IssueRecoveryActionCard, type RecoveryResolveOutcome } from "./IssueRecoveryActionCard";
|
||||
|
||||
interface IssueChatMessageContext {
|
||||
feedbackDataSharingPreference: FeedbackDataSharingPreference;
|
||||
@@ -297,6 +299,14 @@ interface IssueChatThreadProps {
|
||||
blockedBy?: IssueRelationIssueSummary[];
|
||||
blockerAttention?: IssueBlockerAttention | null;
|
||||
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
||||
recoveryAction?: IssueRecoveryAction | null;
|
||||
onResolveRecoveryAction?: (outcome: RecoveryResolveOutcome) => void;
|
||||
canFalsePositiveRecoveryAction?: boolean;
|
||||
legacyRecoverySourceIssue?: {
|
||||
identifier: string | null;
|
||||
href: string;
|
||||
title?: string | null;
|
||||
} | null;
|
||||
assigneeUserId?: string | null;
|
||||
onResumeFromBacklog?: () => Promise<void> | void;
|
||||
resumeFromBacklogPending?: boolean;
|
||||
@@ -332,6 +342,7 @@ interface IssueChatThreadProps {
|
||||
showComposer?: boolean;
|
||||
showJumpToLatest?: boolean;
|
||||
emptyMessage?: string;
|
||||
footer?: ReactNode;
|
||||
variant?: "full" | "embedded";
|
||||
enableLiveTranscriptPolling?: boolean;
|
||||
transcriptsByRunId?: ReadonlyMap<string, readonly IssueChatTranscriptEntry[]>;
|
||||
@@ -3609,6 +3620,10 @@ export function IssueChatThread({
|
||||
blockedBy = [],
|
||||
blockerAttention = null,
|
||||
successfulRunHandoff = null,
|
||||
recoveryAction = null,
|
||||
onResolveRecoveryAction,
|
||||
canFalsePositiveRecoveryAction = false,
|
||||
legacyRecoverySourceIssue = null,
|
||||
companyId,
|
||||
projectId,
|
||||
issueStatus,
|
||||
@@ -3636,6 +3651,7 @@ export function IssueChatThread({
|
||||
showComposer = true,
|
||||
showJumpToLatest,
|
||||
emptyMessage,
|
||||
footer,
|
||||
variant = "full",
|
||||
enableLiveTranscriptPolling = true,
|
||||
transcriptsByRunId,
|
||||
@@ -4244,11 +4260,49 @@ export function IssueChatThread({
|
||||
onResume={onResumeFromBacklog}
|
||||
resuming={resumeFromBacklogPending}
|
||||
/>
|
||||
{recoveryAction ? (
|
||||
<IssueRecoveryActionCard
|
||||
action={recoveryAction}
|
||||
agentMap={agentMap}
|
||||
onResolve={onResolveRecoveryAction}
|
||||
canFalsePositive={canFalsePositiveRecoveryAction}
|
||||
/>
|
||||
) : null}
|
||||
{legacyRecoverySourceIssue ? (
|
||||
<SystemNotice
|
||||
tone="info"
|
||||
label="Legacy recovery issue"
|
||||
body={
|
||||
<span>
|
||||
Legacy recovery issue. Newer recovery actions live on the source issue
|
||||
{legacyRecoverySourceIssue.identifier ? (
|
||||
<>
|
||||
{" — "}
|
||||
<Link
|
||||
to={legacyRecoverySourceIssue.href}
|
||||
className="underline-offset-2 hover:underline"
|
||||
>
|
||||
{legacyRecoverySourceIssue.identifier}
|
||||
{legacyRecoverySourceIssue.title ? (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— {legacyRecoverySourceIssue.title}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
"."
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<IssueBlockedNotice
|
||||
issueStatus={issueStatus}
|
||||
blockers={unresolvedBlockers}
|
||||
blockerAttention={blockerAttention}
|
||||
successfulRunHandoff={successfulRunHandoff}
|
||||
successfulRunHandoff={recoveryAction ? null : successfulRunHandoff}
|
||||
agentName={
|
||||
successfulRunHandoff?.assigneeAgentId
|
||||
? agentMap?.get(successfulRunHandoff.assigneeAgentId)?.name ?? null
|
||||
@@ -4258,6 +4312,7 @@ export function IssueChatThread({
|
||||
<IssueAssigneePausedNotice agent={assignedAgent} />
|
||||
</div>
|
||||
) : null}
|
||||
{footer ? <div data-testid="issue-chat-thread-footer">{footer}</div> : null}
|
||||
<div ref={bottomAnchorRef} />
|
||||
{showComposer ? (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
|
||||
|
||||
const mockIssuesApiGet = vi.hoisted(() => 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> = {}): 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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<IssueLinkQuicklook
|
||||
issuePathId="PAP-1"
|
||||
issuePrefetch={issue}
|
||||
to="/companies/company-1/issues/PAP-1"
|
||||
>
|
||||
PAP-1
|
||||
</IssueLinkQuicklook>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,8 @@ export const IssueLinkQuicklook = React.forwardRef<
|
||||
issuePathId: string;
|
||||
disableIssueQuicklook?: boolean;
|
||||
issuePrefetch?: Issue | null;
|
||||
issueQuicklookSide?: React.ComponentProps<typeof PopoverContent>["side"];
|
||||
issueQuicklookAlign?: React.ComponentProps<typeof PopoverContent>["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<
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-72 p-3"
|
||||
side="top"
|
||||
align="start"
|
||||
side={issueQuicklookSide}
|
||||
align={issueQuicklookAlign}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AnchorHTMLAttributes, ReactElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Agent, IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import { IssueRecoveryActionCard, deriveRecoveryCardState } from "./IssueRecoveryActionCard";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount());
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
function render(element: ReactElement) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => root?.render(element));
|
||||
return container;
|
||||
}
|
||||
|
||||
function click(element: Element | null) {
|
||||
if (!element) throw new Error("Expected element to exist");
|
||||
act(() => {
|
||||
element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
const ownerAgent: Agent = {
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
companyId: "company-1",
|
||||
name: "ClaudeCoder",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
urlKey: "claudecoder",
|
||||
} as unknown as Agent;
|
||||
|
||||
const returnAgent: Agent = {
|
||||
...ownerAgent,
|
||||
id: "22222222-2222-2222-2222-222222222222",
|
||||
name: "CodexCoder",
|
||||
urlKey: "codexcoder",
|
||||
} as Agent;
|
||||
|
||||
function buildAction(overrides: Partial<IssueRecoveryAction> = {}): IssueRecoveryAction {
|
||||
return {
|
||||
id: "00000000-0000-0000-0000-0000000000aa",
|
||||
companyId: "company-1",
|
||||
sourceIssueId: "00000000-0000-0000-0000-0000000000ff",
|
||||
recoveryIssueId: null,
|
||||
kind: "missing_disposition",
|
||||
status: "active",
|
||||
ownerType: "agent",
|
||||
ownerAgentId: ownerAgent.id,
|
||||
ownerUserId: null,
|
||||
previousOwnerAgentId: returnAgent.id,
|
||||
returnOwnerAgentId: returnAgent.id,
|
||||
cause: "missing_disposition",
|
||||
fingerprint: "fp",
|
||||
evidence: {
|
||||
summary: "Run finished but no disposition was chosen.",
|
||||
sourceRunId: "7accd7a4-c9ca-4db2-9233-3228a037cc09",
|
||||
},
|
||||
nextAction: "Choose and record a valid issue disposition.",
|
||||
wakePolicy: { type: "wake_owner" },
|
||||
monitorPolicy: null,
|
||||
attemptCount: 1,
|
||||
maxAttempts: 3,
|
||||
timeoutAt: null,
|
||||
lastAttemptAt: "2026-05-09T19:30:00.000Z",
|
||||
outcome: null,
|
||||
resolutionNote: null,
|
||||
resolvedAt: null,
|
||||
createdAt: "2026-05-09T19:30:00.000Z",
|
||||
updatedAt: "2026-05-09T19:30:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveRecoveryCardState", () => {
|
||||
it("maps active missing_disposition to needed", () => {
|
||||
expect(deriveRecoveryCardState(buildAction())).toBe("needed");
|
||||
});
|
||||
|
||||
it("maps active_run_watchdog to observe_only", () => {
|
||||
expect(deriveRecoveryCardState(buildAction({ kind: "active_run_watchdog" }))).toBe("observe_only");
|
||||
});
|
||||
|
||||
it("maps escalated status to escalated", () => {
|
||||
expect(deriveRecoveryCardState(buildAction({ status: "escalated" }))).toBe("escalated");
|
||||
});
|
||||
|
||||
it("maps resolved/cancelled to resolved", () => {
|
||||
expect(deriveRecoveryCardState(buildAction({ status: "resolved" }))).toBe("resolved");
|
||||
expect(deriveRecoveryCardState(buildAction({ status: "cancelled" }))).toBe("resolved");
|
||||
});
|
||||
});
|
||||
|
||||
describe("IssueRecoveryActionCard", () => {
|
||||
it("renders required fields and an aria-label naming the state", () => {
|
||||
const node = render(
|
||||
<IssueRecoveryActionCard
|
||||
action={buildAction()}
|
||||
agentMap={new Map([
|
||||
[ownerAgent.id, ownerAgent],
|
||||
[returnAgent.id, returnAgent],
|
||||
])}
|
||||
onResolve={() => {}}
|
||||
/>,
|
||||
);
|
||||
const section = node.querySelector("section[aria-label]");
|
||||
expect(section?.getAttribute("aria-label")).toBe("Recovery action: needed");
|
||||
expect(node.textContent).toContain("RECOVERY NEEDED");
|
||||
expect(node.textContent).toContain("Missing Disposition");
|
||||
expect(node.textContent).not.toContain("missing_disposition");
|
||||
expect(node.textContent).toContain("This issue's run finished, but no next step was chosen.");
|
||||
expect(node.textContent).toContain("ClaudeCoder");
|
||||
expect(node.textContent).toContain("CodexCoder");
|
||||
expect(node.textContent).toContain("Choose and record a valid issue disposition.");
|
||||
expect(node.textContent).toContain("Corrective wake queued");
|
||||
});
|
||||
|
||||
it("falls back to em dash when wake policy is absent", () => {
|
||||
const node = render(
|
||||
<IssueRecoveryActionCard action={buildAction({ wakePolicy: null })} />,
|
||||
);
|
||||
expect(node.textContent).toContain("—");
|
||||
});
|
||||
|
||||
it("renders observe_only tone for active_run_watchdog", () => {
|
||||
const node = render(
|
||||
<IssueRecoveryActionCard action={buildAction({ kind: "active_run_watchdog" })} />,
|
||||
);
|
||||
const section = node.querySelector("section[aria-label]");
|
||||
expect(section?.getAttribute("aria-label")).toBe("Recovery action: observing active run");
|
||||
expect(node.textContent).toContain("OBSERVING ACTIVE RUN");
|
||||
});
|
||||
|
||||
it("renders the resolved label and outcome when resolved", () => {
|
||||
const node = render(
|
||||
<IssueRecoveryActionCard action={buildAction({ status: "resolved", outcome: "restored", resolvedAt: "2026-05-09T19:35:00.000Z" })} />,
|
||||
);
|
||||
expect(node.textContent).toContain("RECOVERY RESOLVED");
|
||||
expect(node.textContent).toContain("Resolved as restored");
|
||||
});
|
||||
|
||||
it("calls resolve with done and does not offer delegated recovery", () => {
|
||||
const onResolve = vi.fn();
|
||||
const node = render(
|
||||
<IssueRecoveryActionCard action={buildAction()} onResolve={onResolve} />,
|
||||
);
|
||||
click(node.querySelector("[data-testid='recovery-action-resolve-trigger']"));
|
||||
|
||||
expect(document.body.textContent).toContain("Mark issue done");
|
||||
expect(document.body.textContent).not.toContain("Mark blocked");
|
||||
expect(document.body.textContent).not.toContain("Delegate follow-up issue");
|
||||
click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Mark issue done")) ?? null);
|
||||
|
||||
expect(onResolve).toHaveBeenCalledWith("done");
|
||||
});
|
||||
|
||||
it("does not offer blocked recovery resolution without a blocker selection flow", () => {
|
||||
const node = render(
|
||||
<IssueRecoveryActionCard action={buildAction()} onResolve={() => {}} canFalsePositive />,
|
||||
);
|
||||
click(node.querySelector("[data-testid='recovery-action-resolve-trigger']"));
|
||||
|
||||
expect(document.body.textContent).toContain("Mark issue done");
|
||||
expect(document.body.textContent).toContain("Send for review");
|
||||
expect(document.body.textContent).toContain("False positive, done");
|
||||
expect(document.body.textContent).toContain("False positive, review");
|
||||
expect(document.body.textContent).not.toContain("Mark blocked");
|
||||
});
|
||||
|
||||
it("hides false-positive options unless canFalsePositive is set", () => {
|
||||
const first = render(
|
||||
<IssueRecoveryActionCard action={buildAction()} onResolve={() => {}} />,
|
||||
);
|
||||
click(first.querySelector("[data-testid='recovery-action-resolve-trigger']"));
|
||||
expect(document.body.textContent).not.toContain("False positive");
|
||||
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
|
||||
const onResolve = vi.fn();
|
||||
const second = render(
|
||||
<IssueRecoveryActionCard action={buildAction()} onResolve={onResolve} canFalsePositive />,
|
||||
);
|
||||
click(second.querySelector("[data-testid='recovery-action-resolve-trigger']"));
|
||||
expect(document.body.textContent).toContain("False positive, done");
|
||||
expect(document.body.textContent).toContain("False positive, review");
|
||||
click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("False positive, done")) ?? null);
|
||||
expect(onResolve).toHaveBeenCalledWith("false_positive_done");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,537 @@
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
Agent,
|
||||
IssueRecoveryAction,
|
||||
IssueRecoveryActionKind,
|
||||
IssueRecoveryActionOutcome,
|
||||
IssueRecoveryActionStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import { Eye, OctagonAlert, RefreshCw, Sparkles, TriangleAlert } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { agentUrl } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
deriveRecoveryDisplayState,
|
||||
type RecoveryDisplayState,
|
||||
} from "@/lib/recovery-display";
|
||||
|
||||
export type RecoveryCardCardState = RecoveryDisplayState;
|
||||
export const deriveRecoveryCardState = deriveRecoveryDisplayState;
|
||||
|
||||
export type RecoveryResolveOutcome =
|
||||
| "done"
|
||||
| "in_review"
|
||||
| "false_positive_done"
|
||||
| "false_positive_in_review";
|
||||
|
||||
export interface IssueRecoveryActionCardProps {
|
||||
action: IssueRecoveryAction;
|
||||
agentMap?: ReadonlyMap<string, Agent>;
|
||||
/** Preferred state hint (e.g. observe_only when watchdog tone is requested). Falls back to derived state. */
|
||||
forcedState?: RecoveryCardCardState;
|
||||
/** Optional click handler for resolve menu actions. If omitted, the buttons are not rendered. */
|
||||
onResolve?: (outcome: RecoveryResolveOutcome) => void;
|
||||
/** Whether the viewer can run destructive board-only actions (e.g. false-positive dismissal). */
|
||||
canFalsePositive?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<IssueRecoveryActionKind, string> = {
|
||||
missing_disposition: "Missing Disposition",
|
||||
stranded_assigned_issue: "Stranded Issue",
|
||||
active_run_watchdog: "Active Watchdog",
|
||||
issue_graph_liveness: "Graph Liveness",
|
||||
};
|
||||
|
||||
const KIND_HEADLINE: Record<IssueRecoveryActionKind, string> = {
|
||||
missing_disposition: "This issue's run finished, but no next step was chosen.",
|
||||
stranded_assigned_issue:
|
||||
"Paperclip retried this issue's last run and it still has no live execution path.",
|
||||
active_run_watchdog:
|
||||
"The active run has been silent. Recovery is observing without interrupting it.",
|
||||
issue_graph_liveness:
|
||||
"Paperclip detected this issue lost a live action path. A recovery owner needs to act.",
|
||||
};
|
||||
|
||||
const STATE_TONE: Record<RecoveryCardCardState, {
|
||||
label: string;
|
||||
containerClass: string;
|
||||
iconWrapClass: string;
|
||||
iconClass: string;
|
||||
labelClass: string;
|
||||
Icon: typeof TriangleAlert;
|
||||
divider: string;
|
||||
}> = {
|
||||
needed: {
|
||||
label: "RECOVERY NEEDED",
|
||||
containerClass:
|
||||
"border-amber-300/70 bg-amber-50/85 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100",
|
||||
iconWrapClass: "bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-200",
|
||||
iconClass: "text-amber-700 dark:text-amber-300",
|
||||
labelClass: "text-amber-900 dark:text-amber-200",
|
||||
Icon: TriangleAlert,
|
||||
divider: "border-amber-300/60 dark:border-amber-500/30",
|
||||
},
|
||||
in_progress: {
|
||||
label: "RECOVERY IN PROGRESS",
|
||||
containerClass:
|
||||
"border-sky-300/70 bg-sky-50/80 text-sky-950 dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-100",
|
||||
iconWrapClass: "bg-sky-100 text-sky-800 dark:bg-sky-500/20 dark:text-sky-200",
|
||||
iconClass: "text-sky-700 dark:text-sky-300",
|
||||
labelClass: "text-sky-900 dark:text-sky-200",
|
||||
Icon: RefreshCw,
|
||||
divider: "border-sky-300/60 dark:border-sky-500/30",
|
||||
},
|
||||
observe_only: {
|
||||
label: "OBSERVING ACTIVE RUN",
|
||||
containerClass:
|
||||
"border-border bg-muted/40 text-foreground dark:bg-muted/20",
|
||||
iconWrapClass: "bg-muted text-foreground/70",
|
||||
iconClass: "text-muted-foreground",
|
||||
labelClass: "text-muted-foreground",
|
||||
Icon: Eye,
|
||||
divider: "border-border/70",
|
||||
},
|
||||
escalated: {
|
||||
label: "RECOVERY ESCALATED",
|
||||
containerClass:
|
||||
"border-red-400/60 bg-red-50/85 text-red-950 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100",
|
||||
iconWrapClass: "bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-200",
|
||||
iconClass: "text-red-700 dark:text-red-300",
|
||||
labelClass: "text-red-900 dark:text-red-200",
|
||||
Icon: OctagonAlert,
|
||||
divider: "border-red-400/50 dark:border-red-500/30",
|
||||
},
|
||||
resolved: {
|
||||
label: "RECOVERY RESOLVED",
|
||||
containerClass:
|
||||
"border-emerald-300/70 bg-emerald-50/80 text-emerald-950 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-100",
|
||||
iconWrapClass: "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200",
|
||||
iconClass: "text-emerald-700 dark:text-emerald-300",
|
||||
labelClass: "text-emerald-900 dark:text-emerald-200",
|
||||
Icon: Sparkles,
|
||||
divider: "border-emerald-300/60 dark:border-emerald-500/30",
|
||||
},
|
||||
};
|
||||
|
||||
const OUTCOME_LABEL: Record<IssueRecoveryActionOutcome, string> = {
|
||||
restored: "restored",
|
||||
delegated: "delegated to follow-up",
|
||||
false_positive: "false positive",
|
||||
blocked: "blocked",
|
||||
escalated: "escalated",
|
||||
cancelled: "cancelled",
|
||||
};
|
||||
|
||||
function readEvidenceString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.length > 240 ? `${trimmed.slice(0, 237)}…` : trimmed;
|
||||
}
|
||||
|
||||
function pickEvidenceSummary(action: IssueRecoveryAction): string | null {
|
||||
const evidence = action.evidence ?? {};
|
||||
const candidates = [
|
||||
"summary",
|
||||
"detectedProgressSummary",
|
||||
"missingDisposition",
|
||||
"retryReason",
|
||||
"latestRunErrorCode",
|
||||
"latestRunStatus",
|
||||
"latestIssueStatus",
|
||||
] as const;
|
||||
for (const key of candidates) {
|
||||
const next = readEvidenceString(evidence[key]);
|
||||
if (next) return next;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readEvidenceRunId(action: IssueRecoveryAction, key: "sourceRunId" | "correctiveRunId" | "latestRunId") {
|
||||
const evidence = action.evidence ?? {};
|
||||
const next = readEvidenceString(evidence[key]);
|
||||
return next;
|
||||
}
|
||||
|
||||
function readWakePolicySummary(action: IssueRecoveryAction): string | null {
|
||||
const policy = action.wakePolicy;
|
||||
if (!policy) return null;
|
||||
const type = readEvidenceString(policy.type);
|
||||
if (!type) return null;
|
||||
if (type === "wake_owner") return "Corrective wake queued";
|
||||
if (type === "board_escalation") return "Escalated to board";
|
||||
if (type === "manual") return "Manual";
|
||||
if (type === "monitor") {
|
||||
const interval = readEvidenceString(policy.intervalLabel);
|
||||
return interval ? `Monitor scheduled · ${interval}` : "Monitor scheduled";
|
||||
}
|
||||
return type.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function formatTimeShort(value: string | Date | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
const now = Date.now();
|
||||
const diffMs = date.getTime() - now;
|
||||
const absMin = Math.round(Math.abs(diffMs) / 60_000);
|
||||
if (absMin < 60) {
|
||||
return diffMs >= 0 ? `in ${absMin}m` : `${absMin}m ago`;
|
||||
}
|
||||
return date.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shortenRunId(runId: string | null | undefined) {
|
||||
if (!runId) return null;
|
||||
if (runId.length <= 12) return runId;
|
||||
return runId.slice(0, 8);
|
||||
}
|
||||
|
||||
function MetadataRow({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[7.5rem_1fr] gap-x-3 gap-y-0 px-3 py-1.5 text-xs sm:px-4">
|
||||
<dt className="truncate text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="min-w-0 break-words text-foreground/90">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MissingValue() {
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
|
||||
function AgentLink({
|
||||
agentId,
|
||||
agentMap,
|
||||
fallback,
|
||||
}: {
|
||||
agentId: string | null | undefined;
|
||||
agentMap?: ReadonlyMap<string, Agent>;
|
||||
fallback?: string | null;
|
||||
}) {
|
||||
if (!agentId) {
|
||||
return fallback ? <span>{fallback}</span> : <MissingValue />;
|
||||
}
|
||||
const agent = agentMap?.get(agentId);
|
||||
const label = agent?.name ?? `agent ${agentId.slice(0, 8)}`;
|
||||
if (agent) {
|
||||
return (
|
||||
<Link
|
||||
to={agentUrl(agent)}
|
||||
className="rounded-sm font-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <span className="font-medium">{label}</span>;
|
||||
}
|
||||
|
||||
function RunChip({
|
||||
runId,
|
||||
agentId,
|
||||
status,
|
||||
}: {
|
||||
runId: string | null;
|
||||
agentId: string | null | undefined;
|
||||
status?: string | null;
|
||||
}) {
|
||||
if (!runId) return <MissingValue />;
|
||||
const short = shortenRunId(runId);
|
||||
const inner = (
|
||||
<>
|
||||
<code className="rounded bg-background/80 px-1.5 py-0.5 font-mono text-[11px] text-foreground/80">
|
||||
run {short}
|
||||
</code>
|
||||
{status ? (
|
||||
<span className="font-sans text-[11px] text-muted-foreground">{status}</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
if (agentId) {
|
||||
return (
|
||||
<Link
|
||||
to={`/agents/${agentId}/runs/${runId}`}
|
||||
className="inline-flex items-center gap-2 rounded-sm underline-offset-2 hover:underline"
|
||||
>
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <span className="inline-flex items-center gap-2">{inner}</span>;
|
||||
}
|
||||
|
||||
const RESOLVE_OPTIONS: Array<{
|
||||
outcome: RecoveryResolveOutcome;
|
||||
label: string;
|
||||
description: string;
|
||||
destructive?: boolean;
|
||||
boardOnly?: boolean;
|
||||
}> = [
|
||||
{
|
||||
outcome: "done",
|
||||
label: "Mark issue done",
|
||||
description: "Restore by recording the requested work as complete.",
|
||||
},
|
||||
{
|
||||
outcome: "in_review",
|
||||
label: "Send for review",
|
||||
description: "Hand off to a reviewer with a real review path.",
|
||||
},
|
||||
{
|
||||
outcome: "false_positive_done",
|
||||
label: "False positive, done",
|
||||
description: "Dismiss recovery and mark the source issue complete.",
|
||||
destructive: true,
|
||||
boardOnly: true,
|
||||
},
|
||||
{
|
||||
outcome: "false_positive_in_review",
|
||||
label: "False positive, review",
|
||||
description: "Dismiss recovery and send the source issue for review.",
|
||||
destructive: true,
|
||||
boardOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function IssueRecoveryActionCard({
|
||||
action,
|
||||
agentMap,
|
||||
forcedState,
|
||||
onResolve,
|
||||
canFalsePositive = false,
|
||||
className,
|
||||
}: IssueRecoveryActionCardProps) {
|
||||
const cardState: RecoveryCardCardState = forcedState ?? deriveRecoveryCardState(action);
|
||||
const tone = STATE_TONE[cardState];
|
||||
const ToneIcon = tone.Icon;
|
||||
|
||||
const headline = useMemo(() => {
|
||||
if (cardState === "resolved" && action.outcome) {
|
||||
return `Recovery resolved as ${OUTCOME_LABEL[action.outcome] ?? action.outcome}.`;
|
||||
}
|
||||
return KIND_HEADLINE[action.kind] ?? KIND_HEADLINE.missing_disposition;
|
||||
}, [action.kind, action.outcome, cardState]);
|
||||
|
||||
const wakeSummary = readWakePolicySummary(action);
|
||||
const evidenceSummary = pickEvidenceSummary(action);
|
||||
const sourceRunId = readEvidenceRunId(action, "sourceRunId") ?? readEvidenceRunId(action, "latestRunId");
|
||||
const correctiveRunId = readEvidenceRunId(action, "correctiveRunId");
|
||||
const showAttempt = action.attemptCount > 1 && action.maxAttempts !== null;
|
||||
const showTimeoutInline = (() => {
|
||||
if (!action.timeoutAt) return false;
|
||||
try {
|
||||
const date = action.timeoutAt instanceof Date ? action.timeoutAt : new Date(action.timeoutAt);
|
||||
const diffMs = date.getTime() - Date.now();
|
||||
return diffMs > 0 && diffMs < 60 * 60 * 1000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
const updatedAtLabel = formatTimeShort(action.updatedAt);
|
||||
|
||||
const ariaState = ({
|
||||
needed: "needed",
|
||||
in_progress: "in progress",
|
||||
observe_only: "observing active run",
|
||||
escalated: "escalated",
|
||||
resolved: "resolved",
|
||||
} satisfies Record<RecoveryCardCardState, string>)[cardState];
|
||||
|
||||
const showResolveActions = onResolve !== undefined && cardState !== "resolved";
|
||||
const visibleResolveOptions = RESOLVE_OPTIONS.filter((option) => {
|
||||
if (option.boardOnly && !canFalsePositive) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
role="status"
|
||||
aria-label={`Recovery action: ${ariaState}`}
|
||||
data-recovery-state={cardState}
|
||||
data-recovery-kind={action.kind}
|
||||
className={cn(
|
||||
"relative w-full overflow-hidden rounded-lg border text-sm shadow-[0_1px_0_rgba(15,23,42,0.02)]",
|
||||
tone.containerClass,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<header className="flex items-start gap-3 px-3 py-2.5 sm:px-4">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md",
|
||||
tone.iconWrapClass,
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<ToneIcon className={cn("h-4 w-4", tone.iconClass)} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] font-semibold uppercase tracking-[0.14em]">
|
||||
<span className={tone.labelClass}>{tone.label}</span>
|
||||
<span className="text-muted-foreground/60" aria-hidden>·</span>
|
||||
<code className="rounded bg-background/70 px-1.5 py-0.5 font-mono text-[11px] tracking-normal text-muted-foreground">
|
||||
{KIND_LABEL[action.kind] ?? action.kind}
|
||||
</code>
|
||||
{updatedAtLabel ? (
|
||||
<>
|
||||
<span className="text-muted-foreground/60" aria-hidden>·</span>
|
||||
<span className="font-medium normal-case tracking-normal text-muted-foreground">
|
||||
{updatedAtLabel}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-[14px] leading-6">{headline}</p>
|
||||
</div>
|
||||
</header>
|
||||
<dl className={cn("border-t bg-background/40 dark:bg-background/20", tone.divider)}>
|
||||
<MetadataRow label="Owner">
|
||||
<span className="inline-flex flex-wrap items-center gap-1.5">
|
||||
{action.ownerType === "agent" && action.ownerAgentId ? (
|
||||
<>
|
||||
<span className="text-muted-foreground">Recovery:</span>
|
||||
<AgentLink agentId={action.ownerAgentId} agentMap={agentMap} />
|
||||
</>
|
||||
) : action.ownerType === "board" ? (
|
||||
<span className="font-medium">Board</span>
|
||||
) : action.ownerType === "user" && action.ownerUserId ? (
|
||||
<span className="font-medium">user {action.ownerUserId.slice(0, 6)}</span>
|
||||
) : action.ownerType === "system" ? (
|
||||
<span className="font-medium">System</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">unassigned — pick one to wake them</span>
|
||||
)}
|
||||
{action.returnOwnerAgentId ? (
|
||||
<>
|
||||
<span className="text-muted-foreground">→ Returns to:</span>
|
||||
<AgentLink agentId={action.returnOwnerAgentId} agentMap={agentMap} />
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</MetadataRow>
|
||||
<MetadataRow label="Source run">
|
||||
<RunChip runId={sourceRunId} agentId={action.previousOwnerAgentId} />
|
||||
</MetadataRow>
|
||||
{correctiveRunId ? (
|
||||
<MetadataRow label="Corrective run">
|
||||
<RunChip runId={correctiveRunId} agentId={action.previousOwnerAgentId} />
|
||||
</MetadataRow>
|
||||
) : null}
|
||||
<MetadataRow label="Evidence">
|
||||
{evidenceSummary ? (
|
||||
<span className="break-words font-mono text-[11px] text-foreground/80">{evidenceSummary}</span>
|
||||
) : (
|
||||
<MissingValue />
|
||||
)}
|
||||
</MetadataRow>
|
||||
<MetadataRow label="Next action">
|
||||
{action.nextAction ? <span>{action.nextAction}</span> : <MissingValue />}
|
||||
</MetadataRow>
|
||||
<MetadataRow label="Wake">
|
||||
<span className="inline-flex flex-wrap items-center gap-1.5">
|
||||
{wakeSummary ? <span>{wakeSummary}</span> : <MissingValue />}
|
||||
{showAttempt ? (
|
||||
<span className="rounded-md border border-border/50 bg-background/60 px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
attempt {action.attemptCount} of {action.maxAttempts}
|
||||
</span>
|
||||
) : null}
|
||||
{showTimeoutInline ? (
|
||||
<span className="rounded-md border border-border/50 bg-background/60 px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
Times out {formatTimeShort(action.timeoutAt) ?? "soon"}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</MetadataRow>
|
||||
{cardState === "resolved" && action.outcome ? (
|
||||
<MetadataRow label="Resolution">
|
||||
<span className={cn("font-medium", tone.labelClass)}>
|
||||
Resolved as {OUTCOME_LABEL[action.outcome]}
|
||||
{action.resolvedAt ? ` · ${formatTimeShort(action.resolvedAt) ?? ""}` : ""}
|
||||
</span>
|
||||
</MetadataRow>
|
||||
) : null}
|
||||
</dl>
|
||||
{showResolveActions ? (
|
||||
<div className={cn("flex flex-wrap items-center gap-2 border-t px-3 py-2.5 sm:px-4", tone.divider)}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="default"
|
||||
data-testid="recovery-action-resolve-trigger"
|
||||
aria-label="Resolve recovery"
|
||||
>
|
||||
Resolve…
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-72 p-1.5"
|
||||
>
|
||||
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
Resolve recovery
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{visibleResolveOptions.map((option) => (
|
||||
<button
|
||||
key={option.outcome}
|
||||
type="button"
|
||||
onClick={() => onResolve?.(option.outcome)}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
|
||||
"hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
option.destructive ? "text-destructive" : null,
|
||||
)}
|
||||
>
|
||||
<span className="font-medium leading-5">{option.label}</span>
|
||||
<span className="text-[11px] leading-4 text-muted-foreground">{option.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{cardState === "observe_only" ? (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Recovery is observing without interrupting the live run.
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
The card stays open until an explicit decision is recorded.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export type { IssueRecoveryActionStatus };
|
||||
|
||||
export default IssueRecoveryActionCard;
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import type { Issue, IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Eye, Flag, X } from "lucide-react";
|
||||
import {
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
withIssueDetailHeaderSeed,
|
||||
} from "../lib/issueDetailBreadcrumb";
|
||||
import { cn } from "../lib/utils";
|
||||
import { deriveActiveRecoveryDisplayState, RECOVERY_CHIP_DEFAULT_TONE } from "../lib/recovery-display";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
|
||||
import { hasAssignedBacklogBlocker } from "../lib/issue-blockers";
|
||||
@@ -92,6 +93,8 @@ export function IssueRow({
|
||||
Planning
|
||||
</span>
|
||||
) : null;
|
||||
const recoveryAction = issue.activeRecoveryAction ?? null;
|
||||
const recoveryIndicator = recoveryAction ? renderRecoveryChip(recoveryAction, selected) : null;
|
||||
const parkedBlockerIndicator = hasAssignedBacklogBlocker(issue.blockedBy) ? (
|
||||
<span
|
||||
data-testid="issue-row-parked-blocker"
|
||||
@@ -125,6 +128,7 @@ export function IssueRow({
|
||||
{productivityReviewIndicator}
|
||||
{planningModeIndicator}
|
||||
{parkedBlockerIndicator}
|
||||
{recoveryIndicator}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
|
||||
<span className={cn("line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none", titleClassName)}>
|
||||
@@ -151,6 +155,7 @@ export function IssueRow({
|
||||
</span>
|
||||
{planningModeIndicator}
|
||||
{parkedBlockerIndicator}
|
||||
{recoveryIndicator}
|
||||
</>
|
||||
)}
|
||||
{mobileMeta ? (
|
||||
@@ -230,3 +235,27 @@ export function IssueRow({
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRecoveryChip(action: IssueRecoveryAction, selected: boolean): ReactNode {
|
||||
const state = deriveActiveRecoveryDisplayState(action);
|
||||
if (!state) return null;
|
||||
const tone = RECOVERY_CHIP_DEFAULT_TONE[state];
|
||||
const Icon = tone.icon;
|
||||
return (
|
||||
<span
|
||||
data-testid="issue-row-recovery-indicator"
|
||||
data-recovery-state={state}
|
||||
role="status"
|
||||
aria-label={tone.label}
|
||||
className={cn(
|
||||
"ml-1.5 inline-flex shrink-0 items-center gap-0.5 rounded-full border px-2 py-0.5 text-[10px] font-medium",
|
||||
tone.className,
|
||||
selected ? "!border-muted-foreground !text-muted-foreground" : null,
|
||||
)}
|
||||
title={`${tone.label} — open the source issue to act.`}
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5" aria-hidden />
|
||||
{tone.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLAnchorElement> & {
|
||||
to: string;
|
||||
issueQuicklookAlign?: string;
|
||||
issueQuicklookSide?: string;
|
||||
issuePrefetch?: unknown;
|
||||
state?: unknown;
|
||||
children?: ReactNode;
|
||||
}) => (
|
||||
<a
|
||||
href={to}
|
||||
data-quicklook-align={issueQuicklookAlign}
|
||||
data-quicklook-side={issueQuicklookSide}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function issue(id: string, overrides: Partial<Issue> = {}): 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(
|
||||
<IssueSiblingNavigation
|
||||
navigation={{
|
||||
previous: issue("1", { title: "Previous sibling title" }),
|
||||
next: issue("3", { title: "Next sibling title" }),
|
||||
currentIndex: 1,
|
||||
totalCount: 3,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<IssueSiblingNavigation
|
||||
navigation={{
|
||||
previous: null,
|
||||
next: issue("2"),
|
||||
currentIndex: 0,
|
||||
totalCount: 2,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<nav
|
||||
aria-label="Sub-issue navigation"
|
||||
className="mt-4 flex flex-col gap-3 sm:mt-6 sm:grid sm:grid-cols-2"
|
||||
>
|
||||
{navigation.previous ? (
|
||||
<SiblingLink direction="previous" issue={navigation.previous} linkState={linkState} />
|
||||
) : null}
|
||||
{navigation.next ? (
|
||||
<SiblingLink
|
||||
direction="next"
|
||||
issue={navigation.next}
|
||||
linkState={linkState}
|
||||
className={!navigation.previous ? "sm:col-start-2" : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Link
|
||||
to={createIssueDetailPath(issuePathId)}
|
||||
state={withIssueDetailHeaderSeed(linkState, issue)}
|
||||
issuePrefetch={issue}
|
||||
issueQuicklookSide="top"
|
||||
issueQuicklookAlign={direction === "previous" ? "start" : "end"}
|
||||
aria-label={`${ariaDirection}: ${identifier} - ${issue.title}`}
|
||||
className={cn(
|
||||
"group min-w-0 rounded-lg border border-border bg-card px-3 py-2.5 text-left no-underline transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring",
|
||||
direction === "next" && "sm:text-right",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<div className={cn(
|
||||
"flex items-center gap-1.5 text-xs text-muted-foreground transition-colors group-hover:text-foreground",
|
||||
direction === "next" && "sm:justify-end",
|
||||
)}>
|
||||
{direction === "previous" ? <Icon className="h-3.5 w-3.5 shrink-0" /> : null}
|
||||
<span>{label}</span>
|
||||
{direction === "next" ? <Icon className="h-3.5 w-3.5 shrink-0" /> : null}
|
||||
</div>
|
||||
<div className={cn(
|
||||
"flex min-w-0 items-center gap-1.5 text-xs font-mono text-muted-foreground transition-colors group-hover:text-foreground",
|
||||
direction === "next" && "sm:justify-end",
|
||||
)}>
|
||||
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} />
|
||||
<span className="shrink-0">{identifier}</span>
|
||||
</div>
|
||||
<div className="truncate text-sm text-foreground">
|
||||
{issue.title}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,12 @@ export type ManagedRoutineMissingRef = {
|
||||
resourceKey: string;
|
||||
};
|
||||
|
||||
export type ManagedRoutineDefaultDrift = {
|
||||
changedFields: string[];
|
||||
defaultTitle?: string | null;
|
||||
defaultDescription?: string | null;
|
||||
};
|
||||
|
||||
export type ManagedRoutinesListItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
@@ -37,6 +43,7 @@ export type ManagedRoutinesListItem = {
|
||||
lastRunStatus?: string | null;
|
||||
managedByPluginDisplayName?: string | null;
|
||||
missingRefs?: ManagedRoutineMissingRef[];
|
||||
defaultDrift?: ManagedRoutineDefaultDrift | null;
|
||||
};
|
||||
|
||||
export type ManagedRoutinesListProps = {
|
||||
|
||||
@@ -46,6 +46,9 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
|
||||
"issue.successful_run_handoff_required": "flagged missing next step on",
|
||||
"issue.successful_run_handoff_resolved": "recorded next step chosen on",
|
||||
"issue.successful_run_handoff_escalated": "escalated missing next step on",
|
||||
"issue.recovery_action_opened": "opened a recovery action on",
|
||||
"issue.recovery_action_resolved": "resolved the recovery action on",
|
||||
"issue.recovery_action_escalated": "escalated the recovery action on",
|
||||
"agent.created": "created",
|
||||
"agent.updated": "updated",
|
||||
"agent.paused": "paused",
|
||||
@@ -98,6 +101,9 @@ const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
|
||||
"issue.successful_run_handoff_required": "Run finished without a clear next step",
|
||||
"issue.successful_run_handoff_resolved": "Next step chosen",
|
||||
"issue.successful_run_handoff_escalated": "Run finished without a next step - recovery escalated",
|
||||
"issue.recovery_action_opened": "Opened a source-scoped recovery action",
|
||||
"issue.recovery_action_resolved": "Resolved the recovery action",
|
||||
"issue.recovery_action_escalated": "Escalated the recovery action",
|
||||
"agent.created": "created an agent",
|
||||
"agent.updated": "updated the agent",
|
||||
"agent.paused": "paused the agent",
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
Issue,
|
||||
IssueBlockedInboxAttention,
|
||||
IssueBlockedInboxReason,
|
||||
IssueBlockedInboxSeverity,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
BLOCKED_REASON_VARIANT_ORDER,
|
||||
blockedBadgeTone,
|
||||
blockedReasonLabel,
|
||||
blockedReasonVariant,
|
||||
blockedRowMatchesSearch,
|
||||
blockedSeverityRank,
|
||||
blockedVariantLabel,
|
||||
buildBlockedInboxRows,
|
||||
compareBlockedAttention,
|
||||
compareBlockedRows,
|
||||
formatStoppedAge,
|
||||
groupBlockedInboxRows,
|
||||
sortBlockedInboxRows,
|
||||
type BlockedInboxIssueRow,
|
||||
} from "./blockedInbox";
|
||||
|
||||
function makeAttention(
|
||||
overrides: Partial<IssueBlockedInboxAttention> = {},
|
||||
): IssueBlockedInboxAttention {
|
||||
return {
|
||||
kind: "blocked",
|
||||
state: "needs_attention",
|
||||
reason: "blocked_chain_stalled",
|
||||
severity: "medium",
|
||||
stoppedSinceAt: "2026-05-08T12:00:00.000Z",
|
||||
owner: { type: "agent", agentId: null, userId: null, label: "QA" },
|
||||
action: { label: "Resolve PAP-1", detail: null },
|
||||
sourceIssue: null,
|
||||
leafIssue: null,
|
||||
recoveryIssue: null,
|
||||
approvalId: null,
|
||||
interactionId: null,
|
||||
sampleIssueIdentifier: null,
|
||||
redaction: { externalDetailsRedacted: false, secretFieldsOmitted: true },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeIssue(
|
||||
overrides: Partial<Issue> & { id: string },
|
||||
attention: IssueBlockedInboxAttention | null = null,
|
||||
): Issue {
|
||||
const { id, ...rest } = overrides;
|
||||
return {
|
||||
id,
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
title: "Title",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
workMode: "standard",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: 1,
|
||||
identifier: "PAP-1",
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
blockedInboxAttention: attention,
|
||||
createdAt: new Date("2026-05-09T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-09T00:00:00.000Z"),
|
||||
...rest,
|
||||
} as Issue;
|
||||
}
|
||||
|
||||
describe("blockedInbox", () => {
|
||||
it("maps every reason to a known variant and label", () => {
|
||||
const reasons: IssueBlockedInboxReason[] = [
|
||||
"pending_board_decision",
|
||||
"pending_user_decision",
|
||||
"missing_successful_run_disposition",
|
||||
"blocked_chain_stalled",
|
||||
"blocked_by_unassigned_issue",
|
||||
"blocked_by_assigned_backlog_issue",
|
||||
"blocked_by_cancelled_issue",
|
||||
"blocked_by_uninvokable_assignee",
|
||||
"in_review_without_action_path",
|
||||
"invalid_review_participant",
|
||||
"open_recovery_issue",
|
||||
"external_owner_action",
|
||||
];
|
||||
for (const reason of reasons) {
|
||||
const variant = blockedReasonVariant(reason);
|
||||
expect(BLOCKED_REASON_VARIANT_ORDER).toContain(variant);
|
||||
expect(blockedVariantLabel(variant)).toBeTruthy();
|
||||
expect(blockedReasonLabel(reason)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("ranks severity critical first and low last", () => {
|
||||
const order: IssueBlockedInboxSeverity[] = ["critical", "high", "medium", "low"];
|
||||
const ranks = order.map((s) => blockedSeverityRank(s));
|
||||
expect([...ranks].sort((a, b) => a - b)).toEqual(ranks);
|
||||
});
|
||||
|
||||
it("compares by severity first, then stoppedSinceAt", () => {
|
||||
const a = makeAttention({
|
||||
severity: "critical",
|
||||
stoppedSinceAt: "2026-05-08T13:00:00.000Z",
|
||||
});
|
||||
const b = makeAttention({
|
||||
severity: "high",
|
||||
stoppedSinceAt: "2026-05-08T10:00:00.000Z",
|
||||
});
|
||||
const c = makeAttention({
|
||||
severity: "high",
|
||||
stoppedSinceAt: "2026-05-08T12:00:00.000Z",
|
||||
});
|
||||
expect(compareBlockedAttention(a, b)).toBeLessThan(0);
|
||||
// both 'high', earlier stoppedSinceAt sorts first
|
||||
expect(compareBlockedAttention(b, c)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("keeps equal unstopped attention comparisons deterministic", () => {
|
||||
const a = makeAttention({ severity: "high", stoppedSinceAt: null });
|
||||
const b = makeAttention({ severity: "high", stoppedSinceAt: null });
|
||||
expect(compareBlockedAttention(a, b)).toBe(0);
|
||||
});
|
||||
|
||||
it("buildBlockedInboxRows skips issues without attention", () => {
|
||||
const issues = [
|
||||
makeIssue({ id: "issue-1" }, makeAttention()),
|
||||
makeIssue({ id: "issue-2" }, null),
|
||||
];
|
||||
const rows = buildBlockedInboxRows(issues);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].issue.id).toBe("issue-1");
|
||||
});
|
||||
|
||||
it("groupBlockedInboxRows orders groups by canonical variant order and sorts within group", () => {
|
||||
const issues = [
|
||||
makeIssue(
|
||||
{ id: "external-1" },
|
||||
makeAttention({ reason: "external_owner_action", severity: "low" }),
|
||||
),
|
||||
makeIssue(
|
||||
{ id: "stalled-1" },
|
||||
makeAttention({
|
||||
reason: "blocked_chain_stalled",
|
||||
severity: "high",
|
||||
stoppedSinceAt: "2026-05-09T01:00:00.000Z",
|
||||
}),
|
||||
),
|
||||
makeIssue(
|
||||
{ id: "stalled-2" },
|
||||
makeAttention({
|
||||
reason: "blocked_chain_stalled",
|
||||
severity: "critical",
|
||||
stoppedSinceAt: "2026-05-09T05:00:00.000Z",
|
||||
}),
|
||||
),
|
||||
makeIssue(
|
||||
{ id: "decision-1" },
|
||||
makeAttention({ reason: "pending_board_decision", severity: "medium" }),
|
||||
),
|
||||
];
|
||||
const groups = groupBlockedInboxRows(buildBlockedInboxRows(issues));
|
||||
expect(groups.map((g) => g.variant)).toEqual([
|
||||
"needs_decision",
|
||||
"stalled",
|
||||
"external_wait",
|
||||
]);
|
||||
const stalled = groups.find((g) => g.variant === "stalled")!;
|
||||
expect(stalled.rows.map((r) => r.issue.id)).toEqual(["stalled-2", "stalled-1"]);
|
||||
});
|
||||
|
||||
it("sortBlockedInboxRows supports recent and longest-stopped ordering", () => {
|
||||
const rows = buildBlockedInboxRows([
|
||||
makeIssue(
|
||||
{ id: "old", title: "Old stopped" },
|
||||
makeAttention({
|
||||
severity: "low",
|
||||
stoppedSinceAt: "2026-05-06T00:00:00.000Z",
|
||||
}),
|
||||
),
|
||||
makeIssue(
|
||||
{ id: "recent", title: "Recently stopped" },
|
||||
makeAttention({
|
||||
severity: "critical",
|
||||
stoppedSinceAt: "2026-05-09T00:00:00.000Z",
|
||||
}),
|
||||
),
|
||||
makeIssue(
|
||||
{ id: "middle", title: "Middle stopped" },
|
||||
makeAttention({
|
||||
severity: "medium",
|
||||
stoppedSinceAt: "2026-05-08T00:00:00.000Z",
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(sortBlockedInboxRows(rows, "most_recent").map((row) => row.issue.id)).toEqual([
|
||||
"recent",
|
||||
"middle",
|
||||
"old",
|
||||
]);
|
||||
expect(sortBlockedInboxRows(rows, "longest_stopped").map((row) => row.issue.id)).toEqual([
|
||||
"old",
|
||||
"middle",
|
||||
"recent",
|
||||
]);
|
||||
expect(compareBlockedRows(rows[0], rows[1], "most_recent")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("blockedRowMatchesSearch matches title, identifier, owner, action and reason", () => {
|
||||
const issue = makeIssue(
|
||||
{ id: "issue-1", identifier: "PAP-77", title: "Resume parked work" },
|
||||
makeAttention({
|
||||
reason: "blocked_by_assigned_backlog_issue",
|
||||
owner: { type: "agent", agentId: null, userId: null, label: "Charlie" },
|
||||
action: { label: "Resume parked blocker", detail: null },
|
||||
}),
|
||||
);
|
||||
const row: BlockedInboxIssueRow = buildBlockedInboxRows([issue])[0];
|
||||
expect(blockedRowMatchesSearch(row, "")).toBe(true);
|
||||
expect(blockedRowMatchesSearch(row, "pap-77")).toBe(true);
|
||||
expect(blockedRowMatchesSearch(row, "parked")).toBe(true);
|
||||
expect(blockedRowMatchesSearch(row, "charlie")).toBe(true);
|
||||
expect(blockedRowMatchesSearch(row, "no match")).toBe(false);
|
||||
});
|
||||
|
||||
it("blockedBadgeTone reflects the highest severity present", () => {
|
||||
const empty: BlockedInboxIssueRow[] = [];
|
||||
expect(blockedBadgeTone(empty)).toBe("muted");
|
||||
|
||||
const issues = [
|
||||
makeIssue({ id: "a" }, makeAttention({ severity: "low" })),
|
||||
makeIssue({ id: "b" }, makeAttention({ severity: "high" })),
|
||||
];
|
||||
expect(blockedBadgeTone(buildBlockedInboxRows(issues))).toBe("amber");
|
||||
|
||||
const critical = [
|
||||
...issues,
|
||||
makeIssue({ id: "c" }, makeAttention({ severity: "critical" })),
|
||||
];
|
||||
expect(blockedBadgeTone(buildBlockedInboxRows(critical))).toBe("red");
|
||||
});
|
||||
|
||||
it("formatStoppedAge produces stable buckets", () => {
|
||||
const now = new Date("2026-05-10T00:00:00.000Z").getTime();
|
||||
expect(formatStoppedAge(null)).toBe("stopped");
|
||||
expect(formatStoppedAge("2026-05-09T23:59:30.000Z", now)).toBe("stopped just now");
|
||||
expect(formatStoppedAge("2026-05-09T23:30:00.000Z", now)).toBe("stopped 30m");
|
||||
expect(formatStoppedAge("2026-05-09T20:00:00.000Z", now)).toBe("stopped 4h");
|
||||
expect(formatStoppedAge("2026-05-07T00:00:00.000Z", now)).toBe("stopped 3d");
|
||||
expect(formatStoppedAge("2026-04-15T00:00:00.000Z", now)).toBe("stopped 3w");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import type {
|
||||
Issue,
|
||||
IssueBlockedInboxAttention,
|
||||
IssueBlockedInboxReason,
|
||||
IssueBlockedInboxSeverity,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export type BlockedReasonVariant =
|
||||
| "needs_decision"
|
||||
| "stalled"
|
||||
| "needs_attention"
|
||||
| "recovery_required"
|
||||
| "external_wait"
|
||||
| "owner_paused";
|
||||
|
||||
const VARIANT_BY_REASON: Record<IssueBlockedInboxReason, BlockedReasonVariant> = {
|
||||
pending_board_decision: "needs_decision",
|
||||
pending_user_decision: "needs_decision",
|
||||
missing_successful_run_disposition: "needs_decision",
|
||||
blocked_chain_stalled: "stalled",
|
||||
blocked_by_unassigned_issue: "needs_attention",
|
||||
blocked_by_assigned_backlog_issue: "needs_attention",
|
||||
blocked_by_cancelled_issue: "needs_attention",
|
||||
in_review_without_action_path: "needs_attention",
|
||||
invalid_review_participant: "needs_attention",
|
||||
open_recovery_issue: "recovery_required",
|
||||
external_owner_action: "external_wait",
|
||||
blocked_by_uninvokable_assignee: "owner_paused",
|
||||
};
|
||||
|
||||
export const BLOCKED_REASON_VARIANT_ORDER: BlockedReasonVariant[] = [
|
||||
"needs_decision",
|
||||
"stalled",
|
||||
"needs_attention",
|
||||
"recovery_required",
|
||||
"external_wait",
|
||||
"owner_paused",
|
||||
];
|
||||
|
||||
export const BLOCKED_VARIANT_LABELS: Record<BlockedReasonVariant, string> = {
|
||||
needs_decision: "Needs decision",
|
||||
stalled: "Blocked chain stalled",
|
||||
needs_attention: "Needs attention",
|
||||
recovery_required: "Recovery required",
|
||||
external_wait: "External wait",
|
||||
owner_paused: "Owner paused",
|
||||
};
|
||||
|
||||
const REASON_LABELS: Record<IssueBlockedInboxReason, string> = {
|
||||
pending_board_decision: "Pending board decision",
|
||||
pending_user_decision: "Pending user decision",
|
||||
missing_successful_run_disposition: "Pick disposition",
|
||||
blocked_chain_stalled: "Blocked chain stalled",
|
||||
blocked_by_unassigned_issue: "Unassigned blocker",
|
||||
blocked_by_assigned_backlog_issue: "Parked blocker",
|
||||
blocked_by_cancelled_issue: "Cancelled blocker",
|
||||
in_review_without_action_path: "Review without action path",
|
||||
invalid_review_participant: "Invalid review participant",
|
||||
open_recovery_issue: "Recovery in progress",
|
||||
external_owner_action: "External owner action",
|
||||
blocked_by_uninvokable_assignee: "Owner paused",
|
||||
};
|
||||
|
||||
const SEVERITY_RANK: Record<IssueBlockedInboxSeverity, number> = {
|
||||
critical: 0,
|
||||
high: 1,
|
||||
medium: 2,
|
||||
low: 3,
|
||||
};
|
||||
|
||||
export type BlockedInboxBadgeTone = "muted" | "amber" | "red";
|
||||
|
||||
export function blockedReasonVariant(reason: IssueBlockedInboxReason): BlockedReasonVariant {
|
||||
return VARIANT_BY_REASON[reason] ?? "needs_attention";
|
||||
}
|
||||
|
||||
export function blockedReasonLabel(reason: IssueBlockedInboxReason): string {
|
||||
return REASON_LABELS[reason] ?? "Stopped";
|
||||
}
|
||||
|
||||
export function blockedVariantLabel(variant: BlockedReasonVariant): string {
|
||||
return BLOCKED_VARIANT_LABELS[variant];
|
||||
}
|
||||
|
||||
export function blockedSeverityRank(severity: IssueBlockedInboxSeverity): number {
|
||||
return SEVERITY_RANK[severity] ?? 9;
|
||||
}
|
||||
|
||||
export function compareBlockedAttention(
|
||||
a: IssueBlockedInboxAttention,
|
||||
b: IssueBlockedInboxAttention,
|
||||
): number {
|
||||
const sevDiff = blockedSeverityRank(a.severity) - blockedSeverityRank(b.severity);
|
||||
if (sevDiff !== 0) return sevDiff;
|
||||
const aSince = a.stoppedSinceAt ? new Date(a.stoppedSinceAt).getTime() : Number.POSITIVE_INFINITY;
|
||||
const bSince = b.stoppedSinceAt ? new Date(b.stoppedSinceAt).getTime() : Number.POSITIVE_INFINITY;
|
||||
const sinceDiff = aSince - bSince;
|
||||
return Number.isFinite(sinceDiff) ? sinceDiff : 0;
|
||||
}
|
||||
|
||||
export interface BlockedInboxIssueRow {
|
||||
issue: Issue;
|
||||
attention: IssueBlockedInboxAttention;
|
||||
variant: BlockedReasonVariant;
|
||||
reasonLabel: string;
|
||||
stoppedAtMs: number | null;
|
||||
}
|
||||
|
||||
export type BlockedInboxGroupBy = "blocker_type" | "none";
|
||||
export type BlockedInboxSort = "urgency" | "most_recent" | "longest_stopped";
|
||||
|
||||
export const BLOCKED_GROUP_OPTIONS: readonly [BlockedInboxGroupBy, string][] = [
|
||||
["blocker_type", "Blocker type"],
|
||||
["none", "None"],
|
||||
];
|
||||
|
||||
export const BLOCKED_SORT_OPTIONS: readonly [BlockedInboxSort, string][] = [
|
||||
["urgency", "Most urgent"],
|
||||
["most_recent", "Most recent"],
|
||||
["longest_stopped", "Longest stopped"],
|
||||
];
|
||||
|
||||
export interface BlockedInboxGroup {
|
||||
variant: BlockedReasonVariant;
|
||||
label: string;
|
||||
rows: BlockedInboxIssueRow[];
|
||||
}
|
||||
|
||||
export function buildBlockedInboxRows(issues: readonly Issue[]): BlockedInboxIssueRow[] {
|
||||
const rows: BlockedInboxIssueRow[] = [];
|
||||
for (const issue of issues) {
|
||||
const attention = issue.blockedInboxAttention;
|
||||
if (!attention) continue;
|
||||
rows.push({
|
||||
issue,
|
||||
attention,
|
||||
variant: blockedReasonVariant(attention.reason),
|
||||
reasonLabel: blockedReasonLabel(attention.reason),
|
||||
stoppedAtMs: attention.stoppedSinceAt ? new Date(attention.stoppedSinceAt).getTime() : null,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function issueTimestampMs(value: Date | string | null | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
function blockedRowRecencyMs(row: BlockedInboxIssueRow): number {
|
||||
return row.stoppedAtMs ?? issueTimestampMs(row.issue.updatedAt) ?? 0;
|
||||
}
|
||||
|
||||
function compareBlockedRowsByTitle(a: BlockedInboxIssueRow, b: BlockedInboxIssueRow): number {
|
||||
const byTitle = a.issue.title.localeCompare(b.issue.title);
|
||||
if (byTitle !== 0) return byTitle;
|
||||
return a.issue.id.localeCompare(b.issue.id);
|
||||
}
|
||||
|
||||
export function compareBlockedRows(
|
||||
a: BlockedInboxIssueRow,
|
||||
b: BlockedInboxIssueRow,
|
||||
sort: BlockedInboxSort = "urgency",
|
||||
): number {
|
||||
if (sort === "most_recent") {
|
||||
const recencyDiff = blockedRowRecencyMs(b) - blockedRowRecencyMs(a);
|
||||
if (recencyDiff !== 0) return recencyDiff;
|
||||
const attentionDiff = compareBlockedAttention(a.attention, b.attention);
|
||||
if (attentionDiff !== 0) return attentionDiff;
|
||||
return compareBlockedRowsByTitle(a, b);
|
||||
}
|
||||
|
||||
if (sort === "longest_stopped") {
|
||||
const aStopped = a.stoppedAtMs ?? Number.POSITIVE_INFINITY;
|
||||
const bStopped = b.stoppedAtMs ?? Number.POSITIVE_INFINITY;
|
||||
const stoppedDiff = aStopped - bStopped;
|
||||
if (stoppedDiff !== 0) return stoppedDiff;
|
||||
const severityDiff = blockedSeverityRank(a.attention.severity) - blockedSeverityRank(b.attention.severity);
|
||||
if (severityDiff !== 0) return severityDiff;
|
||||
return compareBlockedRowsByTitle(a, b);
|
||||
}
|
||||
|
||||
const attentionDiff = compareBlockedAttention(a.attention, b.attention);
|
||||
if (attentionDiff !== 0) return attentionDiff;
|
||||
const recencyDiff = blockedRowRecencyMs(b) - blockedRowRecencyMs(a);
|
||||
if (recencyDiff !== 0) return recencyDiff;
|
||||
return compareBlockedRowsByTitle(a, b);
|
||||
}
|
||||
|
||||
export function sortBlockedInboxRows(
|
||||
rows: readonly BlockedInboxIssueRow[],
|
||||
sort: BlockedInboxSort = "urgency",
|
||||
): BlockedInboxIssueRow[] {
|
||||
return [...rows].sort((a, b) => compareBlockedRows(a, b, sort));
|
||||
}
|
||||
|
||||
export function groupBlockedInboxRows(
|
||||
rows: readonly BlockedInboxIssueRow[],
|
||||
sort: BlockedInboxSort = "urgency",
|
||||
): BlockedInboxGroup[] {
|
||||
const buckets = new Map<BlockedReasonVariant, BlockedInboxIssueRow[]>();
|
||||
for (const row of rows) {
|
||||
const list = buckets.get(row.variant) ?? [];
|
||||
list.push(row);
|
||||
buckets.set(row.variant, list);
|
||||
}
|
||||
const groups: BlockedInboxGroup[] = [];
|
||||
for (const variant of BLOCKED_REASON_VARIANT_ORDER) {
|
||||
const list = buckets.get(variant);
|
||||
if (!list || list.length === 0) continue;
|
||||
const sorted = sortBlockedInboxRows(list, sort);
|
||||
groups.push({ variant, label: BLOCKED_VARIANT_LABELS[variant], rows: sorted });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function blockedRowMatchesSearch(row: BlockedInboxIssueRow, query: string): boolean {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
const haystack = [
|
||||
row.issue.title,
|
||||
row.issue.identifier ?? "",
|
||||
row.attention.owner.label ?? "",
|
||||
row.attention.action.label,
|
||||
row.attention.action.detail ?? "",
|
||||
row.reasonLabel,
|
||||
row.attention.leafIssue?.identifier ?? "",
|
||||
row.attention.leafIssue?.title ?? "",
|
||||
row.attention.recoveryIssue?.identifier ?? "",
|
||||
row.attention.recoveryIssue?.title ?? "",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
}
|
||||
|
||||
export function blockedBadgeTone(rows: readonly BlockedInboxIssueRow[]): BlockedInboxBadgeTone {
|
||||
if (rows.length === 0) return "muted";
|
||||
let highest: IssueBlockedInboxSeverity = "low";
|
||||
for (const row of rows) {
|
||||
if (blockedSeverityRank(row.attention.severity) < blockedSeverityRank(highest)) {
|
||||
highest = row.attention.severity;
|
||||
}
|
||||
}
|
||||
if (highest === "critical") return "red";
|
||||
if (highest === "high") return "amber";
|
||||
return "muted";
|
||||
}
|
||||
|
||||
export function formatStoppedAge(stoppedSinceAt: string | null, now: number = Date.now()): string {
|
||||
if (!stoppedSinceAt) return "stopped";
|
||||
const then = new Date(stoppedSinceAt).getTime();
|
||||
if (!Number.isFinite(then)) return "stopped";
|
||||
const seconds = Math.max(0, Math.round((now - then) / 1000));
|
||||
if (seconds < 60) return "stopped just now";
|
||||
if (seconds < 3600) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
return `stopped ${m}m`;
|
||||
}
|
||||
if (seconds < 86_400) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
return `stopped ${h}h`;
|
||||
}
|
||||
if (seconds < 86_400 * 7) {
|
||||
const d = Math.floor(seconds / 86_400);
|
||||
return `stopped ${d}d`;
|
||||
}
|
||||
if (seconds < 86_400 * 30) {
|
||||
const w = Math.floor(seconds / (86_400 * 7));
|
||||
return `stopped ${w}w`;
|
||||
}
|
||||
const mo = Math.floor(seconds / (86_400 * 30));
|
||||
return `stopped ${mo}mo`;
|
||||
}
|
||||
@@ -1002,6 +1002,12 @@ describe("inbox helpers", () => {
|
||||
expect(loadLastInboxTab()).toBe("all");
|
||||
});
|
||||
|
||||
it("persists the blocked inbox tab", () => {
|
||||
localStorage.clear();
|
||||
saveLastInboxTab("blocked");
|
||||
expect(loadLastInboxTab()).toBe("blocked");
|
||||
});
|
||||
|
||||
it("persists inbox filters per company", () => {
|
||||
saveInboxFilterPreferences("company-1", {
|
||||
allCategoryFilter: "approvals",
|
||||
|
||||
+8
-2
@@ -25,7 +25,7 @@ export const INBOX_NESTING_KEY = "paperclip:inbox:nesting";
|
||||
export const INBOX_GROUP_BY_KEY = "paperclip:inbox:group-by";
|
||||
export const INBOX_FILTER_PREFERENCES_KEY_PREFIX = "paperclip:inbox:filters";
|
||||
export const INBOX_COLLAPSED_GROUPS_KEY_PREFIX = "paperclip:inbox:collapsed-groups";
|
||||
export type InboxTab = "mine" | "recent" | "unread" | "all";
|
||||
export type InboxTab = "mine" | "recent" | "unread" | "blocked" | "all";
|
||||
export type InboxCategoryFilter =
|
||||
| "everything"
|
||||
| "issues_i_touched"
|
||||
@@ -630,7 +630,13 @@ export function resolveInboxNestingEnabled(preferenceEnabled: boolean, isMobile:
|
||||
export function loadLastInboxTab(): InboxTab {
|
||||
try {
|
||||
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
|
||||
if (raw === "all" || raw === "unread" || raw === "recent" || raw === "mine") return raw;
|
||||
if (
|
||||
raw === "all"
|
||||
|| raw === "unread"
|
||||
|| raw === "recent"
|
||||
|| raw === "mine"
|
||||
|| raw === "blocked"
|
||||
) return raw;
|
||||
if (raw === "new") return "mine";
|
||||
return "mine";
|
||||
} catch {
|
||||
|
||||
@@ -324,6 +324,72 @@ describe("buildIssueChatMessages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers derived agent attribution when a board-authored comment is proven to come from a run", () => {
|
||||
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "Claude")]]);
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: [
|
||||
createComment({
|
||||
authorUserId: "user-1",
|
||||
derivedAuthorAgentId: "agent-1",
|
||||
derivedCreatedByRunId: "run-1",
|
||||
}),
|
||||
],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [],
|
||||
liveRuns: [],
|
||||
agentMap,
|
||||
currentUserId: "user-1",
|
||||
userLabelMap: new Map([["user-1", "Dotta"]]),
|
||||
});
|
||||
|
||||
expect(messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
metadata: {
|
||||
custom: {
|
||||
authorName: "Claude",
|
||||
authorType: "agent",
|
||||
authorAgentId: "agent-1",
|
||||
authorUserId: "user-1",
|
||||
runId: "run-1",
|
||||
runAgentId: "agent-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a comment as agent-authored when runAgentId is set from activity log", () => {
|
||||
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "Claude")]]);
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: [
|
||||
createComment({
|
||||
authorUserId: "user-1",
|
||||
runId: "run-1",
|
||||
runAgentId: "agent-1",
|
||||
}),
|
||||
],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [],
|
||||
liveRuns: [],
|
||||
agentMap,
|
||||
currentUserId: "user-1",
|
||||
userLabelMap: new Map([["user-1", "Dotta"]]),
|
||||
});
|
||||
|
||||
expect(messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
metadata: {
|
||||
custom: {
|
||||
authorName: "Claude",
|
||||
authorType: "agent",
|
||||
authorAgentId: "agent-1",
|
||||
authorUserId: "user-1",
|
||||
runId: "run-1",
|
||||
runAgentId: "agent-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("orders events before comments and appends active live runs as running assistant messages", () => {
|
||||
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "CodexCoder")]]);
|
||||
const comments = [
|
||||
|
||||
@@ -337,14 +337,32 @@ function createAssistantMetadata(custom: Record<string, unknown>) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
function effectiveCommentAuthorAgentId(comment: IssueChatComment) {
|
||||
return comment.authorAgentId ?? comment.runAgentId ?? comment.derivedAuthorAgentId ?? null;
|
||||
}
|
||||
|
||||
function effectiveCommentRunId(comment: IssueChatComment) {
|
||||
return comment.runId ?? comment.derivedCreatedByRunId ?? null;
|
||||
}
|
||||
|
||||
function effectiveCommentRunAgentId(comment: IssueChatComment) {
|
||||
return comment.runAgentId ?? effectiveCommentAuthorAgentId(comment);
|
||||
}
|
||||
|
||||
function effectiveCommentAuthorType(comment: IssueChatComment) {
|
||||
return effectiveCommentAuthorAgentId(comment) ? "agent" : comment.authorType;
|
||||
}
|
||||
|
||||
function authorNameForComment(
|
||||
comment: IssueChatComment,
|
||||
agentMap?: Map<string, Agent>,
|
||||
currentUserId?: string | null,
|
||||
userLabelMap?: ReadonlyMap<string, string> | null,
|
||||
options?: { isSystemNotice?: boolean },
|
||||
) {
|
||||
if (comment.authorAgentId) {
|
||||
return agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8);
|
||||
const authorAgentId = effectiveCommentAuthorAgentId(comment);
|
||||
if (authorAgentId) {
|
||||
return agentMap?.get(authorAgentId)?.name ?? (options?.isSystemNotice ? "Paperclip" : authorAgentId.slice(0, 8));
|
||||
}
|
||||
const authorUserId = comment.authorUserId ?? null;
|
||||
if (!authorUserId) return "You";
|
||||
@@ -367,20 +385,21 @@ function createCommentMessage(args: {
|
||||
}): ThreadMessage {
|
||||
const { comment, agentMap, currentUserId, userLabelMap, companyId, projectId } = args;
|
||||
const createdAt = toDate(comment.createdAt);
|
||||
const authorName = authorNameForComment(comment, agentMap, currentUserId, userLabelMap);
|
||||
const isSystemNotice = comment.authorType === "system";
|
||||
const authorAgentId = effectiveCommentAuthorAgentId(comment);
|
||||
const authorName = authorNameForComment(comment, agentMap, currentUserId, userLabelMap, { isSystemNotice });
|
||||
const custom = {
|
||||
kind: isSystemNotice ? "system_notice" : "comment",
|
||||
commentId: comment.id,
|
||||
anchorId: `comment-${comment.id}`,
|
||||
authorName,
|
||||
authorType: comment.authorType,
|
||||
authorAgentId: comment.authorAgentId,
|
||||
authorType: effectiveCommentAuthorType(comment),
|
||||
authorAgentId,
|
||||
authorUserId: comment.authorUserId,
|
||||
companyId: companyId ?? comment.companyId,
|
||||
projectId: projectId ?? null,
|
||||
runId: comment.runId ?? null,
|
||||
runAgentId: comment.runAgentId ?? null,
|
||||
runId: effectiveCommentRunId(comment),
|
||||
runAgentId: effectiveCommentRunAgentId(comment),
|
||||
clientStatus: comment.clientStatus ?? null,
|
||||
queueState: comment.queueState ?? null,
|
||||
queueTargetRunId: comment.queueTargetRunId ?? null,
|
||||
@@ -402,7 +421,7 @@ function createCommentMessage(args: {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (comment.authorAgentId) {
|
||||
if (authorAgentId) {
|
||||
const message: ThreadAssistantMessage = {
|
||||
id: comment.id,
|
||||
role: "assistant",
|
||||
|
||||
@@ -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> = {},
|
||||
): 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, Issue>();
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ export const queryKeys = {
|
||||
listMineByMe: (companyId: string) => ["issues", companyId, "mine-by-me"] as const,
|
||||
listTouchedByMe: (companyId: string) => ["issues", companyId, "touched-by-me"] as const,
|
||||
listUnreadTouchedByMe: (companyId: string) => ["issues", companyId, "unread-touched-by-me"] as const,
|
||||
listBlockedAttention: (companyId: string) => ["issues", companyId, "blocked-attention"] as const,
|
||||
countBlockedAttention: (companyId: string) => ["issues", companyId, "blocked-attention", "count"] as const,
|
||||
labels: (companyId: string) => ["issues", companyId, "labels"] as const,
|
||||
listByProject: (companyId: string, projectId: string) =>
|
||||
["issues", companyId, "project", projectId] as const,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import { Eye, OctagonAlert, RefreshCw, TriangleAlert } from "lucide-react";
|
||||
|
||||
export type RecoveryDisplayState =
|
||||
| "needed"
|
||||
| "in_progress"
|
||||
| "observe_only"
|
||||
| "escalated"
|
||||
| "resolved";
|
||||
|
||||
export type ActiveRecoveryDisplayState = Exclude<RecoveryDisplayState, "resolved">;
|
||||
|
||||
export const RECOVERY_CHIP_DEFAULT_TONE: Record<
|
||||
ActiveRecoveryDisplayState,
|
||||
{ className: string; icon: typeof TriangleAlert; label: string }
|
||||
> = {
|
||||
needed: {
|
||||
className:
|
||||
"border-amber-500/60 bg-amber-500/15 text-amber-700 dark:text-amber-300",
|
||||
icon: TriangleAlert,
|
||||
label: "Recovery needed",
|
||||
},
|
||||
in_progress: {
|
||||
className:
|
||||
"border-sky-500/60 bg-sky-500/15 text-sky-700 dark:text-sky-300",
|
||||
icon: RefreshCw,
|
||||
label: "Recovery in progress",
|
||||
},
|
||||
observe_only: {
|
||||
className: "border-border bg-muted text-muted-foreground",
|
||||
icon: Eye,
|
||||
label: "Observing active run",
|
||||
},
|
||||
escalated: {
|
||||
className: "border-red-500/60 bg-red-500/15 text-red-700 dark:text-red-300",
|
||||
icon: OctagonAlert,
|
||||
label: "Recovery escalated",
|
||||
},
|
||||
};
|
||||
|
||||
export function deriveRecoveryDisplayState(
|
||||
action: Pick<IssueRecoveryAction, "status" | "kind" | "outcome">,
|
||||
): RecoveryDisplayState {
|
||||
if (action.status === "resolved") return "resolved";
|
||||
if (action.status === "escalated") return "escalated";
|
||||
if (action.status === "cancelled") return "resolved";
|
||||
if (action.kind === "active_run_watchdog") return "observe_only";
|
||||
if (action.outcome === "delegated") return "in_progress";
|
||||
return "needed";
|
||||
}
|
||||
|
||||
export function deriveActiveRecoveryDisplayState(
|
||||
action: Pick<IssueRecoveryAction, "status" | "kind" | "outcome">,
|
||||
): ActiveRecoveryDisplayState | null {
|
||||
const state = deriveRecoveryDisplayState(action);
|
||||
return state === "resolved" ? null : state;
|
||||
}
|
||||
+12
-1
@@ -46,10 +46,19 @@ export * from "react-router-dom";
|
||||
type CompanyLinkProps = React.ComponentProps<typeof RouterDom.Link> & {
|
||||
disableIssueQuicklook?: boolean;
|
||||
issuePrefetch?: Issue | null;
|
||||
issueQuicklookSide?: React.ComponentProps<typeof IssueLinkQuicklook>["issueQuicklookSide"];
|
||||
issueQuicklookAlign?: React.ComponentProps<typeof IssueLinkQuicklook>["issueQuicklookAlign"];
|
||||
};
|
||||
|
||||
export const Link = React.forwardRef<HTMLAnchorElement, CompanyLinkProps>(
|
||||
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<HTMLAnchorElement, CompanyLinkProps>(
|
||||
issuePathId={issuePathId}
|
||||
disableIssueQuicklook={disableIssueQuicklook}
|
||||
issuePrefetch={issuePrefetch}
|
||||
issueQuicklookSide={issueQuicklookSide}
|
||||
issueQuicklookAlign={issueQuicklookAlign}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
+185
-2
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user