From 4693d770aab4ec8342833cfed78dd4ac43c49f7a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:11:05 -1000 Subject: [PATCH] Add company artifacts page (#7621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators need a way to inspect files and work products created by agents across a company without opening each issue one by one. > - The existing issue detail surfaces already show attachments and outputs, but there was no company-level artifacts index or search-result affordance for artifact-like records. > - The backend needed a company-scoped artifacts projection API that preserves issue/run attribution and safe links back to source records. > - The UI needed a first-class Artifacts page, sidebar entry, reusable artifact cards, and deep-link handling that keeps company prefixes intact. > - This pull request adds the company artifacts API and page, then wires artifacts into search and issue output surfaces. > - The benefit is a single place to browse, filter, and open generated work products and attachments while preserving company boundaries. ## Linked Issues or Issue Description Fixes #7622. Feature request fields: - Problem/motivation: company operators need a consolidated artifacts surface for attachments and work products produced by agents. - Proposed solution: add a company-scoped artifacts projection endpoint, a board Artifacts route, reusable cards, sidebar navigation, and artifact search integration. - Alternatives considered: keep artifact discovery only on individual issue pages; that forces operators to know the source issue before finding generated outputs. - Roadmap alignment: checked `ROADMAP.md`; this is a focused board UI/API improvement and does not duplicate a listed roadmap item. ## What Changed - Added shared artifact types and validators. - Added a company-scoped artifact projection service/API with tests for attachment/work-product attribution. - Added Artifacts board UI route, API client, sidebar link, cards, filters, and storybook coverage. - Added artifact result handling to company search and issue output/deep-link flows. - Rebased the branch onto the latest `public-gh/master` state and resolved the route-test conflict by preserving both upstream team-catalog coverage and artifact route coverage. - Fixed a local Sidebar test helper so it no longer depends on a runtime-undefined `React.act` export in this dependency install. ## Verification - `pnpm --filter @paperclipai/ui exec vitest run src/components/artifacts/ArtifactCard.test.tsx src/api/artifacts.test.ts src/lib/company-routes.test.ts` - `pnpm --filter @paperclipai/ui exec vitest run src/pages/Artifacts.test.tsx src/pages/Search.test.tsx src/components/Sidebar.test.tsx` - `pnpm exec vitest run server/src/__tests__/company-artifacts-service.test.ts server/src/__tests__/company-search-service.test.ts server/src/__tests__/company-search-rate-limit-routes.test.ts server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts` - Confirmed the PR diff does not include `pnpm-lock.yaml` or `.github/workflows/*`. - Duplicate search: no open PRs or issues found for `artifact page ArtifactCard` in `paperclipai/paperclip`. Screenshots are intentionally omitted per the internal task instruction not to add design screenshots or images to this PR unless they are specifically part of the work. I also attempted browser capture in this runner, but `agent-browser` failed to launch Chrome and Playwright Chromium is missing `libatk-1.0.so.0`. ## Risks - Low-to-medium risk: this adds a new API projection and UI surface, so attribution/link regressions could affect artifact navigation. - Company scoping is covered in the new service/API tests. - No database migrations are included. - No lockfile or workflow changes are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5 coding agent with tool use and local command execution. Exact hosted model identifier is not exposed in this runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots (intentionally omitted per task instruction; browser capture unavailable in this runner) - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- packages/shared/src/index.ts | 17 + packages/shared/src/types/artifact.ts | 41 ++ packages/shared/src/types/index.ts | 10 + packages/shared/src/types/search.ts | 17 +- packages/shared/src/validators/artifact.ts | 57 ++ packages/shared/src/validators/index.ts | 12 + .../companies-route-path-guard.test.ts | 3 + .../company-artifacts-service.test.ts | 519 ++++++++++++++++++ .../__tests__/company-branding-route.test.ts | 5 + .../company-portability-routes.test.ts | 6 + .../company-search-rate-limit-routes.test.ts | 2 +- .../__tests__/company-search-service.test.ts | 55 +- ...ue-agent-mutation-ownership-routes.test.ts | 123 ++++- server/src/routes/companies.ts | 10 + server/src/routes/issues.ts | 56 ++ server/src/routes/openapi.ts | 24 + server/src/services/company-artifacts.ts | 433 +++++++++++++++ server/src/services/company-search.ts | 64 ++- server/src/services/index.ts | 1 + ui/src/App.tsx | 3 + ui/src/api/artifacts.test.ts | 75 +++ ui/src/api/artifacts.ts | 71 +++ ui/src/components/IssueAttachmentsSection.tsx | 9 +- ui/src/components/Sidebar.test.tsx | 41 +- ui/src/components/Sidebar.tsx | 2 + .../artifacts/ArtifactCard.test.tsx | 165 ++++++ ui/src/components/artifacts/ArtifactCard.tsx | 187 +++++++ .../issue-output/IssueOutputSection.tsx | 10 +- ui/src/components/search/SearchResultRow.tsx | 52 +- ui/src/lib/company-routes.test.ts | 18 + ui/src/lib/company-routes.ts | 1 + ui/src/lib/queryKeys.ts | 4 + ui/src/pages/Artifacts.test.tsx | 231 ++++++++ ui/src/pages/Artifacts.tsx | 164 ++++++ ui/src/pages/IssueDetail.tsx | 31 ++ ui/src/pages/Search.test.tsx | 92 +++- ui/src/pages/Search.tsx | 16 +- ui/storybook/stories/artifacts.stories.tsx | 143 +++++ ui/storybook/stories/search.stories.tsx | 7 +- 39 files changed, 2706 insertions(+), 71 deletions(-) create mode 100644 packages/shared/src/types/artifact.ts create mode 100644 packages/shared/src/validators/artifact.ts create mode 100644 server/src/__tests__/company-artifacts-service.test.ts create mode 100644 server/src/services/company-artifacts.ts create mode 100644 ui/src/api/artifacts.test.ts create mode 100644 ui/src/api/artifacts.ts create mode 100644 ui/src/components/artifacts/ArtifactCard.test.tsx create mode 100644 ui/src/components/artifacts/ArtifactCard.tsx create mode 100644 ui/src/pages/Artifacts.test.tsx create mode 100644 ui/src/pages/Artifacts.tsx create mode 100644 ui/storybook/stories/artifacts.stories.tsx diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2cb1e698..1cd4d297 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -404,6 +404,7 @@ export type { ProjectManagedByPlugin, ProjectWorkspace, CompanySearchHighlight, + CompanySearchArtifactSummary, CompanySearchIssueSummary, CompanySearchResponse, CompanySearchResult, @@ -446,6 +447,13 @@ export type { IssueWorkProductProvider, IssueWorkProductStatus, IssueWorkProductReviewState, + CompanyArtifact, + CompanyArtifactAgentSummary, + CompanyArtifactIssueSummary, + CompanyArtifactMediaKind, + CompanyArtifactProjectSummary, + CompanyArtifactSource, + CompanyArtifactsResponse, CreateDocumentAnnotationCommentRequest, CreateDocumentAnnotationThreadRequest, DocumentAnnotationAnchorRemapSnapshot, @@ -982,6 +990,14 @@ export { issueWorkProductTypeSchema, issueWorkProductStatusSchema, issueWorkProductReviewStateSchema, + COMPANY_ARTIFACTS_DEFAULT_LIMIT, + COMPANY_ARTIFACTS_MAX_LIMIT, + COMPANY_ARTIFACTS_MAX_QUERY_LENGTH, + companyArtifactMediaKindSchema, + companyArtifactSchema, + companyArtifactSourceSchema, + companyArtifactsQuerySchema, + companyArtifactsResponseSchema, updateExecutionWorkspaceSchema, executionWorkspaceStatusSchema, executionWorkspaceCloseActionKindSchema, @@ -1016,6 +1032,7 @@ export { type CreateIssueAttachmentMetadata, type CreateIssueWorkProduct, type UpdateIssueWorkProduct, + type CompanyArtifactsQuery, type UpdateExecutionWorkspace, type IssueDocumentFormat, type UpsertIssueDocument, diff --git a/packages/shared/src/types/artifact.ts b/packages/shared/src/types/artifact.ts new file mode 100644 index 00000000..413ed68f --- /dev/null +++ b/packages/shared/src/types/artifact.ts @@ -0,0 +1,41 @@ +export type CompanyArtifactSource = "document" | "attachment" | "work_product"; + +export type CompanyArtifactMediaKind = "image" | "video" | "text" | "document" | "file" | "empty"; + +export interface CompanyArtifactIssueSummary { + id: string; + identifier: string; + title: string; +} + +export interface CompanyArtifactProjectSummary { + id: string; + name: string; +} + +export interface CompanyArtifactAgentSummary { + id: string; + name: string; +} + +export interface CompanyArtifact { + id: string; + source: CompanyArtifactSource; + mediaKind: CompanyArtifactMediaKind; + title: string; + previewText: string | null; + contentType: string | null; + contentPath: string | null; + openPath: string | null; + downloadPath: string | null; + issue: CompanyArtifactIssueSummary; + project: CompanyArtifactProjectSummary | null; + createdByAgent: CompanyArtifactAgentSummary | null; + updatedAt: string; + href: string; +} + +export interface CompanyArtifactsResponse { + artifacts: CompanyArtifact[]; + nextCursor: string | null; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index a235150f..9526d13b 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -162,6 +162,7 @@ export type { export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js"; export type { CompanySearchHighlight, + CompanySearchArtifactSummary, CompanySearchIssueSummary, CompanySearchResponse, CompanySearchResult, @@ -213,6 +214,15 @@ export type { IssueWorkProductReviewState, AttachmentArtifactWorkProductMetadata, } from "./work-product.js"; +export type { + CompanyArtifact, + CompanyArtifactAgentSummary, + CompanyArtifactIssueSummary, + CompanyArtifactMediaKind, + CompanyArtifactProjectSummary, + CompanyArtifactSource, + CompanyArtifactsResponse, +} from "./artifact.js"; export type { Issue, IssueWorkMode, diff --git a/packages/shared/src/types/search.ts b/packages/shared/src/types/search.ts index 145c5ba3..3b453cfe 100644 --- a/packages/shared/src/types/search.ts +++ b/packages/shared/src/types/search.ts @@ -1,9 +1,9 @@ import type { IssuePriority, IssueStatus } from "../constants.js"; -export const COMPANY_SEARCH_SCOPES = ["all", "issues", "comments", "documents", "agents", "projects"] as const; +export const COMPANY_SEARCH_SCOPES = ["all", "issues", "comments", "documents", "artifacts", "agents", "projects"] as const; export type CompanySearchScope = (typeof COMPANY_SEARCH_SCOPES)[number]; -export type CompanySearchResultType = "issue" | "agent" | "project"; +export type CompanySearchResultType = "issue" | "artifact" | "agent" | "project"; export interface CompanySearchHighlight { start: number; @@ -29,6 +29,18 @@ export interface CompanySearchIssueSummary { updatedAt: string; } +export interface CompanySearchArtifactSummary { + id: string; + source: "document" | "attachment" | "work_product"; + mediaKind: "image" | "video" | "text" | "document" | "file" | "empty"; + issueId: string; + issueIdentifier: string; + issueTitle: string; + projectId: string | null; + projectName: string | null; + updatedAt: string; +} + export interface CompanySearchResult { id: string; type: CompanySearchResultType; @@ -40,6 +52,7 @@ export interface CompanySearchResult { snippet: string | null; snippets: CompanySearchSnippet[]; issue?: CompanySearchIssueSummary; + artifact?: CompanySearchArtifactSummary; updatedAt: string | null; previewImageUrl: string | null; } diff --git a/packages/shared/src/validators/artifact.ts b/packages/shared/src/validators/artifact.ts new file mode 100644 index 00000000..72251d41 --- /dev/null +++ b/packages/shared/src/validators/artifact.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +export const COMPANY_ARTIFACTS_DEFAULT_LIMIT = 30; +export const COMPANY_ARTIFACTS_MAX_LIMIT = 100; +export const COMPANY_ARTIFACTS_MAX_QUERY_LENGTH = 160; + +export const companyArtifactSourceSchema = z.enum(["document", "attachment", "work_product"]); + +export const companyArtifactMediaKindSchema = z.enum(["image", "video", "text", "document", "file", "empty"]); + +export const companyArtifactsQuerySchema = z.object({ + kind: z.enum(["image", "video", "text", "document", "file", "all"]).optional().default("all"), + projectId: z.string().uuid().optional(), + q: z.string().trim().max(COMPANY_ARTIFACTS_MAX_QUERY_LENGTH).optional(), + limit: z.coerce + .number() + .int() + .min(1) + .max(COMPANY_ARTIFACTS_MAX_LIMIT) + .optional() + .default(COMPANY_ARTIFACTS_DEFAULT_LIMIT), + cursor: z.string().min(1).optional(), +}); + +export const companyArtifactSchema = z.object({ + id: z.string().min(1), + source: companyArtifactSourceSchema, + mediaKind: companyArtifactMediaKindSchema, + title: z.string(), + previewText: z.string().nullable(), + contentType: z.string().nullable(), + contentPath: z.string().nullable(), + openPath: z.string().nullable(), + downloadPath: z.string().nullable(), + issue: z.object({ + id: z.string().uuid(), + identifier: z.string(), + title: z.string(), + }), + project: z.object({ + id: z.string().uuid(), + name: z.string(), + }).nullable(), + createdByAgent: z.object({ + id: z.string().uuid(), + name: z.string(), + }).nullable(), + updatedAt: z.string().datetime(), + href: z.string().min(1), +}); + +export const companyArtifactsResponseSchema = z.object({ + artifacts: z.array(companyArtifactSchema), + nextCursor: z.string().nullable(), +}); + +export type CompanyArtifactsQuery = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index e8cc4233..85719b7e 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -326,6 +326,18 @@ export { type UpdateIssueWorkProduct, } from "./work-product.js"; +export { + COMPANY_ARTIFACTS_DEFAULT_LIMIT, + COMPANY_ARTIFACTS_MAX_LIMIT, + COMPANY_ARTIFACTS_MAX_QUERY_LENGTH, + companyArtifactMediaKindSchema, + companyArtifactSchema, + companyArtifactSourceSchema, + companyArtifactsQuerySchema, + companyArtifactsResponseSchema, + type CompanyArtifactsQuery, +} from "./artifact.js"; + export { executionWorkspaceConfigSchema, updateExecutionWorkspaceSchema, diff --git a/server/src/__tests__/companies-route-path-guard.test.ts b/server/src/__tests__/companies-route-path-guard.test.ts index 414936a9..6ae4391f 100644 --- a/server/src/__tests__/companies-route-path-guard.test.ts +++ b/server/src/__tests__/companies-route-path-guard.test.ts @@ -19,6 +19,9 @@ vi.mock("../services/index.js", () => ({ previewImport: vi.fn(), importBundle: vi.fn(), }), + companyArtifactsService: () => ({ + list: vi.fn(), + }), accessService: () => ({ canUser: vi.fn(), ensureMembership: vi.fn(), diff --git a/server/src/__tests__/company-artifacts-service.test.ts b/server/src/__tests__/company-artifacts-service.test.ts new file mode 100644 index 00000000..429c724b --- /dev/null +++ b/server/src/__tests__/company-artifacts-service.test.ts @@ -0,0 +1,519 @@ +import { Readable } from "node:stream"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + agents, + assets, + companies, + createDb, + documents, + heartbeatRuns, + issueAttachments, + issueComments, + issueDocuments, + issues, + issueWorkProducts, + projects, +} from "@paperclipai/db"; +import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { companyRoutes } from "../routes/companies.js"; +import { companyArtifactsService } from "../services/company-artifacts.js"; +import type { StorageService } from "../storage/types.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres company artifacts tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +function createStorageService(files: Record = {}): StorageService { + return { + provider: "local_disk", + putFile: vi.fn(), + getObject: vi.fn(async (_companyId, objectKey, options) => { + const body = files[objectKey] ?? Buffer.alloc(0); + const range = options?.range; + const ranged = range ? body.subarray(range.start, range.end + 1) : body; + return { + stream: Readable.from(ranged), + contentType: "text/plain", + contentLength: ranged.length, + }; + }), + headObject: vi.fn(), + deleteObject: vi.fn(), + }; +} + +describeEmbeddedPostgres("companyArtifactsService", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-artifacts-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(issueWorkProducts); + await db.delete(issueAttachments); + await db.delete(assets); + await db.delete(issueComments); + await db.delete(issueDocuments); + await db.delete(documents); + await db.delete(heartbeatRuns); + await db.delete(issues); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedArtifacts() { + const companyId = "11111111-1111-4111-8111-111111111111"; + const otherCompanyId = "22222222-2222-4222-8222-222222222222"; + const agentId = "33333333-3333-4333-8333-333333333333"; + const otherAgentId = "44444444-4444-4444-8444-444444444444"; + const projectId = "55555555-5555-4555-8555-555555555555"; + const issueId = "66666666-6666-4666-8666-666666666666"; + const secondIssueId = "77777777-7777-4777-8777-777777777777"; + const otherIssueId = "88888888-8888-4888-8888-888888888888"; + const runId = "99999999-9999-4999-8999-999999999999"; + const otherRunId = "19191919-1919-4191-8191-191919191919"; + const directAttachmentId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const workProductAttachmentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + + await db.insert(companies).values([ + { id: companyId, name: "Paperclip", issuePrefix: "PAP", requireBoardApprovalForNewAgents: false }, + { id: otherCompanyId, name: "OtherCo", issuePrefix: "OTH", requireBoardApprovalForNewAgents: false }, + ]); + await db.insert(agents).values([ + { id: agentId, companyId, name: "Coder", role: "engineer" }, + { id: otherAgentId, companyId: otherCompanyId, name: "Other", role: "engineer" }, + ]); + await db.insert(projects).values({ id: projectId, companyId, name: "Artifacts", status: "in_progress" }); + await db.insert(issues).values([ + { + id: issueId, + companyId, + projectId, + identifier: "PAP-1", + title: "Make the reel", + status: "done", + priority: "medium", + }, + { + id: secondIssueId, + companyId, + identifier: "PAP-2", + title: "Write the plan", + status: "done", + priority: "medium", + }, + { + id: otherIssueId, + companyId: otherCompanyId, + identifier: "OTH-1", + title: "Other output", + status: "done", + priority: "medium", + }, + ]); + await db.insert(heartbeatRuns).values([ + { + id: runId, + companyId, + agentId, + status: "completed", + }, + { + id: otherRunId, + companyId: otherCompanyId, + agentId: otherAgentId, + status: "completed", + }, + ]); + await db.insert(documents).values([ + { + id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + companyId, + title: "Review Notes", + latestBody: "# Review\n\nAgent-created review document with useful details.", + createdByAgentId: agentId, + updatedAt: new Date("2026-01-04T00:00:00.000Z"), + }, + { + id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + companyId, + title: "Continuation Summary", + latestBody: "System handoff", + createdByAgentId: agentId, + updatedAt: new Date("2026-01-05T00:00:00.000Z"), + }, + { + id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + companyId, + title: "User Upload Notes", + latestBody: "User-authored context", + createdByUserId: "user-1", + updatedAt: new Date("2026-01-06T00:00:00.000Z"), + }, + { + id: "ffffffff-ffff-4fff-8fff-ffffffffffff", + companyId: otherCompanyId, + title: "Other Company Plan", + latestBody: "Must not cross tenants", + createdByAgentId: otherAgentId, + updatedAt: new Date("2026-01-07T00:00:00.000Z"), + }, + ]); + await db.insert(issueDocuments).values([ + { + companyId, + issueId: secondIssueId, + documentId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + key: "review", + }, + { + companyId, + issueId, + documentId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + }, + { + companyId, + issueId, + documentId: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + key: "user-notes", + }, + { + companyId: otherCompanyId, + issueId: otherIssueId, + documentId: "ffffffff-ffff-4fff-8fff-ffffffffffff", + key: "plan", + }, + ]); + await db.insert(issueComments).values({ + id: "12121212-1212-4121-8121-121212121212", + companyId, + issueId, + authorType: "agent", + authorAgentId: agentId, + body: "comment with screenshot", + }); + await db.insert(assets).values([ + { + id: "13131313-1313-4131-8131-131313131313", + companyId, + provider: "local_disk", + objectKey: "direct-video.mp4", + contentType: "video/mp4", + byteSize: 100, + sha256: "sha256-direct-video", + originalFilename: "direct-video.mp4", + createdByAgentId: agentId, + }, + { + id: "14141414-1414-4141-8141-141414141414", + companyId, + provider: "local_disk", + objectKey: "primary-cut.mp4", + contentType: "video/mp4", + byteSize: 200, + sha256: "sha256-primary-cut", + originalFilename: "primary-cut.mp4", + createdByAgentId: agentId, + }, + { + id: "15151515-1515-4151-8151-151515151515", + companyId, + provider: "local_disk", + objectKey: "operator-screenshot.png", + contentType: "image/png", + byteSize: 300, + sha256: "sha256-user", + originalFilename: "operator-screenshot.png", + createdByUserId: "user-1", + }, + { + id: "16161616-1616-4161-8161-161616161616", + companyId, + provider: "local_disk", + objectKey: "comment-screenshot.png", + contentType: "image/png", + byteSize: 400, + sha256: "sha256-comment", + originalFilename: "comment-screenshot.png", + createdByAgentId: agentId, + }, + { + id: "17171717-1717-4171-8171-171717171717", + companyId, + provider: "local_disk", + objectKey: "notes.txt", + contentType: "text/plain", + byteSize: 64, + sha256: "sha256-notes", + originalFilename: "notes.txt", + createdByAgentId: agentId, + }, + ]); + await db.insert(issueAttachments).values([ + { + id: directAttachmentId, + companyId, + issueId, + assetId: "13131313-1313-4131-8131-131313131313", + updatedAt: new Date("2026-01-03T00:00:00.000Z"), + }, + { + id: workProductAttachmentId, + companyId, + issueId, + assetId: "14141414-1414-4141-8141-141414141414", + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + }, + { + companyId, + issueId, + assetId: "15151515-1515-4151-8151-151515151515", + updatedAt: new Date("2026-01-08T00:00:00.000Z"), + }, + { + companyId, + issueId, + assetId: "16161616-1616-4161-8161-161616161616", + issueCommentId: "12121212-1212-4121-8121-121212121212", + updatedAt: new Date("2026-01-09T00:00:00.000Z"), + }, + { + companyId, + issueId, + assetId: "17171717-1717-4171-8171-171717171717", + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }, + ]); + await db.insert(issueWorkProducts).values({ + id: "18181818-1818-4181-8181-181818181818", + companyId, + projectId, + issueId, + type: "artifact", + provider: "paperclip", + title: "Primary Cut", + status: "ready_for_review", + summary: "Main render for review", + isPrimary: true, + metadata: { + attachmentId: workProductAttachmentId, + contentType: "video/mp4", + byteSize: 200, + contentPath: `/api/attachments/${workProductAttachmentId}/content`, + openPath: `/api/attachments/${workProductAttachmentId}/content`, + downloadPath: `/api/attachments/${workProductAttachmentId}/content?download=1`, + originalFilename: "primary-cut.mp4", + }, + createdByRunId: runId, + updatedAt: new Date("2026-01-02T12:00:00.000Z"), + }); + + return { companyId, projectId, issueId, otherRunId }; + } + + it("projects agent-created documents, direct attachments, and work products while excluding noisy sources", async () => { + const { companyId } = await seedArtifacts(); + const storage = createStorageService({ "notes.txt": Buffer.from("Text file preview from an agent output.") }); + const result = await companyArtifactsService(db, storage).list(companyId, { limit: 20 }); + + expect(result.nextCursor).toBeNull(); + expect(result.artifacts.map((artifact) => artifact.title)).toEqual([ + "Review Notes", + "direct-video.mp4", + "Primary Cut", + "notes.txt", + ]); + expect(result.artifacts.map((artifact) => artifact.source)).toEqual([ + "document", + "attachment", + "work_product", + "attachment", + ]); + expect(result.artifacts.find((artifact) => artifact.title === "notes.txt")?.previewText) + .toBe("Text file preview from an agent output."); + expect(result.artifacts.some((artifact) => artifact.title === "primary-cut.mp4")).toBe(false); + expect(result.artifacts.some((artifact) => artifact.title === "Continuation Summary")).toBe(false); + expect(result.artifacts.some((artifact) => artifact.title === "operator-screenshot.png")).toBe(false); + expect(result.artifacts.some((artifact) => artifact.title === "comment-screenshot.png")).toBe(false); + expect(result.artifacts.some((artifact) => artifact.issue.identifier === "OTH-1")).toBe(false); + }); + + it("supports project, kind, search, and cursor filters", async () => { + const { companyId, projectId } = await seedArtifacts(); + const storage = createStorageService({ "notes.txt": Buffer.from("Searchable notes preview") }); + + const projectVideos = await companyArtifactsService(db, storage).list(companyId, { + projectId, + kind: "video", + limit: 10, + }); + expect(projectVideos.artifacts.map((artifact) => artifact.title)).toEqual(["direct-video.mp4", "Primary Cut"]); + + const search = await companyArtifactsService(db, storage).list(companyId, { + q: "review document", + limit: 10, + }); + expect(search.artifacts.map((artifact) => artifact.title)).toEqual(["Review Notes"]); + + const firstPage = await companyArtifactsService(db, storage).list(companyId, { limit: 2 }); + expect(firstPage.artifacts.map((artifact) => artifact.title)).toEqual(["Review Notes", "direct-video.mp4"]); + expect(firstPage.nextCursor).toEqual(expect.any(String)); + + const secondPage = await companyArtifactsService(db, storage).list(companyId, { + limit: 10, + cursor: firstPage.nextCursor ?? undefined, + }); + expect(secondPage.artifacts.map((artifact) => artifact.title)).toEqual(["Primary Cut", "notes.txt"]); + + const pageAfterPrimaryWorkProduct = await companyArtifactsService(db, storage).list(companyId, { limit: 3 }); + expect(pageAfterPrimaryWorkProduct.artifacts.map((artifact) => artifact.title)).toEqual([ + "Review Notes", + "direct-video.mp4", + "Primary Cut", + ]); + + const afterPrimaryCursor = await companyArtifactsService(db, storage).list(companyId, { + limit: 10, + cursor: pageAfterPrimaryWorkProduct.nextCursor ?? undefined, + }); + expect(afterPrimaryCursor.artifacts.map((artifact) => artifact.title)).toEqual(["notes.txt"]); + }); + + it("deduplicates work product attachments beyond the work product fetch window", async () => { + const { companyId, projectId, issueId } = await seedArtifacts(); + const dedupedAttachmentId = "abababab-abab-4bab-8bab-abababababab"; + + await db.insert(assets).values({ + id: "acacacac-acac-4cac-8cac-acacacacacac", + companyId, + provider: "local_disk", + objectKey: "late-render.mp4", + contentType: "video/mp4", + byteSize: 500, + sha256: "sha256-late-render", + originalFilename: "late-render.mp4", + createdByAgentId: "33333333-3333-4333-8333-333333333333", + }); + await db.insert(issueAttachments).values({ + id: dedupedAttachmentId, + companyId, + issueId, + assetId: "acacacac-acac-4cac-8cac-acacacacacac", + updatedAt: new Date("2026-01-20T00:00:00.000Z"), + }); + + const fillerWorkProducts = Array.from({ length: 21 }, (_, index) => ({ + id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`, + companyId, + projectId, + issueId, + type: "artifact" as const, + provider: "paperclip", + title: `Filler Video ${index + 1}`, + status: "ready_for_review" as const, + summary: "Filler artifact to push the attachment-backed work product past the fetch window", + metadata: { contentType: "video/mp4" }, + createdByRunId: "99999999-9999-4999-8999-999999999999", + updatedAt: new Date(`2026-01-10T00:${String(index).padStart(2, "0")}:00.000Z`), + })); + await db.insert(issueWorkProducts).values([ + ...fillerWorkProducts, + { + id: "adadadad-adad-4dad-8dad-adadadadadad", + companyId, + projectId, + issueId, + type: "artifact", + provider: "paperclip", + title: "Late Render", + status: "ready_for_review", + summary: "Attachment-backed work product outside the limited fetch window", + metadata: { + attachmentId: dedupedAttachmentId, + contentType: "video/mp4", + byteSize: 500, + contentPath: `/api/attachments/${dedupedAttachmentId}/content`, + openPath: `/api/attachments/${dedupedAttachmentId}/content`, + downloadPath: `/api/attachments/${dedupedAttachmentId}/content?download=1`, + originalFilename: "late-render.mp4", + }, + createdByRunId: "99999999-9999-4999-8999-999999999999", + updatedAt: new Date("2026-01-01T12:00:00.000Z"), + }, + ]); + + const result = await companyArtifactsService(db, createStorageService()).list(companyId, { + kind: "video", + limit: 20, + }); + + expect(result.artifacts.some((artifact) => artifact.title === "late-render.mp4")).toBe(false); + }); + + it("does not project a foreign agent from a malformed work product run reference", async () => { + const { companyId, issueId, otherRunId } = await seedArtifacts(); + + await db.insert(issueWorkProducts).values({ + id: "1a1a1a1a-1a1a-4a1a-8a1a-1a1a1a1a1a1a", + companyId, + issueId, + type: "artifact", + provider: "paperclip", + title: "Forged Run Artifact", + status: "ready_for_review", + summary: "Historically malformed run attribution", + metadata: { contentType: "text/plain" }, + createdByRunId: otherRunId, + updatedAt: new Date("2026-01-10T00:00:00.000Z"), + }); + + const result = await companyArtifactsService(db, createStorageService()).list(companyId, { limit: 20 }); + const forged = result.artifacts.find((artifact) => artifact.title === "Forged Run Artifact"); + + expect(forged).toBeTruthy(); + expect(forged?.createdByAgent).toBeNull(); + expect(result.artifacts.some((artifact) => artifact.createdByAgent?.name === "Other")).toBe(false); + }); +}); + +describe("company artifacts route authorization", () => { + it("rejects agent access across company boundaries before reading artifacts", async () => { + const app = express(); + app.use((_req, _res, next) => { + (_req as any).actor = { + type: "agent", + agentId: "agent-1", + companyId: "company-allowed", + }; + next(); + }); + app.use("/api/companies", companyRoutes({} as any, createStorageService())); + app.use(errorHandler); + + const res = await request(app).get("/api/companies/company-denied/artifacts"); + + expect(res.status).toBe(403); + expect(res.body.error).toBe("Agent key cannot access another company"); + }); +}); diff --git a/server/src/__tests__/company-branding-route.test.ts b/server/src/__tests__/company-branding-route.test.ts index f49511af..6ad52168 100644 --- a/server/src/__tests__/company-branding-route.test.ts +++ b/server/src/__tests__/company-branding-route.test.ts @@ -31,6 +31,10 @@ const mockCompanyPortabilityService = vi.hoisted(() => ({ importBundle: vi.fn(), })); +const mockCompanyArtifactsService = vi.hoisted(() => ({ + list: vi.fn(), +})); + const mockLogActivity = vi.hoisted(() => vi.fn()); const mockFeedbackService = vi.hoisted(() => ({ listIssueVotesForUser: vi.fn(), @@ -43,6 +47,7 @@ vi.mock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, budgetService: () => mockBudgetService, + companyArtifactsService: () => mockCompanyArtifactsService, companyPortabilityService: () => mockCompanyPortabilityService, companyService: () => mockCompanyService, feedbackService: () => mockFeedbackService, diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts index 42590525..a3701b76 100644 --- a/server/src/__tests__/company-portability-routes.test.ts +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -31,6 +31,10 @@ const mockCompanyPortabilityService = vi.hoisted(() => ({ importBundle: vi.fn(), })); +const mockCompanyArtifactsService = vi.hoisted(() => ({ + list: vi.fn(), +})); + const mockLogActivity = vi.hoisted(() => vi.fn()); const mockFeedbackService = vi.hoisted(() => ({ listIssueVotesForUser: vi.fn(), @@ -71,6 +75,7 @@ vi.mock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, budgetService: () => mockBudgetService, + companyArtifactsService: () => mockCompanyArtifactsService, companyPortabilityService: () => mockCompanyPortabilityService, companyService: () => mockCompanyService, feedbackService: () => mockFeedbackService, @@ -82,6 +87,7 @@ function registerCompanyRouteMocks() { accessService: () => mockAccessService, agentService: () => mockAgentService, budgetService: () => mockBudgetService, + companyArtifactsService: () => mockCompanyArtifactsService, companyPortabilityService: () => mockCompanyPortabilityService, companyService: () => mockCompanyService, feedbackService: () => mockFeedbackService, diff --git a/server/src/__tests__/company-search-rate-limit-routes.test.ts b/server/src/__tests__/company-search-rate-limit-routes.test.ts index ed070930..092a2c7b 100644 --- a/server/src/__tests__/company-search-rate-limit-routes.test.ts +++ b/server/src/__tests__/company-search-rate-limit-routes.test.ts @@ -13,7 +13,7 @@ function createSearchResponse(query: CompanySearchQuery): CompanySearchResponse limit: query.limit, offset: query.offset, results: [], - countsByType: { issue: 0, agent: 0, project: 0 }, + countsByType: { issue: 0, artifact: 0, agent: 0, project: 0 }, hasMore: false, }; } diff --git a/server/src/__tests__/company-search-service.test.ts b/server/src/__tests__/company-search-service.test.ts index ada5a888..940f4c0c 100644 --- a/server/src/__tests__/company-search-service.test.ts +++ b/server/src/__tests__/company-search-service.test.ts @@ -202,6 +202,59 @@ describeEmbeddedPostgres("companySearchService", () => { expect(result.results[0]?.snippet).toMatch(/parser/i); }); + it("searches artifact projections through the artifacts scope", async () => { + const companyId = await createCompany(); + const agentId = await createAgent(companyId, { name: "Artifact Writer" }); + const issueId = await createIssue(companyId, { + identifier: "TST-88", + title: "Produce artifact", + }); + const documentId = randomUUID(); + await db.insert(documents).values({ + id: documentId, + companyId, + title: "Launch Artifact Brief", + latestBody: "The searchable artifact body mentions a comet-tail preview.", + format: "markdown", + createdByAgentId: agentId, + }); + await db.insert(issueDocuments).values({ + companyId, + issueId, + documentId, + key: "brief", + }); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "comet-tail", scope: "artifacts" })); + + expect(result.results).toHaveLength(1); + expect(result.results[0]).toMatchObject({ + type: "artifact", + title: "Launch Artifact Brief", + href: expect.stringContaining("#document-brief"), + artifact: expect.objectContaining({ + mediaKind: "document", + issueIdentifier: "TST-88", + }), + }); + expect(result.results[0]?.snippet).toMatch(/comet tail/i); + expect(result.countsByType).toEqual({ issue: 0, artifact: 1, agent: 0, project: 0 }); + }); + + it("does not pass high-offset search fetch windows through to artifact query validation", async () => { + const companyId = await createCompany(); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ + q: "artifact", + scope: "artifacts", + limit: "50", + offset: "75", + })); + + expect(result.results).toEqual([]); + expect(result.countsByType.artifact).toBe(0); + }); + it("excludes hidden issues and other companies' data", async () => { const companyId = await createCompany("Visible Co"); const otherCompanyId = await createCompany("Other Co"); @@ -387,7 +440,7 @@ describeEmbeddedPostgres("companySearchService", () => { const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "needle", limit: "2", offset: "2" })); expect(result.results.map((row) => row.id)).toEqual([agentIds[2], projectIds[0]]); - expect(result.countsByType).toEqual({ issue: 0, agent: 3, project: 3 }); + expect(result.countsByType).toEqual({ issue: 0, artifact: 0, agent: 3, project: 3 }); expect(result.hasMore).toBe(true); }); diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 2794a830..1fed2d47 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -201,35 +201,43 @@ function makeAgent(id: string, overrides: Record = {}) { function createRunContextDb( contextSnapshot: Record = {}, - runAgentId: string = ownerAgentId, + runAgentOrRows: string | Record[] = ownerAgentId, runId: string = ownerRunId, ) { + const runRows = Array.isArray(runAgentOrRows) + ? runAgentOrRows + : [{ + id: runId, + companyId, + agentId: runAgentOrRows, + agentCompanyId: companyId, + contextSnapshot, + }]; + const firstRun = runRows[0] ?? {}; + const runAgentId = typeof firstRun.agentId === "string" ? firstRun.agentId : ownerAgentId; + const runAgentCompanyId = typeof firstRun.agentCompanyId === "string" ? firstRun.agentCompanyId : companyId; + const rowsForSelection = (selection: Record) => { + const keys = Object.keys(selection); + if (keys.includes("entityId")) return []; + if (keys.includes("contextSnapshot")) return runRows; + if (keys.includes("agentCompanyId")) return runRows; + return [{ id: runAgentId, companyId: runAgentCompanyId, permissions: {}, role: "engineer", reportsTo: null }]; + }; + const buildQuery = (selection: Record) => { + const whereResult = { + orderBy: vi.fn(async () => []), + then: async (resolve: (rows: unknown[]) => unknown) => resolve(rowsForSelection(selection)), + }; + const query = { + innerJoin: vi.fn(() => query), + where: vi.fn(() => whereResult), + }; + return query; + }; return { transaction: async (callback: (tx: Record) => Promise) => callback({}), select: vi.fn((selection: Record = {}) => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - orderBy: vi.fn(async () => []), - then: async (resolve: (rows: unknown[]) => unknown) => { - const keys = Object.keys(selection); - if (keys.includes("entityId")) { - return resolve([]); - } - if (keys.includes("contextSnapshot")) { - return resolve([{ - id: runId, - companyId, - agentId: runAgentId, - contextSnapshot, - }]); - } - if (keys.includes("permissions")) { - return resolve([{ id: runAgentId, companyId, permissions: {}, role: "engineer", reportsTo: null }]); - } - return resolve([{ id: runAgentId, companyId, permissions: {}, role: "engineer", reportsTo: null }]); - }, - })), - })), + from: vi.fn(() => buildQuery(selection)), })), }; } @@ -622,6 +630,73 @@ describe("agent issue mutation checkout ownership", () => { ); }); + it("stores the authenticated agent run id when creating work products", async () => { + const app = await createApp(ownerActor()); + + await request(app).post(`/api/issues/${issueId}/work-products`).send({ + type: "artifact", + provider: "test", + title: "Artifact", + }).expect(201); + + expect(mockWorkProductService.createForIssue).toHaveBeenCalledWith( + issueId, + companyId, + expect.objectContaining({ createdByRunId: ownerRunId }), + ); + }); + + it("rejects agent-created work products with a forged run id", async () => { + const app = await createApp(ownerActor()); + + const res = await request(app).post(`/api/issues/${issueId}/work-products`).send({ + type: "artifact", + provider: "test", + title: "Artifact", + createdByRunId: "66666666-6666-4666-8666-666666666666", + }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toBe("createdByRunId must match the authenticated agent run"); + expect(mockWorkProductService.createForIssue).not.toHaveBeenCalled(); + }); + + it("rejects work product updates with a forged agent run id", async () => { + const app = await createApp(ownerActor()); + + const res = await request(app).patch("/api/work-products/product-1").send({ + createdByRunId: "66666666-6666-4666-8666-666666666666", + }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toBe("createdByRunId must match the authenticated agent run"); + expect(mockWorkProductService.update).not.toHaveBeenCalled(); + }); + + it("rejects board-created work products with a foreign-company run id", async () => { + const app = await createApp( + boardActor(), + createRunContextDb({}, [{ + id: "66666666-6666-4666-8666-666666666666", + companyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + agentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + agentCompanyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + contextSnapshot: {}, + }]), + ); + + const res = await request(app).post(`/api/issues/${issueId}/work-products`).send({ + type: "artifact", + provider: "test", + title: "Artifact", + createdByRunId: "66666666-6666-4666-8666-666666666666", + }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toBe("createdByRunId is not valid for this company"); + expect(mockWorkProductService.createForIssue).not.toHaveBeenCalled(); + }); + it.each([ [ "work product create", diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 2704f502..fcc13423 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -5,6 +5,7 @@ import type { Db } from "@paperclipai/db"; import { agents as agentsTable } from "@paperclipai/db"; import { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION, + companyArtifactsQuerySchema, companyPortabilityExportSchema, companyPortabilityImportSchema, companyPortabilityPreviewSchema, @@ -21,6 +22,7 @@ import { accessService, agentService, budgetService, + companyArtifactsService, companyPortabilityService, companyService, feedbackService, @@ -37,6 +39,7 @@ export function companyRoutes(db: Db, storage?: StorageService) { const portability = companyPortabilityService(db, storage); const access = accessService(db); const budgets = budgetService(db); + const artifacts = companyArtifactsService(db, storage); const feedback = feedbackService(db); const importJobs = new Map(); const importJobTerminalRetentionMs = 5 * 60 * 1000; @@ -125,6 +128,13 @@ export function companyRoutes(db: Db, storage?: StorageService) { }); }); + router.get("/:companyId/artifacts", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const query = companyArtifactsQuerySchema.parse(req.query); + res.json(await artifacts.list(companyId, query)); + }); + router.get("/:companyId", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 1136ca15..d97c73e8 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -6,6 +6,7 @@ import { and, desc, eq, inArray, notInArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, + agents, documents, executionWorkspaces, heartbeatRuns, @@ -1926,6 +1927,55 @@ export function issueRoutes( return false; } + async function loadWorkProductRunAttribution(runId: string) { + return await db + .select({ + id: heartbeatRuns.id, + companyId: heartbeatRuns.companyId, + agentId: heartbeatRuns.agentId, + agentCompanyId: agents.companyId, + }) + .from(heartbeatRuns) + .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) + .where(eq(heartbeatRuns.id, runId)) + .then((rows) => rows[0] ?? null); + } + + async function resolveWorkProductCreatedByRunId( + req: Request, + res: Response, + companyId: string, + input: { createdByRunId?: string | null }, + mode: "create" | "update", + ): Promise { + const hasCreatedByRunId = Object.prototype.hasOwnProperty.call(input, "createdByRunId"); + if (mode === "update" && !hasCreatedByRunId) return undefined; + + const requestedRunId = input.createdByRunId ?? null; + if (req.actor.type === "agent") { + const actorRunId = req.actor.runId?.trim() || null; + if (requestedRunId && requestedRunId !== actorRunId) { + res.status(403).json({ error: "createdByRunId must match the authenticated agent run" }); + return undefined; + } + if (!actorRunId) return requestedRunId; + const run = await loadWorkProductRunAttribution(actorRunId); + if (!run || run.companyId !== companyId || run.agentCompanyId !== companyId || run.agentId !== req.actor.agentId) { + res.status(403).json({ error: "createdByRunId is not valid for this work product actor" }); + return undefined; + } + return actorRunId; + } + + if (!requestedRunId) return null; + const run = await loadWorkProductRunAttribution(requestedRunId); + if (!run || run.companyId !== companyId || run.agentCompanyId !== companyId) { + res.status(403).json({ error: "createdByRunId is not valid for this company" }); + return undefined; + } + return requestedRunId; + } + function assertStructuredCommentFieldsAllowed( req: Request, res: Response, @@ -3669,6 +3719,9 @@ export function issueRoutes( projectId: req.body.projectId ?? issue.projectId ?? null, sourceTrust: await sourceTrustForActorWrite(issue, actor), }; + const createdByRunId = await resolveWorkProductCreatedByRunId(req, res, issue.companyId, req.body, "create"); + if (createdByRunId === undefined) return; + createInput.createdByRunId = createdByRunId; if (requiresPaperclipAttachmentMetadata(createInput)) { createInput.metadata = await canonicalizePaperclipArtifactMetadata({ issue, @@ -3862,6 +3915,9 @@ export function issueRoutes( if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; const actor = getActorInfo(req); const patch = { ...req.body }; + const createdByRunId = await resolveWorkProductCreatedByRunId(req, res, existing.companyId, req.body, "update"); + if (createdByRunId === undefined && Object.prototype.hasOwnProperty.call(req.body, "createdByRunId")) return; + if (createdByRunId !== undefined) patch.createdByRunId = createdByRunId; if (requiresPaperclipAttachmentMetadata(patch, existing)) { if (patch.metadata !== undefined) { patch.metadata = await canonicalizePaperclipArtifactMetadata({ diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 73e9841d..7a0b8fb6 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -35,6 +35,8 @@ import { createCompanySchema, updateCompanySchema, updateCompanyBrandingSchema, + companyArtifactsQuerySchema, + companyArtifactsResponseSchema, // Routine createRoutineSchema, updateRoutineSchema, @@ -789,6 +791,28 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, }); +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/artifacts", + tags: ["companies"], + summary: "List company artifacts", + request: { + params: z.object({ companyId: z.string() }), + query: companyArtifactsQuerySchema, + }, + responses: { + 200: { + description: "Company artifact projection", + content: { + "application/json": { + schema: companyArtifactsResponseSchema, + }, + }, + }, + 401: r.unauthorized, + }, +}); + registry.registerPath({ method: "patch", path: "/api/companies/{companyId}", diff --git a/server/src/services/company-artifacts.ts b/server/src/services/company-artifacts.ts new file mode 100644 index 00000000..3e787dce --- /dev/null +++ b/server/src/services/company-artifacts.ts @@ -0,0 +1,433 @@ +import { buffer } from "node:stream/consumers"; +import { and, desc, eq, isNotNull, isNull, notInArray, or, sql, type SQL } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; +import type { Db } from "@paperclipai/db"; +import { + agents, + assets, + companies, + documents, + heartbeatRuns, + issueAttachments, + issueDocuments, + issues, + issueWorkProducts, + projects, +} from "@paperclipai/db"; +import { + attachmentArtifactWorkProductMetadataSchema, + COMPANY_ARTIFACTS_MAX_LIMIT, + companyArtifactsQuerySchema, + SYSTEM_ISSUE_DOCUMENT_KEYS, + type CompanyArtifact, + type CompanyArtifactMediaKind, + type CompanyArtifactsQuery, + type CompanyArtifactsResponse, +} from "@paperclipai/shared"; +import { badRequest, notFound } from "../errors.js"; +import type { StorageService } from "../storage/types.js"; + +const TEXT_PREVIEW_BYTES = 4096; +const PREVIEW_TEXT_MAX_LENGTH = 280; + +type ArtifactCursor = { + updatedAt: string; + id: string; +}; + +function encodeCursor(cursor: ArtifactCursor) { + return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); +} + +function decodeCursor(cursor: string | undefined): ArtifactCursor | null { + if (!cursor) return null; + try { + const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as Partial; + if (typeof parsed.id !== "string" || typeof parsed.updatedAt !== "string") { + throw new Error("Invalid cursor"); + } + const date = new Date(parsed.updatedAt); + if (Number.isNaN(date.getTime())) { + throw new Error("Invalid cursor date"); + } + return { id: parsed.id, updatedAt: date.toISOString() }; + } catch { + throw badRequest("Invalid artifacts cursor"); + } +} + +function cursorCondition(updatedAt: SQL, artifactId: SQL, cursor: ArtifactCursor | null) { + if (!cursor) return undefined; + return sql`(${updatedAt} < ${cursor.updatedAt}::timestamptz OR (${updatedAt} = ${cursor.updatedAt}::timestamptz AND ${artifactId} < ${cursor.id}))`; +} + +function escapeLikePattern(value: string) { + return value.replace(/[\\%_]/g, (match) => `\\${match}`); +} + +function normalizePreviewText(input: string | null | undefined) { + if (!input) return null; + const stripped = input + .replace(/```[\s\S]*?```/g, " ") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[[^\]]*]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)]\([^)]*\)/g, "$1") + .replace(/[#>*_\-~|]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!stripped) return null; + return stripped.length > PREVIEW_TEXT_MAX_LENGTH + ? `${stripped.slice(0, PREVIEW_TEXT_MAX_LENGTH - 3).trimEnd()}...` + : stripped; +} + +function classifyMediaKind(contentType: string | null | undefined, fallback: CompanyArtifactMediaKind = "file") { + const normalized = (contentType ?? "").toLowerCase(); + if (!normalized) return fallback; + if (normalized.startsWith("image/")) return "image"; + if (normalized.startsWith("video/")) return "video"; + if ( + normalized.startsWith("text/") || + normalized === "application/json" || + normalized.endsWith("+json") || + normalized === "application/xml" || + normalized.endsWith("+xml") || + normalized === "application/markdown" + ) { + return "text"; + } + return "file"; +} + +function contentTypeKindCondition(contentTypeExpression: SQL, kind: CompanyArtifactsQuery["kind"]) { + if (!kind || kind === "all") return undefined; + if (kind === "image") return sql`${contentTypeExpression} ILIKE 'image/%'`; + if (kind === "video") return sql`${contentTypeExpression} ILIKE 'video/%'`; + if (kind === "text") { + return sql`(${contentTypeExpression} ILIKE 'text/%' OR ${contentTypeExpression} IN ('application/json', 'application/xml', 'application/markdown') OR ${contentTypeExpression} ILIKE '%+json' OR ${contentTypeExpression} ILIKE '%+xml')`; + } + if (kind === "file") { + return sql`NOT (${contentTypeExpression} ILIKE 'image/%' OR ${contentTypeExpression} ILIKE 'video/%' OR ${contentTypeExpression} ILIKE 'text/%' OR ${contentTypeExpression} IN ('application/json', 'application/xml', 'application/markdown') OR ${contentTypeExpression} ILIKE '%+json' OR ${contentTypeExpression} ILIKE '%+xml')`; + } + return undefined; +} + +function buildIssueHref(companyPrefix: string, identifier: string, anchor: string) { + return `/${encodeURIComponent(companyPrefix)}/issues/${encodeURIComponent(identifier)}#${anchor}`; +} + +function attachmentContentPath(attachmentId: string) { + return `/api/attachments/${attachmentId}/content`; +} + +async function readTextAttachmentPreview( + storage: StorageService | undefined, + input: { companyId: string; objectKey: string; byteSize: number }, +) { + if (!storage || input.byteSize <= 0) return null; + try { + const object = await storage.getObject(input.companyId, input.objectKey, { + range: { start: 0, end: Math.min(input.byteSize, TEXT_PREVIEW_BYTES) - 1 }, + }); + const body = await buffer(object.stream); + return normalizePreviewText(body.toString("utf8")); + } catch { + return null; + } +} + +export function companyArtifactsService(db: Db, storage?: StorageService) { + return { + list: async (companyId: string, rawQuery: Partial = {}): Promise => { + const query = companyArtifactsQuerySchema.parse(rawQuery); + const cursor = decodeCursor(query.cursor); + const company = await db + .select({ id: companies.id, issuePrefix: companies.issuePrefix }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + if (!company) throw notFound("Company not found"); + + const fetchLimit = Math.min(query.limit + 1, COMPANY_ARTIFACTS_MAX_LIMIT + 1); + const q = query.q ? `%${escapeLikePattern(query.q)}%` : null; + const artifacts: CompanyArtifact[] = []; + const workProductAttachmentIds = new Set(); + + if (query.kind === "all" || query.kind === "document") { + const createdAgent = alias(agents, "document_created_agent"); + const updatedAgent = alias(agents, "document_updated_agent"); + const documentArtifactId = sql`concat('document:', ${documents.id})`; + const documentConditions: SQL[] = [ + eq(documents.companyId, companyId), + or(isNotNull(documents.createdByAgentId), isNotNull(documents.updatedByAgentId))!, + notInArray(issueDocuments.key, [...SYSTEM_ISSUE_DOCUMENT_KEYS]), + ]; + const documentCursor = cursorCondition(sql`${documents.updatedAt}`, documentArtifactId, cursor); + if (documentCursor) documentConditions.push(documentCursor); + if (query.projectId) documentConditions.push(eq(issues.projectId, query.projectId)); + if (q) { + documentConditions.push(sql`( + coalesce(${documents.title}, '') ILIKE ${q} ESCAPE '\\' + OR ${documents.latestBody} ILIKE ${q} ESCAPE '\\' + OR coalesce(${issues.identifier}, '') ILIKE ${q} ESCAPE '\\' + OR ${issues.title} ILIKE ${q} ESCAPE '\\' + )`); + } + + const documentRows = await db + .select({ + artifactId: documentArtifactId, + documentId: documents.id, + issueId: issues.id, + issueIdentifier: issues.identifier, + issueTitle: issues.title, + projectId: projects.id, + projectName: projects.name, + key: issueDocuments.key, + title: documents.title, + latestBody: documents.latestBody, + createdByAgentId: sql`coalesce(${createdAgent.id}, ${updatedAgent.id})`, + createdByAgentName: sql`coalesce(${createdAgent.name}, ${updatedAgent.name})`, + updatedAt: documents.updatedAt, + }) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .innerJoin(issues, eq(issueDocuments.issueId, issues.id)) + .leftJoin(projects, eq(issues.projectId, projects.id)) + .leftJoin(createdAgent, eq(documents.createdByAgentId, createdAgent.id)) + .leftJoin(updatedAgent, eq(documents.updatedByAgentId, updatedAgent.id)) + .where(and(...documentConditions)) + .orderBy(desc(documents.updatedAt), desc(documentArtifactId)) + .limit(fetchLimit); + + for (const row of documentRows) { + const identifier = row.issueIdentifier ?? row.issueId; + artifacts.push({ + id: row.artifactId, + source: "document", + mediaKind: "document", + title: row.title ?? row.key, + previewText: normalizePreviewText(row.latestBody), + contentType: "text/markdown", + contentPath: null, + openPath: null, + downloadPath: null, + issue: { id: row.issueId, identifier, title: row.issueTitle }, + project: row.projectId && row.projectName ? { id: row.projectId, name: row.projectName } : null, + createdByAgent: row.createdByAgentId && row.createdByAgentName + ? { id: row.createdByAgentId, name: row.createdByAgentName } + : null, + updatedAt: row.updatedAt.toISOString(), + href: buildIssueHref(company.issuePrefix, identifier, `document-${row.key}`), + }); + } + } + + if (query.kind !== "document") { + const workProductAgent = alias(agents, "work_product_agent"); + const workProductArtifactId = sql`concat('work_product:', ${issueWorkProducts.id})`; + const workProductContentType = sql`coalesce(${issueWorkProducts.metadata}->>'contentType', '')`; + const workProductBaseConditions: SQL[] = [ + eq(issueWorkProducts.companyId, companyId), + eq(issueWorkProducts.type, "artifact"), + eq(issueWorkProducts.provider, "paperclip"), + ]; + const workProductConditions: SQL[] = [...workProductBaseConditions]; + const workProductCursor = cursorCondition(sql`${issueWorkProducts.updatedAt}`, workProductArtifactId, cursor); + const workProductKind = contentTypeKindCondition(workProductContentType, query.kind); + if (workProductCursor) workProductConditions.push(workProductCursor); + if (workProductKind) { + workProductBaseConditions.push(workProductKind); + workProductConditions.push(workProductKind); + } + if (query.projectId) { + const projectCondition = eq(issues.projectId, query.projectId); + workProductBaseConditions.push(projectCondition); + workProductConditions.push(projectCondition); + } + if (q) { + const searchCondition = sql`( + ${issueWorkProducts.title} ILIKE ${q} ESCAPE '\\' + OR coalesce(${issueWorkProducts.summary}, '') ILIKE ${q} ESCAPE '\\' + OR coalesce(${issues.identifier}, '') ILIKE ${q} ESCAPE '\\' + OR ${issues.title} ILIKE ${q} ESCAPE '\\' + )`; + workProductBaseConditions.push(searchCondition); + workProductConditions.push(searchCondition); + } + + const workProductRows = await db + .select({ + artifactId: workProductArtifactId, + workProductId: issueWorkProducts.id, + issueId: issues.id, + issueIdentifier: issues.identifier, + issueTitle: issues.title, + projectId: projects.id, + projectName: projects.name, + title: issueWorkProducts.title, + summary: issueWorkProducts.summary, + metadata: issueWorkProducts.metadata, + createdByAgentId: workProductAgent.id, + createdByAgentName: workProductAgent.name, + updatedAt: issueWorkProducts.updatedAt, + }) + .from(issueWorkProducts) + .innerJoin(issues, eq(issueWorkProducts.issueId, issues.id)) + .leftJoin(projects, eq(issues.projectId, projects.id)) + .leftJoin( + heartbeatRuns, + and( + eq(issueWorkProducts.createdByRunId, heartbeatRuns.id), + eq(heartbeatRuns.companyId, issueWorkProducts.companyId), + ), + ) + .leftJoin( + workProductAgent, + and( + eq(heartbeatRuns.agentId, workProductAgent.id), + eq(workProductAgent.companyId, issueWorkProducts.companyId), + ), + ) + .where(and(...workProductConditions)) + .orderBy(desc(issueWorkProducts.updatedAt), desc(workProductArtifactId)) + .limit(fetchLimit); + + const workProductAttachmentRows = await db + .select({ + attachmentId: sql`${issueWorkProducts.metadata}->>'attachmentId'`, + }) + .from(issueWorkProducts) + .innerJoin(issues, eq(issueWorkProducts.issueId, issues.id)) + .where(and(...workProductBaseConditions, sql`${issueWorkProducts.metadata}->>'attachmentId' IS NOT NULL`)); + + for (const row of workProductAttachmentRows) { + if (row.attachmentId) { + workProductAttachmentIds.add(row.attachmentId); + } + } + + for (const row of workProductRows) { + const metadata = attachmentArtifactWorkProductMetadataSchema.safeParse(row.metadata); + const attachmentMetadata = metadata.success ? metadata.data : null; + if (attachmentMetadata) { + workProductAttachmentIds.add(attachmentMetadata.attachmentId); + } + const contentType = attachmentMetadata?.contentType ?? null; + const identifier = row.issueIdentifier ?? row.issueId; + artifacts.push({ + id: row.artifactId, + source: "work_product", + mediaKind: classifyMediaKind(contentType, attachmentMetadata ? "file" : "empty"), + title: row.title, + previewText: normalizePreviewText(row.summary), + contentType, + contentPath: attachmentMetadata?.contentPath ?? null, + openPath: attachmentMetadata?.openPath ?? (typeof row.metadata?.openPath === "string" ? row.metadata.openPath : null), + downloadPath: attachmentMetadata?.downloadPath ?? null, + issue: { id: row.issueId, identifier, title: row.issueTitle }, + project: row.projectId && row.projectName ? { id: row.projectId, name: row.projectName } : null, + createdByAgent: row.createdByAgentId && row.createdByAgentName + ? { id: row.createdByAgentId, name: row.createdByAgentName } + : null, + updatedAt: row.updatedAt.toISOString(), + href: buildIssueHref(company.issuePrefix, identifier, `work-product-${row.workProductId}`), + }); + } + + const attachmentAgent = alias(agents, "attachment_agent"); + const attachmentArtifactId = sql`concat('attachment:', ${issueAttachments.id})`; + const attachmentConditions: SQL[] = [ + eq(issueAttachments.companyId, companyId), + isNull(issueAttachments.issueCommentId), + isNotNull(assets.createdByAgentId), + ]; + const attachmentCursor = cursorCondition(sql`${issueAttachments.updatedAt}`, attachmentArtifactId, cursor); + const attachmentKind = contentTypeKindCondition(sql`${assets.contentType}`, query.kind); + if (attachmentCursor) attachmentConditions.push(attachmentCursor); + if (attachmentKind) attachmentConditions.push(attachmentKind); + if (query.projectId) attachmentConditions.push(eq(issues.projectId, query.projectId)); + if (q) { + attachmentConditions.push(sql`( + coalesce(${assets.originalFilename}, '') ILIKE ${q} ESCAPE '\\' + OR coalesce(${issues.identifier}, '') ILIKE ${q} ESCAPE '\\' + OR ${issues.title} ILIKE ${q} ESCAPE '\\' + )`); + } + + const attachmentRows = await db + .select({ + artifactId: attachmentArtifactId, + attachmentId: issueAttachments.id, + companyId: issueAttachments.companyId, + issueId: issues.id, + issueIdentifier: issues.identifier, + issueTitle: issues.title, + projectId: projects.id, + projectName: projects.name, + objectKey: assets.objectKey, + contentType: assets.contentType, + byteSize: assets.byteSize, + originalFilename: assets.originalFilename, + createdByAgentId: attachmentAgent.id, + createdByAgentName: attachmentAgent.name, + updatedAt: issueAttachments.updatedAt, + }) + .from(issueAttachments) + .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) + .innerJoin(issues, eq(issueAttachments.issueId, issues.id)) + .leftJoin(projects, eq(issues.projectId, projects.id)) + .leftJoin(attachmentAgent, eq(assets.createdByAgentId, attachmentAgent.id)) + .where(and(...attachmentConditions)) + .orderBy(desc(issueAttachments.updatedAt), desc(attachmentArtifactId)) + .limit(fetchLimit); + + const attachmentArtifacts = await Promise.all(attachmentRows.map(async (row): Promise => { + if (workProductAttachmentIds.has(row.attachmentId)) return null; + const mediaKind = classifyMediaKind(row.contentType); + const contentPath = attachmentContentPath(row.attachmentId); + const identifier = row.issueIdentifier ?? row.issueId; + return { + id: row.artifactId, + source: "attachment", + mediaKind, + title: row.originalFilename ?? "Attachment", + previewText: mediaKind === "text" + ? await readTextAttachmentPreview(storage, { + companyId: row.companyId, + objectKey: row.objectKey, + byteSize: row.byteSize, + }) + : null, + contentType: row.contentType, + contentPath, + openPath: contentPath, + downloadPath: `${contentPath}?download=1`, + issue: { id: row.issueId, identifier, title: row.issueTitle }, + project: row.projectId && row.projectName ? { id: row.projectId, name: row.projectName } : null, + createdByAgent: row.createdByAgentId && row.createdByAgentName + ? { id: row.createdByAgentId, name: row.createdByAgentName } + : null, + updatedAt: row.updatedAt.toISOString(), + href: buildIssueHref(company.issuePrefix, identifier, `attachment-${row.attachmentId}`), + }; + })); + + artifacts.push(...attachmentArtifacts.filter((artifact): artifact is CompanyArtifact => artifact !== null)); + } + + const sorted = artifacts + .sort((a, b) => { + const dateDiff = Date.parse(b.updatedAt) - Date.parse(a.updatedAt); + if (dateDiff !== 0) return dateDiff; + return b.id.localeCompare(a.id); + }); + const page = sorted.slice(0, query.limit); + const nextCursor = sorted.length > query.limit + ? encodeCursor({ id: page[page.length - 1]?.id ?? "", updatedAt: page[page.length - 1]?.updatedAt ?? new Date(0).toISOString() }) + : null; + + return { artifacts: page, nextCursor }; + }, + }; +} diff --git a/server/src/services/company-search.ts b/server/src/services/company-search.ts index 9738dd90..6c2d9479 100644 --- a/server/src/services/company-search.ts +++ b/server/src/services/company-search.ts @@ -6,6 +6,10 @@ import { COMPANY_SEARCH_MAX_LIMIT, COMPANY_SEARCH_MAX_OFFSET, COMPANY_SEARCH_MAX_TOKENS, + COMPANY_ARTIFACTS_MAX_LIMIT, + COMPANY_ARTIFACTS_MAX_QUERY_LENGTH, + type CompanyArtifact, + type CompanySearchArtifactSummary, type CompanySearchIssueSummary, type CompanySearchQuery, type CompanySearchResponse, @@ -14,6 +18,7 @@ import { type CompanySearchScope, type CompanySearchSnippet, } from "@paperclipai/shared"; +import { companyArtifactsService } from "./company-artifacts.js"; const MIN_TOKEN_LENGTH = 2; const MIN_FUZZY_QUERY_LENGTH = 4; @@ -188,7 +193,7 @@ function matchTerms(normalizedQuery: string, tokens: string[]) { } function makeCounts(results: CompanySearchResult[]) { - const counts: Record = { issue: 0, agent: 0, project: 0 }; + const counts: Record = { issue: 0, artifact: 0, agent: 0, project: 0 }; for (const result of results) counts[result.type] += 1; return counts; } @@ -201,6 +206,10 @@ function scopeIncludesAgents(scope: CompanySearchScope) { return scope === "all" || scope === "agents"; } +function scopeIncludesArtifacts(scope: CompanySearchScope) { + return scope === "all" || scope === "artifacts"; +} + function scopeIncludesProjects(scope: CompanySearchScope) { return scope === "all" || scope === "projects"; } @@ -286,6 +295,49 @@ function scoreSimpleRow(row: SimpleSearchRow, normalizedQuery: string, tokens: s return score; } +function artifactResult(artifact: CompanyArtifact, normalizedQuery: string, tokens: string[]): CompanySearchResult { + const terms = matchTerms(normalizedQuery, tokens); + const snippet = createSnippet( + "artifact", + "Artifact", + artifact.previewText ?? artifact.title, + terms, + ); + const summary: CompanySearchArtifactSummary = { + id: artifact.id, + source: artifact.source, + mediaKind: artifact.mediaKind, + issueId: artifact.issue.id, + issueIdentifier: artifact.issue.identifier, + issueTitle: artifact.issue.title, + projectId: artifact.project?.id ?? null, + projectName: artifact.project?.name ?? null, + updatedAt: artifact.updatedAt, + }; + const score = scoreSimpleRow({ + id: artifact.id, + title: artifact.title, + description: [artifact.previewText, artifact.issue.identifier, artifact.issue.title, artifact.project?.name] + .filter(Boolean) + .join(" "), + updatedAt: new Date(artifact.updatedAt), + }, normalizedQuery, tokens); + return { + id: artifact.id, + type: "artifact", + score, + title: artifact.title, + href: artifact.href, + matchedFields: ["artifact"], + sourceLabel: snippet?.label ?? "Artifact", + snippet: snippet?.text ?? artifact.previewText, + snippets: snippet ? [snippet] : [], + artifact: summary, + updatedAt: artifact.updatedAt, + previewImageUrl: artifact.mediaKind === "image" ? artifact.contentPath : null, + }; +} + function simpleTextCondition(fields: SQL[], containsPattern: string, tokenArray: SQL) { const phraseConditions = fields.map((field) => sql`lower(coalesce(${field}, '')) LIKE ${containsPattern} ESCAPE '\\'`); const tokenConditions = fields.map((field) => tokenMatchExpression(field, tokenArray)); @@ -306,7 +358,7 @@ export function companySearchService(db: Db) { const scope = query.scope; const limit = query.limit; const offset = query.offset; - const emptyCounts: Record = { issue: 0, agent: 0, project: 0 }; + const emptyCounts: Record = { issue: 0, artifact: 0, agent: 0, project: 0 }; if (normalizedQuery.length === 0) { return { query: query.q, @@ -643,8 +695,16 @@ export function companySearchService(db: Db) { .limit(fetchLimit) : []; + const artifactRows = scopeIncludesArtifacts(scope) + ? await companyArtifactsService(db).list(companyId, { + q: normalizedQuery.slice(0, COMPANY_ARTIFACTS_MAX_QUERY_LENGTH), + limit: Math.min(fetchLimit, COMPANY_ARTIFACTS_MAX_LIMIT), + }).then((result) => result.artifacts) + : []; + const results: CompanySearchResult[] = [ ...(issueRows as IssueSearchRow[]).map((row) => issueResult(row, prefix, normalizedQuery, tokens)), + ...artifactRows.map((artifact) => artifactResult(artifact, normalizedQuery, tokens)), ...(agentRows as SimpleSearchRow[]).map((row) => { const terms = matchTerms(normalizedQuery, tokens); const snippet = createSnippet("capabilities", "Agent", row.description ?? row.role ?? row.title, terms); diff --git a/server/src/services/index.ts b/server/src/services/index.ts index cb193e2f..c0ac9f47 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -1,4 +1,5 @@ export { companyService } from "./companies.js"; +export { companyArtifactsService } from "./company-artifacts.js"; export { companySearchService } from "./company-search.js"; export { feedbackService } from "./feedback.js"; export { companySkillService } from "./company-skills.js"; diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 94522182..6e51901c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -22,6 +22,7 @@ import { RoutineDetail } from "./pages/RoutineDetail"; import { UserProfile } from "./pages/UserProfile"; import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail"; import { Goals } from "./pages/Goals"; +import { Artifacts } from "./pages/Artifacts"; import { GoalDetail } from "./pages/GoalDetail"; import { Approvals } from "./pages/Approvals"; import { ApprovalDetail } from "./pages/ApprovalDetail"; @@ -127,6 +128,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -307,6 +309,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/artifacts.test.ts b/ui/src/api/artifacts.test.ts new file mode 100644 index 00000000..fffb68b9 --- /dev/null +++ b/ui/src/api/artifacts.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockApi = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock("./client", () => ({ + api: mockApi, +})); + +import { artifactsApi, type CompanyArtifact } from "./artifacts"; + +function sampleArtifact(overrides: Partial = {}): CompanyArtifact { + return { + id: "wp-1", + source: "work_product", + mediaKind: "video", + title: "Primary cut", + previewText: null, + contentType: "video/mp4", + contentPath: "/files/wp-1.mp4", + openPath: "/files/wp-1.mp4", + downloadPath: "/files/wp-1.mp4?download=1", + issue: { id: "issue-1", identifier: "PAP-10205", title: "Demo reel" }, + project: { id: "proj-1", name: "Paperclip App" }, + createdByAgent: { id: "agent-1", name: "ClaudeCoder" }, + updatedAt: "2026-06-01T00:00:00.000Z", + href: "/issues/PAP-10205#work-product-wp-1", + ...overrides, + }; +} + +describe("artifactsApi.list", () => { + beforeEach(() => { + mockApi.get.mockReset(); + mockApi.get.mockResolvedValue({ artifacts: [], nextCursor: null }); + }); + + it("calls the company-scoped artifacts endpoint with no params", async () => { + await artifactsApi.list("company-1"); + expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/artifacts"); + }); + + it("omits the kind param when filtering by all", async () => { + await artifactsApi.list("company-1", { kind: "all" }); + expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/artifacts"); + }); + + it("serializes kind, project, search, and pagination params", async () => { + await artifactsApi.list("company-1", { + kind: "video", + projectId: "proj-1", + q: "demo reel", + limit: 24, + cursor: "abc", + }); + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/artifacts?kind=video&projectId=proj-1&q=demo+reel&limit=24&cursor=abc", + ); + }); + + it("returns the envelope shape from the backend", async () => { + const artifact = sampleArtifact(); + mockApi.get.mockResolvedValue({ artifacts: [artifact], nextCursor: "next" }); + const result = await artifactsApi.list("company-1"); + expect(result).toEqual({ artifacts: [artifact], nextCursor: "next" }); + }); + + it("normalizes a bare array response into the envelope shape", async () => { + const artifact = sampleArtifact(); + mockApi.get.mockResolvedValue([artifact]); + const result = await artifactsApi.list("company-1"); + expect(result).toEqual({ artifacts: [artifact], nextCursor: null }); + }); +}); diff --git a/ui/src/api/artifacts.ts b/ui/src/api/artifacts.ts new file mode 100644 index 00000000..78508305 --- /dev/null +++ b/ui/src/api/artifacts.ts @@ -0,0 +1,71 @@ +import { api } from "./client"; +import type { + CompanyArtifact, + CompanyArtifactMediaKind, + CompanyArtifactsResponse, +} from "@paperclipai/shared"; + +export type { + CompanyArtifact, + CompanyArtifactMediaKind as ArtifactMediaKind, + CompanyArtifactsResponse, + CompanyArtifactSource as ArtifactSource, +} from "@paperclipai/shared"; + +/** + * Company-level Artifacts client (PAP-10359). + * + * Talks to the company-scoped artifacts projection endpoint + * (`GET /api/companies/:companyId/artifacts`) defined by the approved + * Artifacts plan (PAP-10353). The endpoint flattens agent-produced issue + * documents, direct attachments, and `artifact` work products into a single + * card-ready list so the UI never has to stitch issue-specific endpoints + * together. + * + * The `CompanyArtifact` shape is imported from `@paperclipai/shared` so the + * frontend and server stay synchronized as the contract evolves. + */ + +export type ArtifactKindFilter = Exclude | "all"; + +export interface ListArtifactsParams { + kind?: ArtifactKindFilter; + projectId?: string; + q?: string; + limit?: number; + cursor?: string; +} + +function buildArtifactsQuery(params?: ListArtifactsParams): string { + const search = new URLSearchParams(); + if (params?.kind && params.kind !== "all") search.set("kind", params.kind); + if (params?.projectId) search.set("projectId", params.projectId); + if (params?.q) search.set("q", params.q); + if (params?.limit != null) search.set("limit", String(params.limit)); + if (params?.cursor) search.set("cursor", params.cursor); + const qs = search.toString(); + return qs ? `?${qs}` : ""; +} + +/** + * Normalize the endpoint response. The contract is an envelope + * (`{ artifacts, nextCursor }`), but we also tolerate a bare array so the page + * keeps working if the backend ships the simpler shape. + */ +function normalizeArtifactsResponse( + raw: CompanyArtifactsResponse | CompanyArtifact[], +): CompanyArtifactsResponse { + if (Array.isArray(raw)) { + return { artifacts: raw, nextCursor: null }; + } + return { artifacts: raw.artifacts ?? [], nextCursor: raw.nextCursor ?? null }; +} + +export const artifactsApi = { + list: async (companyId: string, params?: ListArtifactsParams): Promise => { + const raw = await api.get( + `/companies/${companyId}/artifacts${buildArtifactsQuery(params)}`, + ); + return normalizeArtifactsResponse(raw); + }, +}; diff --git a/ui/src/components/IssueAttachmentsSection.tsx b/ui/src/components/IssueAttachmentsSection.tsx index b5607c08..56658045 100644 --- a/ui/src/components/IssueAttachmentsSection.tsx +++ b/ui/src/components/IssueAttachmentsSection.tsx @@ -103,7 +103,7 @@ function MarkdownAttachmentCard({ }); return ( -
+
@@ -142,7 +142,7 @@ function VideoAttachmentCard({ }) { const filename = attachmentFilename(attachment); return ( -
+
@@ -166,7 +166,7 @@ function GenericAttachmentRow({ }) { const filename = attachmentFilename(attachment); return ( -
+
(
onImageClick(attachment)} > ({ SidebarAgents: () => null, })); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; - async function flushReact() { - await act(async () => { + for (let index = 0; index < 5; index += 1) { await Promise.resolve(); await new Promise((resolve) => window.setTimeout(resolve, 0)); - }); + } + flushSync(() => {}); } describe("Sidebar", () => { @@ -109,7 +107,7 @@ describe("Sidebar", () => { defaultOptions: { queries: { retry: false } }, }); - await act(async () => { + flushSync(() => { root.render( @@ -142,7 +140,7 @@ describe("Sidebar", () => { const workLinks = [...container.querySelectorAll("nav a")].map((anchor) => anchor.textContent?.trim()); expect(workLinks).not.toContain("Search"); - await act(async () => { + flushSync(() => { root.unmount(); }); }); @@ -159,7 +157,7 @@ describe("Sidebar", () => { expect(workSectionContainer?.textContent).toContain("Issues"); expect(workSectionContainer?.textContent).toContain("Goals"); - await act(async () => { + flushSync(() => { root.unmount(); }); }); @@ -181,7 +179,7 @@ describe("Sidebar", () => { expect(primaryNavText).toContain("Inbox"); expect(primaryNavText).not.toContain("Plugin slot outlet"); - await act(async () => { + flushSync(() => { root.unmount(); }); }); @@ -192,7 +190,26 @@ describe("Sidebar", () => { expect(container.textContent).not.toContain("Workspaces"); - await act(async () => { + flushSync(() => { + root.unmount(); + }); + }); + + it("shows an Artifacts nav item directly below Goals", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); + const root = await renderSidebar(); + + const artifactsLink = [...container.querySelectorAll("a")].find( + (anchor) => anchor.textContent === "Artifacts", + ); + expect(artifactsLink?.getAttribute("href")).toBe("/artifacts"); + + const navText = container.querySelector("nav")?.textContent ?? ""; + expect(navText).toContain("Goals"); + expect(navText).toContain("Artifacts"); + expect(navText.indexOf("Goals")).toBeLessThan(navText.indexOf("Artifacts")); + + flushSync(() => { root.unmount(); }); }); @@ -204,7 +221,7 @@ describe("Sidebar", () => { const link = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Workspaces"); expect(link?.getAttribute("href")).toBe("/workspaces"); - await act(async () => { + flushSync(() => { root.unmount(); }); }); diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index bce1f64a..9c4e9a9c 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -11,6 +11,7 @@ import { Boxes, Repeat, GitBranch, + Package, Settings, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; @@ -97,6 +98,7 @@ export function Sidebar() { + {showWorkspacesLink ? ( ) : null} diff --git a/ui/src/components/artifacts/ArtifactCard.test.tsx b/ui/src/components/artifacts/ArtifactCard.test.tsx new file mode 100644 index 00000000..2c7503ff --- /dev/null +++ b/ui/src/components/artifacts/ArtifactCard.test.tsx @@ -0,0 +1,165 @@ +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/router", () => ({ + Link: ({ + to, + children, + disableIssueQuicklook, + ...props + }: { + to: string; + children: ReactNode; + disableIssueQuicklook?: boolean; + }) => ( + + {children} + + ), +})); + +import { ArtifactCard } from "./ArtifactCard"; +import type { CompanyArtifact } from "@/api/artifacts"; + +function makeArtifact(overrides: Partial = {}): CompanyArtifact { + return { + id: "art-1", + source: "attachment", + mediaKind: "image", + title: "Hero shot", + previewText: null, + contentType: "image/png", + contentPath: "/files/art-1.png", + openPath: "/files/art-1.png", + downloadPath: "/files/art-1.png?download=1", + issue: { id: "issue-1", identifier: "PAP-10306", title: "Landing visuals" }, + project: { id: "proj-1", name: "Paperclip App" }, + createdByAgent: { id: "agent-1", name: "ClaudeCoder" }, + updatedAt: "2026-06-01T00:00:00.000Z", + href: "/issues/PAP-10306#attachment-art-1", + ...overrides, + }; +} + +describe("ArtifactCard", () => { + it("renders an image preview with cover image and links to the issue anchor", () => { + const markup = renderToStaticMarkup(); + expect(markup).toContain('href="/issues/PAP-10306#attachment-art-1"'); + expect(markup).toContain('data-disable-issue-quicklook="true"'); + expect(markup).toContain('data-media-kind="image"'); + expect(markup).toContain("rounded-[8px]"); + expect(markup).toContain('src="/files/art-1.png"'); + expect(markup).toContain("object-cover"); + expect(markup).toContain("Hero shot"); + expect(markup).toContain("flex h-7 items-start justify-between gap-2"); + expect(markup).toContain("leading-7"); + expect(markup).toContain("Last edited Jun 1, 2026"); + expect(markup).not.toContain("Landing visuals"); + expect(markup).not.toContain("Edited "); + }); + + it("renders only the artifact subject and absolute metadata under the preview", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Social launch clip"); + expect(markup).toContain("Last edited Oct 8, 2025"); + expect(markup).not.toContain("Make artifact page look like this"); + expect(markup).not.toContain(">PAP-10370<"); + }); + + it("renders a video preview with a video element and play glyph", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('data-media-kind="video"'); + expect(markup).toContain(" { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('data-media-kind="video"'); + expect(markup).not.toContain(" { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('data-media-kind="document"'); + expect(markup).toContain("This is the plan preview excerpt."); + expect(markup).toContain("text-base"); + expect(markup).toContain("leading-6"); + expect(markup).toContain("max-h-full"); + expect(markup).toContain("overflow-hidden"); + expect(markup).not.toContain('data-lucide="file-text"'); + }); + + it("renders a placeholder for empty artifacts without an image or video", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('data-media-kind="empty"'); + expect(markup).not.toContain(" { + const markup = renderToStaticMarkup( + , + ); + expect(markup).not.toContain('aria-label="Download file"'); + expect(markup).not.toContain('aria-label="Open file in new tab"'); + }); + + it("reserves the same title row height when file actions are absent", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("flex h-7 items-start justify-between gap-2"); + expect(markup).toContain("leading-7"); + expect(markup).toContain("Last edited Jun 1, 2026"); + expect(markup).not.toContain('aria-label="Download file"'); + expect(markup).not.toContain('aria-label="Open file in new tab"'); + }); +}); diff --git a/ui/src/components/artifacts/ArtifactCard.tsx b/ui/src/components/artifacts/ArtifactCard.tsx new file mode 100644 index 00000000..842c0ee6 --- /dev/null +++ b/ui/src/components/artifacts/ArtifactCard.tsx @@ -0,0 +1,187 @@ +import { useState } from "react"; +import { Download, ExternalLink, Paperclip, Play } from "lucide-react"; +import type { CompanyArtifact } from "@/api/artifacts"; +import { Link } from "@/lib/router"; +import { cn, formatDate } from "@/lib/utils"; + +interface ArtifactCardProps { + artifact: CompanyArtifact; +} + +/** + * Stable, fixed-height preview region shared by every card variant. The fixed + * aspect ratio is what keeps image / video / text / placeholder cards from + * shifting layout as previews load (or fail to load). + */ +function PreviewFrame({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} + +function PlaceholderPreview({ label }: { label?: string }) { + return ( + +
+
+
+ ); +} + +function ImagePreview({ artifact }: { artifact: CompanyArtifact }) { + const [errored, setErrored] = useState(false); + if (errored || !artifact.contentPath) { + return ; + } + return ( + + {artifact.title} setErrored(true)} + /> + + ); +} + +function VideoPreview({ artifact }: { artifact: CompanyArtifact }) { + const [errored, setErrored] = useState(false); + if (errored || !artifact.contentPath) { + return ( + +
+
+
+ ); + } + return ( + + + ); +} + +function TextPreview({ artifact }: { artifact: CompanyArtifact }) { + const preview = artifact.previewText?.trim(); + if (!preview) { + return ; + } + return ( + +
+

+ {preview} +

+
+
+ + ); +} + +function ArtifactPreview({ artifact }: { artifact: CompanyArtifact }) { + switch (artifact.mediaKind) { + case "image": + return ; + case "video": + return ; + case "text": + case "document": + return ; + case "file": + return ; + case "empty": + default: + return ; + } +} + +function SecondaryAction({ + href, + download, + title, + children, +}: { + href: string; + download?: boolean; + title: string; + children: React.ReactNode; +}) { + return ( + event.stopPropagation()} + className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" + > + {children} + + ); +} + +export function ArtifactCard({ artifact }: ArtifactCardProps) { + return ( + + + +
+
+

+ {artifact.title} +

+
+ {artifact.openPath ? ( + + + + ) : null} + {artifact.downloadPath ? ( + + + + ) : null} +
+
+ +
+ Last edited {formatDate(artifact.updatedAt)} + {artifact.createdByAgent ? ( + <> + · + {artifact.createdByAgent.name} + + ) : null} +
+
+ + ); +} diff --git a/ui/src/components/issue-output/IssueOutputSection.tsx b/ui/src/components/issue-output/IssueOutputSection.tsx index 5b7faa0f..10c25f95 100644 --- a/ui/src/components/issue-output/IssueOutputSection.tsx +++ b/ui/src/components/issue-output/IssueOutputSection.tsx @@ -34,13 +34,19 @@ export function IssueOutputSection({ workProducts, resolveCreatorName }: IssueOu {count}
- + {/* Stable anchor target so company Artifacts cards can deep-link to a + specific work product inside its issue context (PAP-10359). */} +
+ +
{rest.length > 0 && (

Also produced

{rest.map((item) => ( - +
+ +
))}
)} diff --git a/ui/src/components/search/SearchResultRow.tsx b/ui/src/components/search/SearchResultRow.tsx index 3913e4f5..408427e5 100644 --- a/ui/src/components/search/SearchResultRow.tsx +++ b/ui/src/components/search/SearchResultRow.tsx @@ -1,5 +1,5 @@ import { memo, type ComponentType, type SVGProps } from "react"; -import { Bot, FileText, Hexagon, MessageSquare, Quote } from "lucide-react"; +import { Bot, FileText, Hexagon, MessageSquare, Paperclip, Quote } from "lucide-react"; import type { Agent, CompanySearchResult } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { cn } from "@/lib/utils"; @@ -15,6 +15,7 @@ type SnippetStyle = { const SNIPPET_STYLES: Record = { comment: { Icon: MessageSquare, label: "Comment" }, document: { Icon: FileText, label: "Doc" }, + artifact: { Icon: Paperclip, label: "Artifact" }, description: { Icon: Quote, label: "Description" }, }; @@ -109,6 +110,55 @@ function SearchResultRowImpl({ ); } + if (result.type === "artifact") { + const artifact = result.artifact; + if (!artifact) return null; + const updated = formatRelativeTime(result.updatedAt ?? artifact.updatedAt); + return ( + + +
+
+ {result.title} + + {artifact.issueIdentifier} + +
+ {result.snippet ? ( + + ) : null} +
+ {artifact.issueTitle} + {updated ? {updated} : null} +
+
+
+ {updated ? {updated} : null} + {result.previewImageUrl ? ( + + ) : null} +
+ + ); + } + const issue = result.issue; if (!issue) return null; const assigneeName = issue.assigneeAgentId diff --git a/ui/src/lib/company-routes.test.ts b/ui/src/lib/company-routes.test.ts index 4d22ec59..6278224d 100644 --- a/ui/src/lib/company-routes.test.ts +++ b/ui/src/lib/company-routes.test.ts @@ -64,4 +64,22 @@ describe("company routes", () => { "/teams-catalog/core-exec-team", ); }); + + it("treats /artifacts as a board route that needs a company prefix", () => { + expect(isBoardPathWithoutPrefix("/artifacts")).toBe(true); + expect(extractCompanyPrefixFromPath("/artifacts")).toBeNull(); + expect(applyCompanyPrefix("/artifacts", "PAP")).toBe("/PAP/artifacts"); + expect(toCompanyRelativePath("/PAP/artifacts")).toBe("/artifacts"); + }); + + it("preserves artifact deep-link anchors when applying the company prefix", () => { + expect(applyCompanyPrefix("/issues/PAP-10205#work-product-wp-1", "PAP")).toBe( + "/PAP/issues/PAP-10205#work-product-wp-1", + ); + expect(applyCompanyPrefix("/issues/PAP-10306#attachment-att-1", "PAP")).toBe( + "/PAP/issues/PAP-10306#attachment-att-1", + ); + // Already-prefixed paths are returned untouched. + expect(applyCompanyPrefix("/PAP/artifacts", "PAP")).toBe("/PAP/artifacts"); + }); }); diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index 67197a76..27119d6a 100644 --- a/ui/src/lib/company-routes.ts +++ b/ui/src/lib/company-routes.ts @@ -12,6 +12,7 @@ const BOARD_ROUTE_ROOTS = new Set([ "issues", "routines", "goals", + "artifacts", "approvals", "costs", "usage", diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 67214b27..91206b1f 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -116,6 +116,10 @@ export const queryKeys = { list: (companyId: string) => ["goals", companyId] as const, detail: (id: string) => ["goals", "detail", id] as const, }, + artifacts: { + list: (companyId: string, kind?: string, q?: string) => + ["artifacts", companyId, kind ?? "all", q ?? ""] as const, + }, budgets: { overview: (companyId: string) => ["budgets", "overview", companyId] as const, }, diff --git a/ui/src/pages/Artifacts.test.tsx b/ui/src/pages/Artifacts.test.tsx new file mode 100644 index 00000000..eabc37bf --- /dev/null +++ b/ui/src/pages/Artifacts.test.tsx @@ -0,0 +1,231 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Artifacts } from "./Artifacts"; +import type { CompanyArtifact } from "../api/artifacts"; + +const companyState = vi.hoisted(() => ({ + selectedCompanyId: "company-1", +})); + +const breadcrumbState = vi.hoisted(() => ({ + setBreadcrumbs: vi.fn(), +})); + +const artifactsApiMock = vi.hoisted(() => ({ + list: vi.fn(), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => companyState, +})); + +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => breadcrumbState, +})); + +vi.mock("../api/artifacts", () => ({ + artifactsApi: artifactsApiMock, +})); + +vi.mock("../components/artifacts/ArtifactCard", () => ({ + ArtifactCard: ({ artifact }: { artifact: CompanyArtifact }) => ( +
{artifact.title}
+ ), +})); + +type ObserverCallback = IntersectionObserverCallback; + +let latestObserverCallback: ObserverCallback | null = null; + +class MockIntersectionObserver { + readonly root = null; + readonly rootMargin = ""; + readonly thresholds = []; + + constructor(callback: ObserverCallback) { + latestObserverCallback = callback; + } + + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + takeRecords = vi.fn(() => []); +} + +function sampleArtifact(overrides: Partial = {}): CompanyArtifact { + return { + id: "artifact-1", + source: "document", + mediaKind: "document", + title: "Launch Brief", + previewText: "launch brief preview", + contentType: "text/markdown", + contentPath: null, + openPath: null, + downloadPath: null, + issue: { id: "issue-1", identifier: "PAP-42", title: "Ship launch" }, + project: null, + createdByAgent: null, + updatedAt: "2026-06-01T00:00:00.000Z", + href: "/PAP/issues/PAP-42#document-brief", + ...overrides, + }; +} + +async function flush() { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function waitForAssertion(assertion: () => void, attempts = 50) { + let lastError: unknown; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await flush(); + } + } + throw lastError; +} + +function renderArtifacts(container: HTMLDivElement) { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + flushSync(() => { + root.render( + + + , + ); + }); + return { root, queryClient }; +} + +describe("Artifacts page", () => { + let container: HTMLDivElement; + let originalIntersectionObserver: typeof IntersectionObserver | undefined; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + breadcrumbState.setBreadcrumbs.mockReset(); + artifactsApiMock.list.mockReset(); + latestObserverCallback = null; + originalIntersectionObserver = window.IntersectionObserver; + window.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver; + }); + + afterEach(() => { + window.IntersectionObserver = originalIntersectionObserver as typeof IntersectionObserver; + container.remove(); + }); + + it("debounces artifact search into the artifacts API", async () => { + artifactsApiMock.list + .mockResolvedValueOnce({ artifacts: [sampleArtifact()], nextCursor: null }) + .mockResolvedValueOnce({ artifacts: [], nextCursor: null }); + + const { root } = renderArtifacts(container); + + await waitForAssertion(() => { + expect(artifactsApiMock.list).toHaveBeenCalledWith("company-1", { + kind: "all", + q: undefined, + limit: 30, + cursor: undefined, + }); + }); + + const input = container.querySelector('input[aria-label="Search artifacts"]') as HTMLInputElement; + expect(input).not.toBeNull(); + + flushSync(() => { + const nativeSetter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!; + nativeSetter.call(input, "launch"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + await waitForAssertion(() => { + expect(artifactsApiMock.list).toHaveBeenLastCalledWith("company-1", { + kind: "all", + q: "launch", + limit: 30, + cursor: undefined, + }); + }); + + flushSync(() => { + root.unmount(); + }); + }); + + it("keeps the artifacts grid max-width constrained and left aligned", async () => { + artifactsApiMock.list.mockResolvedValue({ artifacts: [sampleArtifact()], nextCursor: null }); + + const { root } = renderArtifacts(container); + + await waitForAssertion(() => { + expect(container.querySelector('[data-testid="artifact-card"]')).not.toBeNull(); + }); + + const pageShell = container.firstElementChild as HTMLElement | null; + expect(pageShell?.className).toContain("max-w-6xl"); + expect(pageShell?.className).not.toContain("mx-auto"); + + flushSync(() => { + root.unmount(); + }); + }); + + it("fetches the next artifact page when the sentinel intersects", async () => { + artifactsApiMock.list + .mockResolvedValueOnce({ + artifacts: [sampleArtifact({ id: "artifact-1", title: "First Artifact" })], + nextCursor: "cursor-2", + }) + .mockResolvedValueOnce({ + artifacts: [sampleArtifact({ id: "artifact-2", title: "Second Artifact" })], + nextCursor: null, + }); + + const { root } = renderArtifacts(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("First Artifact"); + expect(latestObserverCallback).not.toBeNull(); + }); + + latestObserverCallback?.( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ); + + await waitForAssertion(() => { + expect(artifactsApiMock.list).toHaveBeenLastCalledWith("company-1", { + kind: "all", + q: undefined, + limit: 30, + cursor: "cursor-2", + }); + expect(container.textContent).toContain("Second Artifact"); + }); + + flushSync(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/pages/Artifacts.tsx b/ui/src/pages/Artifacts.tsx new file mode 100644 index 00000000..50e7e944 --- /dev/null +++ b/ui/src/pages/Artifacts.tsx @@ -0,0 +1,164 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Package, Search, X } from "lucide-react"; +import { artifactsApi, type ArtifactKindFilter } from "../api/artifacts"; +import { useCompany } from "../context/CompanyContext"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { queryKeys } from "../lib/queryKeys"; +import { EmptyState } from "../components/EmptyState"; +import { PageSkeleton } from "../components/PageSkeleton"; +import { ArtifactCard } from "../components/artifacts/ArtifactCard"; +import { cn } from "@/lib/utils"; +import { Input } from "@/components/ui/input"; + +const ARTIFACTS_PAGE_SIZE = 30; +const SEARCH_DEBOUNCE_MS = 250; + +const KIND_FILTERS: { value: ArtifactKindFilter; label: string }[] = [ + { value: "all", label: "All" }, + { value: "image", label: "Images" }, + { value: "video", label: "Videos" }, + { value: "document", label: "Documents" }, + { value: "text", label: "Text" }, + { value: "file", label: "Files" }, +]; + +export function Artifacts() { + const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const [kind, setKind] = useState("all"); + const [draftQuery, setDraftQuery] = useState(""); + const [query, setQuery] = useState(""); + const loadMoreRef = useRef(null); + + useEffect(() => { + setBreadcrumbs([{ label: "Artifacts" }]); + }, [setBreadcrumbs]); + + useEffect(() => { + const handle = window.setTimeout(() => setQuery(draftQuery.trim()), SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(handle); + }, [draftQuery]); + + const { + data, + isLoading, + isFetching, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + error, + } = useInfiniteQuery({ + queryKey: queryKeys.artifacts.list(selectedCompanyId!, kind, query), + queryFn: ({ pageParam }) => + artifactsApi.list(selectedCompanyId!, { + kind, + q: query || undefined, + limit: ARTIFACTS_PAGE_SIZE, + cursor: pageParam, + }), + enabled: !!selectedCompanyId, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + }); + + useEffect(() => { + const target = loadMoreRef.current; + if (!target || !hasNextPage || isFetchingNextPage) return; + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + void fetchNextPage(); + } + }, { rootMargin: "320px 0px" }); + observer.observe(target); + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + const artifacts = useMemo(() => data?.pages.flatMap((page) => page.artifacts) ?? [], [data]); + const searching = query.length > 0; + + if (!selectedCompanyId) { + return ; + } + + return ( +
+
+
+ + setDraftQuery(event.currentTarget.value)} + placeholder="Search artifacts..." + aria-label="Search artifacts" + className="h-9 pl-9 pr-9 text-sm" + /> + {draftQuery.length > 0 ? ( + + ) : null} +
+ +
+ {KIND_FILTERS.map((filter) => ( + + ))} +
+
+ + {error &&

{error.message}

} + + {isLoading ? ( + + ) : artifacts.length === 0 ? ( + + ) : ( + <> +
+ {artifacts.map((artifact) => ( + + ))} +
+
+ {isFetchingNextPage + ? "Loading more artifacts..." + : hasNextPage + ? null + : isFetching + ? "Updating artifacts..." + : null} +
+ + )} +
+ ); +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index e7294973..a337a385 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -2872,6 +2872,37 @@ export function IssueDetail() { setHandoffFocusSignal((current) => current + 1); }, [location.hash]); + // Scroll + briefly highlight work-product / direct-attachment anchors so the + // company Artifacts page (PAP-10359) can deep-link to a specific artifact in + // its issue context. Retries while the section data loads in. + useEffect(() => { + const match = location.hash.match(/^#(work-product|attachment)-(.+)$/); + if (!match) return; + const targetId = `${match[1]}-${decodeURIComponent(match[2]!)}`; + let cancelled = false; + let attempts = 0; + let timer: ReturnType | undefined; + const tryScroll = () => { + if (cancelled) return; + const element = document.getElementById(targetId); + if (!element) { + if (attempts < 30) { + attempts += 1; + timer = setTimeout(tryScroll, 100); + } + return; + } + element.scrollIntoView({ behavior: "smooth", block: "center" }); + element.classList.add("ring-2", "ring-primary/50", "transition-shadow"); + timer = setTimeout(() => element.classList.remove("ring-2", "ring-primary/50", "transition-shadow"), 3000); + }; + tryScroll(); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [location.hash, workProducts, attachments]); + useEffect(() => { if (pendingCommentComposerFocusKey === 0) return; if (detailTab !== "chat") return; diff --git a/ui/src/pages/Search.test.tsx b/ui/src/pages/Search.test.tsx index 046e39ae..b2715835 100644 --- a/ui/src/pages/Search.test.tsx +++ b/ui/src/pages/Search.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { act } from "react"; import type { ReactNode } from "react"; +import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -86,9 +86,8 @@ vi.mock("../components/Identity", () => ({ (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; async function flush() { - await act(async () => { - await Promise.resolve(); - }); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); } async function waitForAssertion(assertion: () => void, attempts = 50) { @@ -110,7 +109,7 @@ function renderSearch(initialPath: string, container: HTMLDivElement, node?: Rea const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); - act(() => { + flushSync(() => { root.render( @@ -170,7 +169,7 @@ describe("Search page", () => { scope: "all", limit: 20, offset: 0, - countsByType: { issue: 1, agent: 0, project: 0 }, + countsByType: { issue: 1, artifact: 0, agent: 0, project: 0 }, hasMore: false, results: [ { @@ -228,7 +227,72 @@ describe("Search page", () => { expect(container.textContent).toContain("1 result"); }); - act(() => { + flushSync(() => { + root.unmount(); + }); + }); + + it("renders artifact search results in the company search surface", async () => { + searchApiMock.search.mockResolvedValueOnce({ + query: "launch brief", + normalizedQuery: "launch brief", + scope: "artifacts", + limit: 20, + offset: 0, + countsByType: { issue: 0, artifact: 1, agent: 0, project: 0 }, + hasMore: false, + results: [ + { + id: "document:artifact-1", + type: "artifact", + score: 140, + title: "Launch Artifact Brief", + href: "/PAP/issues/PAP-42#document-brief", + matchedFields: ["artifact"], + sourceLabel: "Artifact", + snippet: "launch brief preview text", + snippets: [ + { + field: "artifact", + label: "Artifact", + text: "launch brief preview text", + highlights: [{ start: 0, end: 6 }], + }, + ], + artifact: { + id: "document:artifact-1", + source: "document", + mediaKind: "document", + issueId: "issue-42", + issueIdentifier: "PAP-42", + issueTitle: "Ship launch artifacts", + projectId: null, + projectName: null, + updatedAt: new Date().toISOString(), + }, + updatedAt: new Date().toISOString(), + previewImageUrl: null, + }, + ], + }); + + const { root } = renderSearch("/search?q=launch+brief&scope=artifacts", container); + + await waitForAssertion(() => { + expect(searchApiMock.search).toHaveBeenCalledWith("company-1", { + q: "launch brief", + scope: "artifacts", + limit: 20, + }); + }); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Launch Artifact Brief"); + expect(container.textContent).toContain("PAP-42"); + expect(container.textContent).toContain("launch brief preview text"); + }); + + flushSync(() => { root.unmount(); }); }); @@ -240,7 +304,7 @@ describe("Search page", () => { scope: "all", limit: 20, offset: 0, - countsByType: { issue: 0, agent: 0, project: 0 }, + countsByType: { issue: 0, artifact: 0, agent: 0, project: 0 }, hasMore: false, results: [], }); @@ -250,7 +314,7 @@ describe("Search page", () => { const input = container.querySelector('input[aria-label="Search query"]') as HTMLInputElement; expect(input).not.toBeNull(); - act(() => { + flushSync(() => { const nativeSetter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value", @@ -272,7 +336,7 @@ describe("Search page", () => { }); }); - act(() => { + flushSync(() => { root.unmount(); }); }); @@ -284,7 +348,7 @@ describe("Search page", () => { scope: "all", limit: 20, offset: 0, - countsByType: { issue: 1, agent: 0, project: 0 }, + countsByType: { issue: 1, artifact: 0, agent: 0, project: 0 }, hasMore: false, results: [ { @@ -326,7 +390,7 @@ describe("Search page", () => { expect(navigateMock).toHaveBeenCalledWith("/PAP/issues/PAP-3366", { replace: true }); }); - act(() => { + flushSync(() => { root.unmount(); }); }); @@ -338,7 +402,7 @@ describe("Search page", () => { scope: "comments", limit: 20, offset: 0, - countsByType: { issue: 0, agent: 0, project: 0 }, + countsByType: { issue: 0, artifact: 0, agent: 0, project: 0 }, hasMore: false, results: [], }); @@ -351,7 +415,7 @@ describe("Search page", () => { expect(container.textContent).toContain("Search all scopes"); }); - act(() => { + flushSync(() => { root.unmount(); }); }); diff --git a/ui/src/pages/Search.tsx b/ui/src/pages/Search.tsx index 1271914a..1a1506a6 100644 --- a/ui/src/pages/Search.tsx +++ b/ui/src/pages/Search.tsx @@ -35,23 +35,26 @@ const SCOPE_LABELS: Record = { issues: "Issues", comments: "Comments", documents: "Documents", + artifacts: "Artifacts", agents: "Agents", projects: "Projects", }; -type SubGroupKey = "issues" | "comments" | "documents" | "agents" | "projects"; +type SubGroupKey = "issues" | "comments" | "documents" | "artifacts" | "agents" | "projects"; -const SUBGROUP_ORDER: SubGroupKey[] = ["issues", "comments", "documents", "agents", "projects"]; +const SUBGROUP_ORDER: SubGroupKey[] = ["issues", "comments", "documents", "artifacts", "agents", "projects"]; const SUBGROUP_LABELS: Record = { issues: "Issues", comments: "Comments", documents: "Documents", + artifacts: "Artifacts", agents: "Agents", projects: "Projects", }; function classifyResult(result: CompanySearchResult): SubGroupKey { + if (result.type === "artifact") return "artifacts"; if (result.type === "agent") return "agents"; if (result.type === "project") return "projects"; const matched = new Set(result.matchedFields); @@ -261,7 +264,7 @@ export function Search() { return () => window.removeEventListener("keydown", handler); }, [focusInput]); - const counts = data?.countsByType ?? { issue: 0, agent: 0, project: 0 }; + const counts = data?.countsByType ?? { issue: 0, artifact: 0, agent: 0, project: 0 }; const totalResults = data?.results.length ?? 0; const tabItems = useMemo(() => { @@ -276,8 +279,9 @@ export function Search() { const issuesTotal = counts.issue ?? 0; return COMPANY_SEARCH_SCOPES.map((value) => { let count: number | null = null; - if (value === "all") count = (counts.issue ?? 0) + (counts.agent ?? 0) + (counts.project ?? 0); + if (value === "all") count = (counts.issue ?? 0) + (counts.artifact ?? 0) + (counts.agent ?? 0) + (counts.project ?? 0); else if (value === "issues") count = issuesTotal; + else if (value === "artifacts") count = counts.artifact ?? 0; else if (value === "agents") count = counts.agent ?? 0; else if (value === "projects") count = counts.project ?? 0; return { @@ -342,7 +346,7 @@ export function Search() { } } }} - placeholder="Search issues, comments, documents, agents, projects…" + placeholder="Search issues, comments, documents, artifacts, agents, projects…" aria-label="Search query" className="h-10 pl-9 pr-20 text-sm" /> @@ -452,7 +456,7 @@ function SearchTabContent({

Type to search company memory.

- Issues, comments, plan documents, agents, projects — same surface, ranked by relevance. + Issues, comments, plan documents, artifacts, agents, projects — same surface, ranked by relevance.

{recentSearches.length > 0 ? ( diff --git a/ui/storybook/stories/artifacts.stories.tsx b/ui/storybook/stories/artifacts.stories.tsx new file mode 100644 index 00000000..1336b36f --- /dev/null +++ b/ui/storybook/stories/artifacts.stories.tsx @@ -0,0 +1,143 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Package } from "lucide-react"; +import { ArtifactCard } from "@/components/artifacts/ArtifactCard"; +import { EmptyState } from "@/components/EmptyState"; +import type { CompanyArtifact } from "@/api/artifacts"; + +/** + * Storybook coverage for the company Artifacts page (PAP-10359). Renders the + * responsive three-column grid and every preview card variant with mock data so + * UX/QA can review the layout and capture desktop/mobile screenshots without a + * live backend. + */ + +const SAMPLE_IMAGE = + "data:image/svg+xml;utf8," + + encodeURIComponent( + `Hero render.png`, + ); + +function makeArtifact(overrides: Partial): CompanyArtifact { + return { + id: "art", + source: "attachment", + mediaKind: "image", + title: "Artifact", + previewText: null, + contentType: null, + contentPath: null, + openPath: null, + downloadPath: null, + issue: { id: "issue-1", identifier: "PAP-10306", title: "Landing visuals refresh" }, + project: { id: "proj-1", name: "Paperclip App" }, + createdByAgent: { id: "agent-1", name: "ClaudeCoder" }, + updatedAt: new Date("2026-06-04T12:00:00Z").toISOString(), + href: "/issues/PAP-10306#attachment-art", + ...overrides, + }; +} + +const ARTIFACTS: CompanyArtifact[] = [ + makeArtifact({ + id: "wp-video", + source: "work_product", + mediaKind: "video", + title: "Product demo — primary cut.mp4", + contentType: "video/mp4", + contentPath: null, // exercises the calm video placeholder + play glyph + openPath: "/files/demo.mp4", + downloadPath: "/files/demo.mp4?download=1", + issue: { id: "issue-2", identifier: "PAP-10205", title: "Record the launch walkthrough" }, + href: "/issues/PAP-10205#work-product-wp-video", + }), + makeArtifact({ + id: "img-hero", + mediaKind: "image", + title: "Hero render.png", + contentType: "image/png", + contentPath: SAMPLE_IMAGE, + openPath: SAMPLE_IMAGE, + downloadPath: SAMPLE_IMAGE, + }), + makeArtifact({ + id: "doc-plan", + source: "document", + mediaKind: "document", + title: "Artifacts Page Plan", + previewText: + "Build a company-level Artifacts page at /{companyPrefix}/artifacts, with a sidebar item below Goals and a three-column artifact grid. The page should make agent-produced work easy to find without becoming another attachment dump.", + contentType: "text/markdown", + issue: { id: "issue-3", identifier: "PAP-10341", title: "Draft the rollout plan" }, + createdByAgent: { id: "agent-2", name: "CodexCoder" }, + href: "/issues/PAP-10341#document-plan", + }), + makeArtifact({ + id: "txt-notes", + mediaKind: "text", + title: "review-notes.txt", + previewText: + "Reviewed the primary cut. Color grade looks good; trim the first 1.2s of dead air. Re-export at 1080p and attach the final to the issue.", + contentType: "text/plain", + openPath: "/files/review-notes.txt", + downloadPath: "/files/review-notes.txt?download=1", + issue: { id: "issue-2", identifier: "PAP-10205", title: "Record the launch walkthrough" }, + }), + makeArtifact({ + id: "file-zip", + mediaKind: "file", + title: "design-assets.zip", + contentType: "application/zip", + openPath: "/files/design-assets.zip", + downloadPath: "/files/design-assets.zip?download=1", + issue: { id: "issue-1", identifier: "PAP-10306", title: "Landing visuals refresh" }, + }), + makeArtifact({ + id: "img-broken", + mediaKind: "image", + title: "missing-preview.png (broken source)", + contentType: "image/png", + contentPath: "/files/does-not-exist.png", // exercises the onError image fallback + openPath: "/files/does-not-exist.png", + downloadPath: "/files/does-not-exist.png?download=1", + }), +]; + +function ArtifactsGrid({ artifacts }: { artifacts: CompanyArtifact[] }) { + return ( +
+ {artifacts.map((artifact) => ( + + ))} +
+ ); +} + +const meta: Meta = { + title: "Pages/Artifacts", +}; + +export default meta; + +type Story = StoryObj; + +export const Grid: Story = { + render: () => ( +
+

+ Work your agents have produced — documents, media, and files — across this company's issues. +

+ +
+ ), +}; + +export const Empty: Story = { + render: () => ( +
+ +
+ ), +}; diff --git a/ui/storybook/stories/search.stories.tsx b/ui/storybook/stories/search.stories.tsx index 258d8c71..f926a863 100644 --- a/ui/storybook/stories/search.stories.tsx +++ b/ui/storybook/stories/search.stories.tsx @@ -192,6 +192,7 @@ const fixtureResponse: CompanySearchResponse = { results: [...fixtureResults, ...fixtureAgents, ...fixtureProjects], countsByType: { issue: fixtureResults.length, + artifact: 0, agent: fixtureAgents.length, project: fixtureProjects.length, }, @@ -202,11 +203,12 @@ function ScopeTabsPreview({ active, response, }: { - active: "all" | "issues" | "comments" | "documents" | "agents" | "projects"; + active: "all" | "issues" | "comments" | "documents" | "artifacts" | "agents" | "projects"; response: CompanySearchResponse; }) { const total = (response.countsByType.issue ?? 0) + + (response.countsByType.artifact ?? 0) + (response.countsByType.agent ?? 0) + (response.countsByType.project ?? 0); const items: PageTabItem[] = [ @@ -214,6 +216,7 @@ function ScopeTabsPreview({ { value: "issues", label: }, { value: "comments", label: result.matchedFields.includes("comment")).length} /> }, { value: "documents", label: result.matchedFields.includes("document")).length} /> }, + { value: "artifacts", label: }, { value: "agents", label: }, { value: "projects", label: }, ]; @@ -534,7 +537,7 @@ function SearchStories() {
/search

No results state

- +