diff --git a/templates/design/actions/list-designs.ts b/templates/design/actions/list-designs.ts index 74103c8dbe..930b5785ea 100644 --- a/templates/design/actions/list-designs.ts +++ b/templates/design/actions/list-designs.ts @@ -13,7 +13,7 @@ const PREVIEW_MAX_BYTES = 50_000; export default defineAction({ description: "List all design projects accessible to the current user. " + - "Returns title, id, project type, and timestamps.", + "Returns title, id, project type, owner email, and timestamps.", schema: z.object({ compact: z .enum(["true", "false"]) @@ -43,6 +43,7 @@ export default defineAction({ projectType: schema.designs.projectType, designSystemId: schema.designs.designSystemId, visibility: schema.designs.visibility, + ownerEmail: schema.designs.ownerEmail, createdAt: schema.designs.createdAt, updatedAt: schema.designs.updatedAt, }) @@ -106,6 +107,7 @@ export default defineAction({ projectType: row.projectType, designSystemId: row.designSystemId, visibility: row.visibility, + ownerEmail: row.ownerEmail, createdAt: row.createdAt, updatedAt: row.updatedAt, }; diff --git a/templates/design/app/i18n-data.ts b/templates/design/app/i18n-data.ts index 9f07e4f8cb..f8600e40be 100644 --- a/templates/design/app/i18n-data.ts +++ b/templates/design/app/i18n-data.ts @@ -1353,6 +1353,9 @@ const enUS = { newDesign: "New Design", newDesignLower: "New design", createDesignProject: "Create a design project", + createdBy: "Created by", + allAuthors: "All authors", + me: "Me", openingDesign: "Opening design...", skipToEditor: "Skip to editor", failedToCreateDesign: "Failed to create design", @@ -15375,6 +15378,72 @@ const designComponentSourceOverrides = { }, } satisfies Record, PartialMessages>; +const designAuthorFilterOverrides = { + "zh-CN": { + home: { + createdBy: "创建者", + allAuthors: "所有作者", + me: "我", + }, + }, + "es-ES": { + home: { + createdBy: "Creado por", + allAuthors: "Todos los autores", + me: "Yo", + }, + }, + "fr-FR": { + home: { + createdBy: "Créé par", + allAuthors: "Tous les auteurs", + me: "Moi", + }, + }, + "de-DE": { + home: { + createdBy: "Erstellt von", + allAuthors: "Alle Autoren", + me: "Ich", + }, + }, + "ja-JP": { + home: { + createdBy: "作成者", + allAuthors: "すべての作成者", + me: "自分", + }, + }, + "ko-KR": { + home: { + createdBy: "만든 사람", + allAuthors: "모든 작성자", + me: "나", + }, + }, + "pt-BR": { + home: { + createdBy: "Criado por", + allAuthors: "Todos os autores", + me: "Eu", + }, + }, + "hi-IN": { + home: { + createdBy: "द्वारा बनाया गया", + allAuthors: "सभी लेखक", + me: "मैं", + }, + }, + "ar-SA": { + home: { + createdBy: "أنشأه", + allAuthors: "جميع المؤلفين", + me: "أنا", + }, + }, +} satisfies Record, PartialMessages>; + export const messagesByLocale = { "en-US": enUS, "zh-TW": mergeLocalizedMessages( @@ -15429,6 +15498,7 @@ export const messagesByLocale = { designRuntimeIdentityAndBridgeOverrides["zh-CN"], designComponentInstanceOverrides["zh-CN"], designComponentSourceOverrides["zh-CN"], + designAuthorFilterOverrides["zh-CN"], { root: { commandActions: "操作", @@ -15508,6 +15578,7 @@ export const messagesByLocale = { designRuntimeIdentityAndBridgeOverrides["es-ES"], designComponentInstanceOverrides["es-ES"], designComponentSourceOverrides["es-ES"], + designAuthorFilterOverrides["es-ES"], { root: { commandActions: "Acciones", @@ -15589,6 +15660,7 @@ export const messagesByLocale = { designRuntimeIdentityAndBridgeOverrides["fr-FR"], designComponentInstanceOverrides["fr-FR"], designComponentSourceOverrides["fr-FR"], + designAuthorFilterOverrides["fr-FR"], { root: { commandActions: "Actions", @@ -15670,6 +15742,7 @@ export const messagesByLocale = { designRuntimeIdentityAndBridgeOverrides["de-DE"], designComponentInstanceOverrides["de-DE"], designComponentSourceOverrides["de-DE"], + designAuthorFilterOverrides["de-DE"], { root: { commandActions: "Aktionen", @@ -15751,6 +15824,7 @@ export const messagesByLocale = { designRuntimeIdentityAndBridgeOverrides["ja-JP"], designComponentInstanceOverrides["ja-JP"], designComponentSourceOverrides["ja-JP"], + designAuthorFilterOverrides["ja-JP"], { root: { commandActions: "操作", @@ -15833,6 +15907,7 @@ export const messagesByLocale = { designRuntimeIdentityAndBridgeOverrides["ko-KR"], designComponentInstanceOverrides["ko-KR"], designComponentSourceOverrides["ko-KR"], + designAuthorFilterOverrides["ko-KR"], { root: { commandActions: "작업", @@ -15913,6 +15988,7 @@ export const messagesByLocale = { designRuntimeIdentityAndBridgeOverrides["pt-BR"], designComponentInstanceOverrides["pt-BR"], designComponentSourceOverrides["pt-BR"], + designAuthorFilterOverrides["pt-BR"], { root: { commandActions: "Ações", @@ -15994,6 +16070,7 @@ export const messagesByLocale = { designRuntimeIdentityAndBridgeOverrides["hi-IN"], designComponentInstanceOverrides["hi-IN"], designComponentSourceOverrides["hi-IN"], + designAuthorFilterOverrides["hi-IN"], { root: { commandActions: "क्रियाएं", @@ -16075,6 +16152,7 @@ export const messagesByLocale = { designRuntimeIdentityAndBridgeOverrides["ar-SA"], designComponentInstanceOverrides["ar-SA"], designComponentSourceOverrides["ar-SA"], + designAuthorFilterOverrides["ar-SA"], { root: { commandActions: "الإجراءات", diff --git a/templates/design/app/i18n/zh-TW.ts b/templates/design/app/i18n/zh-TW.ts index 2dec2d488d..9b58134d72 100644 --- a/templates/design/app/i18n/zh-TW.ts +++ b/templates/design/app/i18n/zh-TW.ts @@ -1198,6 +1198,9 @@ const messages = { newDesign: "新Design", newDesignLower: "新設計", createDesignProject: "建立一個設計專案", + createdBy: "建立者", + allAuthors: "所有作者", + me: "我", openingDesign: "正在開啟設計...", skipToEditor: "直接進入編輯器", failedToCreateDesign: "無法建立設計", diff --git a/templates/design/app/lib/design-authors.test.ts b/templates/design/app/lib/design-authors.test.ts new file mode 100644 index 0000000000..f393486e95 --- /dev/null +++ b/templates/design/app/lib/design-authors.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { + ALL_AUTHORS, + collectAuthorEmails, + filterDesignsByAuthor, + MY_DESIGNS, + shouldShowAuthors, +} from "./design-authors"; + +const designs = [ + { id: "a", ownerEmail: "zoe@example.com" }, + { id: "b", ownerEmail: "adam@example.com" }, + { id: "c", ownerEmail: "Zoe@Example.com" }, + { id: "d", ownerEmail: null }, +]; + +describe("collectAuthorEmails", () => { + it("dedupes case variants of one author and orders by display name", () => { + expect(collectAuthorEmails(designs)).toEqual([ + "adam@example.com", + "zoe@example.com", + ]); + }); + + it("returns no authors when nothing records an owner", () => { + expect(collectAuthorEmails([{ ownerEmail: null }, {}])).toEqual([]); + }); +}); + +describe("shouldShowAuthors", () => { + it("stays hidden for a solo workspace with one author", () => { + expect( + shouldShowAuthors({ + orgMemberCount: 1, + authorEmails: ["solo@example.com"], + }), + ).toBe(false); + }); + + it("shows for a team whose designs all belong to one member", () => { + expect( + shouldShowAuthors({ + orgMemberCount: 4, + authorEmails: ["solo@example.com"], + }), + ).toBe(true); + }); + + it("shows when a second author appears without any org", () => { + expect( + shouldShowAuthors({ + orgMemberCount: undefined, + authorEmails: ["a@example.com", "b@example.com"], + }), + ).toBe(true); + }); +}); + +describe("filterDesignsByAuthor", () => { + it("keeps every design for the all-authors value", () => { + expect( + filterDesignsByAuthor(designs, ALL_AUTHORS, "zoe@example.com"), + ).toEqual(designs); + }); + + it("matches one author regardless of stored email casing", () => { + expect( + filterDesignsByAuthor(designs, "zoe@example.com", null).map((d) => d.id), + ).toEqual(["a", "c"]); + }); + + it("resolves the mine value against the viewer email", () => { + expect( + filterDesignsByAuthor(designs, MY_DESIGNS, " Adam@example.com ").map( + (d) => d.id, + ), + ).toEqual(["b"]); + }); + + it("returns nothing for mine when the viewer is unknown", () => { + expect(filterDesignsByAuthor(designs, MY_DESIGNS, null)).toEqual([]); + }); +}); diff --git a/templates/design/app/lib/design-authors.ts b/templates/design/app/lib/design-authors.ts new file mode 100644 index 0000000000..154d089559 --- /dev/null +++ b/templates/design/app/lib/design-authors.ts @@ -0,0 +1,64 @@ +import { emailToName } from "@agent-native/core/client/collab"; + +/** Author-filter values that cannot collide with a real owner email. */ +export const ALL_AUTHORS = "all"; +export const MY_DESIGNS = "me"; + +export interface AuthoredDesign { + ownerEmail?: string | null; +} + +export function normalizeAuthorEmail( + email: string | null | undefined, +): string | null { + const trimmed = email?.trim(); + return trimmed ? trimmed.toLowerCase() : null; +} + +/** Distinct owner emails across the whole library, ordered by display name. */ +export function collectAuthorEmails(designs: AuthoredDesign[]): string[] { + const byNormalized = new Map(); + for (const design of designs) { + const email = design.ownerEmail?.trim(); + if (!email) continue; + const normalized = email.toLowerCase(); + if (!byNormalized.has(normalized)) byNormalized.set(normalized, email); + } + return Array.from(byNormalized.values()).sort((a, b) => + emailToName(a).localeCompare(emailToName(b)), + ); +} + +/** + * Whether author bylines are worth showing on cards. A solo workspace would + * only ever repeat the viewer's own name, so bylines stay hidden until a + * second person is involved — either as an org member or as the owner of a + * design the viewer can see. + */ +export function shouldShowAuthors(input: { + orgMemberCount: number | undefined; + authorEmails: string[]; +}): boolean { + return (input.orgMemberCount ?? 0) > 1 || input.authorEmails.length > 1; +} + +export function filterDesignsByAuthor( + designs: T[], + author: string, + viewerEmail: string | null | undefined, +): T[] { + if (author === ALL_AUTHORS) return designs; + const normalizedViewer = normalizeAuthorEmail(viewerEmail); + // An unknown viewer cannot own anything, so "mine" is empty rather than + // silently falling back to every design. + if (author === MY_DESIGNS) { + if (!normalizedViewer) return []; + return designs.filter( + (design) => normalizeAuthorEmail(design.ownerEmail) === normalizedViewer, + ); + } + const normalizedAuthor = normalizeAuthorEmail(author); + return designs.filter( + (design) => normalizeAuthorEmail(design.ownerEmail) === normalizedAuthor, + ); +} diff --git a/templates/design/app/pages/Index.skip-to-editor.test.tsx b/templates/design/app/pages/Index.skip-to-editor.test.tsx index 2044e1c78b..43d654a7a3 100644 --- a/templates/design/app/pages/Index.skip-to-editor.test.tsx +++ b/templates/design/app/pages/Index.skip-to-editor.test.tsx @@ -28,6 +28,15 @@ vi.mock("@agent-native/core/client/feature-flags", () => ({ useFeatureFlag: () => false, })); +vi.mock("@agent-native/core/client/collab", () => ({ + emailToColor: () => "#000000", + emailToName: (email: string) => email, +})); + +vi.mock("@agent-native/core/client/org", () => ({ + useOrgMembers: () => ({ data: undefined }), +})); + vi.mock("@agent-native/core/client/hooks", () => ({ useActionQuery: (name: string) => { if (name === "list-designs") { @@ -65,6 +74,8 @@ vi.mock("@agent-native/core/client/hooks", () => ({ : vi.fn().mockResolvedValue(undefined), mutate: vi.fn(), }), + useSession: () => ({ session: null, isLoading: false }), + useAvatarUrl: () => null, })); vi.mock("@agent-native/core/client/i18n", () => ({ diff --git a/templates/design/app/pages/Index.tsx b/templates/design/app/pages/Index.tsx index 37e67c71ae..48043a2897 100644 --- a/templates/design/app/pages/Index.tsx +++ b/templates/design/app/pages/Index.tsx @@ -1,14 +1,18 @@ +import { emailToColor, emailToName } from "@agent-native/core/client/collab"; import { type PromptComposerSubmitOptions } from "@agent-native/core/client/composer"; import { useFeatureFlag } from "@agent-native/core/client/feature-flags"; import { useActionQuery, useActionMutation, + useAvatarUrl, + useSession, } from "@agent-native/core/client/hooks"; import { injectSessionReplayIframeBootstrap, SESSION_REPLAY_IFRAME_ATTRIBUTE, } from "@agent-native/core/client/host"; import { useT } from "@agent-native/core/client/i18n"; +import { useOrgMembers } from "@agent-native/core/client/org"; import { CreativeContextShareSheet } from "@agent-native/creative-context/client"; import { useSetHeaderActions, @@ -49,6 +53,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -58,6 +63,13 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, @@ -66,6 +78,14 @@ import { } from "@/components/ui/tooltip"; import { useDesignSystems } from "@/hooks/use-design-systems"; import { sendToDesignAgentChat } from "@/lib/agent-chat"; +import { + ALL_AUTHORS, + collectAuthorEmails, + filterDesignsByAuthor, + MY_DESIGNS, + normalizeAuthorEmail, + shouldShowAuthors, +} from "@/lib/design-authors"; import { clearPendingGeneration, writePendingGeneration, @@ -80,6 +100,7 @@ interface Design { description?: string; projectType: ProjectType; designSystemId?: string | null; + ownerEmail?: string | null; createdAt?: string; updatedAt?: string; /** Preview HTML for the thumbnail. Only present when the list query asks @@ -93,6 +114,7 @@ export default function Index() { const [searchParams, setSearchParams] = useSearchParams(); const queryClient = useQueryClient(); const [search, setSearch] = useState(""); + const [author, setAuthor] = useState(ALL_AUTHORS); const [deleteId, setDeleteId] = useState(null); const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); const [selectedDesignIds, setSelectedDesignIds] = useState>( @@ -134,6 +156,8 @@ export default function Index() { "list-design-templates", { includePreview: "true" }, ); + const { session } = useSession(); + const { data: orgMembersPage } = useOrgMembers(); const createMutation = useActionMutation("create-design"); const createFromTemplateMutation = useActionMutation( @@ -174,11 +198,27 @@ export default function Index() { const selectedTemplate = templateOptions.find((template) => template.id === newTemplateId) ?? null; + const viewerEmail = session?.email ?? null; + const authorEmails = useMemo(() => collectAuthorEmails(designs), [designs]); + const showAuthors = shouldShowAuthors({ + orgMemberCount: orgMembersPage?.totalCount, + authorEmails, + }); + const normalizedViewerEmail = normalizeAuthorEmail(viewerEmail); + const viewerHasDesigns = authorEmails.some( + (email) => normalizeAuthorEmail(email) === normalizedViewerEmail, + ); + // One condition for both the control and the filtering it drives — a hidden + // control with a live filter leaves an empty grid the user cannot reset. + const canFilterByAuthor = authorEmails.length > 1; + const byAuthor = canFilterByAuthor + ? filterDesignsByAuthor(designs, author, viewerEmail) + : designs; const filtered = search - ? designs.filter((d) => + ? byAuthor.filter((d) => d.title.toLowerCase().includes(search.toLowerCase()), ) - : designs; + : byAuthor; const selectedDesignCount = selectedDesignIds.size; const isSelectingDesigns = selectedDesignCount > 0; const allVisibleSelected = @@ -313,6 +353,15 @@ export default function Index() { ); }, []); + // Bulk actions operate on the visible set, so narrowing the visible set has + // to drop selections the user can no longer see. + const handleAuthorChange = useCallback((next: string) => { + setAuthor(next); + setSelectedDesignIds((current) => + current.size === 0 ? current : new Set(), + ); + }, []); + const clearSelection = useCallback(() => { setSelectedDesignIds(new Set()); }, []); @@ -723,6 +772,37 @@ export default function Index() { useSetHeaderActions( designs.length > 0 ? (
+ {canFilterByAuthor ? ( + + ) : null}
-
- {formatDate(design.updatedAt || design.createdAt)} +
+ + {formatDate(design.updatedAt || design.createdAt)} + + {showAuthors && design.ownerEmail ? ( + <> + · + + + ) : null}
@@ -1133,6 +1221,28 @@ export default function Index() { ); } +/** Who created a design, shown on its library card in shared workspaces. */ +function DesignAuthorByline({ email }: { email: string }) { + const name = emailToName(email); + const avatarUrl = useAvatarUrl(email); + + return ( + + + {avatarUrl ? : null} + + {name.trim().charAt(0).toUpperCase() || "?"} + + + {name} + + ); +} + /** * Render the design's index.html as a non-interactive thumbnail. The iframe * renders at a fixed natural size (so designs that assume a desktop viewport diff --git a/templates/design/changelog/2026-08-07-design-cards-now-show-who-created-each-design-with-a-filter-.md b/templates/design/changelog/2026-08-07-design-cards-now-show-who-created-each-design-with-a-filter-.md new file mode 100644 index 0000000000..3a9a3f16a9 --- /dev/null +++ b/templates/design/changelog/2026-08-07-design-cards-now-show-who-created-each-design-with-a-filter-.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-08-07 +--- + +Design cards now show who created each design, with a filter by creator when your workspace has more than one person.