From f3f4df9fcf319e16b34875561133c5ae059a7205 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 24 Aug 2026 07:42:54 +0000 Subject: [PATCH] Restore the live Briefs acceptance surface Co-Authored-By: Paperclip --- packages/shared/src/index.ts | 4 + packages/shared/src/types/briefs.ts | 29 +++ packages/shared/src/types/index.ts | 6 + server/src/__tests__/briefs-routes.test.ts | 115 ++++++++++ server/src/__tests__/briefs-service.test.ts | 101 ++++++++ server/src/__tests__/openapi-routes.test.ts | 1 + server/src/app.ts | 2 + server/src/routes/briefs.ts | 17 ++ server/src/routes/index.ts | 1 + server/src/routes/openapi.ts | 18 ++ server/src/services/briefs.ts | 42 ++++ server/src/services/index.ts | 1 + ui/src/App.tsx | 3 + ui/src/api/briefs.ts | 7 + ui/src/components/CommandPalette.tsx | 5 + ui/src/components/Sidebar.tsx | 2 + ui/src/lib/company-routes.test.ts | 7 + ui/src/lib/company-routes.ts | 1 + ui/src/lib/queryKeys.ts | 3 + ui/src/pages/Briefs.test.tsx | 241 ++++++++++++++++++++ ui/src/pages/Briefs.tsx | 112 +++++++++ 21 files changed, 718 insertions(+) create mode 100644 packages/shared/src/types/briefs.ts create mode 100644 server/src/__tests__/briefs-routes.test.ts create mode 100644 server/src/__tests__/briefs-service.test.ts create mode 100644 server/src/routes/briefs.ts create mode 100644 server/src/services/briefs.ts create mode 100644 ui/src/api/briefs.ts create mode 100644 ui/src/pages/Briefs.test.tsx create mode 100644 ui/src/pages/Briefs.tsx diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 261e6301db5..233817b7470 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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 { diff --git a/packages/shared/src/types/briefs.ts b/packages/shared/src/types/briefs.ts new file mode 100644 index 00000000000..5eb30e57b87 --- /dev/null +++ b/packages/shared/src/types/briefs.ts @@ -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[]; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 246c30f1616..d726cd0203e 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -184,6 +184,12 @@ export type { AdapterEnvironmentCheck, AdapterEnvironmentTestResult, } from "./agent.js"; +export type { + BriefsOverview, + BriefsOverviewAgent, + BriefsOverviewSummaryItem, + BriefsOverviewWarning, +} from "./briefs.js"; export type { AgentEligibilityAgent, AgentEligibilityLifecycleReason, diff --git a/server/src/__tests__/briefs-routes.test.ts b/server/src/__tests__/briefs-routes.test.ts new file mode 100644 index 00000000000..7ddd9d36e78 --- /dev/null +++ b/server/src/__tests__/briefs-routes.test.ts @@ -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) { + const [{ briefsRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/briefs.js"), + vi.importActual("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as typeof req & { actor: Record }).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"], + }, + }); + }); +}); diff --git a/server/src/__tests__/briefs-service.test.ts b/server/src/__tests__/briefs-service.test.ts new file mode 100644 index 00000000000..a0cdf991cc2 --- /dev/null +++ b/server/src/__tests__/briefs-service.test.ts @@ -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); + }); +}); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 9736c256947..37d1f6989af 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -19,6 +19,7 @@ const apiPrefixes: Record = { "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", diff --git a/server/src/app.ts b/server/src/app.ts index 566baa71043..79873dd817b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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)); diff --git a/server/src/routes/briefs.ts b/server/src/routes/briefs.ts new file mode 100644 index 00000000000..26da0abacf3 --- /dev/null +++ b/server/src/routes/briefs.ts @@ -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; +} diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index c637677c9f1..af4b44e0f94 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -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"; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 91710f71d72..d0eee58337c 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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({ diff --git a/server/src/services/briefs.ts b/server/src/services/briefs.ts new file mode 100644 index 00000000000..3f8230e6d8f --- /dev/null +++ b/server/src/services/briefs.ts @@ -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 { + 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(), + }, + ], + }; + }, + }; +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 43524a5642d..43084f02366 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -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, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b09498d7e9b..ca754623b71 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> @@ -443,6 +445,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/briefs.ts b/ui/src/api/briefs.ts new file mode 100644 index 00000000000..b013177f107 --- /dev/null +++ b/ui/src/api/briefs.ts @@ -0,0 +1,7 @@ +import type { BriefsOverview } from "@paperclipai/shared"; +import { api } from "./client"; + +export const briefsApi = { + overview: (companyId: string) => + api.get(`/companies/${companyId}/briefs/overview`), +}; diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 0dedef05bc7..ba3ecc91cca 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -29,6 +29,7 @@ import { History, SquarePen, FileCode2, + FileText, Plus, Search, } from "lucide-react"; @@ -324,6 +325,10 @@ export function CommandPalette() { Goals + go("/briefs")}> + + Briefs + go("/agents")}> Agents diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index cc5e9b26105..7244420eba6 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -5,6 +5,7 @@ import { LayoutDashboard, DollarSign, History, + FileText, Search, SquarePen, Network, @@ -185,6 +186,7 @@ export function Sidebar() { + {showPipelines ? ( ) : null} diff --git a/ui/src/lib/company-routes.test.ts b/ui/src/lib/company-routes.test.ts index 5b6f5e4a376..f0899e679a8 100644 --- a/ui/src/lib/company-routes.test.ts +++ b/ui/src/lib/company-routes.test.ts @@ -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(); diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index 05a2c6a0b7a..c8c0405797a 100644 --- a/ui/src/lib/company-routes.ts +++ b/ui/src/lib/company-routes.ts @@ -19,6 +19,7 @@ const BOARD_ROUTE_ROOTS = new Set([ "activity", "inbox", "board-chat", + "briefs", "artifacts", "u", "design-guide", diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index edfe492644e..f6e54574675 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -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, diff --git a/ui/src/pages/Briefs.test.tsx b/ui/src/pages/Briefs.test.tsx new file mode 100644 index 00000000000..fd3b63354f2 --- /dev/null +++ b/ui/src/pages/Briefs.test.tsx @@ -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(); + return { + ...actual, + builtInAgentsApi: builtInAgentsApiMock, + }; +}); + +vi.mock("@/api/agents", () => ({ + agentsApi: { resume: resumeMock }, +})); + +vi.mock("@/components/ConfigureBuiltInAgentModal", () => ({ + ConfigureBuiltInAgentModal: ({ open }: { open: boolean }) => + open ?
: null, +})); + +vi.mock("react-router-dom", () => ({ + Link: ({ children, to }: { children: React.ReactNode; to: string }) => ( + {children} + ), +})); + +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( + + + , + ); + }); + 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(); + }); +}); diff --git a/ui/src/pages/Briefs.tsx b/ui/src/pages/Briefs.tsx new file mode 100644 index 00000000000..45a552dfcc6 --- /dev/null +++ b/ui/src/pages/Briefs.tsx @@ -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 ; + + if (error instanceof ApiError && error.status === 412) { + return ( + + ); + } + + if (error) { + return ( +

+ {error instanceof Error ? error.message : "Failed to load Briefs."} +

+ ); + } + + if (!data) return null; + + return ( +
+
+ {data.summaryItems.map((item) => ( +
+

{item.label}

+

+ {item.label === "Last checked" ? formatCheckedAt(item.value) : item.value} +

+ {item.detail ? ( +

{item.detail}

+ ) : null} +
+ ))} +
+ +
+

Latest Brief

+
+ +
+
+
+ ); +} + +export function Briefs() { + const { selectedCompanyId, companies } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + + useEffect(() => { + setBreadcrumbs([{ label: "Briefs" }]); + }, [setBreadcrumbs]); + + if (!selectedCompanyId) { + if (companies.length === 0) { + return ( + + ); + } + return ; + } + + return ( +
+

Briefs

+ + + +
+ ); +}