diff --git a/ui/src/api/artifacts.test.ts b/ui/src/api/artifacts.test.ts index 89a41bb41cf..5ab081b0f95 100644 --- a/ui/src/api/artifacts.test.ts +++ b/ui/src/api/artifacts.test.ts @@ -75,6 +75,18 @@ describe("artifactsApi.list", () => { ); }); + it("omits the starred param when false", async () => { + await artifactsApi.list("company-1", { starred: false }); + expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/artifacts"); + }); + + it("serializes starred as the boolean-coercible string and composes with search", async () => { + await artifactsApi.list("company-1", { starred: true, q: "plan" }); + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/artifacts?q=plan&starred=true", + ); + }); + it("preserves groups and selectedGroup from the envelope", async () => { const artifact = sampleArtifact(); const group = { diff --git a/ui/src/api/artifacts.ts b/ui/src/api/artifacts.ts index e0653245d38..92b7cd800de 100644 --- a/ui/src/api/artifacts.ts +++ b/ui/src/api/artifacts.ts @@ -39,6 +39,11 @@ export interface ListArtifactsParams { groupBy?: CompanyArtifactGroupBy; /** When grouping, selects a single stack to expand into its artifacts. */ groupIssueId?: string; + /** + * Restrict to artifacts the current viewer has starred (documents only in V1). + * Requires a board viewer server-side; other actors get an empty list. + */ + starred?: boolean; limit?: number; cursor?: string; } @@ -50,6 +55,10 @@ function buildArtifactsQuery(params?: ListArtifactsParams): string { if (params?.q) search.set("q", params.q); if (params?.groupBy && params.groupBy !== "none") search.set("groupBy", params.groupBy); if (params?.groupIssueId) search.set("groupIssueId", params.groupIssueId); + // Server coerces the string "true" into the boolean filter (see + // companyArtifactsQuerySchema); the page's shareable `?starred=1` param is + // translated here so the two encodings stay decoupled. + if (params?.starred) search.set("starred", "true"); if (params?.limit != null) search.set("limit", String(params.limit)); if (params?.cursor) search.set("cursor", params.cursor); const qs = search.toString(); diff --git a/ui/src/api/resourceMemberships.ts b/ui/src/api/resourceMemberships.ts index 56079837bff..5497575613e 100644 --- a/ui/src/api/resourceMemberships.ts +++ b/ui/src/api/resourceMemberships.ts @@ -18,4 +18,9 @@ export const resourceMembershipsApi = { `/companies/${companyId}/resource-memberships/me/agents/${agentId}`, data, ), + updateDocument: (companyId: string, documentId: string, data: { starred: boolean }) => + api.put( + `/companies/${companyId}/resource-memberships/me/documents/${documentId}`, + data, + ), }; diff --git a/ui/src/components/ArtifactsPanel.tsx b/ui/src/components/ArtifactsPanel.tsx index f5a058b4baf..cb5b3c014f5 100644 --- a/ui/src/components/ArtifactsPanel.tsx +++ b/ui/src/components/ArtifactsPanel.tsx @@ -4,6 +4,8 @@ import type { IssueWorkProduct } from "@paperclipai/shared"; import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { MarkdownBody } from "./MarkdownBody"; +import { StarToggle } from "./StarToggle"; +import { isStarred, useDocumentStarMutation, useResourceMemberships } from "../hooks/useResourceMemberships"; import { cn } from "../lib/utils"; import { FileText, @@ -264,6 +266,9 @@ function DocumentViewer({ queryFn: () => issuesApi.getDocument(taskId, docKey), }); + const membershipsQuery = useResourceMemberships(doc?.companyId ?? null); + const documentStarMutation = useDocumentStarMutation(doc?.companyId ?? null); + const needsAction = status === "ready_for_review" || reviewState === "needs_board_review"; const isApproved = status === "approved" || reviewState === "approved"; const isRejected = status === "changes_requested" || reviewState === "changes_requested"; @@ -275,6 +280,25 @@ function DocumentViewer({

{title}

+ {doc ? ( + + documentStarMutation.mutate({ documentId: doc.id, documentName: title, starred: next }) + } + /> + ) : null} diff --git a/ui/src/components/IssueDocumentsSection.test.tsx b/ui/src/components/IssueDocumentsSection.test.tsx index cc3639120dc..486b0fba177 100644 --- a/ui/src/components/IssueDocumentsSection.test.tsx +++ b/ui/src/components/IssueDocumentsSection.test.tsx @@ -11,6 +11,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IssueDocumentsSection } from "./IssueDocumentsSection"; import { queryKeys } from "../lib/queryKeys"; +// The document StarToggle uses the shared resource-membership mutation, which +// pulls in toast feedback; stub the throwing hook so these provider-less renders +// don't need a ToastProvider (all other real exports stay intact). +vi.mock("../context/ToastContext", async (importOriginal) => ({ + ...(await importOriginal()), + useToastActions: () => ({ pushToast: vi.fn(), dismissToast: vi.fn() }), +})); + const mockIssuesApi = vi.hoisted(() => ({ listDocuments: vi.fn(), listDocumentRevisions: vi.fn(), diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index 1e1938e58f9..65bf1204d3d 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -38,6 +38,8 @@ import { Check, Copy, Diff, Download, FilePenLine, FileText, Lock, MoreHorizonta import { DocumentDiffModal } from "./DocumentDiffModal"; import { DocumentFrameHeader, type DocumentFrameHeaderRevisionActor } from "./DocumentFrameHeader"; import { SourceTrustBadge } from "./SourceTrustBadge"; +import { StarToggle } from "./StarToggle"; +import { isStarred, useDocumentStarMutation, useResourceMemberships } from "../hooks/useResourceMemberships"; import { Badge } from "@/components/ui/badge"; type DraftState = { @@ -451,6 +453,12 @@ export function IssueDocumentsSection({ return map; }, [feedbackVotes]); + // Per-viewer document stars. All documents in a section share a company, so we + // derive it once and drive a single optimistic mutation keyed by document id. + const starCompanyId = sortedDocuments[0]?.companyId ?? null; + const membershipsQuery = useResourceMemberships(starCompanyId); + const documentStarMutation = useDocumentStarMutation(starCompanyId); + const hasRealPlan = sortedDocuments.some((doc) => doc.key === "plan"); const isEmpty = sortedDocuments.length === 0 && !documentSubject.legacyPlanDocument; const newDocumentKeyError = @@ -1058,6 +1066,27 @@ export function IssueDocumentsSection({ titleSlot={showTitle ?

{displayedTitle}

: null} actionsSlot={ <> + + documentStarMutation.mutate({ + documentId: doc.id, + documentName: displayedTitle.trim() || doc.key, + starred: next, + }) + } + /> {canManageDocumentLocks ? ( - - - Group by - {ARTIFACT_GROUP_OPTIONS.map((option) => ( - selectGroupBy(option.value)} - className="justify-between" + {/* Grouping is meaningless in the documents-only Starred view. */} + {!starred ? ( + + + + + + Group by + {ARTIFACT_GROUP_OPTIONS.map((option) => ( + selectGroupBy(option.value)} + className="justify-between" + > + {option.label} + {groupBy === option.value ? : null} + + ))} + + + ) : null}
{ARTIFACT_KIND_FILTERS.map((filter) => ( @@ -308,11 +333,11 @@ export function Artifacts() { key={filter.value} type="button" role="tab" - aria-selected={kind === filter.value} + aria-selected={!starred && kind === filter.value} onClick={() => selectKind(filter.value)} className={cn( "rounded-md px-2.5 py-1 text-xs font-medium transition-colors", - kind === filter.value + !starred && kind === filter.value ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", )} @@ -320,6 +345,25 @@ export function Artifacts() { {filter.label} ))} +
@@ -348,7 +392,7 @@ export function Artifacts() { {isLoading ? ( ) : items.length === 0 ? ( - + ) : ( <>