From 2bf75cab8657015ce1896d02bfd82a6282b73901 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Thu, 25 Jun 2026 09:58:11 -0400 Subject: [PATCH] Refine project workspace UI Co-authored-by: Thomas Petersen Signed-off-by: Thomas Petersen --- desktop/playwright.config.ts | 1 + .../projects/ui/ProjectDetailScreen.tsx | 668 +++++++++++++++++- .../src/features/projects/ui/ProjectsView.tsx | 643 +++++++++++++++-- desktop/src/testing/e2eBridge.ts | 41 ++ .../e2e/projects-avatar-screenshot.spec.ts | 240 +++++++ 5 files changed, 1498 insertions(+), 95 deletions(-) create mode 100644 desktop/tests/e2e/projects-avatar-screenshot.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index bd244110f..ebf511c61 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -46,6 +46,7 @@ export default defineConfig({ "**/identity-archive-hide.spec.ts", "**/relay-connectivity-screenshots.spec.ts", "**/history-icons-screenshots.spec.ts", + "**/projects-avatar-screenshot.spec.ts", "**/unread-pill-screenshots.spec.ts", "**/sidebar-more-unread-overlap.spec.ts", "**/thread-unread-screenshots.spec.ts", diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index 86439d5a7..8cb6c95ce 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -1,22 +1,41 @@ import { ArrowLeft, + Bot, Check, + CheckCircle2, + CircleDot, Copy, ExternalLink, + FileDiff, FolderGit2, + GitBranch, GitFork, + ListTodo, + MessageSquare, Users, } from "lucide-react"; import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { useProjectQuery } from "@/features/projects/hooks"; +import { + type Project, + type ProjectRepoFile, + type ProjectRepoSnapshot, + useProjectIssuesQuery, + useProjectQuery, + useProjectRepoSnapshotQuery, + useRepoStateQuery, +} from "@/features/projects/hooks"; +import type { ProjectIssue } from "@/features/projects/projectIssues.mjs"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { resolveUserLabel } from "@/features/profile/lib/identity"; import { topChromeInset } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { isSafeUrl } from "@/shared/lib/url"; import { Button } from "@/shared/ui/button"; +import { Card } from "@/shared/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { UserAvatar } from "@/shared/ui/UserAvatar"; function CloneUrlRow({ url }: { url: string }) { @@ -30,9 +49,11 @@ function CloneUrlRow({ url }: { url: string }) { }, [url]); return ( -
- - {url} +
+ + + {url} + + ); + })} +
+ ); +} + +function FilePreview({ file }: { file: ProjectRepoFile | null }) { + if (!file) { + return ( +
+ Select a file to inspect its path and contents. +
+ ); + } + + return ( +
+
+ + + {baseName(file.path)} + + + {formatFileSize(file.size)} + +
+
+ {file.previewContent ? ( +
+            {file.previewContent}
+          
+ ) : ( +
+
+
+

+ Path +

+

+ {file.path} +

+
+
+
+

+ File +

+

+ {baseName(file.path)} +

+
+
+

+ Folder +

+

+ {dirName(file.path)} +

+
+
+

+ Size +

+

+ {formatFileSize(file.size)} +

+
+
+
+

+ Preview unavailable for this file. Large and binary files only + show metadata. +

+
+ )} +
+
+ ); +} + +function LatestCommitPanel({ + snapshot, + isLoading, + error, +}: { + snapshot: ProjectRepoSnapshot | null | undefined; + isLoading: boolean; + error: unknown; +}) { + const latestCommit = snapshot?.latestCommit ?? null; + + if (isLoading) { + return

Loading commit…

; + } + + if (!latestCommit) { + return ( +

+ {error + ? "Could not load repository activity from git." + : "No commits are available yet."} +

+ ); + } + + return ( +
+
+
+
+

+ {latestCommit.subject} +

+

+ {latestCommit.authorName} · {compactDate(latestCommit.timestamp)} +

+
+ + {latestCommit.shortHash} + +
+
+
+ + +
+
+ ); +} + +function BranchesPanel({ + project, + repoState, + isLoading, +}: { + project: Project; + repoState: ReturnType["data"]; + isLoading: boolean; +}) { + if (isLoading) { + return ( +

Loading branches…

+ ); + } + + if (!repoState) { + return ( +

+ No branch refs have been published yet. +

+ ); + } + + return ( +
+
+ + + +
+
+ {repoState.branches.slice(0, 12).map((branch) => ( +
+ {branch.name} + + {branch.commit.slice(0, 8)} + +
+ ))} +
+
+ ); +} + +function IssuesPanel({ + issues, + isLoading, +}: { + issues: ProjectIssue[]; + isLoading: boolean; +}) { + if (isLoading) { + return

Loading issues…

; + } + + if (issues.length === 0) { + return ( +

+ No issues yet. Git issues for this project will appear here with their + workflow status. +

+ ); + } + + return ( +
+ {issues.slice(0, 10).map((issue) => ( + +
+
+

+ {issue.title} +

+ {issue.content ? ( +

+ {issue.content} +

+ ) : null} +
+ + {issue.status} + +
+
+ Updated {compactDate(issue.updatedAt)} + {issue.labels.map((label) => ( + + {label} + + ))} +
+
+ ))} +
+ ); +} + +function WorkspaceTabs({ + project, + snapshot, + snapshotError, + snapshotLoading, + repoState, + repoStateLoading, + issues, + issuesLoading, +}: { + project: Project; + snapshot: ProjectRepoSnapshot | null | undefined; + snapshotError: unknown; + snapshotLoading: boolean; + repoState: ReturnType["data"]; + repoStateLoading: boolean; + issues: ProjectIssue[]; + issuesLoading: boolean; +}) { + const files = snapshot?.files ?? []; + const [selectedPath, setSelectedPath] = React.useState(null); + const selectedFile = + files.find((file) => file.path === selectedPath) ?? files[0] ?? null; + + React.useEffect(() => { + if (files.length > 0 && !files.some((file) => file.path === selectedPath)) { + setSelectedPath(files[0].path); + } + }, [files, selectedPath]); + + return ( +
+ +
+
+ + + Workspace + +
+ + + + Files + + + + Activity + + + + Issues + + + + Branches + + +
+ + +
+ + +
+
+ + + + + + + + + + + + +
+
+ ); +} + type ProjectDetailScreenProps = { projectId: string; }; export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) { - const { goProjects } = useAppNavigation(); + const { goChannel, goProjects } = useAppNavigation(); const projectQuery = useProjectQuery(projectId); const project = projectQuery.data; + const repoStateQuery = useRepoStateQuery(project); + const repoSnapshotQuery = useProjectRepoSnapshotQuery(project); + const issuesQuery = useProjectIssuesQuery(project); + const issues = issuesQuery.data ?? []; - const allPubkeys = React.useMemo( - () => - project ? [project.owner, ...project.contributors].filter(Boolean) : [], - [project], + const peoplePubkeys = React.useMemo( + () => (project ? projectPeople(project, issues) : []), + [issues, project], ); - const profilesQuery = useUsersBatchQuery(allPubkeys); + const profilesQuery = useUsersBatchQuery(peoplePubkeys, { + enabled: peoplePubkeys.length > 0, + }); const profiles = profilesQuery.data?.profiles; if (projectQuery.isLoading) { @@ -119,10 +621,8 @@ export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) { ); } - const createdDate = new Date(project.createdAt * 1_000).toLocaleDateString( - undefined, - { year: "numeric", month: "long", day: "numeric" }, - ); + const ownerProfile = profiles?.[normalizePubkey(project.owner)]; + const ownerLabel = resolveUserLabel({ pubkey: project.owner, profiles }); return (
@@ -146,17 +646,76 @@ export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) {
-
-
-
- -

{project.name}

+
+
+
+
+ +
+
+

+ {project.name} +

+ + {project.status} + +
+

+ Work by {ownerLabel} · Created{" "} + {formatCreatedDate(project.createdAt)} +

+
+
+ {project.projectChannelId ? ( + + ) : null}
{project.description ? (

{project.description}

) : null} + +
+ + + + +
{project.cloneUrls.length > 0 ? ( @@ -189,32 +748,48 @@ export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) {
) : null} - {project.contributors.length > 0 ? ( + + + {peoplePubkeys.length > 0 ? (
-

- - - Contributors ({project.contributors.length}) - +

+ + Involved ({peoplePubkeys.length})

-
- {project.contributors.map((pubkey) => { +
+ {peoplePubkeys.map((pubkey) => { + const profile = profiles?.[normalizePubkey(pubkey)]; const label = resolveUserLabel({ pubkey, profiles }); - const avatarUrl = - profiles?.[pubkey.toLowerCase()]?.avatarUrl ?? null; + const isOwner = + normalizePubkey(pubkey) === normalizePubkey(project.owner); return (
- - {label} - +
+

+ {label} +

+

+ {isOwner ? "Project owner" : "Contributor"} +

+
); })} @@ -222,12 +797,35 @@ export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) {
) : null} +
+ +

+ + Agent Work +

+

+ Start agents from project issues so their summaries, branches, + patches, and review notes stay attached to this project. +

+
+ +

+ + Code Discussion +

+

+ Diff messages and NIP-34 patches render in the linked discussion + channel, giving humans and agents a shared review surface. +

+
+
+

Details

-

Created: {createdDate}

+

Repo: {project.repoAddress}

Owner: {resolveUserLabel({ pubkey: project.owner, profiles })}

diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index 8ca16846e..8f4ffc541 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -1,11 +1,232 @@ -import { ExternalLink, FolderGit2, GitFork, Users } from "lucide-react"; +import { + CalendarDays, + FolderGit2, + GitBranch, + GitFork, + LayoutGrid, + List, + MessageSquare, + Trash2, + Users, +} from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { useProjectsQuery } from "@/features/projects/hooks"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { + type Project, + type ProjectActivitySummary, + useDeleteProjectMutation, + useProjectActivitySummariesQuery, + useProjectsQuery, +} from "@/features/projects/hooks"; import { topChromeInset } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +type ProjectsViewMode = "grid" | "list"; + +const PROJECTS_VIEW_MODE_STORAGE_KEY = "buzz.projects.viewMode"; +const MANY_PROJECTS_THRESHOLD = 12; + +function readStoredViewMode(): ProjectsViewMode | null { + try { + const value = globalThis.localStorage?.getItem( + PROJECTS_VIEW_MODE_STORAGE_KEY, + ); + return value === "grid" || value === "list" ? value : null; + } catch { + return null; + } +} + +function writeStoredViewMode(viewMode: ProjectsViewMode) { + try { + globalThis.localStorage?.setItem(PROJECTS_VIEW_MODE_STORAGE_KEY, viewMode); + } catch { + // Persistence is best-effort; the in-memory toggle still works. + } +} + +function pluralize(count: number, singular: string, plural = `${singular}s`) { + return `${count} ${count === 1 ? singular : plural}`; +} + +function formatCreatedDate(createdAt: number) { + return new Date(createdAt * 1_000).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +function projectPeople( + project: Project, + summary?: ProjectActivitySummary, +): string[] { + return [ + ...new Set( + [ + project.owner, + ...project.contributors, + ...(summary?.participantPubkeys ?? []), + ].map(normalizePubkey), + ), + ]; +} + +function getCloneLabel(project: Project) { + return project.cloneUrls[0] ?? "Internal git clone URL pending"; +} + +function getDiscussionLabel(project: Project) { + return project.projectChannelId ? "Discussion linked" : "No discussion"; +} + +function getActivityLabel(summary: ProjectActivitySummary | undefined) { + if (!summary || summary.activityCount === 0) { + return "No activity yet"; + } + + return `${pluralize(summary.issueCount, "issue")} · ${pluralize( + summary.activityCount, + "event", + )}`; +} + +function WorkOwnerBadge({ + avatarUrl, + isAgent, + label, +}: { + avatarUrl: string | null; + isAgent: boolean; + label: string; +}) { + return ( + + + + {isAgent ? "Agent" : "Work by"}: {label} + + + ); +} + +function ProjectPeopleStack({ + pubkeys, + profiles, + workOwnerPubkey, +}: { + pubkeys: string[]; + profiles?: UserProfileLookup; + workOwnerPubkey: string; +}) { + const visible = pubkeys.slice(0, 4); + const remaining = pubkeys.length - visible.length; + + if (visible.length === 0) { + return null; + } + + return ( +
+ {visible.map((pubkey) => { + const profile = profiles?.[normalizePubkey(pubkey)]; + const label = resolveUserLabel({ pubkey, profiles }); + return ( + + ); + })} + {remaining > 0 ? ( + + +{remaining} + + ) : null} +
+ ); +} + +function StatusPill({ status }: { status: string }) { + return ( + + {status} + + ); +} + +function MetadataItem({ + icon: Icon, + children, +}: { + icon: React.ComponentType<{ className?: string }>; + children: React.ReactNode; +}) { + return ( + + + {children} + + ); +} + +function ProjectsViewModeToggle({ + viewMode, + onViewModeChange, +}: { + viewMode: ProjectsViewMode; + onViewModeChange: (viewMode: ProjectsViewMode) => void; +}) { + return ( +
+ Project layout + + +
+ ); +} function EmptyState() { return ( @@ -21,10 +242,324 @@ function EmptyState() { ); } +function ProjectsToolbar({ + projectCount, + viewMode, + onViewModeChange, +}: { + projectCount: number; + viewMode: ProjectsViewMode; + onViewModeChange: (viewMode: ProjectsViewMode) => void; +}) { + return ( +
+
+
+

Projects

+ + {pluralize(projectCount, "project")} + +
+

+ Internal git projects bring code, issues, discussion, and agent work + into one shared space. +

+
+ +
+ ); +} + +function ProjectCardButton({ + project, + onOpen, +}: { + project: Project; + onOpen: (project: Project) => void; +}) { + return ( + + ); +} + +function ProjectDeleteButton({ + project, + disabled, + onDelete, +}: { + project: Project; + disabled: boolean; + onDelete: (project: Project) => void; +}) { + return ( + + ); +} + +function ProjectGridCard({ + project, + people, + profiles, + summary, + onDelete, + onOpen, + deleteDisabled, +}: { + project: Project; + people: string[]; + profiles?: UserProfileLookup; + summary: ProjectActivitySummary | undefined; + onDelete: (project: Project) => void; + onOpen: (project: Project) => void; + deleteDisabled: boolean; +}) { + const ownerProfile = profiles?.[normalizePubkey(project.owner)]; + const ownerLabel = resolveUserLabel({ pubkey: project.owner, profiles }); + + return ( + + +
+
+
+
+ + + {project.name} + +
+

+ {project.dtag} +

+
+ +
+ + + +

+ {project.description || "A shared space for internal git work."} +

+ +
+ {project.defaultBranch} + + {pluralize(people.length, "person", "people")} + + + {getDiscussionLabel(project)} + + + {formatCreatedDate(project.createdAt)} + +
+ +
+
+

+ {getActivityLabel(summary)} +

+
+ + +
+
+
+ + {getCloneLabel(project)} +
+
+
+
+ ); +} + +function ProjectListRow({ + project, + people, + profiles, + summary, + onDelete, + onOpen, + deleteDisabled, +}: { + project: Project; + people: string[]; + profiles?: UserProfileLookup; + summary: ProjectActivitySummary | undefined; + onDelete: (project: Project) => void; + onOpen: (project: Project) => void; + deleteDisabled: boolean; +}) { + const ownerProfile = profiles?.[normalizePubkey(project.owner)]; + const ownerLabel = resolveUserLabel({ pubkey: project.owner, profiles }); + + return ( + + +
+
+
+ + + {project.name} + + +
+

+ {project.description || "A shared space for internal git work."} +

+ +
+ +
+
+ + {project.defaultBranch} + + + {pluralize(people.length, "person", "people")} + + + {getDiscussionLabel(project)} + + + {formatCreatedDate(project.createdAt)} + +
+
+ + {getCloneLabel(project)} +
+
+ +
+

+ {getActivityLabel(summary)} +

+ + +
+
+
+ ); +} + export function ProjectsView() { const { goProject } = useAppNavigation(); const projectsQuery = useProjectsQuery(); const projects = projectsQuery.data ?? []; + const activitySummariesQuery = useProjectActivitySummariesQuery(projects); + const [storedViewMode, setStoredViewMode] = + React.useState(() => readStoredViewMode()); + const viewMode = + storedViewMode ?? + (projects.length > MANY_PROJECTS_THRESHOLD ? "list" : "grid"); + + const projectPubkeys = React.useMemo( + () => [ + ...new Set( + projects.flatMap((project) => + projectPeople( + project, + activitySummariesQuery.data?.[project.repoAddress], + ), + ), + ), + ], + [activitySummariesQuery.data, projects], + ); + const profilesQuery = useUsersBatchQuery(projectPubkeys, { + enabled: projectPubkeys.length > 0, + }); + const profiles = profilesQuery.data?.profiles; + const deleteProjectMutation = useDeleteProjectMutation(); + + const handleViewModeChange = React.useCallback( + (nextViewMode: ProjectsViewMode) => { + setStoredViewMode(nextViewMode); + writeStoredViewMode(nextViewMode); + }, + [], + ); + + const handleOpenProject = React.useCallback( + (project: Project) => { + void goProject(project.dtag); + }, + [goProject], + ); + + const handleDeleteProject = React.useCallback( + async (project: Project) => { + const confirmed = window.confirm(`Delete ${project.name}?`); + if (!confirmed) return; + + try { + await deleteProjectMutation.mutateAsync(project); + toast.success("Project card deleted"); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to delete project card", + ); + } + }, + [deleteProjectMutation], + ); if (projectsQuery.isLoading) { return null; @@ -56,65 +591,53 @@ export function ProjectsView() { topChromeInset.padding, )} > -
-

- {projects.length} {projects.length === 1 ? "project" : "projects"} -

-
+ -
- {projects.map((project) => ( - - -
-
-
- - - {project.name} - -
- {project.description ? ( -

- {project.description} -

- ) : null} -
- {project.cloneUrls.length > 0 ? ( - - - {project.cloneUrls[0]} - - ) : null} - {project.contributors.length > 0 ? ( - - - {project.contributors.length} - - ) : null} - {project.webUrl ? ( - - - Web - - ) : null} -
-
-
-
- ))} -
+ {viewMode === "grid" ? ( +
+ {projects.map((project) => { + const summary = activitySummariesQuery.data?.[project.repoAddress]; + return ( + + void handleDeleteProject(nextProject) + } + onOpen={handleOpenProject} + people={projectPeople(project, summary)} + profiles={profiles} + project={project} + summary={summary} + /> + ); + })} +
+ ) : ( +
+ {projects.map((project) => { + const summary = activitySummariesQuery.data?.[project.repoAddress]; + return ( + + void handleDeleteProject(nextProject) + } + onOpen={handleOpenProject} + people={projectPeople(project, summary)} + profiles={profiles} + project={project} + summary={summary} + /> + ); + })} +
+ )}
); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 04f8bfe98..8ad959d1a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -6506,6 +6506,47 @@ export function maybeInstallE2eTauriMocks() { }, activeConfig, ); + case "get_project_repo_snapshot": + return { + latest_commit: { + hash: "0123456789abcdef0123456789abcdef01234567", + short_hash: "0123456", + author_name: "Brain", + author_email: "brain@example.com", + timestamp: Math.floor(Date.now() / 1000) - 600, + subject: "Add Trello board workflow details", + }, + files: [ + { + path: "desktop/src/features/projects/ui/ProjectDetailScreen.tsx", + kind: "blob", + size: 18420, + preview_content: + 'export function ProjectDetailScreen() {\n return ;\n}\n', + }, + { + path: "desktop/src/features/projects/ui/ProjectsView.tsx", + kind: "blob", + size: 16412, + preview_content: + "export function ProjectsView() {\n return ;\n}\n", + }, + { + path: "desktop/src/features/projects/hooks.ts", + kind: "blob", + size: 9520, + preview_content: + "export function useProjectRepoSnapshotQuery(project) {\n return useQuery({ queryKey: [project.id, 'repo-snapshot'] });\n}\n", + }, + { + path: "crates/buzz-relay/src/api/git/transport.rs", + kind: "blob", + size: 33120, + preview_content: + "// Smart HTTP git transport\n// Handles upload-pack and receive-pack for Buzz git repos.\n", + }, + ], + }; case "get_relay_ws_url": return getRelayWsUrl(activeConfig); case "get_default_relay_url": diff --git a/desktop/tests/e2e/projects-avatar-screenshot.spec.ts b/desktop/tests/e2e/projects-avatar-screenshot.spec.ts new file mode 100644 index 000000000..2cc7b0110 --- /dev/null +++ b/desktop/tests/e2e/projects-avatar-screenshot.spec.ts @@ -0,0 +1,240 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/projects-avatar"; +const BRAIN_PUBKEY = + "1d4f144e07e4c289490acf6d51b50e5450820ee0555783972a22a3074fb1d8bf"; +const THOMAS_PUBKEY = + "29ddeb07aec92535a5b38b7ea1d731bc641fd97ffcf59080ab9a2584d3cbe5c6"; +const BRAIN_AVATAR = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop stop-color='%238b5cf6'/%3E%3Cstop offset='1' stop-color='%2306b6d4'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='32' fill='url(%23g)'/%3E%3Ctext x='32' y='39' text-anchor='middle' font-size='24' font-family='Inter,Arial' fill='white' font-weight='700'%3EB%3C/text%3E%3C/svg%3E"; + +const PROJECT_ID = `${BRAIN_PUBKEY}:git-ticket-trello`; +const PROJECT = { + id: PROJECT_ID, + dtag: "git-ticket-trello", + name: "Git Ticket Trello Board", + description: "Trello-style workflow for moving git tickets back and forth.", + cloneUrls: [ + `https://sprout-oss.stage.blox.sqprod.co/git/${BRAIN_PUBKEY}/git-ticket-trello.git`, + ], + webUrl: null, + owner: BRAIN_PUBKEY, + contributors: [THOMAS_PUBKEY], + createdAt: 1_782_389_983, + projectChannelId: "f147ef69-9ec1-48cf-8e0e-524fb3b33cee", + status: "active", + defaultBranch: "main", + repoAddress: `30617:${BRAIN_PUBKEY}:git-ticket-trello`, +}; + +const SECOND_PROJECT = { + ...PROJECT, + id: `${BRAIN_PUBKEY}:agent-review-queue`, + dtag: "agent-review-queue", + name: "Agent Review Queue", + description: "Track branches, patches, and review notes across agent work.", + cloneUrls: [ + `https://sprout-oss.stage.blox.sqprod.co/git/${BRAIN_PUBKEY}/agent-review-queue.git`, + ], + repoAddress: `30617:${BRAIN_PUBKEY}:agent-review-queue`, + projectChannelId: null, + createdAt: 1_782_300_000, +}; + +const THIRD_PROJECT = { + ...PROJECT, + id: `${BRAIN_PUBKEY}:workflow-sandbox`, + dtag: "workflow-sandbox", + name: "Workflow Sandbox", + description: "Prototype board automations before promoting them to staging.", + cloneUrls: [ + `https://sprout-oss.stage.blox.sqprod.co/git/${BRAIN_PUBKEY}/workflow-sandbox.git`, + ], + repoAddress: `30617:${BRAIN_PUBKEY}:workflow-sandbox`, + status: "draft", + createdAt: 1_782_200_000, +}; + +async function seedProjects(page: Page) { + await page.evaluate( + ({ brainPubkey, project, secondProject, thomasPubkey, thirdProject }) => { + window.__BUZZ_E2E_QUERY_CLIENT__?.setQueryData?.( + ["projects"], + [project, secondProject, thirdProject], + ); + window.__BUZZ_E2E_QUERY_CLIENT__?.setQueryData?.( + ["project", project.dtag], + project, + ); + window.__BUZZ_E2E_QUERY_CLIENT__?.setQueryData?.( + ["project", project.id, "issues"], + [ + { + id: "a".repeat(64), + title: "Move git tickets between Trello columns", + content: + "Persist movement through NIP-34 status events and keep history auditable.", + author: thomasPubkey, + createdAt: 1_782_389_990, + repoAddress: project.repoAddress, + labels: ["feature", "projects"], + recipients: [brainPubkey], + status: "In Progress", + statusEventId: null, + updatedAt: 1_782_390_100, + }, + { + id: "b".repeat(64), + title: "Render agent avatar in project cards", + content: "Show Brain's avatar directly inside the agent pill.", + author: brainPubkey, + createdAt: 1_782_389_995, + repoAddress: project.repoAddress, + labels: ["ui"], + recipients: [thomasPubkey], + status: "Done", + statusEventId: null, + updatedAt: 1_782_390_200, + }, + ], + ); + window.__BUZZ_E2E_QUERY_CLIENT__?.setQueryData?.( + ["project", project.id, "repo-state"], + { + branches: [ + { + name: "main", + commit: "0123456789abcdef0123456789abcdef01234567", + }, + { + name: "feature/trello-board", + commit: "fedcba9876543210fedcba9876543210fedcba98", + }, + ], + tags: [], + head: "refs/heads/main", + updatedAt: 1_782_390_300, + }, + ); + window.__BUZZ_E2E_QUERY_CLIENT__?.setQueryData?.( + [ + "projects", + "activity-summaries", + [ + project.repoAddress, + secondProject.repoAddress, + thirdProject.repoAddress, + ].sort(), + ], + { + [project.repoAddress]: { + repoAddress: project.repoAddress, + issueCount: 2, + activityCount: 5, + updatedAt: 1_782_390_300, + participantPubkeys: [brainPubkey, thomasPubkey], + }, + [secondProject.repoAddress]: { + repoAddress: secondProject.repoAddress, + issueCount: 1, + activityCount: 2, + updatedAt: 1_782_300_100, + participantPubkeys: [brainPubkey], + }, + [thirdProject.repoAddress]: { + repoAddress: thirdProject.repoAddress, + issueCount: 0, + activityCount: 0, + updatedAt: 0, + participantPubkeys: [], + }, + }, + ); + }, + { + brainPubkey: BRAIN_PUBKEY, + project: PROJECT, + secondProject: SECOND_PROJECT, + thomasPubkey: THOMAS_PUBKEY, + thirdProject: THIRD_PROJECT, + }, + ); +} + +test.describe("project cards", () => { + test.use({ viewport: { width: 1280, height: 720 } }); + + test("show grid/list modes, agent avatar, delete action, and detail view", async ({ + page, + }) => { + await installMockBridge(page, { + searchProfiles: [ + { + pubkey: BRAIN_PUBKEY, + displayName: "Brain", + avatarUrl: BRAIN_AVATAR, + isAgent: true, + ownerPubkey: THOMAS_PUBKEY, + }, + { + pubkey: THOMAS_PUBKEY, + displayName: "Thomas P", + avatarUrl: null, + }, + ], + }); + + await page.goto("/"); + await page.waitForFunction(() => Boolean(window.__BUZZ_E2E_QUERY_CLIENT__)); + await page.getByTestId("open-projects-view").click(); + await seedProjects(page); + + const card = page.getByTestId("project-card-git-ticket-trello"); + await expect(card).toBeVisible(); + await expect(card.getByText("Agent: Brain")).toBeVisible(); + await expect( + card.getByTestId("project-work-owner-avatar-image"), + ).toBeVisible(); + + await card.hover(); + await expect( + page.getByLabel("Delete Git Ticket Trello Board"), + ).toBeVisible(); + + await waitForAnimations(page); + await card.screenshot({ path: `${SHOTS}/01-project-grid-card.png` }); + + await page.getByRole("button", { name: "List" }).click(); + const row = page.getByTestId("project-row-git-ticket-trello"); + await expect(row).toBeVisible(); + await waitForAnimations(page); + await row.screenshot({ path: `${SHOTS}/02-project-list-row.png` }); + + await row.click(); + await expect(page.getByRole("tab", { name: "Files" })).toBeVisible(); + await expect( + page.getByRole("button", { + name: "desktop/src/features/projects/ui/ProjectDetailScreen.tsx", + }), + ).toBeVisible(); + await expect(page.getByText("return