mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user