mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
Restore the live Briefs acceptance surface
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
1c61f693a4
commit
f3f4df9fcf
21 files changed
+718
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -184,6 +184,12 @@ export type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "./agent.js";
|
||||
export type {
|
||||
BriefsOverview,
|
||||
BriefsOverviewAgent,
|
||||
BriefsOverviewSummaryItem,
|
||||
BriefsOverviewWarning,
|
||||
} from "./briefs.js";
|
||||
export type {
|
||||
AgentEligibilityAgent,
|
||||
AgentEligibilityLifecycleReason,
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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`),
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -19,6 +19,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
||||
"activity",
|
||||
"inbox",
|
||||
"board-chat",
|
||||
"briefs",
|
||||
"artifacts",
|
||||
"u",
|
||||
"design-guide",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user