Remove decision and review summaries from issue headers (#10891)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Operators use issue pages to read task state and control task work > - The issue header showed separate summaries for open decisions and review paths > - These summaries repeated state that belongs in the Decisions view > - The extra sections added noise before the issue description and thread > - This pull request removes both header summaries and keeps decision actions in the Decisions view > - The benefit is a simpler issue header with one place for decision work ## Linked Issues or Issue Description **What existing behavior does this improve?** The issue detail header shows separate pending-decision and review-path sections. **Subsystem affected** `ui/` — React and Vite board UI. **Current behavior** An issue header can show a decision strip and a larger review panel before the issue content. **Proposed behavior** The issue header does not show either decision section. Operators continue to manage decisions and stalled reviews in the Decisions view. **Reason and benefit** This removes duplicate decision state from the issue header and reduces visual noise. **Breaking changes** The issue page no longer provides these summaries or shortcuts. Decision data, review state, and the Decisions view do not change. ## What Changed - Removed the pending-decision strip and review-path panel from the issue detail header. - Deleted the two unused header components and the panel-specific test. - Kept stalled-review actions and their Storybook examples in the Decisions queue. - Added an issue-detail regression test that covers both removed sections. ## Verification - `pnpm --filter @paperclipai/ui exec vitest run src/pages/IssueDetail.test.tsx` (46 tests passed) - `pnpm --filter @paperclipai/ui typecheck` - `pnpm check:token-gates` - `pnpm build-storybook` - `git diff --check` ## Risks - Low risk. This change removes two issue-header surfaces. It does not change decision APIs or data. - Users must open the Decisions view to find pending decisions and stalled-review actions. > This change does not duplicate planned core work in `ROADMAP.md`. GitHub searches found no related open issue or pull request. ## Model Used - OpenAI Codex, GPT-5. The exact deployment ID and context window are not exposed. Tool use and code execution were enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
6ffe9df842
commit
14d755824c
|
|
@ -1,183 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { flushSync } from "react-dom";
|
||||
import type { ReactElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { IssueReviewAttention } from "@paperclipai/shared";
|
||||
import { IssueReviewPanel, type ReviewPanelIssue } from "./IssueReviewPanel";
|
||||
import { ToastProvider } from "../context/ToastContext";
|
||||
import { ToastViewport } from "./ToastViewport";
|
||||
|
||||
const decideStalledReviewMock = vi.hoisted(() => vi.fn(() => Promise.resolve({})));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: {
|
||||
decideStalledReview: decideStalledReviewMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function act<T>(callback: () => T): T {
|
||||
let result: T | undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
return result as T;
|
||||
}
|
||||
|
||||
/** react-query dispatches the mutationFn on a microtask — let it settle. */
|
||||
async function flushMicrotasks() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root?.unmount());
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function render(element: ReactElement) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
act(() =>
|
||||
root?.render(
|
||||
<ToastProvider>
|
||||
<QueryClientProvider client={client}>
|
||||
{element}
|
||||
<ToastViewport />
|
||||
</QueryClientProvider>
|
||||
</ToastProvider>,
|
||||
),
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
function buildIssue(reviewAttention: IssueReviewAttention | undefined, status: ReviewPanelIssue["status"] = "in_review"): ReviewPanelIssue {
|
||||
return { id: "issue-1", companyId: "c1", status, reviewAttention };
|
||||
}
|
||||
|
||||
const coveredAttention: IssueReviewAttention = {
|
||||
state: "covered",
|
||||
reason: "Review has a maintained action path.",
|
||||
paths: [
|
||||
{
|
||||
kind: "interaction",
|
||||
label: "Pending request confirmation",
|
||||
responder: "Board",
|
||||
since: "2026-08-02T00:00:00.000Z",
|
||||
ref: "interaction-1",
|
||||
},
|
||||
{
|
||||
kind: "human_reviewer",
|
||||
label: "Human reviewer",
|
||||
responder: "Dotta",
|
||||
since: "2026-08-02T00:00:00.000Z",
|
||||
ref: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const stalledAttention: IssueReviewAttention = {
|
||||
state: "stalled",
|
||||
reason: "Issue is in review without a maintained action path.",
|
||||
paths: [],
|
||||
};
|
||||
|
||||
describe("IssueReviewPanel", () => {
|
||||
it("renders nothing when the issue is not in review", () => {
|
||||
const el = render(<IssueReviewPanel issue={buildIssue(coveredAttention, "in_progress")} />);
|
||||
expect(el.querySelector('[data-testid="issue-review-panel"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when reviewAttention is absent (older payloads)", () => {
|
||||
const el = render(<IssueReviewPanel issue={buildIssue(undefined)} />);
|
||||
expect(el.querySelector('[data-testid="issue-review-panel"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("covered: names each maintained path with its responder and outcome hint", () => {
|
||||
const el = render(<IssueReviewPanel issue={buildIssue(coveredAttention)} />);
|
||||
const panel = el.querySelector('[data-testid="issue-review-panel"]');
|
||||
expect(panel?.getAttribute("data-review-state")).toBe("covered");
|
||||
expect(panel?.textContent).toContain("In review");
|
||||
expect(panel?.textContent).toContain("Pending request confirmation");
|
||||
expect(panel?.textContent).toContain("Board");
|
||||
expect(panel?.textContent).toContain("Human reviewer");
|
||||
expect(panel?.textContent).toContain("Dotta");
|
||||
// The outcome hint tells the operator what each verb does.
|
||||
expect(panel?.textContent).toContain("Approving marks this issue done");
|
||||
// Covered reviews do not expose the escape actions.
|
||||
expect(panel?.textContent).not.toContain("Send back to work");
|
||||
});
|
||||
|
||||
it("stalled: shows the amber notice and the three review actions", () => {
|
||||
const el = render(<IssueReviewPanel issue={buildIssue(stalledAttention)} />);
|
||||
const panel = el.querySelector('[data-testid="issue-review-panel"]');
|
||||
expect(panel?.getAttribute("data-review-state")).toBe("stalled");
|
||||
expect(panel?.textContent).toContain("Nobody is reviewing this");
|
||||
expect(panel?.textContent).toContain("Approve");
|
||||
expect(panel?.textContent).toContain("Request changes");
|
||||
expect(panel?.textContent).toContain("Send back to work");
|
||||
});
|
||||
|
||||
it("stalled: request-changes is disabled until a note is entered", () => {
|
||||
const el = render(<IssueReviewPanel issue={buildIssue(stalledAttention)} />);
|
||||
const requestChanges = el.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="stalled-review-request-changes"]',
|
||||
);
|
||||
expect(requestChanges?.disabled).toBe(true);
|
||||
|
||||
const note = el.querySelector<HTMLTextAreaElement>('[data-testid="stalled-review-note"]');
|
||||
act(() => {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)!.set!;
|
||||
setter.call(note, "Please fix the failing test");
|
||||
note!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
expect(requestChanges?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("stalled: approving posts approve to the decision endpoint", async () => {
|
||||
const el = render(<IssueReviewPanel issue={buildIssue(stalledAttention)} />);
|
||||
const approve = el.querySelector<HTMLButtonElement>('[data-testid="stalled-review-approve"]');
|
||||
act(() => approve!.click());
|
||||
await flushMicrotasks();
|
||||
expect(decideStalledReviewMock).toHaveBeenCalledWith("issue-1", {
|
||||
action: "approve",
|
||||
note: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("stalled: send-back forwards the typed note", async () => {
|
||||
const el = render(<IssueReviewPanel issue={buildIssue(stalledAttention)} />);
|
||||
const note = el.querySelector<HTMLTextAreaElement>('[data-testid="stalled-review-note"]');
|
||||
act(() => {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)!.set!;
|
||||
setter.call(note, "Back to you");
|
||||
note!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
const sendBack = el.querySelector<HTMLButtonElement>('[data-testid="stalled-review-send-back"]');
|
||||
act(() => sendBack!.click());
|
||||
await flushMicrotasks();
|
||||
expect(decideStalledReviewMock).toHaveBeenCalledWith("issue-1", {
|
||||
action: "send_back",
|
||||
note: "Back to you",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
import type { IssueReviewAttention, IssueReviewAttentionPath, IssueStatus } from "@paperclipai/shared";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
Clock,
|
||||
HelpCircle,
|
||||
LifeBuoy,
|
||||
ShieldCheck,
|
||||
UserCheck,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn, relativeTime } from "../lib/utils";
|
||||
import { StatusGlyph } from "./StatusGlyph";
|
||||
import { StalledReviewActions } from "./StalledReviewActions";
|
||||
|
||||
/** Minimal shape the panel needs — the full `Issue` satisfies it. */
|
||||
export interface ReviewPanelIssue {
|
||||
id: string;
|
||||
companyId: string;
|
||||
status: IssueStatus;
|
||||
reviewAttention?: IssueReviewAttention;
|
||||
}
|
||||
|
||||
const PATH_ICON: Record<IssueReviewAttentionPath["kind"], LucideIcon> = {
|
||||
execution_participant: Users,
|
||||
interaction: HelpCircle,
|
||||
approval: ShieldCheck,
|
||||
monitor: Clock,
|
||||
human_reviewer: UserCheck,
|
||||
active_run: Activity,
|
||||
queued_wake: Bell,
|
||||
recovery: LifeBuoy,
|
||||
};
|
||||
|
||||
/**
|
||||
* Persistent review panel pinned above the thread whenever an issue is
|
||||
* `in_review` (PAP-16080 §4.4). Driven by `issue.reviewAttention` (P2):
|
||||
*
|
||||
* - **covered** — names WHAT is being reviewed (each maintained path), WHO
|
||||
* decides it, and since when, plus what each outcome does. Keeps a stalled
|
||||
* review from ever being the *only* thing an operator sees, and surfaces the
|
||||
* responder so a covered review reads as "someone has this".
|
||||
* - **stalled** — the amber "nobody is reviewing this" notice with the three
|
||||
* escape actions (approve / request changes / send back), so an agent-owned
|
||||
* review can never become an invisible zombie (the PAP-14994 failure).
|
||||
*
|
||||
* Renders nothing when the issue is not in review, or when `reviewAttention` is
|
||||
* absent (older payloads) — the thread simply shows as it does today.
|
||||
*/
|
||||
export function IssueReviewPanel({ issue }: { issue: ReviewPanelIssue }) {
|
||||
const reviewAttention = issue.reviewAttention;
|
||||
if (issue.status !== "in_review" || !reviewAttention) return null;
|
||||
|
||||
if (reviewAttention.state === "stalled") {
|
||||
return <StalledReviewPanel issue={issue} reason={reviewAttention.reason} />;
|
||||
}
|
||||
if (reviewAttention.state === "covered") {
|
||||
return <CoveredReviewPanel reviewAttention={reviewAttention} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function CoveredReviewPanel({ reviewAttention }: { reviewAttention: IssueReviewAttention }) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-3 rounded-xl border border-violet-500/30 bg-violet-500/5 px-4 py-3"
|
||||
data-testid="issue-review-panel"
|
||||
data-review-state="covered"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<StatusGlyph status="in_review" size="md" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-violet-950 dark:text-violet-100">In review</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{reviewAttention.reason ?? "This issue has a maintained review path."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-1.5" data-testid="issue-review-paths">
|
||||
{reviewAttention.paths.map((path, index) => (
|
||||
<ReviewPathRow key={`${path.kind}-${path.ref ?? index}`} path={path} />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="text-(length:--text-nano) text-muted-foreground">
|
||||
Approving marks this issue done. Requesting changes or sending it back returns it to the
|
||||
assignee.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewPathRow({ path }: { path: IssueReviewAttentionPath }) {
|
||||
const Icon = PATH_ICON[path.kind] ?? Activity;
|
||||
return (
|
||||
<li className="flex items-center gap-2 text-xs" data-review-path-kind={path.kind}>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0 text-violet-600 dark:text-violet-400" aria-hidden />
|
||||
<span className="font-medium text-foreground">{path.label}</span>
|
||||
{path.responder && (
|
||||
<>
|
||||
<PathDot />
|
||||
<span className="text-muted-foreground">{path.responder}</span>
|
||||
</>
|
||||
)}
|
||||
{path.since && (
|
||||
<>
|
||||
<PathDot />
|
||||
<span className="text-muted-foreground" title={new Date(path.since).toLocaleString()}>
|
||||
{relativeTime(path.since)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function PathDot() {
|
||||
return (
|
||||
<span className="text-muted-foreground/60" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StalledReviewPanel({
|
||||
issue,
|
||||
reason,
|
||||
}: {
|
||||
issue: ReviewPanelIssue;
|
||||
reason: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 rounded-xl border border-amber-400/60 bg-amber-50/70 px-4 py-3",
|
||||
"dark:border-amber-500/40 dark:bg-amber-500/10",
|
||||
)}
|
||||
data-testid="issue-review-panel"
|
||||
data-review-state="stalled"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-amber-950 dark:text-amber-100">
|
||||
Nobody is reviewing this
|
||||
</p>
|
||||
<p className="text-xs text-amber-900/80 dark:text-amber-100/80">
|
||||
{reason
|
||||
?? "No reviewer, interaction, approval, or monitor exists — the review has no owner."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StalledReviewActions issueId={issue.id} companyId={issue.companyId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Scale } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { decisionsApi } from "../api/decisions";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
/**
|
||||
* Information-scent breadcrumb on a target issue: when a decision proposed
|
||||
* *elsewhere* targets this issue (via `decision_target_issues`), surface it so
|
||||
* the pending decision isn't lost in another thread. It never actuates inline —
|
||||
* decisions are always decided from the one Decisions inbox (PAP-14966 §3).
|
||||
*/
|
||||
export function PendingDecisionStrip({ companyId, issueId }: { companyId: string; issueId: string }) {
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.decisions.forTargetIssue(companyId, issueId),
|
||||
queryFn: () => decisionsApi.list(companyId, { targetIssueId: issueId, status: "open" }),
|
||||
enabled: !!companyId && !!issueId,
|
||||
});
|
||||
|
||||
const count = data?.length ?? 0;
|
||||
if (count === 0) return null;
|
||||
|
||||
// Deep-link to the single decision when there's just one; otherwise the inbox.
|
||||
const to = count === 1 ? `/decisions?decisionId=${data![0]!.id}` : "/decisions";
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="flex items-center gap-2 rounded-lg border-l-2 border-violet-500/60 bg-violet-500/5 px-3 py-2 text-sm text-violet-900 transition-colors hover:bg-violet-500/10 dark:text-violet-100"
|
||||
>
|
||||
<Scale className="h-4 w-4 shrink-0 text-violet-600 dark:text-violet-400" aria-hidden />
|
||||
<span className="font-medium">
|
||||
{count === 1 ? "1 pending decision affects this issue" : `${count} pending decisions affect this issue`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Review in Decisions →</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -69,6 +69,10 @@ const mockProjectsApi = vi.hoisted(() => ({
|
|||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockDecisionsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getGeneral: vi.fn(),
|
||||
getExperimental: vi.fn(),
|
||||
|
|
@ -134,6 +138,10 @@ vi.mock("../api/projects", () => ({
|
|||
projectsApi: mockProjectsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/decisions", () => ({
|
||||
decisionsApi: mockDecisionsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
|
@ -1010,6 +1018,7 @@ describe("IssueDetail", () => {
|
|||
mockAccessApi.listUserDirectory.mockResolvedValue({ users: [] });
|
||||
mockAuthApi.getSession.mockResolvedValue({ session: null, user: null });
|
||||
mockProjectsApi.list.mockResolvedValue([]);
|
||||
mockDecisionsApi.list.mockResolvedValue([]);
|
||||
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
|
||||
keyboardShortcuts: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
|
|
@ -1066,6 +1075,39 @@ describe("IssueDetail", () => {
|
|||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not load or render decision sections in the issue header", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({
|
||||
status: "in_review",
|
||||
reviewAttention: {
|
||||
state: "covered",
|
||||
reason: "Review has a maintained action path.",
|
||||
paths: [
|
||||
{
|
||||
kind: "interaction",
|
||||
label: "Pending request confirmation",
|
||||
responder: "Board",
|
||||
since: "2026-04-21T00:00:00.000Z",
|
||||
ref: "interaction-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Issue detail smoke");
|
||||
expect(container.querySelector('[data-testid="issue-review-panel"]')).toBeNull();
|
||||
expect(mockDecisionsApi.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates status and priority from the task header controls", async () => {
|
||||
const issue = createIssue({ status: "todo", priority: "medium" });
|
||||
mockIssuesApi.get.mockResolvedValue(issue);
|
||||
|
|
|
|||
|
|
@ -122,8 +122,6 @@ import {
|
|||
} from "../components/IssueMonitorBanner";
|
||||
import { IssueScheduledRetryCard } from "../components/IssueScheduledRetryCard";
|
||||
import { IssueProperties } from "../components/IssueProperties";
|
||||
import { PendingDecisionStrip } from "../components/PendingDecisionStrip";
|
||||
import { IssueReviewPanel } from "../components/IssueReviewPanel";
|
||||
import { PauseAffectsSummaryView } from "../components/interrupt-handoff/InterruptHandoffViews";
|
||||
import { computePauseAffectsSummary } from "../lib/interrupt-handoff";
|
||||
import { useIssueExternalObjects } from "../hooks/useIssueExternalObjects";
|
||||
|
|
@ -4527,10 +4525,6 @@ export function IssueDetail() {
|
|||
checkingNow={checkIssueMonitorNow.isPending}
|
||||
/>
|
||||
|
||||
<PendingDecisionStrip companyId={issue.companyId} issueId={issue.id} />
|
||||
|
||||
<IssueReviewPanel issue={issue} />
|
||||
|
||||
{taskChatShellEnabled ? null : (
|
||||
<InlineEditor
|
||||
value={issue.description ?? ""}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,10 @@
|
|||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { AttentionItem, IssueReviewAttention, IssueReviewAttentionPath } from "@paperclipai/shared";
|
||||
import { IssueReviewPanel, type ReviewPanelIssue } from "@/components/IssueReviewPanel";
|
||||
import type { AttentionItem } from "@paperclipai/shared";
|
||||
import { AttentionQueueRow } from "@/components/AttentionQueueRow";
|
||||
|
||||
// PAP-16080 §4.4 — the review panel that pins above an `in_review` thread and
|
||||
// the same three verbs inline on the /decisions card. These stories are the
|
||||
// design/QA reference for the covered, stalled, and post-action states in both
|
||||
// light and dark themes.
|
||||
|
||||
function path(overrides: Partial<IssueReviewAttentionPath> & Pick<IssueReviewAttentionPath, "kind" | "label">): IssueReviewAttentionPath {
|
||||
return {
|
||||
responder: null,
|
||||
since: "2026-08-02T00:30:00.000Z",
|
||||
ref: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function issue(reviewAttention: IssueReviewAttention): ReviewPanelIssue {
|
||||
return { id: "issue-1", companyId: "company-1", status: "in_review", reviewAttention };
|
||||
}
|
||||
// Stalled review actions remain available where decisions are handled. The
|
||||
// issue header no longer duplicates decision or review-path summaries.
|
||||
|
||||
function Frame({
|
||||
label,
|
||||
|
|
@ -29,7 +13,7 @@ function Frame({
|
|||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
/** Content width in px — narrow (390) exercises the stalled action row's base stacked layout. */
|
||||
/** Content width in px — narrow (390) exercises the action row's stacked layout. */
|
||||
width?: number;
|
||||
}) {
|
||||
return (
|
||||
|
|
@ -40,101 +24,6 @@ function Frame({
|
|||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Product/Issue/Review panel",
|
||||
component: IssueReviewPanel,
|
||||
args: { issue: issue({ state: "none", paths: [], reason: null }) },
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Pinned above the thread whenever an issue is `in_review`. Covered names each maintained review path (what/who/since) and what each outcome does; stalled shows the amber \"nobody is reviewing this\" notice with approve / request-changes / send-back. The same three verbs actuate inline on the /decisions card when a review goes stalled.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof IssueReviewPanel>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const CoveredSinglePath: Story = {
|
||||
name: "Covered · one maintained path",
|
||||
render: () => (
|
||||
<Frame label="In review · a pending confirmation is waiting on the board">
|
||||
<IssueReviewPanel
|
||||
issue={issue({
|
||||
state: "covered",
|
||||
reason: "Review has a maintained action path.",
|
||||
paths: [
|
||||
path({
|
||||
kind: "interaction",
|
||||
label: "Pending request confirmation",
|
||||
responder: "Board",
|
||||
ref: "interaction-1",
|
||||
}),
|
||||
],
|
||||
})}
|
||||
/>
|
||||
</Frame>
|
||||
),
|
||||
};
|
||||
|
||||
export const CoveredMultiplePaths: Story = {
|
||||
name: "Covered · reviewer + monitor",
|
||||
render: () => (
|
||||
<Frame label="In review · a named human reviewer plus a scheduled monitor">
|
||||
<IssueReviewPanel
|
||||
issue={issue({
|
||||
state: "covered",
|
||||
reason: "Review has 2 maintained action paths.",
|
||||
paths: [
|
||||
path({ kind: "human_reviewer", label: "Human reviewer", responder: "Dotta" }),
|
||||
path({
|
||||
kind: "monitor",
|
||||
label: "Scheduled review monitor",
|
||||
responder: null,
|
||||
since: "2026-08-02T00:10:00.000Z",
|
||||
}),
|
||||
],
|
||||
})}
|
||||
/>
|
||||
</Frame>
|
||||
),
|
||||
};
|
||||
|
||||
export const Stalled: Story = {
|
||||
name: "Stalled · nobody is reviewing this",
|
||||
render: () => (
|
||||
<Frame label="In review · no reviewer, interaction, approval, or monitor exists">
|
||||
<IssueReviewPanel
|
||||
issue={issue({
|
||||
state: "stalled",
|
||||
paths: [],
|
||||
reason:
|
||||
"Issue is in review without a maintained reviewer, interaction, approval, monitor, run, wake, or recovery path.",
|
||||
})}
|
||||
/>
|
||||
</Frame>
|
||||
),
|
||||
};
|
||||
|
||||
export const StalledNarrow: Story = {
|
||||
name: "Stalled · 390px phone (action row stacks)",
|
||||
render: () => (
|
||||
<Frame width={390} label="In review · phone width — the three verbs stack, never overlap">
|
||||
<IssueReviewPanel
|
||||
issue={issue({
|
||||
state: "stalled",
|
||||
paths: [],
|
||||
reason:
|
||||
"Issue is in review without a maintained reviewer, interaction, approval, monitor, run, wake, or recovery path.",
|
||||
})}
|
||||
/>
|
||||
</Frame>
|
||||
),
|
||||
};
|
||||
|
||||
const stalledReviewRow: AttentionItem = {
|
||||
id: "review:issue-1",
|
||||
companyId: "company-1",
|
||||
|
|
@ -152,8 +41,16 @@ const stalledReviewRow: AttentionItem = {
|
|||
whyNow:
|
||||
"Issue is in review without a maintained reviewer, interaction, approval, monitor, run, wake, or recovery path.",
|
||||
decisionVerbs: [
|
||||
{ id: "choose_review_path", label: "Choose review path", description: "Add a reviewer or waiting path, return the issue to work, or accept it." },
|
||||
{ id: "request_changes", label: "Request changes", description: "Return the issue to the assignee with changes requested." },
|
||||
{
|
||||
id: "choose_review_path",
|
||||
label: "Choose review path",
|
||||
description: "Add a reviewer or waiting path, return the issue to work, or accept it.",
|
||||
},
|
||||
{
|
||||
id: "request_changes",
|
||||
label: "Request changes",
|
||||
description: "Return the issue to the assignee with changes requested.",
|
||||
},
|
||||
],
|
||||
inlineResolvable: true,
|
||||
entryRule: "",
|
||||
|
|
@ -185,12 +82,36 @@ const stalledReviewRow: AttentionItem = {
|
|||
trainingExampleId: null,
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Product/Decisions/Stalled review actions",
|
||||
component: AttentionQueueRow,
|
||||
args: {
|
||||
item: stalledReviewRow,
|
||||
companyId: "company-1",
|
||||
expanded: true,
|
||||
onToggleExpand: () => {},
|
||||
onDismiss: () => {},
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Stalled reviews remain actionable in the Decisions queue after removing decision summaries from the issue header.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof AttentionQueueRow>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const DecisionsCardInline: Story = {
|
||||
name: "Decisions card · stalled review resolves in-row",
|
||||
name: "Stalled review resolves in-row",
|
||||
render: () => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
return (
|
||||
<Frame label="/decisions · a stalled review actuates the three verbs inline">
|
||||
<Frame label="Decisions · a stalled review actuates the three verbs inline">
|
||||
<AttentionQueueRow
|
||||
item={stalledReviewRow}
|
||||
companyId="company-1"
|
||||
|
|
@ -204,11 +125,11 @@ export const DecisionsCardInline: Story = {
|
|||
};
|
||||
|
||||
export const DecisionsCardInlineNarrow: Story = {
|
||||
name: "Decisions card · 390px phone (verbs stack in-row)",
|
||||
name: "390px phone (verbs stack in-row)",
|
||||
render: () => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
return (
|
||||
<Frame width={390} label="/decisions · phone width — the inline verbs stack, never overlap">
|
||||
<Frame width={390} label="Decisions · phone width — the inline verbs stack, never overlap">
|
||||
<AttentionQueueRow
|
||||
item={stalledReviewRow}
|
||||
companyId="company-1"
|
||||
|
|
|
|||
Loading…
Reference in New Issue