Refine project workspace UI

Co-authored-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-06-25 09:58:11 -04:00
parent d8baf230c1
commit 2bf75cab86
5 changed files with 1498 additions and 95 deletions
+1
View File
@@ -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",
@@ -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 (
<div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-3 py-2">
<GitFork className="h-4 w-4 shrink-0 text-muted-foreground" />
<code className="min-w-0 flex-1 truncate text-xs">{url}</code>
<div className="flex items-center gap-2 rounded-lg border border-border/50 bg-card/60 px-3 py-2">
<GitFork className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<code className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{url}
</code>
<Button
className="h-6 w-6 shrink-0"
onClick={handleCopy}
@@ -49,21 +70,502 @@ function CloneUrlRow({ url }: { url: string }) {
);
}
function ProjectStatCard({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: string | number;
}) {
return (
<Card className="flex items-center gap-3 border-border/50 bg-card/60 p-3 shadow-none">
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="text-2xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</p>
<p className="truncate text-sm font-semibold text-foreground">
{value}
</p>
</div>
</Card>
);
}
function formatCreatedDate(createdAt: number) {
return new Date(createdAt * 1_000).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
}
function compactDate(createdAt: number) {
return new Date(createdAt * 1_000).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
function projectPeople(project: Project, issues: ProjectIssue[]) {
return [
...new Set(
[
project.owner,
...project.contributors,
...issues.flatMap((issue) => [issue.author, ...issue.recipients]),
]
.filter(Boolean)
.map(normalizePubkey),
),
];
}
function formatFileSize(size: number | null) {
if (size === null) return "—";
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function baseName(path: string) {
return path.split("/").pop() || path;
}
function dirName(path: string) {
const index = path.lastIndexOf("/");
return index >= 0 ? path.slice(0, index) : "/";
}
function FileBrowser({
files,
selectedFile,
onSelectFile,
}: {
files: ProjectRepoFile[];
selectedFile: ProjectRepoFile | null;
onSelectFile: (file: ProjectRepoFile) => void;
}) {
if (files.length === 0) {
return (
<div className="flex h-full items-center justify-center p-4 text-center text-sm text-muted-foreground">
No files have been pushed yet.
</div>
);
}
return (
<div className="h-full overflow-y-auto py-2">
{files.slice(0, 200).map((file) => {
const isSelected = selectedFile?.path === file.path;
return (
<button
className={cn(
"flex w-full min-w-0 items-center gap-2 px-3 py-1.5 text-left font-mono text-xs transition-colors",
isSelected
? "bg-primary/10 text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}
key={file.path}
onClick={() => onSelectFile(file)}
type="button"
>
<FileDiff className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{file.path}</span>
</button>
);
})}
</div>
);
}
function FilePreview({ file }: { file: ProjectRepoFile | null }) {
if (!file) {
return (
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-muted-foreground">
Select a file to inspect its path and contents.
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex min-h-9 items-center gap-2 border-border/50 border-b bg-muted/20 px-3">
<FileDiff className="h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate font-mono text-xs text-foreground">
{baseName(file.path)}
</span>
<span className="ml-auto shrink-0 text-2xs text-muted-foreground">
{formatFileSize(file.size)}
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto bg-background/60">
{file.previewContent ? (
<pre className="min-h-full overflow-x-auto p-4 font-mono text-xs leading-relaxed text-foreground">
<code>{file.previewContent}</code>
</pre>
) : (
<div className="space-y-3 p-4">
<div className="space-y-3 rounded-lg border border-border/50 bg-background/50 p-4">
<div>
<p className="text-2xs font-medium uppercase tracking-wide text-muted-foreground">
Path
</p>
<p className="mt-1 break-all font-mono text-sm text-foreground">
{file.path}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div>
<p className="text-2xs font-medium uppercase tracking-wide text-muted-foreground">
File
</p>
<p className="mt-1 truncate text-sm text-foreground">
{baseName(file.path)}
</p>
</div>
<div>
<p className="text-2xs font-medium uppercase tracking-wide text-muted-foreground">
Folder
</p>
<p className="mt-1 truncate font-mono text-sm text-foreground">
{dirName(file.path)}
</p>
</div>
<div>
<p className="text-2xs font-medium uppercase tracking-wide text-muted-foreground">
Size
</p>
<p className="mt-1 text-sm text-foreground">
{formatFileSize(file.size)}
</p>
</div>
</div>
</div>
<p className="text-sm text-muted-foreground">
Preview unavailable for this file. Large and binary files only
show metadata.
</p>
</div>
)}
</div>
</div>
);
}
function LatestCommitPanel({
snapshot,
isLoading,
error,
}: {
snapshot: ProjectRepoSnapshot | null | undefined;
isLoading: boolean;
error: unknown;
}) {
const latestCommit = snapshot?.latestCommit ?? null;
if (isLoading) {
return <p className="p-4 text-sm text-muted-foreground">Loading commit…</p>;
}
if (!latestCommit) {
return (
<p className="p-4 text-sm text-muted-foreground">
{error
? "Could not load repository activity from git."
: "No commits are available yet."}
</p>
);
}
return (
<div className="space-y-3 p-4">
<div className="rounded-lg border border-border/50 bg-background/50 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<p className="line-clamp-2 text-sm font-medium text-foreground">
{latestCommit.subject}
</p>
<p className="truncate text-xs text-muted-foreground">
{latestCommit.authorName} · {compactDate(latestCommit.timestamp)}
</p>
</div>
<code className="shrink-0 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
{latestCommit.shortHash}
</code>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<ProjectStatCard
icon={CircleDot}
label="Commit"
value={latestCommit.shortHash}
/>
<ProjectStatCard
icon={Users}
label="Author"
value={latestCommit.authorName}
/>
</div>
</div>
);
}
function BranchesPanel({
project,
repoState,
isLoading,
}: {
project: Project;
repoState: ReturnType<typeof useRepoStateQuery>["data"];
isLoading: boolean;
}) {
if (isLoading) {
return (
<p className="p-4 text-sm text-muted-foreground">Loading branches…</p>
);
}
if (!repoState) {
return (
<p className="p-4 text-sm text-muted-foreground">
No branch refs have been published yet.
</p>
);
}
return (
<div className="space-y-3 p-4">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<ProjectStatCard
icon={GitBranch}
label="Default"
value={project.defaultBranch}
/>
<ProjectStatCard
icon={CircleDot}
label="Branches"
value={repoState.branches.length}
/>
<ProjectStatCard
icon={CheckCircle2}
label="Tags"
value={repoState.tags.length}
/>
</div>
<div className="space-y-1">
{repoState.branches.slice(0, 12).map((branch) => (
<div
className="flex items-center justify-between gap-3 rounded-md bg-muted/30 px-3 py-1.5 text-sm"
key={branch.name}
>
<span className="min-w-0 truncate font-mono">{branch.name}</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{branch.commit.slice(0, 8)}
</span>
</div>
))}
</div>
</div>
);
}
function IssuesPanel({
issues,
isLoading,
}: {
issues: ProjectIssue[];
isLoading: boolean;
}) {
if (isLoading) {
return <p className="p-4 text-sm text-muted-foreground">Loading issues…</p>;
}
if (issues.length === 0) {
return (
<p className="p-4 text-sm text-muted-foreground">
No issues yet. Git issues for this project will appear here with their
workflow status.
</p>
);
}
return (
<div className="space-y-2 p-4">
{issues.slice(0, 10).map((issue) => (
<Card
className="space-y-2 border-border/50 bg-card/60 p-3 shadow-none"
key={issue.id}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{issue.title}
</p>
{issue.content ? (
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{issue.content}
</p>
) : null}
</div>
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-2xs font-medium uppercase tracking-wide text-muted-foreground">
{issue.status}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 text-2xs text-muted-foreground">
<span>Updated {compactDate(issue.updatedAt)}</span>
{issue.labels.map((label) => (
<span
className="rounded-md border border-border/70 px-1.5 py-0.5"
key={label}
>
{label}
</span>
))}
</div>
</Card>
))}
</div>
);
}
function WorkspaceTabs({
project,
snapshot,
snapshotError,
snapshotLoading,
repoState,
repoStateLoading,
issues,
issuesLoading,
}: {
project: Project;
snapshot: ProjectRepoSnapshot | null | undefined;
snapshotError: unknown;
snapshotLoading: boolean;
repoState: ReturnType<typeof useRepoStateQuery>["data"];
repoStateLoading: boolean;
issues: ProjectIssue[];
issuesLoading: boolean;
}) {
const files = snapshot?.files ?? [];
const [selectedPath, setSelectedPath] = React.useState<string | null>(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 (
<section className="overflow-hidden rounded-xl border border-border/50 bg-card/60 shadow-none">
<Tabs className="flex min-h-[32rem] flex-col" defaultValue="files">
<div className="flex items-center justify-between gap-3 border-border/50 border-b bg-muted/20 px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<FolderGit2 className="h-4 w-4 text-muted-foreground" />
<span className="truncate text-sm font-medium text-foreground">
Workspace
</span>
</div>
<TabsList className="h-8">
<TabsTrigger className="h-7 gap-1 px-2" value="files">
<FolderGit2 className="h-3.5 w-3.5" />
Files
</TabsTrigger>
<TabsTrigger className="h-7 gap-1 px-2" value="activity">
<CircleDot className="h-3.5 w-3.5" />
Activity
</TabsTrigger>
<TabsTrigger className="h-7 gap-1 px-2" value="issues">
<ListTodo className="h-3.5 w-3.5" />
Issues
</TabsTrigger>
<TabsTrigger className="h-7 gap-1 px-2" value="branches">
<GitBranch className="h-3.5 w-3.5" />
Branches
</TabsTrigger>
</TabsList>
</div>
<TabsContent className="m-0 min-h-0 flex-1" value="files">
<div className="grid h-[32rem] min-h-0 grid-cols-1 lg:grid-cols-[18rem_minmax(0,1fr)]">
<aside className="min-h-0 border-border/50 border-b bg-background/35 lg:border-r lg:border-b-0">
<div className="flex h-8 items-center justify-between border-border/50 border-b px-3">
<span className="text-2xs font-semibold uppercase tracking-wide text-muted-foreground">
Explorer
</span>
<span className="text-2xs text-muted-foreground">
{files.length} files
</span>
</div>
{snapshotLoading ? (
<p className="p-3 text-sm text-muted-foreground">
Loading files…
</p>
) : snapshotError ? (
<p className="p-3 text-sm text-muted-foreground">
Could not load file tree.
</p>
) : (
<FileBrowser
files={files}
onSelectFile={(file) => setSelectedPath(file.path)}
selectedFile={selectedFile}
/>
)}
</aside>
<FilePreview file={selectedFile} />
</div>
</TabsContent>
<TabsContent className="m-0 min-h-0 flex-1" value="activity">
<LatestCommitPanel
error={snapshotError}
isLoading={snapshotLoading}
snapshot={snapshot}
/>
</TabsContent>
<TabsContent className="m-0 min-h-0 flex-1" value="issues">
<IssuesPanel isLoading={issuesLoading} issues={issues} />
</TabsContent>
<TabsContent className="m-0 min-h-0 flex-1" value="branches">
<BranchesPanel
isLoading={repoStateLoading}
project={project}
repoState={repoState}
/>
</TabsContent>
</Tabs>
</section>
);
}
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 (
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
@@ -146,17 +646,76 @@ export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) {
</Button>
</div>
<div className="mx-auto w-full max-w-2xl space-y-6">
<section className="space-y-2">
<div className="flex items-center gap-2">
<FolderGit2 className="h-4 w-4 text-muted-foreground" />
<h2 className="text-lg font-semibold">{project.name}</h2>
<div className="mx-auto w-full max-w-5xl space-y-5">
<section className="space-y-3 rounded-xl border border-border/50 bg-card/60 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<UserAvatar
accent={ownerProfile?.isAgent === true}
avatarUrl={ownerProfile?.avatarUrl ?? null}
className="shrink-0"
displayName={ownerLabel}
size="md"
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h2 className="truncate text-lg font-semibold">
{project.name}
</h2>
<span className="rounded-full bg-muted px-2 py-0.5 text-2xs font-medium uppercase tracking-wide text-muted-foreground">
{project.status}
</span>
</div>
<p className="text-xs text-muted-foreground">
Work by {ownerLabel} · Created{" "}
{formatCreatedDate(project.createdAt)}
</p>
</div>
</div>
{project.projectChannelId ? (
<Button
className="shrink-0 gap-1.5"
onClick={() => {
if (project.projectChannelId) {
void goChannel(project.projectChannelId);
}
}}
size="sm"
variant="outline"
>
<MessageSquare className="h-4 w-4" />
Open Discussion
</Button>
) : null}
</div>
{project.description ? (
<p className="text-sm text-muted-foreground">
{project.description}
</p>
) : null}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-4">
<ProjectStatCard
icon={GitBranch}
label="Branch"
value={project.defaultBranch}
/>
<ProjectStatCard
icon={ListTodo}
label="Issues"
value={issues.length}
/>
<ProjectStatCard
icon={Users}
label="Involved"
value={peoplePubkeys.length}
/>
<ProjectStatCard
icon={MessageSquare}
label="Discussion"
value={project.projectChannelId ? "Linked" : "Not linked"}
/>
</div>
</section>
{project.cloneUrls.length > 0 ? (
@@ -189,32 +748,48 @@ export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) {
</section>
) : null}
{project.contributors.length > 0 ? (
<WorkspaceTabs
issues={issues}
issuesLoading={issuesQuery.isLoading}
project={project}
repoState={repoStateQuery.data}
repoStateLoading={repoStateQuery.isLoading}
snapshot={repoSnapshotQuery.data}
snapshotError={repoSnapshotQuery.error}
snapshotLoading={repoSnapshotQuery.isLoading}
/>
{peoplePubkeys.length > 0 ? (
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
<span className="flex items-center gap-1.5">
<Users className="h-4 w-4" />
Contributors ({project.contributors.length})
</span>
<h3 className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<Users className="h-4 w-4" />
Involved ({peoplePubkeys.length})
</h3>
<div className="space-y-1.5">
{project.contributors.map((pubkey) => {
<div className="grid gap-1.5 sm:grid-cols-2">
{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 (
<div
className="flex items-center gap-2 rounded-md bg-muted/30 px-3 py-1.5"
className="flex min-w-0 items-center gap-2 rounded-lg border border-border/40 bg-card/50 px-3 py-2"
key={pubkey}
>
<UserAvatar
avatarUrl={avatarUrl}
accent={profile?.isAgent === true || isOwner}
avatarUrl={profile?.avatarUrl ?? null}
displayName={label}
size="xs"
/>
<span className="truncate text-sm text-muted-foreground">
{label}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground">
{label}
</p>
<p className="truncate text-xs text-muted-foreground">
{isOwner ? "Project owner" : "Contributor"}
</p>
</div>
</div>
);
})}
@@ -222,12 +797,35 @@ export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) {
</section>
) : null}
<section className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Card className="space-y-2 border-border/50 bg-card/60 p-4 shadow-none">
<h3 className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<Bot className="h-4 w-4" />
Agent Work
</h3>
<p className="text-sm text-muted-foreground">
Start agents from project issues so their summaries, branches,
patches, and review notes stay attached to this project.
</p>
</Card>
<Card className="space-y-2 border-border/50 bg-card/60 p-4 shadow-none">
<h3 className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<FileDiff className="h-4 w-4" />
Code Discussion
</h3>
<p className="text-sm text-muted-foreground">
Diff messages and NIP-34 patches render in the linked discussion
channel, giving humans and agents a shared review surface.
</p>
</Card>
</section>
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Details
</h3>
<div className="space-y-1 text-sm text-muted-foreground">
<p>Created: {createdDate}</p>
<p className="truncate">Repo: {project.repoAddress}</p>
<p className="truncate">
Owner: {resolveUserLabel({ pubkey: project.owner, profiles })}
</p>
+583 -60
View File
@@ -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 (
<span className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-border/50 bg-muted/30 px-1.5 py-0.5 text-xs text-muted-foreground">
<UserAvatar
accent={isAgent}
avatarUrl={avatarUrl}
displayName={label}
size="xs"
testId="project-work-owner-avatar"
/>
<span className="truncate">
{isAgent ? "Agent" : "Work by"}: {label}
</span>
</span>
);
}
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 (
<div className="flex items-center -space-x-1.5">
{visible.map((pubkey) => {
const profile = profiles?.[normalizePubkey(pubkey)];
const label = resolveUserLabel({ pubkey, profiles });
return (
<UserAvatar
accent={
normalizePubkey(pubkey) === normalizePubkey(workOwnerPubkey)
}
avatarUrl={profile?.avatarUrl ?? null}
className="ring-2 ring-card"
displayName={label}
key={pubkey}
size="xs"
/>
);
})}
{remaining > 0 ? (
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-muted px-1 text-3xs font-semibold text-muted-foreground ring-2 ring-card">
+{remaining}
</span>
) : null}
</div>
);
}
function StatusPill({ status }: { status: string }) {
return (
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-2xs font-medium uppercase tracking-wide text-muted-foreground">
{status}
</span>
);
}
function MetadataItem({
icon: Icon,
children,
}: {
icon: React.ComponentType<{ className?: string }>;
children: React.ReactNode;
}) {
return (
<span className="flex min-w-0 items-center gap-1.5">
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
<span className="min-w-0 truncate">{children}</span>
</span>
);
}
function ProjectsViewModeToggle({
viewMode,
onViewModeChange,
}: {
viewMode: ProjectsViewMode;
onViewModeChange: (viewMode: ProjectsViewMode) => void;
}) {
return (
<fieldset className="flex items-center rounded-lg border border-border/60 bg-muted/30 p-1">
<legend className="sr-only">Project layout</legend>
<Button
aria-pressed={viewMode === "grid"}
className="h-7 gap-1.5 px-2"
onClick={() => onViewModeChange("grid")}
size="xs"
type="button"
variant={viewMode === "grid" ? "secondary" : "ghost"}
>
<LayoutGrid className="h-3.5 w-3.5" />
Grid
</Button>
<Button
aria-pressed={viewMode === "list"}
className="h-7 gap-1.5 px-2"
onClick={() => onViewModeChange("list")}
size="xs"
type="button"
variant={viewMode === "list" ? "secondary" : "ghost"}
>
<List className="h-3.5 w-3.5" />
List
</Button>
</fieldset>
);
}
function EmptyState() {
return (
@@ -21,10 +242,324 @@ function EmptyState() {
);
}
function ProjectsToolbar({
projectCount,
viewMode,
onViewModeChange,
}: {
projectCount: number;
viewMode: ProjectsViewMode;
onViewModeChange: (viewMode: ProjectsViewMode) => void;
}) {
return (
<div className="mb-4 flex flex-col gap-3 border-b border-border/50 pb-4 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold text-foreground">Projects</h2>
<span className="rounded-full bg-muted px-2 py-0.5 text-2xs font-medium uppercase tracking-wide text-muted-foreground">
{pluralize(projectCount, "project")}
</span>
</div>
<p className="max-w-2xl text-sm text-muted-foreground">
Internal git projects bring code, issues, discussion, and agent work
into one shared space.
</p>
</div>
<ProjectsViewModeToggle
onViewModeChange={onViewModeChange}
viewMode={viewMode}
/>
</div>
);
}
function ProjectCardButton({
project,
onOpen,
}: {
project: Project;
onOpen: (project: Project) => void;
}) {
return (
<button
className="absolute inset-0 rounded-xl"
onClick={() => onOpen(project)}
type="button"
>
<span className="sr-only">View {project.name}</span>
</button>
);
}
function ProjectDeleteButton({
project,
disabled,
onDelete,
}: {
project: Project;
disabled: boolean;
onDelete: (project: Project) => void;
}) {
return (
<Button
aria-label={`Delete ${project.name}`}
className="h-7 w-7 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onDelete(project);
}}
size="icon"
type="button"
variant="ghost"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
);
}
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 (
<Card
className="group relative flex min-h-52 flex-col overflow-hidden border-border/50 bg-card/60 p-3 shadow-none transition-colors hover:border-border hover:bg-muted/30"
data-testid={`project-card-${project.dtag}`}
>
<ProjectCardButton onOpen={onOpen} project={project} />
<div className="flex min-h-0 flex-1 flex-col gap-3">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 items-center gap-2">
<FolderGit2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium text-foreground">
{project.name}
</span>
</div>
<p className="truncate font-mono text-2xs text-muted-foreground/70">
{project.dtag}
</p>
</div>
<StatusPill status={project.status} />
</div>
<WorkOwnerBadge
avatarUrl={ownerProfile?.avatarUrl ?? null}
isAgent={ownerProfile?.isAgent === true}
label={ownerLabel}
/>
<p className="line-clamp-3 min-h-12 text-sm text-muted-foreground">
{project.description || "A shared space for internal git work."}
</p>
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
<MetadataItem icon={GitBranch}>{project.defaultBranch}</MetadataItem>
<MetadataItem icon={Users}>
{pluralize(people.length, "person", "people")}
</MetadataItem>
<MetadataItem icon={MessageSquare}>
{getDiscussionLabel(project)}
</MetadataItem>
<MetadataItem icon={CalendarDays}>
{formatCreatedDate(project.createdAt)}
</MetadataItem>
</div>
<div className="mt-auto space-y-2 rounded-lg border border-border/50 bg-muted/25 px-2.5 py-2">
<div className="flex min-w-0 items-center justify-between gap-2">
<p className="truncate text-xs text-muted-foreground">
{getActivityLabel(summary)}
</p>
<div className="relative z-10 flex shrink-0 items-center gap-1">
<ProjectPeopleStack
profiles={profiles}
pubkeys={people}
workOwnerPubkey={project.owner}
/>
<ProjectDeleteButton
disabled={deleteDisabled}
onDelete={onDelete}
project={project}
/>
</div>
</div>
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground/80">
<GitFork className="h-3.5 w-3.5 shrink-0" />
<span className="truncate font-mono">{getCloneLabel(project)}</span>
</div>
</div>
</div>
</Card>
);
}
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 (
<Card
className="group relative overflow-hidden border-border/50 bg-card/60 p-3 shadow-none transition-colors hover:border-border hover:bg-muted/30"
data-testid={`project-row-${project.dtag}`}
>
<ProjectCardButton onOpen={onOpen} project={project} />
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.5fr)_minmax(14rem,1fr)_auto] lg:items-center">
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 items-center gap-2">
<FolderGit2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium text-foreground">
{project.name}
</span>
<StatusPill status={project.status} />
</div>
<p className="line-clamp-1 text-sm text-muted-foreground">
{project.description || "A shared space for internal git work."}
</p>
<WorkOwnerBadge
avatarUrl={ownerProfile?.avatarUrl ?? null}
isAgent={ownerProfile?.isAgent === true}
label={ownerLabel}
/>
</div>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<MetadataItem icon={GitBranch}>
{project.defaultBranch}
</MetadataItem>
<MetadataItem icon={Users}>
{pluralize(people.length, "person", "people")}
</MetadataItem>
<MetadataItem icon={MessageSquare}>
{getDiscussionLabel(project)}
</MetadataItem>
<MetadataItem icon={CalendarDays}>
{formatCreatedDate(project.createdAt)}
</MetadataItem>
</div>
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground/75">
<GitFork className="h-3.5 w-3.5 shrink-0" />
<span className="truncate font-mono">{getCloneLabel(project)}</span>
</div>
</div>
<div className="relative z-10 flex min-w-0 items-center justify-start gap-2 lg:justify-end">
<p className="truncate text-xs text-muted-foreground">
{getActivityLabel(summary)}
</p>
<ProjectPeopleStack
profiles={profiles}
pubkeys={people}
workOwnerPubkey={project.owner}
/>
<ProjectDeleteButton
disabled={deleteDisabled}
onDelete={onDelete}
project={project}
/>
</div>
</div>
</Card>
);
}
export function ProjectsView() {
const { goProject } = useAppNavigation();
const projectsQuery = useProjectsQuery();
const projects = projectsQuery.data ?? [];
const activitySummariesQuery = useProjectActivitySummariesQuery(projects);
const [storedViewMode, setStoredViewMode] =
React.useState<ProjectsViewMode | null>(() => 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,
)}
>
<div className="mb-3 flex items-center gap-2">
<h2 className="text-sm font-medium text-muted-foreground">
{projects.length} {projects.length === 1 ? "project" : "projects"}
</h2>
</div>
<ProjectsToolbar
onViewModeChange={handleViewModeChange}
projectCount={projects.length}
viewMode={viewMode}
/>
<div className="space-y-2">
{projects.map((project) => (
<Card
className="relative p-4 transition-colors hover:bg-muted/50"
key={project.id}
>
<button
className="absolute inset-0 rounded-lg"
onClick={() => {
void goProject(project.dtag);
}}
type="button"
>
<span className="sr-only">View {project.name}</span>
</button>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-center gap-2">
<FolderGit2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-semibold">
{project.name}
</span>
</div>
{project.description ? (
<p className="line-clamp-2 text-sm text-muted-foreground">
{project.description}
</p>
) : null}
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground/70">
{project.cloneUrls.length > 0 ? (
<span className="flex items-center gap-1">
<GitFork className="h-4 w-4" />
{project.cloneUrls[0]}
</span>
) : null}
{project.contributors.length > 0 ? (
<span className="flex items-center gap-1">
<Users className="h-4 w-4" />
{project.contributors.length}
</span>
) : null}
{project.webUrl ? (
<span className="flex items-center gap-1">
<ExternalLink className="h-4 w-4" />
Web
</span>
) : null}
</div>
</div>
</div>
</Card>
))}
</div>
{viewMode === "grid" ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => {
const summary = activitySummariesQuery.data?.[project.repoAddress];
return (
<ProjectGridCard
deleteDisabled={deleteProjectMutation.isPending}
key={project.id}
onDelete={(nextProject) =>
void handleDeleteProject(nextProject)
}
onOpen={handleOpenProject}
people={projectPeople(project, summary)}
profiles={profiles}
project={project}
summary={summary}
/>
);
})}
</div>
) : (
<div className="space-y-2">
{projects.map((project) => {
const summary = activitySummariesQuery.data?.[project.repoAddress];
return (
<ProjectListRow
deleteDisabled={deleteProjectMutation.isPending}
key={project.id}
onDelete={(nextProject) =>
void handleDeleteProject(nextProject)
}
onOpen={handleOpenProject}
people={projectPeople(project, summary)}
profiles={profiles}
project={project}
summary={summary}
/>
);
})}
</div>
)}
</div>
);
}
+41
View File
@@ -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 <WorkspaceTabs defaultValue="files" />;\n}\n',
},
{
path: "desktop/src/features/projects/ui/ProjectsView.tsx",
kind: "blob",
size: 16412,
preview_content:
"export function ProjectsView() {\n return <ProjectsToolbar />;\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":
@@ -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 <WorkspaceTabs")).toBeVisible();
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/03-project-detail-files-tab.png`,
clip: { x: 240, y: 64, width: 880, height: 620 },
});
await page.getByRole("tab", { name: "Issues" }).click();
await expect(
page.getByText("Move git tickets between Trello columns"),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/04-project-detail-issues-tab.png`,
clip: { x: 240, y: 64, width: 880, height: 620 },
});
});
});