mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(board): materialize program items as Main-PM roots, make reports actionable (#711)
Two coupled gaps in the Board Program output path. Approved items were created unowned and in BACKLOG. Nothing dispatches BACKLOG, and once activated a cell PM claimed the parentless task as a root, where _cell_pm_complete resolves its merge target through resolve_parent_branch — which for a parentless task falls through to the project head rung. The result was a cell branch merging straight into the trunk, bypassing the Main-PM root, the root->master PR and the CEO gate (live: PRs #703 and #704 both targeted slave directly). All eight materializers now create a PENDING, main-pm-assigned root with team=Team.MAIN_PM, matching what approve_and_start does for an intake draft. The team is load-bearing, not cosmetic: _next_hint_pr_fail, _deliver_pr_fail_to_owner, delegate's wave-chain dispatch and the PR layer label all key on it, and a cell-teamed root drops the 'do NOT re-submit the root' steer that exists because of PR #138's infinite pr_fail loop. The item's own cell survives as a delegation hint in the description, which is what the Main PM's briefing renders. Periscope, Sentinel and Coroner produced artifacts with no way to act on them — three panel surfaces carried explicit 'no approve/reject UI' comments while each item already held a machine-readable suggested action. They now have per-item approve and dismiss, modelled on the roadmap queue: idempotent per item, CEO-gated, deep-copy-before-mutate so SQLAlchemy's dirty check still fires, and every decision recorded through record_decision so it reaches the next cycle's prompt. Approving materializes through the same corrected Main-PM-owned path. Target project resolves to each engine's own existing anchor — RoboCo's project for Periscope and Sentinel, the incident's project for Coroner — and fails with a clean invalid_state naming what is unresolvable rather than guessing at a repo. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -1,15 +1,26 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Postmortem } from "@/lib/api/coroner";
|
||||
|
||||
const { listPostmortems } = vi.hoisted(() => ({
|
||||
listPostmortems: vi.fn(async () => [] as Postmortem[]),
|
||||
}));
|
||||
const { listPostmortems, approveProcessChange, rejectProcessChange } =
|
||||
vi.hoisted(() => ({
|
||||
listPostmortems: vi.fn(async () => [] as Postmortem[]),
|
||||
approveProcessChange: vi.fn(async () => ({
|
||||
status: "approved",
|
||||
materialized_task_id: "task-1",
|
||||
detail: "materialized as a Main-PM-owned task",
|
||||
})),
|
||||
rejectProcessChange: vi.fn(async () => ({
|
||||
status: "rejected",
|
||||
detail: "dismissed; feeds the next cycle's prompt",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
coronerApi: { listPostmortems },
|
||||
coronerApi: { listPostmortems, approveProcessChange, rejectProcessChange },
|
||||
}));
|
||||
|
||||
import { CoronerPostmortemsCard } from "../coroner-postmortems-card";
|
||||
@@ -21,24 +32,32 @@ function withQueryClient(ui: ReactNode) {
|
||||
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
const SAMPLE_POSTMORTEM: Postmortem = {
|
||||
task_id: "pm-1",
|
||||
title: "Coroner postmortem",
|
||||
completed_at: "2026-07-24T10:00:00Z",
|
||||
incident_task_id: "abc12345-0000-0000-0000-000000000000",
|
||||
incident_kind: "bounced",
|
||||
incident_title: "Fix worktree venv rot",
|
||||
incident_summary: "The task bounced 3 times over a stale venv symptom.",
|
||||
root_cause: "The gate never verified the venv's dev extras were installed.",
|
||||
failed_stage: "awaiting_qa",
|
||||
process_change_kind: "conventions_rule",
|
||||
process_change_description: "Add a venv-freshness check to make quality.",
|
||||
playbook_id: null,
|
||||
};
|
||||
function buildPostmortem(overrides: Partial<Postmortem> = {}): Postmortem {
|
||||
return {
|
||||
task_id: "pm-1",
|
||||
title: "Coroner postmortem",
|
||||
completed_at: "2026-07-24T10:00:00Z",
|
||||
incident_task_id: "abc12345-0000-0000-0000-000000000000",
|
||||
incident_kind: "bounced",
|
||||
incident_title: "Fix worktree venv rot",
|
||||
incident_summary: "The task bounced 3 times over a stale venv symptom.",
|
||||
root_cause: "The gate never verified the venv's dev extras were installed.",
|
||||
failed_stage: "awaiting_qa",
|
||||
process_change_kind: "conventions_rule",
|
||||
process_change_description: "Add a venv-freshness check to make quality.",
|
||||
playbook_id: null,
|
||||
process_change_status: "proposed",
|
||||
process_change_reject_reason: null,
|
||||
process_change_materialized_task_id: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const SAMPLE_POSTMORTEM: Postmortem = buildPostmortem();
|
||||
|
||||
describe("CoronerPostmortemsCard", () => {
|
||||
beforeEach(() => {
|
||||
listPostmortems.mockClear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -61,15 +80,11 @@ describe("CoronerPostmortemsCard", () => {
|
||||
expect(screen.getByText("bounced 3+ times")).toBeInTheDocument();
|
||||
expect(screen.getByText("awaiting_qa")).toBeInTheDocument();
|
||||
expect(screen.getByText("conventions_rule")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/stale venv symptom/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/stale venv symptom/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/never verified the venv's dev extras/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/add a venv-freshness check/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/add a venv-freshness check/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an offline state and retries on error", async () => {
|
||||
@@ -82,4 +97,70 @@ describe("CoronerPostmortemsCard", () => {
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
// Defect 2 fix: a proposed process change now gets its own approve/dismiss
|
||||
// action.
|
||||
it("renders approve/dismiss controls for a proposed process change", async () => {
|
||||
listPostmortems.mockResolvedValueOnce([SAMPLE_POSTMORTEM]);
|
||||
render(withQueryClient(<CoronerPostmortemsCard />));
|
||||
|
||||
await screen.findByText("Fix worktree venv rot");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /approve/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /dismiss/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the actions once the process change already drafted a playbook", async () => {
|
||||
listPostmortems.mockResolvedValueOnce([
|
||||
buildPostmortem({
|
||||
process_change_kind: "playbook",
|
||||
process_change_status: "not_applicable",
|
||||
}),
|
||||
]);
|
||||
render(withQueryClient(<CoronerPostmortemsCard />));
|
||||
|
||||
await screen.findByText("Fix worktree venv rot");
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /approve/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Drafted as playbook")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("approves the process change via the API", async () => {
|
||||
const user = userEvent.setup();
|
||||
listPostmortems.mockResolvedValueOnce([SAMPLE_POSTMORTEM]);
|
||||
render(withQueryClient(<CoronerPostmortemsCard />));
|
||||
|
||||
await screen.findByText("Fix worktree venv rot");
|
||||
await user.click(screen.getByRole("button", { name: /approve/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(approveProcessChange).toHaveBeenCalledWith("pm-1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("dismisses the process change with a reason via the dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
listPostmortems.mockResolvedValueOnce([SAMPLE_POSTMORTEM]);
|
||||
render(withQueryClient(<CoronerPostmortemsCard />));
|
||||
|
||||
await screen.findByText("Fix worktree venv rot");
|
||||
await user.click(screen.getByRole("button", { name: /dismiss/i }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.type(screen.getByLabelText("Reason"), "one-off incident");
|
||||
await user.click(
|
||||
within(dialog).getByRole("button", { name: /^dismiss$/i }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(rejectProcessChange).toHaveBeenCalledWith(
|
||||
"pm-1",
|
||||
"one-off incident",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import type { MarketBrief } from "@/lib/api/periscope";
|
||||
@@ -20,6 +21,7 @@ function buildBrief(overrides: Partial<MarketBrief> = {}): MarketBrief {
|
||||
claim: "Competitor X launched an autonomous PR-review agent",
|
||||
source_url: "https://example.com/competitor-x-launch",
|
||||
relevance: "Directly overlaps our pr_reviewer role",
|
||||
status: "proposed",
|
||||
},
|
||||
],
|
||||
threats: ["Feature parity gap on PR review"],
|
||||
@@ -29,17 +31,28 @@ function buildBrief(overrides: Partial<MarketBrief> = {}): MarketBrief {
|
||||
};
|
||||
}
|
||||
|
||||
const { listBriefs } = vi.hoisted(() => ({
|
||||
const { listBriefs, approveFinding, rejectFinding } = vi.hoisted(() => ({
|
||||
listBriefs: vi.fn(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolveListRef.current = r as (v: unknown) => void;
|
||||
}),
|
||||
),
|
||||
approveFinding: vi.fn(async () => ({
|
||||
status: "approved",
|
||||
finding_id: "finding-0",
|
||||
materialized_task_id: "task-1",
|
||||
detail: "materialized as a Main-PM-owned task",
|
||||
})),
|
||||
rejectFinding: vi.fn(async () => ({
|
||||
status: "rejected",
|
||||
finding_id: "finding-0",
|
||||
detail: "dismissed; feeds the next cycle's prompt",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
periscopeApi: { listBriefs },
|
||||
periscopeApi: { listBriefs, approveFinding, rejectFinding },
|
||||
}));
|
||||
|
||||
import { MarketBriefsTab } from "../market-briefs-tab";
|
||||
@@ -90,28 +103,94 @@ describe("MarketBriefsTab", () => {
|
||||
"href",
|
||||
"https://example.com/competitor-x-launch",
|
||||
);
|
||||
expect(screen.getByText("Feature parity gap on PR review")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Feature parity gap on PR review"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Lean into our findings-ledger differentiator"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Emphasize the structured findings ledger in messaging",
|
||||
),
|
||||
screen.getByText("Emphasize the structured findings ledger in messaging"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no approve/reject controls — a report has no queue action", async () => {
|
||||
// Defect 2 fix: a proposed finding now gets its own approve/dismiss action
|
||||
// even though the brief itself stays a read-only report — was "renders no
|
||||
// approve/reject controls".
|
||||
it("renders per-finding approve/dismiss controls for a proposed finding", async () => {
|
||||
render(withQueryClient(<MarketBriefsTab />));
|
||||
resolveListRef.current?.([buildBrief()]);
|
||||
|
||||
await screen.findByText("A rival tool shipped agentic PR review");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /approve/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /dismiss/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the actions once a finding is approved", async () => {
|
||||
render(withQueryClient(<MarketBriefsTab />));
|
||||
resolveListRef.current?.([
|
||||
buildBrief({
|
||||
findings: [
|
||||
{
|
||||
id: "finding-0",
|
||||
claim: "Competitor X launched an autonomous PR-review agent",
|
||||
source_url: "https://example.com/competitor-x-launch",
|
||||
relevance: "Directly overlaps our pr_reviewer role",
|
||||
status: "approved",
|
||||
materialized_task_id: "task-1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
await screen.findByText("A rival tool shipped agentic PR review");
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /approve/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /reject/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Approved")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("approves a finding via the API", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(withQueryClient(<MarketBriefsTab />));
|
||||
resolveListRef.current?.([buildBrief()]);
|
||||
|
||||
await screen.findByText("A rival tool shipped agentic PR review");
|
||||
await user.click(screen.getByRole("button", { name: /approve/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(approveFinding).toHaveBeenCalledWith("brief-1", "finding-0"),
|
||||
);
|
||||
});
|
||||
|
||||
it("dismisses a finding with a reason via the dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(withQueryClient(<MarketBriefsTab />));
|
||||
resolveListRef.current?.([buildBrief()]);
|
||||
|
||||
await screen.findByText("A rival tool shipped agentic PR review");
|
||||
await user.click(screen.getByRole("button", { name: /dismiss/i }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.type(
|
||||
screen.getByLabelText("Reason"),
|
||||
"not actionable this quarter",
|
||||
);
|
||||
await user.click(
|
||||
within(dialog).getByRole("button", { name: /^dismiss$/i }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(rejectFinding).toHaveBeenCalledWith(
|
||||
"brief-1",
|
||||
"finding-0",
|
||||
"not actionable this quarter",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits optional sections when threats/opportunities/positioning are absent", async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import type { QualityReport } from "@/lib/api/sentinel";
|
||||
@@ -18,27 +19,41 @@ function buildReport(overrides: Partial<QualityReport> = {}): QualityReport {
|
||||
{
|
||||
id: "item-0",
|
||||
area: "waivers",
|
||||
observation: "Minor findings in roboco/services/task.py keep getting waived",
|
||||
observation:
|
||||
"Minor findings in roboco/services/task.py keep getting waived",
|
||||
evidence: "5 waived-minor findings this week (prior week: 1)",
|
||||
suggested_action: "Convert to a Pest Control bug task",
|
||||
status: "proposed",
|
||||
},
|
||||
],
|
||||
overall_assessment: "Drift is concentrated in one hotspot file, not systemic yet",
|
||||
overall_assessment:
|
||||
"Drift is concentrated in one hotspot file, not systemic yet",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const { listReports } = vi.hoisted(() => ({
|
||||
const { listReports, approveItem, rejectItem } = vi.hoisted(() => ({
|
||||
listReports: vi.fn(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolveListRef.current = r as (v: unknown) => void;
|
||||
}),
|
||||
),
|
||||
approveItem: vi.fn(async () => ({
|
||||
status: "approved",
|
||||
item_id: "item-0",
|
||||
materialized_task_id: "task-1",
|
||||
detail: "materialized as a Main-PM-owned task",
|
||||
})),
|
||||
rejectItem: vi.fn(async () => ({
|
||||
status: "rejected",
|
||||
item_id: "item-0",
|
||||
detail: "dismissed; feeds the next cycle's prompt",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
sentinelApi: { listReports },
|
||||
sentinelApi: { listReports, approveItem, rejectItem },
|
||||
}));
|
||||
|
||||
import { QualityReportsTab } from "../quality-reports-tab";
|
||||
@@ -100,17 +115,81 @@ describe("QualityReportsTab", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no approve/reject controls — a report has no queue action", async () => {
|
||||
// Defect 2 fix: a proposed drift item now gets its own approve/dismiss
|
||||
// action even though the report itself stays a read-only report — was
|
||||
// "renders no approve/reject controls".
|
||||
it("renders per-item approve/dismiss controls for a proposed item", async () => {
|
||||
render(withQueryClient(<QualityReportsTab />));
|
||||
resolveListRef.current?.([buildReport()]);
|
||||
|
||||
await screen.findByText("Waived findings climbed 3x this week");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /approve/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /dismiss/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the actions once an item is approved", async () => {
|
||||
render(withQueryClient(<QualityReportsTab />));
|
||||
resolveListRef.current?.([
|
||||
buildReport({
|
||||
items: [
|
||||
{
|
||||
id: "item-0",
|
||||
area: "waivers",
|
||||
observation: "Minor findings keep getting waived",
|
||||
evidence: "5 waived this week",
|
||||
suggested_action: "Convert to a Pest Control bug task",
|
||||
status: "approved",
|
||||
materialized_task_id: "task-1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
await screen.findByText("Waived findings climbed 3x this week");
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /approve/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /reject/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Approved")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("approves an item via the API", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(withQueryClient(<QualityReportsTab />));
|
||||
resolveListRef.current?.([buildReport()]);
|
||||
|
||||
await screen.findByText("Waived findings climbed 3x this week");
|
||||
await user.click(screen.getByRole("button", { name: /approve/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(approveItem).toHaveBeenCalledWith("report-1", "item-0"),
|
||||
);
|
||||
});
|
||||
|
||||
it("dismisses an item with a reason via the dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(withQueryClient(<QualityReportsTab />));
|
||||
resolveListRef.current?.([buildReport()]);
|
||||
|
||||
await screen.findByText("Waived findings climbed 3x this week");
|
||||
await user.click(screen.getByRole("button", { name: /dismiss/i }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.type(screen.getByLabelText("Reason"), "already tracked");
|
||||
await user.click(
|
||||
within(dialog).getByRole("button", { name: /^dismiss$/i }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(rejectItem).toHaveBeenCalledWith(
|
||||
"report-1",
|
||||
"item-0",
|
||||
"already tracked",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the overall-assessment section when absent", async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { coronerApi } from "@/lib/api";
|
||||
import type { Postmortem } from "@/lib/api/coroner";
|
||||
import {
|
||||
@@ -11,10 +12,24 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { Stethoscope } from "lucide-react";
|
||||
import { CheckCircle2, Stethoscope, XCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const _MIN_REASON_CHARS = 4;
|
||||
|
||||
const INCIDENT_KIND_LABELS: Record<string, string> = {
|
||||
bounced: "bounced 3+ times",
|
||||
@@ -22,7 +37,46 @@ const INCIDENT_KIND_LABELS: Record<string, string> = {
|
||||
budget: "budget-blocked",
|
||||
};
|
||||
|
||||
function PostmortemRow({ postmortem }: { postmortem: Postmortem }) {
|
||||
function processChangeStatusBadge(postmortem: Postmortem) {
|
||||
if (postmortem.process_change_status === "approved") {
|
||||
return (
|
||||
<HelpTip label="Materialized as a Main-PM-owned task">
|
||||
<Badge variant="secondary" className="bg-green-600/10 text-green-700">
|
||||
Approved
|
||||
</Badge>
|
||||
</HelpTip>
|
||||
);
|
||||
}
|
||||
if (postmortem.process_change_status === "rejected") {
|
||||
return (
|
||||
<HelpTip label="Dismissed — not added to the backlog">
|
||||
<Badge variant="outline">Dismissed</Badge>
|
||||
</HelpTip>
|
||||
);
|
||||
}
|
||||
if (postmortem.process_change_status === "not_applicable") {
|
||||
return (
|
||||
<HelpTip label="Already drafted straight into the playbook review queue — nothing else to decide">
|
||||
<Badge variant="outline">Drafted as playbook</Badge>
|
||||
</HelpTip>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function PostmortemRow({
|
||||
postmortem,
|
||||
onApprove,
|
||||
onReject,
|
||||
approving,
|
||||
}: {
|
||||
postmortem: Postmortem;
|
||||
onApprove: (taskId: string) => void;
|
||||
onReject: (postmortem: Postmortem) => void;
|
||||
approving: boolean;
|
||||
}) {
|
||||
const isProposed = postmortem.process_change_status === "proposed";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -47,6 +101,7 @@ function PostmortemRow({ postmortem }: { postmortem: Postmortem }) {
|
||||
<Badge>{postmortem.process_change_kind}</Badge>
|
||||
</HelpTip>
|
||||
)}
|
||||
{processChangeStatusBadge(postmortem)}
|
||||
</div>
|
||||
{postmortem.incident_summary && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -65,20 +120,58 @@ function PostmortemRow({ postmortem }: { postmortem: Postmortem }) {
|
||||
{postmortem.process_change_description}
|
||||
</p>
|
||||
)}
|
||||
{postmortem.process_change_status === "rejected" &&
|
||||
postmortem.process_change_reject_reason && (
|
||||
<p className="text-sm text-destructive">
|
||||
Dismissed: {postmortem.process_change_reject_reason}
|
||||
</p>
|
||||
)}
|
||||
{postmortem.completed_at && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(postmortem.completed_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
{isProposed && (
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<HelpTip label="Records your reason — not added to the backlog">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => onReject(postmortem)}
|
||||
>
|
||||
<XCircle className="mr-1 h-3.5 w-3.5" />
|
||||
Dismiss
|
||||
</Button>
|
||||
</HelpTip>
|
||||
<HelpTip label="Materializes this process change as a Main-PM-owned task — needs normal PM activation to start">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={approving}
|
||||
onClick={() => onApprove(postmortem.task_id)}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
|
||||
Approve
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Read-only Postmortems list — a Coroner postmortem completes atomically at
|
||||
// propose_postmortem time (no per-item approve/reject like Pest Control/
|
||||
// Roadmap), so unlike those review queues this never hides on empty; it's a
|
||||
// retrospective report the CEO checks, not an urgent action queue.
|
||||
// Postmortems list — a Coroner postmortem completes atomically at
|
||||
// propose_postmortem time (no per-item approve/reject at the TASK level
|
||||
// like Pest Control/Roadmap), so this never hides on empty; it's a
|
||||
// retrospective report the CEO checks. Its single process change still
|
||||
// carries its own per-item approve/dismiss action, below each row.
|
||||
export function CoronerPostmortemsCard({ className }: { className?: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [rejecting, setRejecting] = useState<Postmortem | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [approvingTaskId, setApprovingTaskId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: postmortems,
|
||||
isLoading,
|
||||
@@ -90,45 +183,143 @@ export function CoronerPostmortemsCard({ className }: { className?: string }) {
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["coroner", "postmortems"] });
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (taskId: string) => coronerApi.approveProcessChange(taskId),
|
||||
onSuccess: (result) => {
|
||||
invalidate();
|
||||
if (
|
||||
result.status === "approved" ||
|
||||
result.status === "already_approved"
|
||||
) {
|
||||
toast.success("Process change approved — materialized as a task");
|
||||
} else {
|
||||
toast.warning(result.detail);
|
||||
}
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
`Approve failed: ${e instanceof Error ? e.message : "error"}`,
|
||||
),
|
||||
onSettled: () => setApprovingTaskId(null),
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: ({ taskId, reason }: { taskId: string; reason: string }) =>
|
||||
coronerApi.rejectProcessChange(taskId, reason),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success("Process change dismissed");
|
||||
closeReject();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
`Dismiss failed: ${e instanceof Error ? e.message : "error"}`,
|
||||
),
|
||||
});
|
||||
|
||||
const closeReject = () => {
|
||||
setRejecting(null);
|
||||
setReason("");
|
||||
};
|
||||
|
||||
const confirmReject = () => {
|
||||
if (!rejecting) return;
|
||||
if (reason.trim().length < _MIN_REASON_CHARS) {
|
||||
toast.error("Give a brief reason for dismissing");
|
||||
return;
|
||||
}
|
||||
rejectMutation.mutate({ taskId: rejecting.task_id, reason: reason.trim() });
|
||||
};
|
||||
|
||||
const handleApprove = (taskId: string) => {
|
||||
setApprovingTaskId(taskId);
|
||||
approveMutation.mutate(taskId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Stethoscope className="h-5 w-5" />
|
||||
Postmortems
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
The Auditor's Coroner autopsies — bounced, cancelled, or
|
||||
budget-blocked incidents, each with a root cause and one proposed
|
||||
process change.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<>
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Stethoscope className="h-5 w-5" />
|
||||
Postmortems
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
The Auditor's Coroner autopsies — bounced, cancelled, or
|
||||
budget-blocked incidents, each with a root cause and one proposed
|
||||
process change.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<OfflineState
|
||||
title="Failed to load Postmortems"
|
||||
description="Could not reach the orchestrator API. Check the backend is running."
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : !postmortems || postmortems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No postmortems yet — Coroner autopsies open only when a task
|
||||
bounces 3+ times, is cancelled after work started, or is
|
||||
budget-blocked.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{postmortems.map((pm) => (
|
||||
<PostmortemRow
|
||||
key={pm.task_id}
|
||||
postmortem={pm}
|
||||
onApprove={handleApprove}
|
||||
onReject={setRejecting}
|
||||
approving={approvingTaskId === pm.task_id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={!!rejecting} onOpenChange={() => closeReject()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dismiss process change</DialogTitle>
|
||||
<DialogDescription>
|
||||
This records your reason and feeds the next cycle's prompt —
|
||||
it is not added to the backlog.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coroner-reject-reason">Reason</Label>
|
||||
<Textarea
|
||||
id="coroner-reject-reason"
|
||||
placeholder="e.g. one-off incident; not worth a standing process change..."
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<OfflineState
|
||||
title="Failed to load Postmortems"
|
||||
description="Could not reach the orchestrator API. Check the backend is running."
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : !postmortems || postmortems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No postmortems yet — Coroner autopsies open only when a task
|
||||
bounces 3+ times, is cancelled after work started, or is
|
||||
budget-blocked.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{postmortems.map((pm) => (
|
||||
<PostmortemRow key={pm.task_id} postmortem={pm} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeReject}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmReject}
|
||||
disabled={rejectMutation.isPending}
|
||||
>
|
||||
{rejectMutation.isPending ? "Dismissing..." : "Dismiss"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { periscopeApi } from "@/lib/api";
|
||||
import type { MarketBrief } from "@/lib/api/periscope";
|
||||
import type { MarketBrief, MarketBriefFinding } from "@/lib/api/periscope";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const _MIN_REASON_CHARS = 4;
|
||||
|
||||
interface RejectTarget {
|
||||
taskId: string;
|
||||
finding: MarketBriefFinding;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only skeleton placeholder shaped like a brief card
|
||||
@@ -28,12 +49,115 @@ function BriefCardSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function findingStatusBadge(finding: MarketBriefFinding) {
|
||||
if (finding.status === "approved") {
|
||||
return (
|
||||
<HelpTip label="Materialized as a Main-PM-owned task">
|
||||
<Badge variant="secondary" className="bg-green-600/10 text-green-700">
|
||||
Approved
|
||||
</Badge>
|
||||
</HelpTip>
|
||||
);
|
||||
}
|
||||
if (finding.status === "rejected") {
|
||||
return (
|
||||
<HelpTip label="Dismissed — not added to the backlog">
|
||||
<Badge variant="outline">Dismissed</Badge>
|
||||
</HelpTip>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One brief, read-only — headline/findings/sources/threats/opportunities.
|
||||
// No approve/reject UI: a market brief is a report, not a queue item.
|
||||
// One finding — claim/relevance/source, plus a per-finding approve/dismiss
|
||||
// action (a market signal is worth acting on individually, even though the
|
||||
// brief itself is a report).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BriefCard({ brief }: { brief: MarketBrief }) {
|
||||
function FindingRow({
|
||||
taskId,
|
||||
finding,
|
||||
onApprove,
|
||||
onReject,
|
||||
approving,
|
||||
}: {
|
||||
taskId: string;
|
||||
finding: MarketBriefFinding;
|
||||
onApprove: (taskId: string, findingId: string) => void;
|
||||
onReject: (target: RejectTarget) => void;
|
||||
approving: boolean;
|
||||
}) {
|
||||
const isProposed = finding.status === "proposed";
|
||||
|
||||
return (
|
||||
<li className="space-y-1 border-b pb-3 last:border-b-0 last:pb-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="whitespace-pre-wrap text-sm">{finding.claim}</p>
|
||||
{findingStatusBadge(finding)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{finding.relevance} —{" "}
|
||||
<a
|
||||
href={finding.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
source
|
||||
</a>
|
||||
</p>
|
||||
{finding.status === "rejected" && finding.reject_reason && (
|
||||
<p className="text-xs text-destructive">
|
||||
Dismissed: {finding.reject_reason}
|
||||
</p>
|
||||
)}
|
||||
{isProposed && (
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<HelpTip label="Records your reason — not added to the backlog">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => onReject({ taskId, finding })}
|
||||
>
|
||||
<XCircle className="mr-1 h-3.5 w-3.5" />
|
||||
Dismiss
|
||||
</Button>
|
||||
</HelpTip>
|
||||
<HelpTip label="Materializes this finding as a Main-PM-owned task — needs normal PM activation to start">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={approving}
|
||||
onClick={() => onApprove(taskId, finding.id)}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
|
||||
Approve
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One brief — headline/findings/sources/threats/opportunities, each finding
|
||||
// with its own approve/dismiss action.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BriefCard({
|
||||
brief,
|
||||
onApprove,
|
||||
onReject,
|
||||
approvingFindingId,
|
||||
}: {
|
||||
brief: MarketBrief;
|
||||
onApprove: (taskId: string, findingId: string) => void;
|
||||
onReject: (target: RejectTarget) => void;
|
||||
approvingFindingId: string | null;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -52,20 +176,14 @@ function BriefCard({ brief }: { brief: MarketBrief }) {
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{brief.findings.map((f) => (
|
||||
<li key={f.id} className="text-sm">
|
||||
<p className="whitespace-pre-wrap">{f.claim}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{f.relevance} —{" "}
|
||||
<a
|
||||
href={f.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
source
|
||||
</a>
|
||||
</p>
|
||||
</li>
|
||||
<FindingRow
|
||||
key={f.id}
|
||||
taskId={brief.task_id}
|
||||
finding={f}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
approving={approvingFindingId === f.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -125,6 +243,13 @@ function BriefCard({ brief }: { brief: MarketBrief }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function MarketBriefsTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const [rejecting, setRejecting] = useState<RejectTarget | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [approvingFindingId, setApprovingFindingId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const {
|
||||
data: briefs = [],
|
||||
isLoading,
|
||||
@@ -136,37 +261,152 @@ export function MarketBriefsTab() {
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["periscope", "briefs"] });
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: ({
|
||||
taskId,
|
||||
findingId,
|
||||
}: {
|
||||
taskId: string;
|
||||
findingId: string;
|
||||
}) => periscopeApi.approveFinding(taskId, findingId),
|
||||
onSuccess: (result) => {
|
||||
invalidate();
|
||||
if (
|
||||
result.status === "approved" ||
|
||||
result.status === "already_approved"
|
||||
) {
|
||||
toast.success("Finding approved — materialized as a task");
|
||||
} else {
|
||||
toast.warning(result.detail);
|
||||
}
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
`Approve failed: ${e instanceof Error ? e.message : "error"}`,
|
||||
),
|
||||
onSettled: () => setApprovingFindingId(null),
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: ({
|
||||
taskId,
|
||||
findingId,
|
||||
reason,
|
||||
}: {
|
||||
taskId: string;
|
||||
findingId: string;
|
||||
reason: string;
|
||||
}) => periscopeApi.rejectFinding(taskId, findingId, reason),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success("Finding dismissed");
|
||||
closeReject();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
`Dismiss failed: ${e instanceof Error ? e.message : "error"}`,
|
||||
),
|
||||
});
|
||||
|
||||
const closeReject = () => {
|
||||
setRejecting(null);
|
||||
setReason("");
|
||||
};
|
||||
|
||||
const confirmReject = () => {
|
||||
if (!rejecting) return;
|
||||
if (reason.trim().length < _MIN_REASON_CHARS) {
|
||||
toast.error("Give a brief reason for dismissing");
|
||||
return;
|
||||
}
|
||||
rejectMutation.mutate({
|
||||
taskId: rejecting.taskId,
|
||||
findingId: rejecting.finding.id,
|
||||
reason: reason.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleApprove = (taskId: string, findingId: string) => {
|
||||
setApprovingFindingId(findingId);
|
||||
approveMutation.mutate({ taskId, findingId });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Market Briefs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<BriefCardSkeleton />
|
||||
<BriefCardSkeleton />
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Market Briefs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<BriefCardSkeleton />
|
||||
<BriefCardSkeleton />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<OfflineState
|
||||
title="Failed to load market briefs"
|
||||
description="Could not reach the orchestrator API. Check the backend is running."
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : briefs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No market briefs filed yet. The Head of Marketing files one
|
||||
weekly, once Periscope is enabled — each cited finding can be
|
||||
approved or dismissed individually once one lands.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{briefs.map((b) => (
|
||||
<BriefCard
|
||||
key={b.task_id}
|
||||
brief={b}
|
||||
onApprove={handleApprove}
|
||||
onReject={setRejecting}
|
||||
approvingFindingId={approvingFindingId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={!!rejecting} onOpenChange={() => closeReject()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dismiss market-brief finding</DialogTitle>
|
||||
<DialogDescription>
|
||||
This records your reason and feeds the next cycle's prompt —
|
||||
it is not added to the backlog.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="periscope-reject-reason">Reason</Label>
|
||||
<Textarea
|
||||
id="periscope-reject-reason"
|
||||
placeholder="e.g. not actionable right now; already covered by an open task..."
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<OfflineState
|
||||
title="Failed to load market briefs"
|
||||
description="Could not reach the orchestrator API. Check the backend is running."
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : briefs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No market briefs filed yet. The Head of Marketing files one
|
||||
weekly, once Periscope is enabled — they appear here as reports,
|
||||
not a queue to act on.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{briefs.map((b) => (
|
||||
<BriefCard key={b.task_id} brief={b} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeReject}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmReject}
|
||||
disabled={rejectMutation.isPending}
|
||||
>
|
||||
{rejectMutation.isPending ? "Dismissing..." : "Dismiss"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { sentinelApi } from "@/lib/api";
|
||||
import type { QualityReport } from "@/lib/api/sentinel";
|
||||
import type { QualityReport, QualityReportItem } from "@/lib/api/sentinel";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const _MIN_REASON_CHARS = 4;
|
||||
|
||||
interface RejectTarget {
|
||||
taskId: string;
|
||||
item: QualityReportItem;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only skeleton placeholder shaped like a report card
|
||||
@@ -27,12 +49,112 @@ function ReportCardSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function itemStatusBadge(item: QualityReportItem) {
|
||||
if (item.status === "approved") {
|
||||
return (
|
||||
<HelpTip label="Materialized as a Main-PM-owned task">
|
||||
<Badge variant="secondary" className="bg-green-600/10 text-green-700">
|
||||
Approved
|
||||
</Badge>
|
||||
</HelpTip>
|
||||
);
|
||||
}
|
||||
if (item.status === "rejected") {
|
||||
return (
|
||||
<HelpTip label="Dismissed — not added to the backlog">
|
||||
<Badge variant="outline">Dismissed</Badge>
|
||||
</HelpTip>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One report, read-only — headline/items/overall assessment. No approve/
|
||||
// reject UI: a quality report is a report, not a queue item.
|
||||
// One drift item — area/observation/evidence/suggested action, plus a
|
||||
// per-item approve/dismiss action.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ReportCard({ report }: { report: QualityReport }) {
|
||||
function DriftItemRow({
|
||||
taskId,
|
||||
item,
|
||||
onApprove,
|
||||
onReject,
|
||||
approving,
|
||||
}: {
|
||||
taskId: string;
|
||||
item: QualityReportItem;
|
||||
onApprove: (taskId: string, itemId: string) => void;
|
||||
onReject: (target: RejectTarget) => void;
|
||||
approving: boolean;
|
||||
}) {
|
||||
const isProposed = item.status === "proposed";
|
||||
|
||||
return (
|
||||
<li className="space-y-1 border-b pb-3 last:border-b-0 last:pb-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="uppercase text-xs">
|
||||
{item.area}
|
||||
</Badge>
|
||||
{itemStatusBadge(item)}
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{item.observation}</p>
|
||||
<p className="text-xs text-muted-foreground whitespace-pre-wrap">
|
||||
Evidence: {item.evidence}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground whitespace-pre-wrap">
|
||||
Suggested action: {item.suggested_action}
|
||||
</p>
|
||||
{item.status === "rejected" && item.reject_reason && (
|
||||
<p className="text-xs text-destructive">
|
||||
Dismissed: {item.reject_reason}
|
||||
</p>
|
||||
)}
|
||||
{isProposed && (
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<HelpTip label="Records your reason — not added to the backlog">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => onReject({ taskId, item })}
|
||||
>
|
||||
<XCircle className="mr-1 h-3.5 w-3.5" />
|
||||
Dismiss
|
||||
</Button>
|
||||
</HelpTip>
|
||||
<HelpTip label="Materializes this item as a Main-PM-owned task — needs normal PM activation to start">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={approving}
|
||||
onClick={() => onApprove(taskId, item.id)}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
|
||||
Approve
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One report — headline/items/overall assessment, each item with its own
|
||||
// approve/dismiss action.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ReportCard({
|
||||
report,
|
||||
onApprove,
|
||||
onReject,
|
||||
approvingItemId,
|
||||
}: {
|
||||
report: QualityReport;
|
||||
onApprove: (taskId: string, itemId: string) => void;
|
||||
onReject: (target: RejectTarget) => void;
|
||||
approvingItemId: string | null;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -51,18 +173,14 @@ function ReportCard({ report }: { report: QualityReport }) {
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
{report.items.map((item) => (
|
||||
<li key={item.id} className="text-sm space-y-1">
|
||||
<Badge variant="secondary" className="uppercase text-xs">
|
||||
{item.area}
|
||||
</Badge>
|
||||
<p className="whitespace-pre-wrap">{item.observation}</p>
|
||||
<p className="text-xs text-muted-foreground whitespace-pre-wrap">
|
||||
Evidence: {item.evidence}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground whitespace-pre-wrap">
|
||||
Suggested action: {item.suggested_action}
|
||||
</p>
|
||||
</li>
|
||||
<DriftItemRow
|
||||
key={item.id}
|
||||
taskId={report.task_id}
|
||||
item={item}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
approving={approvingItemId === item.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -87,6 +205,11 @@ function ReportCard({ report }: { report: QualityReport }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function QualityReportsTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const [rejecting, setRejecting] = useState<RejectTarget | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [approvingItemId, setApprovingItemId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: reports = [],
|
||||
isLoading,
|
||||
@@ -98,37 +221,147 @@ export function QualityReportsTab() {
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["sentinel", "reports"] });
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: ({ taskId, itemId }: { taskId: string; itemId: string }) =>
|
||||
sentinelApi.approveItem(taskId, itemId),
|
||||
onSuccess: (result) => {
|
||||
invalidate();
|
||||
if (
|
||||
result.status === "approved" ||
|
||||
result.status === "already_approved"
|
||||
) {
|
||||
toast.success("Item approved — materialized as a task");
|
||||
} else {
|
||||
toast.warning(result.detail);
|
||||
}
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
`Approve failed: ${e instanceof Error ? e.message : "error"}`,
|
||||
),
|
||||
onSettled: () => setApprovingItemId(null),
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: ({
|
||||
taskId,
|
||||
itemId,
|
||||
reason,
|
||||
}: {
|
||||
taskId: string;
|
||||
itemId: string;
|
||||
reason: string;
|
||||
}) => sentinelApi.rejectItem(taskId, itemId, reason),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success("Item dismissed");
|
||||
closeReject();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
`Dismiss failed: ${e instanceof Error ? e.message : "error"}`,
|
||||
),
|
||||
});
|
||||
|
||||
const closeReject = () => {
|
||||
setRejecting(null);
|
||||
setReason("");
|
||||
};
|
||||
|
||||
const confirmReject = () => {
|
||||
if (!rejecting) return;
|
||||
if (reason.trim().length < _MIN_REASON_CHARS) {
|
||||
toast.error("Give a brief reason for dismissing");
|
||||
return;
|
||||
}
|
||||
rejectMutation.mutate({
|
||||
taskId: rejecting.taskId,
|
||||
itemId: rejecting.item.id,
|
||||
reason: reason.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleApprove = (taskId: string, itemId: string) => {
|
||||
setApprovingItemId(itemId);
|
||||
approveMutation.mutate({ taskId, itemId });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quality Reports</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<ReportCardSkeleton />
|
||||
<ReportCardSkeleton />
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quality Reports</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<ReportCardSkeleton />
|
||||
<ReportCardSkeleton />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<OfflineState
|
||||
title="Failed to load quality reports"
|
||||
description="Could not reach the orchestrator API. Check the backend is running."
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : reports.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No quality reports filed yet. The Auditor files one weekly, once
|
||||
Sentinel is enabled — each drift item can be approved or dismissed
|
||||
individually once one lands.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{reports.map((r) => (
|
||||
<ReportCard
|
||||
key={r.task_id}
|
||||
report={r}
|
||||
onApprove={handleApprove}
|
||||
onReject={setRejecting}
|
||||
approvingItemId={approvingItemId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={!!rejecting} onOpenChange={() => closeReject()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dismiss quality-report item</DialogTitle>
|
||||
<DialogDescription>
|
||||
This records your reason and feeds the next cycle's prompt —
|
||||
it is not added to the backlog.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sentinel-reject-reason">Reason</Label>
|
||||
<Textarea
|
||||
id="sentinel-reject-reason"
|
||||
placeholder="e.g. already tracked elsewhere; not worth a task this cycle..."
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<OfflineState
|
||||
title="Failed to load quality reports"
|
||||
description="Could not reach the orchestrator API. Check the backend is running."
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : reports.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No quality reports filed yet. The Auditor files one weekly, once
|
||||
Sentinel is enabled — they appear here as reports, not a queue to
|
||||
act on.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{reports.map((r) => (
|
||||
<ReportCard key={r.task_id} report={r} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeReject}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmReject}
|
||||
disabled={rejectMutation.isPending}
|
||||
>
|
||||
{rejectMutation.isPending ? "Dismissing..." : "Dismiss"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,13 @@ import api from "./client";
|
||||
// Coroner (Board Program) engine — the Auditor's event-triggered postmortem:
|
||||
// a task bounced >=3x, was cancelled after work started, or was budget-
|
||||
// blocked. One propose_postmortem call completes the autopsy atomically —
|
||||
// there is no per-item approve/reject like Pest Control/Roadmap, so this is
|
||||
// a plain read-only list. Mirrors lib/api/pest-control.ts.
|
||||
// the EXPLORATION TASK has no per-item decision to wait on — but the
|
||||
// postmortem's single process change still carries its own proposed/
|
||||
// approved/rejected status the CEO decides on afterward (unless it already
|
||||
// drafted a playbook: process_change_status "not_applicable", nothing left
|
||||
// to decide). Unlike Periscope/Sentinel there is no item id — a postmortem
|
||||
// is one process change, not a list — so the action routes key on the task
|
||||
// id alone.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Postmortem {
|
||||
@@ -21,6 +26,16 @@ export interface Postmortem {
|
||||
process_change_kind: string | null;
|
||||
process_change_description: string | null;
|
||||
playbook_id: string | null;
|
||||
process_change_status:
|
||||
"proposed" | "approved" | "rejected" | "not_applicable";
|
||||
process_change_reject_reason: string | null;
|
||||
process_change_materialized_task_id: string | null;
|
||||
}
|
||||
|
||||
export interface ProcessChangeActionResult {
|
||||
status: string;
|
||||
materialized_task_id?: string | null;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export const coronerApi = {
|
||||
@@ -28,4 +43,23 @@ export const coronerApi = {
|
||||
const { data } = await api.get<Postmortem[]>("/coroner/postmortems");
|
||||
return data;
|
||||
},
|
||||
approveProcessChange: async (
|
||||
taskId: string,
|
||||
): Promise<ProcessChangeActionResult> => {
|
||||
const { data } = await api.post<ProcessChangeActionResult>(
|
||||
`/coroner/postmortems/${taskId}/process-change/approve`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
},
|
||||
rejectProcessChange: async (
|
||||
taskId: string,
|
||||
reason: string,
|
||||
): Promise<ProcessChangeActionResult> => {
|
||||
const { data } = await api.post<ProcessChangeActionResult>(
|
||||
`/coroner/postmortems/${taskId}/process-change/reject`,
|
||||
{ reason },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,9 +3,11 @@ import api from "./client";
|
||||
// ---------------------------------------------------------------------------
|
||||
// Periscope (Board Program) engine — the Head of Marketing files a weekly
|
||||
// market-research brief (competitors, adjacent-tool releases, positioning
|
||||
// shifts). Unlike Roadmap/Pest Control this is a REPORT, not a queue item:
|
||||
// read-only here, no approve/reject. Mirrors lib/api/pest-control.ts's shape
|
||||
// minus the mutating routes.
|
||||
// shifts). The brief itself is read-only — a report, not a queue item, the
|
||||
// exploration task completes atomically at propose time — but each cited
|
||||
// finding still carries its own proposed/approved/rejected status the CEO
|
||||
// decides on afterward. Mirrors lib/api/roadmap.ts's per-item approve/reject
|
||||
// shape.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MarketBriefFinding {
|
||||
@@ -13,6 +15,9 @@ export interface MarketBriefFinding {
|
||||
claim: string;
|
||||
source_url: string;
|
||||
relevance: string;
|
||||
status: "proposed" | "approved" | "rejected";
|
||||
reject_reason?: string | null;
|
||||
materialized_task_id?: string | null;
|
||||
}
|
||||
|
||||
export interface MarketBrief {
|
||||
@@ -26,9 +31,37 @@ export interface MarketBrief {
|
||||
positioning_note: string;
|
||||
}
|
||||
|
||||
export interface MarketBriefFindingActionResult {
|
||||
status: string;
|
||||
finding_id: string;
|
||||
materialized_task_id?: string | null;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export const periscopeApi = {
|
||||
listBriefs: async (): Promise<MarketBrief[]> => {
|
||||
const { data } = await api.get<MarketBrief[]>("/periscope/briefs");
|
||||
return data;
|
||||
},
|
||||
approveFinding: async (
|
||||
taskId: string,
|
||||
findingId: string,
|
||||
): Promise<MarketBriefFindingActionResult> => {
|
||||
const { data } = await api.post<MarketBriefFindingActionResult>(
|
||||
`/periscope/briefs/${taskId}/findings/${findingId}/approve`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
},
|
||||
rejectFinding: async (
|
||||
taskId: string,
|
||||
findingId: string,
|
||||
reason: string,
|
||||
): Promise<MarketBriefFindingActionResult> => {
|
||||
const { data } = await api.post<MarketBriefFindingActionResult>(
|
||||
`/periscope/briefs/${taskId}/findings/${findingId}/reject`,
|
||||
{ reason },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,9 +3,11 @@ import api from "./client";
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sentinel (Board Program) engine — the Auditor files a weekly "state of
|
||||
// quality" report (waiver-accumulation trends, conventions-violation
|
||||
// hotspots, budget anomalies). Like Periscope this is a REPORT, not a queue
|
||||
// item: read-only here, no approve/reject. Mirrors lib/api/periscope.ts's
|
||||
// shape exactly.
|
||||
// hotspots, budget anomalies). The report itself is read-only — a report,
|
||||
// not a queue item, the exploration task completes atomically at propose
|
||||
// time — but each drift item still carries its own proposed/approved/
|
||||
// rejected status the CEO decides on afterward. Mirrors lib/api/
|
||||
// periscope.ts's per-item approve/reject shape.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface QualityReportItem {
|
||||
@@ -14,6 +16,9 @@ export interface QualityReportItem {
|
||||
observation: string;
|
||||
evidence: string;
|
||||
suggested_action: string;
|
||||
status: "proposed" | "approved" | "rejected";
|
||||
reject_reason?: string | null;
|
||||
materialized_task_id?: string | null;
|
||||
}
|
||||
|
||||
export interface QualityReport {
|
||||
@@ -25,9 +30,37 @@ export interface QualityReport {
|
||||
overall_assessment: string;
|
||||
}
|
||||
|
||||
export interface QualityReportItemActionResult {
|
||||
status: string;
|
||||
item_id: string;
|
||||
materialized_task_id?: string | null;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export const sentinelApi = {
|
||||
listReports: async (): Promise<QualityReport[]> => {
|
||||
const { data } = await api.get<QualityReport[]>("/sentinel/reports");
|
||||
return data;
|
||||
},
|
||||
approveItem: async (
|
||||
taskId: string,
|
||||
itemId: string,
|
||||
): Promise<QualityReportItemActionResult> => {
|
||||
const { data } = await api.post<QualityReportItemActionResult>(
|
||||
`/sentinel/reports/${taskId}/items/${itemId}/approve`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
},
|
||||
rejectItem: async (
|
||||
taskId: string,
|
||||
itemId: string,
|
||||
reason: string,
|
||||
): Promise<QualityReportItemActionResult> => {
|
||||
const { data } = await api.post<QualityReportItemActionResult>(
|
||||
`/sentinel/reports/${taskId}/items/${itemId}/reject`,
|
||||
{ reason },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user