feat(artifacts): star documents from headers/cards + Starred tab (PAP-14811)

P2 UI for per-user document stars (parent PAP-14541, on top of P1
PAP-14810 server/contract). All star affordances reuse StarToggle and the
resource-membership optimistic machinery.

- api/hooks: add resourceMembershipsApi.updateDocument (PUT
  .../resource-memberships/me/documents/:id { starred }) and a
  useDocumentStarMutation hook with an applyDocumentStarChange optimistic
  update over starredDocumentIds/documentStarredAt.
- IssueDocumentsSection: StarToggle in the DocumentFrameHeader actionsSlot
  (covers IssueDetail, CaseDetail, SkillStudio).
- ArtifactCard: star on document-source cards (id decodes to document:<id>),
  visible-at-rest when starred, hover-reveal otherwise.
- ArtifactsPanel DocumentViewer: star in the viewer header.
- Artifacts page: URL-driven Starred tab (?starred=1) that composes with q,
  resets kind + grouping (documents-only V1), with a dedicated empty state.

Tests: artifacts query serialization, Starred-tab param wiring, and the
optimistic flip + rollback for the document star mutation. UI typecheck green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 4.8 committed 2026-07-21 14:45:24 +00:00
1 parent c39d350de6
commit 07d2c4d379
12 files changed
+589 -50

No files matched your search

+12
View File
@@ -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 = {
+9
View File
@@ -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();
+5
View File
@@ -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<ResourceMembershipUpdateResult>(
`/companies/${companyId}/resource-memberships/me/documents/${documentId}`,
data,
),
};
+24
View File
@@ -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({
<ArrowLeft className="h-4 w-4" />
</button>
<h3 className="text-sm font-semibold flex-1 truncate">{title}</h3>
{doc ? (
<StarToggle
size="row"
starred={isStarred(membershipsQuery.data, "document", doc.id)}
pending={
documentStarMutation.isPending &&
documentStarMutation.variables?.documentId === doc.id
}
error={
documentStarMutation.isError &&
documentStarMutation.variables?.documentId === doc.id
}
resourceName={title}
revealClassName="opacity-100"
onToggle={(next) =>
documentStarMutation.mutate({ documentId: doc.id, documentName: title, starred: next })
}
/>
) : null}
<button onClick={onBack} className="text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
</button>
@@ -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<typeof import("../context/ToastContext")>()),
useToastActions: () => ({ pushToast: vi.fn(), dismissToast: vi.fn() }),
}));
const mockIssuesApi = vi.hoisted(() => ({
listDocuments: vi.fn(),
listDocumentRevisions: vi.fn(),
@@ -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 ? <p className="mt-2 text-sm font-medium">{displayedTitle}</p> : null}
actionsSlot={
<>
<StarToggle
size="row"
starred={isStarred(membershipsQuery.data, "document", doc.id)}
pending={
documentStarMutation.isPending &&
documentStarMutation.variables?.documentId === doc.id
}
error={
documentStarMutation.isError &&
documentStarMutation.variables?.documentId === doc.id
}
resourceName={displayedTitle.trim() || doc.key}
revealClassName="opacity-100"
onToggle={(next) =>
documentStarMutation.mutate({
documentId: doc.id,
documentName: displayedTitle.trim() || doc.key,
starred: next,
})
}
/>
{canManageDocumentLocks ? (
<Button
variant="ghost"
+56 -11
View File
@@ -3,6 +3,20 @@ 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";
import { useCompany } from "@/context/CompanyContext";
import {
isStarred,
useDocumentStarMutation,
useResourceMemberships,
} from "@/hooks/useResourceMemberships";
import { StarToggle } from "@/components/StarToggle";
/** Document artifact ids are `document:<documents.id>`; other sources have no star. */
function documentIdFromArtifactId(artifact: CompanyArtifact): string | null {
if (artifact.source !== "document") return null;
const prefix = "document:";
return artifact.id.startsWith(prefix) ? artifact.id.slice(prefix.length) : null;
}
interface ArtifactCardProps {
artifact: CompanyArtifact;
@@ -190,7 +204,35 @@ function SecondaryAction({
);
}
/**
* Star control for a document artifact card. Rendered only for `document`
* sources (mounted conditionally so the membership hooks stay off non-document
* cards). Reuses the shared row StarToggle + resource-membership optimistic
* machinery. Lives outside the hover-reveal group so a starred document stays
* visibly marked at rest while unstarred stars reveal on hover/focus.
*/
function ArtifactCardStar({ documentId, name }: { documentId: string; name: string }) {
const { selectedCompanyId } = useCompany();
const membershipsQuery = useResourceMemberships(selectedCompanyId);
const starMutation = useDocumentStarMutation(selectedCompanyId);
const starred = isStarred(membershipsQuery.data, "document", documentId);
const pending = starMutation.isPending && starMutation.variables?.documentId === documentId;
const error = starMutation.isError && starMutation.variables?.documentId === documentId;
return (
<StarToggle
size="row"
starred={starred}
pending={pending}
error={error}
resourceName={name}
onToggle={(next) => starMutation.mutate({ documentId, documentName: name, starred: next })}
/>
);
}
export function ArtifactCard({ artifact }: ArtifactCardProps) {
const documentId = documentIdFromArtifactId(artifact);
return (
<Link
// design-allow(card-pattern): navigation <Link> card; Card renders a div and would break anchor semantics (C5a Run 3)
@@ -210,17 +252,20 @@ export function ArtifactCard({ artifact }: ArtifactCardProps) {
>
{artifact.title}
</h3>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{artifact.openPath ? (
<SecondaryAction href={artifact.openPath} title="Open file in new tab">
<ExternalLink className="h-3.5 w-3.5" />
</SecondaryAction>
) : null}
{artifact.downloadPath ? (
<SecondaryAction href={artifact.downloadPath} download title="Download file">
<Download className="h-3.5 w-3.5" />
</SecondaryAction>
) : null}
<div className="flex shrink-0 items-center gap-0.5">
{documentId ? <ArtifactCardStar documentId={documentId} name={artifact.title} /> : null}
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{artifact.openPath ? (
<SecondaryAction href={artifact.openPath} title="Open file in new tab">
<ExternalLink className="h-3.5 w-3.5" />
</SecondaryAction>
) : null}
{artifact.downloadPath ? (
<SecondaryAction href={artifact.downloadPath} download title="Download file">
<Download className="h-3.5 w-3.5" />
</SecondaryAction>
) : null}
</div>
</div>
</div>
@@ -0,0 +1,175 @@
// @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 type { ResourceMemberships } from "@paperclipai/shared";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const membershipsApiMock = vi.hoisted(() => ({
listMine: vi.fn(),
updateDocument: vi.fn(),
}));
const pushToast = vi.hoisted(() => vi.fn());
vi.mock("../api/resourceMemberships", () => ({
resourceMembershipsApi: membershipsApiMock,
}));
vi.mock("../context/ToastContext", () => ({
useToastActions: () => ({ pushToast }),
}));
import { useDocumentStarMutation, useResourceMemberships } from "./useResourceMemberships";
import { queryKeys } from "../lib/queryKeys";
const MINE_KEY = queryKeys.resourceMemberships.mine("company-1");
function emptyMemberships(): ResourceMemberships {
return {
projectMemberships: {},
agentMemberships: {},
starredProjectIds: [],
starredAgentIds: [],
starredDocumentIds: [],
projectStarredAt: {},
agentStarredAt: {},
documentStarredAt: {},
updatedAt: null,
};
}
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flush() {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
type HarnessHandle = { mutate: ReturnType<typeof useDocumentStarMutation>["mutate"] };
function Harness({ onReady }: { onReady: (handle: HarnessHandle) => void }) {
useResourceMemberships("company-1");
const mutation = useDocumentStarMutation("company-1");
onReady({ mutate: mutation.mutate });
return <div data-testid="harness" />;
}
function starredIds(queryClient: QueryClient): string[] {
return queryClient.getQueryData<ResourceMemberships>(MINE_KEY)?.starredDocumentIds ?? [];
}
describe("useDocumentStarMutation", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
membershipsApiMock.listMine.mockReset();
membershipsApiMock.updateDocument.mockReset();
pushToast.mockReset();
});
afterEach(async () => {
if (root) {
await act(async () => { root?.unmount(); });
}
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
async function render(onReady: (handle: HarnessHandle) => void) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
root = createRoot(container);
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<Harness onReady={onReady} />
</QueryClientProvider>,
);
});
// Let the initial memberships query settle into the cache.
await act(async () => { await flush(); });
return queryClient;
}
it("optimistically stars a document before the server responds and confirms on success", async () => {
membershipsApiMock.listMine.mockResolvedValue(emptyMemberships());
let resolveUpdate: (() => void) | null = null;
membershipsApiMock.updateDocument.mockReturnValue(
new Promise((resolve) => {
resolveUpdate = () =>
resolve({
resourceType: "document",
resourceId: "doc-1",
state: "joined",
starredAt: new Date(),
updatedAt: new Date(),
});
}),
);
let handle: HarnessHandle | null = null;
const queryClient = await render((h) => { handle = h; });
expect(starredIds(queryClient)).not.toContain("doc-1");
await act(async () => {
handle!.mutate({ documentId: "doc-1", documentName: "Launch Brief", starred: true });
await flush();
});
// Optimistic flip is applied to the cache while the request is still in flight.
expect(starredIds(queryClient)).toContain("doc-1");
expect(membershipsApiMock.updateDocument).toHaveBeenCalledWith("company-1", "doc-1", { starred: true });
// Server truth now reflects the star; the post-settle refetch stays consistent.
membershipsApiMock.listMine.mockResolvedValue({
...emptyMemberships(),
starredDocumentIds: ["doc-1"],
documentStarredAt: { "doc-1": new Date().toISOString() },
});
await act(async () => {
resolveUpdate?.();
await flush();
});
// Server confirmation (starredAt set) keeps the star in place.
expect(starredIds(queryClient)).toContain("doc-1");
});
it("rolls back the optimistic unstar and toasts when the server rejects", async () => {
membershipsApiMock.listMine.mockResolvedValue({
...emptyMemberships(),
starredDocumentIds: ["doc-1"],
documentStarredAt: { "doc-1": new Date().toISOString() },
});
membershipsApiMock.updateDocument.mockRejectedValue(new Error("nope"));
let handle: HarnessHandle | null = null;
const queryClient = await render((h) => { handle = h; });
expect(starredIds(queryClient)).toContain("doc-1");
await act(async () => {
handle!.mutate({ documentId: "doc-1", documentName: "Launch Brief", starred: false });
await flush();
});
// Rolled back to the pre-mutation (starred) snapshot after the rejection.
expect(starredIds(queryClient)).toContain("doc-1");
expect(pushToast).toHaveBeenCalledWith(
expect.objectContaining({ title: "Couldn't unstar Launch Brief.", tone: "error" }),
);
});
});
+90
View File
@@ -98,6 +98,40 @@ function applyMembershipChange(
};
}
/**
* Apply an optimistic document star change to the cached memberships snapshot.
* Documents have no join/leave state — they only carry a per-viewer star, so
* this mirrors the `document` half of the server rules: starring prepends the id
* (newest-first, matching starredAt DESC) and stamps `documentStarredAt`;
* unstarring drops both.
*/
function applyDocumentStarChange(
current: ResourceMemberships | undefined,
documentId: string,
starred: boolean,
): ResourceMemberships {
const base = current ?? emptyMemberships();
const currentIds = base.starredDocumentIds ?? [];
const nextStarredAt = { ...(base.documentStarredAt ?? {}) };
const previouslyStarred = currentIds.includes(documentId);
let starredIds = currentIds;
if (starred && !previouslyStarred) {
starredIds = [documentId, ...currentIds];
nextStarredAt[documentId] = new Date().toISOString();
} else if (!starred && previouslyStarred) {
starredIds = currentIds.filter((id) => id !== documentId);
delete nextStarredAt[documentId];
}
return {
...base,
starredDocumentIds: starredIds,
documentStarredAt: nextStarredAt,
updatedAt: new Date(),
};
}
export function resourceMembershipState(
memberships: ResourceMemberships | undefined,
resourceType: JoinableResourceType,
@@ -197,3 +231,59 @@ export function useResourceMembershipMutation(companyId: string | null | undefin
},
});
}
type DocumentStarVariables = {
documentId: string;
/** Human-readable document name, used for the error toast. */
documentName: string;
starred: boolean;
};
/**
* Optimistic star/unstar for documents. Mirrors {@link useResourceMembershipMutation}
* but talks to the document-only route (documents have no join/leave state) and
* touches the `starredDocumentIds` / `documentStarredAt` cache fields.
*/
export function useDocumentStarMutation(companyId: string | null | undefined) {
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const queryKey = queryKeys.resourceMemberships.mine(companyId ?? "__none__");
return useMutation({
mutationFn: (variables: DocumentStarVariables) => {
if (!companyId) throw new Error("Select a company first.");
return resourceMembershipsApi.updateDocument(companyId, variables.documentId, {
starred: variables.starred,
});
},
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<ResourceMemberships>(queryKey);
queryClient.setQueryData<ResourceMemberships>(
queryKey,
applyDocumentStarChange(previous, variables.documentId, variables.starred),
);
return { previous };
},
onError: (error, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous);
}
pushToast({
title: `Couldn't ${variables.starred ? "star" : "unstar"} ${variables.documentName}.`,
body: error instanceof Error ? error.message : "Try again.",
tone: "error",
});
},
onSuccess: (result) => {
queryClient.setQueryData<ResourceMemberships>(
queryKey,
// Loose null-check: a missing or null starredAt both mean "not starred".
(current) => applyDocumentStarChange(current, result.resourceId, result.starredAt != null),
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey });
},
});
}
+2
View File
@@ -275,6 +275,7 @@ export const queryKeys = {
q?: string,
groupBy?: string,
groupIssueId?: string,
starred?: boolean,
) =>
[
"artifacts",
@@ -283,6 +284,7 @@ export const queryKeys = {
q ?? "",
groupBy ?? "none",
groupIssueId ?? "",
starred ? "starred" : "all",
] as const,
},
budgets: {
+96
View File
@@ -182,6 +182,7 @@ describe("Artifacts page", () => {
q: undefined,
groupBy: "task",
groupIssueId: undefined,
starred: false,
limit: 30,
cursor: undefined,
});
@@ -228,6 +229,7 @@ describe("Artifacts page", () => {
q: "launch",
groupBy: "task",
groupIssueId: undefined,
starred: false,
limit: 30,
cursor: undefined,
});
@@ -285,6 +287,7 @@ describe("Artifacts page", () => {
q: undefined,
groupBy: "none",
groupIssueId: undefined,
starred: false,
limit: 30,
cursor: "cursor-2",
});
@@ -325,6 +328,7 @@ describe("Artifacts page", () => {
q: undefined,
groupBy: "none",
groupIssueId: undefined,
starred: false,
limit: 30,
cursor: undefined,
});
@@ -353,6 +357,7 @@ describe("Artifacts page", () => {
q: undefined,
groupBy: "task",
groupIssueId: undefined,
starred: false,
limit: 30,
cursor: undefined,
});
@@ -386,6 +391,7 @@ describe("Artifacts page", () => {
q: undefined,
groupBy: "task",
groupIssueId: "issue-1",
starred: false,
limit: 30,
cursor: undefined,
});
@@ -402,6 +408,95 @@ describe("Artifacts page", () => {
});
});
it("requests starred documents from the ?starred=1 URL, ignoring kind and grouping", async () => {
artifactsApiMock.list.mockResolvedValue({
artifacts: [sampleArtifact({ id: "document:doc-1", title: "Starred Brief" })],
nextCursor: null,
});
const { root } = renderArtifacts(container, ["/artifacts?starred=1&kind=image&groupBy=task"]);
await waitForAssertion(() => {
expect(artifactsApiMock.list).toHaveBeenCalledWith("company-1", {
kind: "all",
q: undefined,
groupBy: "none",
groupIssueId: undefined,
starred: true,
limit: 30,
cursor: undefined,
});
const starredTab = container.querySelector('[data-testid="artifact-starred-tab"]') as HTMLElement;
expect(starredTab.getAttribute("aria-selected")).toBe("true");
// The grouping control is hidden in the documents-only Starred view.
expect(container.querySelector('[data-testid="artifact-group-control"]')).toBeNull();
expect(container.querySelector('[data-testid="artifact-card"]')).not.toBeNull();
});
flushSync(() => {
root.unmount();
});
});
it("composes the search query with the Starred filter", async () => {
artifactsApiMock.list.mockResolvedValue({ artifacts: [], nextCursor: null });
const { root } = renderArtifacts(container, ["/artifacts?starred=1&q=launch"]);
await waitForAssertion(() => {
expect(artifactsApiMock.list).toHaveBeenCalledWith("company-1", {
kind: "all",
q: "launch",
groupBy: "none",
groupIssueId: undefined,
starred: true,
limit: 30,
cursor: undefined,
});
// Empty state guides the user to star a document.
expect(container.textContent).toContain("No starred documents match this search.");
});
flushSync(() => {
root.unmount();
});
});
it("enters the Starred view when the Starred tab is clicked", async () => {
artifactsApiMock.list.mockResolvedValue({ artifacts: [], groups: [sampleGroup()], nextCursor: null });
const { root } = renderArtifacts(container);
const starredTab = await (async () => {
let tab: HTMLElement | null = null;
await waitForAssertion(() => {
tab = container.querySelector('[data-testid="artifact-starred-tab"]');
expect(tab).not.toBeNull();
});
return tab as unknown as HTMLElement;
})();
flushSync(() => {
starredTab.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
await waitForAssertion(() => {
expect(artifactsApiMock.list).toHaveBeenLastCalledWith("company-1", {
kind: "all",
q: undefined,
groupBy: "none",
groupIssueId: undefined,
starred: true,
limit: 30,
cursor: undefined,
});
});
flushSync(() => {
root.unmount();
});
});
it("preserves the media filter when grouping", async () => {
artifactsApiMock.list.mockResolvedValue({
artifacts: [],
@@ -417,6 +512,7 @@ describe("Artifacts page", () => {
q: undefined,
groupBy: "task",
groupIssueId: undefined,
starred: false,
limit: 30,
cursor: undefined,
});
+83 -39
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { ArrowLeft, Check, Layers, Package, Search, X } from "lucide-react";
import { ArrowLeft, Check, Layers, Package, Search, Star, X } from "lucide-react";
import type { To } from "react-router-dom";
import {
artifactsApi,
@@ -66,10 +66,13 @@ export function Artifacts() {
const { setBreadcrumbs } = useBreadcrumbs();
const [searchParams, setSearchParams] = useSearchParams();
const kind = parseKind(searchParams.get("kind"));
const starred = searchParams.get("starred") === "1";
// Starred is documents-only in V1: it ignores the kind filter and grouping so
// the tab always renders a flat list of the viewer's starred documents.
const kind = starred ? "all" : parseKind(searchParams.get("kind"));
const query = searchParams.get("q") ?? "";
const groupBy = parseGroupBy(searchParams.get("groupBy"));
const groupIssueId = searchParams.get("groupIssueId") ?? undefined;
const groupBy = starred ? "none" : parseGroupBy(searchParams.get("groupBy"));
const groupIssueId = starred ? undefined : (searchParams.get("groupIssueId") ?? undefined);
const [draftQuery, setDraftQuery] = useState(query);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
@@ -117,6 +120,9 @@ export function Artifacts() {
const selectKind = useCallback(
(value: ArtifactKindFilter) => {
updateParams((next) => {
// Selecting any kind tab exits the Starred view (they are mutually
// exclusive: Starred resets the kind filter in V1).
next.delete("starred");
if (value === "all") next.delete("kind");
else next.set("kind", value);
});
@@ -124,6 +130,17 @@ export function Artifacts() {
[updateParams],
);
const selectStarred = useCallback(() => {
updateParams((next) => {
// Starred is documents-only: drop the kind filter and any grouping so the
// tab is a clean, shareable flat list (`?starred=1` composes with `q`).
next.set("starred", "1");
next.delete("kind");
next.delete("groupBy");
next.delete("groupIssueId");
});
}, [updateParams]);
const selectGroupBy = useCallback(
(value: ArtifactGroupBy) => {
updateParams((next) => {
@@ -177,13 +194,14 @@ export function Artifacts() {
fetchNextPage,
error,
} = useInfiniteQuery({
queryKey: queryKeys.artifacts.list(selectedCompanyId!, kind, query, groupBy, groupIssueId),
queryKey: queryKeys.artifacts.list(selectedCompanyId!, kind, query, groupBy, groupIssueId, starred),
queryFn: ({ pageParam }) =>
artifactsApi.list(selectedCompanyId!, {
kind,
q: query || undefined,
groupBy,
groupIssueId,
starred,
limit: ARTIFACTS_PAGE_SIZE,
cursor: pageParam,
}),
@@ -233,7 +251,11 @@ export function Artifacts() {
const showGroupCards = viewingStackList;
const items = showGroupCards ? groups : artifacts;
const emptyMessage = showGroupCards
const emptyMessage = starred
? searching
? "No starred documents match this search."
: "No starred documents yet — star a document from its header or card."
: showGroupCards
? searching
? "No artifact stacks match this search."
: "No artifact stacks yet."
@@ -270,37 +292,40 @@ export function Artifacts() {
</div>
<div className="flex flex-wrap items-center gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
aria-label={`Group artifacts (currently ${artifactGroupByLabel(groupBy)})`}
title="Group artifacts"
data-testid="artifact-group-control"
data-group-by={groupBy}
className={cn("h-8 w-8 shrink-0", grouping && "bg-accent")}
>
<Layers className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel>Group by</DropdownMenuLabel>
{ARTIFACT_GROUP_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
data-testid={`artifact-group-option-${option.value}`}
aria-selected={groupBy === option.value}
onSelect={() => selectGroupBy(option.value)}
className="justify-between"
{/* Grouping is meaningless in the documents-only Starred view. */}
{!starred ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
aria-label={`Group artifacts (currently ${artifactGroupByLabel(groupBy)})`}
title="Group artifacts"
data-testid="artifact-group-control"
data-group-by={groupBy}
className={cn("h-8 w-8 shrink-0", grouping && "bg-accent")}
>
{option.label}
{groupBy === option.value ? <Check className="h-3.5 w-3.5" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Layers className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel>Group by</DropdownMenuLabel>
{ARTIFACT_GROUP_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
data-testid={`artifact-group-option-${option.value}`}
aria-selected={groupBy === option.value}
onSelect={() => selectGroupBy(option.value)}
className="justify-between"
>
{option.label}
{groupBy === option.value ? <Check className="h-3.5 w-3.5" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<div className="flex flex-wrap items-center gap-1.5" role="tablist" aria-label="Filter artifacts by type">
{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}
</button>
))}
<button
type="button"
role="tab"
aria-selected={starred}
onClick={selectStarred}
data-testid="artifact-starred-tab"
className={cn(
"inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
starred
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<Star
className={cn("h-3.5 w-3.5", starred && "fill-amber-500 text-amber-500")}
aria-hidden="true"
/>
Starred
</button>
</div>
</div>
</div>
@@ -348,7 +392,7 @@ export function Artifacts() {
{isLoading ? (
<PageSkeleton variant="list" />
) : items.length === 0 ? (
<EmptyState icon={showGroupCards ? Layers : Package} message={emptyMessage} />
<EmptyState icon={starred ? Star : showGroupCards ? Layers : Package} message={emptyMessage} />
) : (
<>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">