Restore the live Briefs acceptance surface

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-24 07:42:54 +00:00
1 parent 1c61f693a4
commit f3f4df9fcf
21 files changed
+718

No files matched your search

+4
View File
@@ -991,6 +991,10 @@ export type {
PluginWebhookDeliveryRecord,
QuotaWindow,
ProviderQuotaResult,
BriefsOverview,
BriefsOverviewAgent,
BriefsOverviewSummaryItem,
BriefsOverviewWarning,
} from "./types/index.js";
export { COMPANY_SEARCH_SCOPES } from "./types/index.js";
export {
+29
View File
@@ -0,0 +1,29 @@
export interface BriefsOverviewWarning {
code: "built_in_agent_paused";
key: string;
agentId: string;
message: string;
pauseReason: string | null;
}
export interface BriefsOverviewAgent {
id: string;
name: string;
status: string;
adapterType: string;
}
export interface BriefsOverviewSummaryItem {
label: string;
value: string;
detail?: string;
}
export interface BriefsOverview {
featureKey: "briefs";
status: "ready" | "paused";
generatedAt: string;
agent: BriefsOverviewAgent;
warning: BriefsOverviewWarning | null;
summaryItems: BriefsOverviewSummaryItem[];
}
+6
View File
@@ -184,6 +184,12 @@ export type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestResult,
} from "./agent.js";
export type {
BriefsOverview,
BriefsOverviewAgent,
BriefsOverviewSummaryItem,
BriefsOverviewWarning,
} from "./briefs.js";
export type {
AgentEligibilityAgent,
AgentEligibilityLifecycleReason,
+115
View File
@@ -0,0 +1,115 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const companyId = "22222222-2222-4222-8222-222222222222";
const mockBriefsService = vi.hoisted(() => ({
overview: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("../services/briefs.js", () => ({
briefsService: () => mockBriefsService,
}));
}
async function createApp(actor: Record<string, unknown>) {
const [{ briefsRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/briefs.js")>("../routes/briefs.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as typeof req & { actor: Record<string, unknown> }).actor = actor;
next();
});
app.use("/api", briefsRoutes({} as never));
app.use(errorHandler);
return app;
}
describe("briefs routes", () => {
beforeEach(() => {
vi.resetModules();
registerModuleMocks();
vi.clearAllMocks();
mockBriefsService.overview.mockResolvedValue({
featureKey: "briefs",
status: "ready",
generatedAt: "2026-07-07T22:45:00.000Z",
agent: {
id: "agent-1",
name: "Briefs Agent",
status: "idle",
adapterType: "codex_local",
},
warning: null,
summaryItems: [],
});
});
it("returns the Briefs overview for actors with company access", async () => {
const app = await createApp({
type: "board",
userId: "board-user",
companyIds: [companyId],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app).get(`/api/companies/${companyId}/briefs/overview`);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockBriefsService.overview).toHaveBeenCalledWith(companyId);
expect(res.body).toMatchObject({ featureKey: "briefs", status: "ready" });
});
it("denies Briefs overview outside the actor company boundary", async () => {
const app = await createApp({
type: "board",
userId: "board-user",
companyIds: ["33333333-3333-4333-8333-333333333333"],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app).get(`/api/companies/${companyId}/briefs/overview`);
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(mockBriefsService.overview).not.toHaveBeenCalled();
});
it("returns 412 until the Briefs agent is ready", async () => {
const { HttpError } = await import("../errors.js");
mockBriefsService.overview.mockRejectedValue(
new HttpError(412, "Built-in agent is not configured: briefs", {
code: "built_in_agent_not_configured",
key: "briefs",
status: "not_provisioned",
agentId: null,
featureKeys: ["briefs"],
}),
);
const app = await createApp({
type: "board",
userId: "board-user",
companyIds: [companyId],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app).get(`/api/companies/${companyId}/briefs/overview`);
expect(res.status, JSON.stringify(res.body)).toBe(412);
expect(res.body).toMatchObject({
code: "built_in_agent_not_configured",
details: {
key: "briefs",
status: "not_provisioned",
featureKeys: ["briefs"],
},
});
});
});
+101
View File
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { HttpError } from "../errors.js";
import { briefsService } from "../services/briefs.js";
const requireBuiltInAgentMock = vi.hoisted(() => vi.fn());
vi.mock("../services/built-in-agents.js", () => ({
builtInAgentService: () => ({
requireBuiltInAgent: requireBuiltInAgentMock,
}),
}));
describe("briefs service", () => {
beforeEach(() => {
requireBuiltInAgentMock.mockReset();
});
it("returns a live overview for the ready Briefs agent", async () => {
requireBuiltInAgentMock.mockResolvedValue({
definition: {
key: "briefs",
displayName: "Briefs Agent",
featureKeys: ["briefs"],
},
agent: {
id: "agent-1",
name: "Briefs Agent",
status: "idle",
adapterType: "codex_local",
},
warning: null,
});
const overview = await briefsService({} as never).overview("company-1", {
now: new Date("2026-07-07T22:45:00.000Z"),
});
expect(requireBuiltInAgentMock).toHaveBeenCalledWith("company-1", "briefs");
expect(overview).toEqual({
featureKey: "briefs",
status: "ready",
generatedAt: "2026-07-07T22:45:00.000Z",
agent: {
id: "agent-1",
name: "Briefs Agent",
status: "idle",
adapterType: "codex_local",
},
warning: null,
summaryItems: [
{ label: "Agent", value: "Briefs Agent", detail: "idle" },
{ label: "Adapter", value: "codex_local" },
{ label: "Last checked", value: "2026-07-07T22:45:00.000Z" },
],
});
});
it("keeps paused Briefs content available with a warning", async () => {
requireBuiltInAgentMock.mockResolvedValue({
definition: {
key: "briefs",
displayName: "Briefs Agent",
featureKeys: ["briefs"],
},
agent: {
id: "agent-1",
name: "Briefs Agent",
status: "paused",
adapterType: "codex_local",
},
warning: {
code: "built_in_agent_paused",
key: "briefs",
agentId: "agent-1",
message: "Built-in agent briefs is paused; scheduled/background work should be skipped.",
pauseReason: "maintenance",
},
});
const overview = await briefsService({} as never).overview("company-1");
expect(overview.status).toBe("paused");
expect(overview.warning).toMatchObject({
code: "built_in_agent_paused",
pauseReason: "maintenance",
});
});
it("passes through the missing, setup, or pending built-in 412", async () => {
const error = new HttpError(412, "Built-in agent is not configured: briefs", {
code: "built_in_agent_not_configured",
key: "briefs",
status: "needs_setup",
agentId: "agent-1",
featureKeys: ["briefs"],
});
requireBuiltInAgentMock.mockRejectedValue(error);
await expect(briefsService({} as never).overview("company-1")).rejects.toBe(error);
});
});
@@ -19,6 +19,7 @@ const apiPrefixes: Record<string, string> = {
"assets.ts": "/api",
"auth.ts": "/api/auth",
"board-chat.ts": "/api",
"briefs.ts": "/api",
"built-in-agents.ts": "/api",
"cloud-upstreams.ts": "/api",
"companies.ts": "/api/companies",
+2
View File
@@ -15,6 +15,7 @@ import { healthRoutes } from "./routes/health.js";
import { companyRoutes } from "./routes/companies.js";
import { companySkillRoutes } from "./routes/company-skills.js";
import { builtInAgentRoutes } from "./routes/built-in-agents.js";
import { briefsRoutes } from "./routes/briefs.js";
import { teamsCatalogRoutes } from "./routes/teams-catalog.js";
import { agentRoutes } from "./routes/agents.js";
import { projectRoutes } from "./routes/projects.js";
@@ -228,6 +229,7 @@ export async function createApp(
api.use(llmRoutes(db));
api.use(companySkillRoutes(db));
api.use(builtInAgentRoutes(db));
api.use(briefsRoutes(db));
api.use(teamsCatalogRoutes(db));
api.use(agentRoutes(db, { pluginWorkerManager: workerManager }));
api.use(assetRoutes(db, opts.storageService));
+17
View File
@@ -0,0 +1,17 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { briefsService } from "../services/briefs.js";
import { assertCompanyAccess } from "./authz.js";
export function briefsRoutes(db: Db) {
const router = Router();
const svc = briefsService(db);
router.get("/companies/:companyId/briefs/overview", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await svc.overview(companyId));
});
return router;
}
+1
View File
@@ -2,6 +2,7 @@ export { healthRoutes } from "./health.js";
export { companyRoutes } from "./companies.js";
export { companySkillRoutes } from "./company-skills.js";
export { builtInAgentRoutes } from "./built-in-agents.js";
export { briefsRoutes } from "./briefs.js";
export { teamsCatalogRoutes } from "./teams-catalog.js";
export { agentRoutes } from "./agents.js";
export { projectRoutes } from "./projects.js";
+18
View File
@@ -449,6 +449,10 @@ const responses = {
description: "Conflict",
content: { "application/json": { schema: ErrorSchema } },
},
preconditionFailed: {
description: "Precondition failed",
content: { "application/json": { schema: ErrorSchema } },
},
unprocessable: {
description: "Unprocessable entity",
content: { "application/json": { schema: ErrorSchema } },
@@ -2798,6 +2802,20 @@ registry.registerPath({
responses: { 200: r.ok(), 401: r.unauthorized },
});
registry.registerPath({
method: "get",
path: "/api/companies/{companyId}/briefs/overview",
tags: ["briefs"],
summary: "Get Briefs overview",
request: { params: z.object({ companyId: z.string() }) },
responses: {
200: r.ok(),
401: r.unauthorized,
403: r.forbidden,
412: r.preconditionFailed,
},
});
// ─── Sidebar ─────────────────────────────────────────────────────────────────
registry.registerPath({
+42
View File
@@ -0,0 +1,42 @@
import type { Db } from "@paperclipai/db";
import type { BriefsOverview } from "@paperclipai/shared";
import { builtInAgentService } from "./built-in-agents.js";
export function briefsService(db: Db) {
const builtIns = builtInAgentService(db);
return {
async overview(companyId: string, options: { now?: Date } = {}): Promise<BriefsOverview> {
const { agent, warning } = await builtIns.requireBuiltInAgent(companyId, "briefs");
const now = options.now ?? new Date();
return {
featureKey: "briefs",
status: warning ? "paused" : "ready",
generatedAt: now.toISOString(),
agent: {
id: agent.id,
name: agent.name,
status: agent.status,
adapterType: agent.adapterType,
},
warning,
summaryItems: [
{
label: "Agent",
value: agent.name,
detail: agent.status,
},
{
label: "Adapter",
value: agent.adapterType,
},
{
label: "Last checked",
value: now.toISOString(),
},
],
};
},
};
}
+1
View File
@@ -3,6 +3,7 @@ export { companyArtifactsService } from "./company-artifacts.js";
export { companySearchService } from "./company-search.js";
export { feedbackService } from "./feedback.js";
export { companySkillService } from "./company-skills.js";
export { briefsService } from "./briefs.js";
export { agentService, deduplicateAgentName } from "./agents.js";
export {
builtInAgentService,
+3
View File
@@ -8,6 +8,7 @@ import { OnboardingWizardVariant } from "./components/OnboardingWizardVariant";
import { CloudAccessGate } from "./components/CloudAccessGate";
import { Dashboard } from "./pages/Dashboard";
import { DashboardLive } from "./pages/DashboardLive";
import { Briefs } from "./pages/Briefs";
import { Timeline } from "./pages/Timeline";
import { Companies } from "./pages/Companies";
import { AGENT_FILTER_TABS, Agents } from "./pages/Agents";
@@ -82,6 +83,7 @@ function boardRoutes() {
<Route index element={<Navigate to="dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="dashboard/live" element={<DashboardLive />} />
<Route path="briefs" element={<Briefs />} />
<Route path="timeline" element={<Timeline />} />
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="companies" element={<Companies />} />
@@ -443,6 +445,7 @@ export function App() {
<Route path="routines" element={<UnprefixedBoardRedirect />} />
<Route path="routines/:routineId" element={<UnprefixedBoardRedirect />} />
<Route path="review-queue" element={<UnprefixedBoardRedirect />} />
<Route path="briefs" element={<UnprefixedBoardRedirect />} />
<Route path="learnings" element={<UnprefixedBoardRedirect />} />
<Route path="pipelines" element={<UnprefixedBoardRedirect />} />
<Route path="pipelines/:pipelineId" element={<UnprefixedBoardRedirect />} />
+7
View File
@@ -0,0 +1,7 @@
import type { BriefsOverview } from "@paperclipai/shared";
import { api } from "./client";
export const briefsApi = {
overview: (companyId: string) =>
api.get<BriefsOverview>(`/companies/${companyId}/briefs/overview`),
};
+5
View File
@@ -29,6 +29,7 @@ import {
History,
SquarePen,
FileCode2,
FileText,
Plus,
Search,
} from "lucide-react";
@@ -324,6 +325,10 @@ export function CommandPalette() {
<Target className="mr-2 h-4 w-4" />
Goals
</CommandItem>
<CommandItem onSelect={() => go("/briefs")}>
<FileText className="mr-2 h-4 w-4" />
Briefs
</CommandItem>
<CommandItem onSelect={() => go("/agents")}>
<Bot className="mr-2 h-4 w-4" />
Agents
+2
View File
@@ -5,6 +5,7 @@ import {
LayoutDashboard,
DollarSign,
History,
FileText,
Search,
SquarePen,
Network,
@@ -185,6 +186,7 @@ export function Sidebar() {
<SidebarSection label="Work" collapsible={{ open: workOpen, onOpenChange: setWorkOpen }}>
<SidebarNavItem to="/issues" label="Tasks" icon={CircleDot} />
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
<SidebarNavItem to="/briefs" label="Briefs" icon={FileText} />
{showPipelines ? (
<SidebarNavItem to="/pipelines" label="Pipelines" icon={GitBranch} />
) : null}
+7
View File
@@ -79,6 +79,13 @@ describe("company routes", () => {
expect(toCompanyRelativePath("/PAP/timeline")).toBe("/timeline");
});
it("treats /briefs as a board route that needs a company prefix", () => {
expect(isBoardPathWithoutPrefix("/briefs")).toBe(true);
expect(extractCompanyPrefixFromPath("/briefs")).toBeNull();
expect(applyCompanyPrefix("/briefs", "PAP")).toBe("/PAP/briefs");
expect(toCompanyRelativePath("/PAP/briefs")).toBe("/briefs");
});
it("treats Skill Studio create mode as an unprefixed board route", () => {
expect(isBoardPathWithoutPrefix("/skills/studio/new")).toBe(true);
expect(extractCompanyPrefixFromPath("/skills/studio/new")).toBeNull();
+1
View File
@@ -19,6 +19,7 @@ const BOARD_ROUTE_ROOTS = new Set([
"activity",
"inbox",
"board-chat",
"briefs",
"artifacts",
"u",
"design-guide",
+3
View File
@@ -60,6 +60,9 @@ export const queryKeys = {
builtInAgents: {
list: (companyId: string) => ["built-in-agents", companyId] as const,
},
briefs: {
overview: (companyId: string) => ["briefs", companyId, "overview"] as const,
},
issues: {
list: (companyId: string) => ["issues", companyId] as const,
mentionPool: (companyId: string) => ["issues", companyId, "mention-pool"] as const,
+241
View File
@@ -0,0 +1,241 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BuiltInAgentState, BuiltInAgentStatus } from "@/api/builtInAgents";
import { ApiError } from "@/api/client";
import { Briefs } from "./Briefs";
const companyState = vi.hoisted(() => ({
selectedCompanyId: "company-1" as string | null,
companies: [{ id: "company-1", name: "Paperclip", issuePrefix: "PAP" }] as Array<{
id: string;
name: string;
issuePrefix: string;
}>,
}));
const breadcrumbState = vi.hoisted(() => ({
setBreadcrumbs: vi.fn(),
}));
const briefsApiMock = vi.hoisted(() => ({
overview: vi.fn(),
}));
const builtInAgentsApiMock = vi.hoisted(() => ({
list: vi.fn(),
provision: vi.fn(),
reset: vi.fn(),
}));
const resumeMock = vi.hoisted(() => vi.fn());
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => companyState,
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => breadcrumbState,
}));
vi.mock("@/api/briefs", () => ({
briefsApi: briefsApiMock,
}));
vi.mock("@/api/builtInAgents", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/api/builtInAgents")>();
return {
...actual,
builtInAgentsApi: builtInAgentsApiMock,
};
});
vi.mock("@/api/agents", () => ({
agentsApi: { resume: resumeMock },
}));
vi.mock("@/components/ConfigureBuiltInAgentModal", () => ({
ConfigureBuiltInAgentModal: ({ open }: { open: boolean }) =>
open ? <div data-testid="configure-modal" /> : null,
}));
vi.mock("react-router-dom", () => ({
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
<a href={to}>{children}</a>
),
}));
function makeBuiltInState(status: BuiltInAgentStatus): BuiltInAgentState {
const provisioned = status !== "not_provisioned";
return {
definition: {
key: "briefs",
displayName: "Briefs Agent",
featureKeys: ["briefs"],
shortPurpose: "Prepares briefs.",
defaultInstructions: "Prepare briefs.",
defaultRole: "general",
allowedAdapterTypes: ["codex_local"],
defaultBudgetMonthlyCents: 0,
},
status,
agentId: provisioned ? "agent-1" : null,
agent: provisioned
? ({
id: "agent-1",
name: "Briefs Agent",
status: status === "paused" ? "paused" : status === "pending_approval" ? "pending_approval" : "idle",
pausedAt: status === "paused" ? "2026-07-07T22:45:00.000Z" : null,
} as BuiltInAgentState["agent"])
: null,
pauseReason: status === "paused" ? "maintenance" : null,
};
}
function makeOverview(status: "ready" | "paused" = "ready") {
return {
featureKey: "briefs" as const,
status,
generatedAt: "2026-07-07T22:45:00.000Z",
agent: {
id: "agent-1",
name: "Briefs Agent",
status: status === "paused" ? "paused" : "idle",
adapterType: "codex_local",
},
warning:
status === "paused"
? {
code: "built_in_agent_paused" as const,
key: "briefs",
agentId: "agent-1",
message: "Briefs is paused.",
pauseReason: "maintenance",
}
: null,
summaryItems: [
{ label: "Agent", value: "Briefs Agent", detail: status === "paused" ? "paused" : "idle" },
{ label: "Adapter", value: "codex_local" },
{ label: "Last checked", value: "2026-07-07T22:45:00.000Z" },
],
};
}
async function flushReact() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
flushSync(() => {});
}
describe("Briefs page", () => {
let container: HTMLDivElement;
let root: Root | null = null;
async function renderBriefs() {
root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
flushSync(() => {
root!.render(
<QueryClientProvider client={queryClient}>
<Briefs />
</QueryClientProvider>,
);
});
await flushReact();
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
companyState.selectedCompanyId = "company-1";
companyState.companies = [{ id: "company-1", name: "Paperclip", issuePrefix: "PAP" }];
briefsApiMock.overview.mockReset();
builtInAgentsApiMock.list.mockReset();
resumeMock.mockReset();
breadcrumbState.setBreadcrumbs.mockReset();
builtInAgentsApiMock.list.mockResolvedValue([makeBuiltInState("ready")]);
briefsApiMock.overview.mockResolvedValue(makeOverview());
});
afterEach(() => {
flushSync(() => {
root?.unmount();
});
root = null;
container.remove();
});
it("renders live Briefs content when the built-in agent is ready", async () => {
await renderBriefs();
expect(briefsApiMock.overview).toHaveBeenCalledWith("company-1");
expect(container.textContent).toContain("Briefs Agent");
expect(container.textContent).toContain("No briefs yet");
expect(breadcrumbState.setBreadcrumbs).toHaveBeenCalledWith([{ label: "Briefs" }]);
});
it.each(["not_provisioned", "needs_setup"] as const)(
"renders setup and does not load the overview for %s",
async (status) => {
builtInAgentsApiMock.list.mockResolvedValue([makeBuiltInState(status)]);
await renderBriefs();
expect(container.textContent).toContain("Set up the Briefs Agent");
expect(briefsApiMock.overview).not.toHaveBeenCalled();
},
);
it("renders the pending approval state without loading the overview", async () => {
builtInAgentsApiMock.list.mockResolvedValue([makeBuiltInState("pending_approval")]);
await renderBriefs();
expect(container.textContent).toContain("pending approval");
expect(briefsApiMock.overview).not.toHaveBeenCalled();
});
it("keeps live content readable and offers resume when paused", async () => {
builtInAgentsApiMock.list.mockResolvedValue([makeBuiltInState("paused")]);
briefsApiMock.overview.mockResolvedValue(makeOverview("paused"));
await renderBriefs();
expect(container.textContent).toContain("Briefs is paused.");
expect(container.textContent).toContain("Resume agent");
expect(container.textContent).toContain("No briefs yet");
});
it("shows setup copy if readiness changes while the overview loads", async () => {
briefsApiMock.overview.mockRejectedValue(
new ApiError("Built-in agent is not configured: briefs", 412, {
code: "built_in_agent_not_configured",
details: { key: "briefs", status: "needs_setup" },
}),
);
await renderBriefs();
expect(container.textContent).toContain("Set up the Briefs Agent");
expect(container.textContent).not.toContain("Built-in agent is not configured");
});
it("asks for a company before loading the Briefs surface", async () => {
companyState.selectedCompanyId = null;
await renderBriefs();
expect(container.textContent).toContain("Select a company to view briefs.");
expect(builtInAgentsApiMock.list).not.toHaveBeenCalled();
expect(briefsApiMock.overview).not.toHaveBeenCalled();
});
});
+112
View File
@@ -0,0 +1,112 @@
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { FileText, LayoutDashboard } from "lucide-react";
import { briefsApi } from "@/api/briefs";
import { ApiError } from "@/api/client";
import { BuiltInAgentGate } from "@/components/BuiltInAgentGate";
import { EmptyState } from "@/components/EmptyState";
import { PageSkeleton } from "@/components/PageSkeleton";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { queryKeys } from "@/lib/queryKeys";
function formatCheckedAt(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
function BriefsOverviewPanel({ companyId }: { companyId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.briefs.overview(companyId),
queryFn: () => briefsApi.overview(companyId),
});
if (isLoading) return <PageSkeleton variant="detail" />;
if (error instanceof ApiError && error.status === 412) {
return (
<EmptyState
icon={FileText}
title="Set up the Briefs Agent"
message="Briefs needs a configured built-in agent before live content can load."
/>
);
}
if (error) {
return (
<p className="text-sm text-destructive">
{error instanceof Error ? error.message : "Failed to load Briefs."}
</p>
);
}
if (!data) return null;
return (
<div className="space-y-5">
<div className="grid border-y border-border sm:grid-cols-3">
{data.summaryItems.map((item) => (
<div
key={item.label}
className="space-y-1 border-b border-border py-3 sm:border-b-0 sm:border-r sm:px-4 first:sm:pl-0 last:sm:border-r-0 last:sm:pr-0"
>
<p className="text-xs font-medium text-muted-foreground">{item.label}</p>
<p className="truncate text-sm font-medium">
{item.label === "Last checked" ? formatCheckedAt(item.value) : item.value}
</p>
{item.detail ? (
<p className="truncate text-xs text-muted-foreground">{item.detail}</p>
) : null}
</div>
))}
</div>
<section className="space-y-2">
<h2 className="text-sm font-semibold">Latest Brief</h2>
<div className="border-y border-border">
<EmptyState
icon={FileText}
title="No briefs yet"
message="New company briefs will appear here when generated."
/>
</div>
</section>
</div>
);
}
export function Briefs() {
const { selectedCompanyId, companies } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
useEffect(() => {
setBreadcrumbs([{ label: "Briefs" }]);
}, [setBreadcrumbs]);
if (!selectedCompanyId) {
if (companies.length === 0) {
return (
<EmptyState
icon={LayoutDashboard}
message="Welcome to Paperclip. Set up your first company and agent to get started."
/>
);
}
return <EmptyState icon={FileText} message="Select a company to view briefs." />;
}
return (
<div className="w-full max-w-5xl space-y-5">
<h1 className="text-xl font-semibold">Briefs</h1>
<BuiltInAgentGate agentKey="briefs" companyId={selectedCompanyId} featureLabel="Briefs">
<BriefsOverviewPanel companyId={selectedCompanyId} />
</BuiltInAgentGate>
</div>
);
}