-
Created: {createdDate}
+
Repo: {project.repoAddress}
Owner: {resolveUserLabel({ pubkey: project.owner, profiles })}
diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx
index 8ca16846e..8f4ffc541 100644
--- a/desktop/src/features/projects/ui/ProjectsView.tsx
+++ b/desktop/src/features/projects/ui/ProjectsView.tsx
@@ -1,11 +1,232 @@
-import { ExternalLink, FolderGit2, GitFork, Users } from "lucide-react";
+import {
+ CalendarDays,
+ FolderGit2,
+ GitBranch,
+ GitFork,
+ LayoutGrid,
+ List,
+ MessageSquare,
+ Trash2,
+ Users,
+} from "lucide-react";
+import * as React from "react";
+import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
-import { useProjectsQuery } from "@/features/projects/hooks";
+import { useUsersBatchQuery } from "@/features/profile/hooks";
+import {
+ resolveUserLabel,
+ type UserProfileLookup,
+} from "@/features/profile/lib/identity";
+import {
+ type Project,
+ type ProjectActivitySummary,
+ useDeleteProjectMutation,
+ useProjectActivitySummariesQuery,
+ useProjectsQuery,
+} from "@/features/projects/hooks";
import { topChromeInset } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
+import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
+import { UserAvatar } from "@/shared/ui/UserAvatar";
+
+type ProjectsViewMode = "grid" | "list";
+
+const PROJECTS_VIEW_MODE_STORAGE_KEY = "buzz.projects.viewMode";
+const MANY_PROJECTS_THRESHOLD = 12;
+
+function readStoredViewMode(): ProjectsViewMode | null {
+ try {
+ const value = globalThis.localStorage?.getItem(
+ PROJECTS_VIEW_MODE_STORAGE_KEY,
+ );
+ return value === "grid" || value === "list" ? value : null;
+ } catch {
+ return null;
+ }
+}
+
+function writeStoredViewMode(viewMode: ProjectsViewMode) {
+ try {
+ globalThis.localStorage?.setItem(PROJECTS_VIEW_MODE_STORAGE_KEY, viewMode);
+ } catch {
+ // Persistence is best-effort; the in-memory toggle still works.
+ }
+}
+
+function pluralize(count: number, singular: string, plural = `${singular}s`) {
+ return `${count} ${count === 1 ? singular : plural}`;
+}
+
+function formatCreatedDate(createdAt: number) {
+ return new Date(createdAt * 1_000).toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ });
+}
+
+function projectPeople(
+ project: Project,
+ summary?: ProjectActivitySummary,
+): string[] {
+ return [
+ ...new Set(
+ [
+ project.owner,
+ ...project.contributors,
+ ...(summary?.participantPubkeys ?? []),
+ ].map(normalizePubkey),
+ ),
+ ];
+}
+
+function getCloneLabel(project: Project) {
+ return project.cloneUrls[0] ?? "Internal git clone URL pending";
+}
+
+function getDiscussionLabel(project: Project) {
+ return project.projectChannelId ? "Discussion linked" : "No discussion";
+}
+
+function getActivityLabel(summary: ProjectActivitySummary | undefined) {
+ if (!summary || summary.activityCount === 0) {
+ return "No activity yet";
+ }
+
+ return `${pluralize(summary.issueCount, "issue")} · ${pluralize(
+ summary.activityCount,
+ "event",
+ )}`;
+}
+
+function WorkOwnerBadge({
+ avatarUrl,
+ isAgent,
+ label,
+}: {
+ avatarUrl: string | null;
+ isAgent: boolean;
+ label: string;
+}) {
+ return (
+
+
+
+ {isAgent ? "Agent" : "Work by"}: {label}
+
+
+ );
+}
+
+function ProjectPeopleStack({
+ pubkeys,
+ profiles,
+ workOwnerPubkey,
+}: {
+ pubkeys: string[];
+ profiles?: UserProfileLookup;
+ workOwnerPubkey: string;
+}) {
+ const visible = pubkeys.slice(0, 4);
+ const remaining = pubkeys.length - visible.length;
+
+ if (visible.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {visible.map((pubkey) => {
+ const profile = profiles?.[normalizePubkey(pubkey)];
+ const label = resolveUserLabel({ pubkey, profiles });
+ return (
+
+ );
+ })}
+ {remaining > 0 ? (
+
+ +{remaining}
+
+ ) : null}
+
+ );
+}
+
+function StatusPill({ status }: { status: string }) {
+ return (
+
+ {status}
+
+ );
+}
+
+function MetadataItem({
+ icon: Icon,
+ children,
+}: {
+ icon: React.ComponentType<{ className?: string }>;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+function ProjectsViewModeToggle({
+ viewMode,
+ onViewModeChange,
+}: {
+ viewMode: ProjectsViewMode;
+ onViewModeChange: (viewMode: ProjectsViewMode) => void;
+}) {
+ return (
+
+ );
+}
function EmptyState() {
return (
@@ -21,10 +242,324 @@ function EmptyState() {
);
}
+function ProjectsToolbar({
+ projectCount,
+ viewMode,
+ onViewModeChange,
+}: {
+ projectCount: number;
+ viewMode: ProjectsViewMode;
+ onViewModeChange: (viewMode: ProjectsViewMode) => void;
+}) {
+ return (
+
+
+
+
Projects
+
+ {pluralize(projectCount, "project")}
+
+
+
+ Internal git projects bring code, issues, discussion, and agent work
+ into one shared space.
+
+
+
+
+ );
+}
+
+function ProjectCardButton({
+ project,
+ onOpen,
+}: {
+ project: Project;
+ onOpen: (project: Project) => void;
+}) {
+ return (
+
+ );
+}
+
+function ProjectDeleteButton({
+ project,
+ disabled,
+ onDelete,
+}: {
+ project: Project;
+ disabled: boolean;
+ onDelete: (project: Project) => void;
+}) {
+ return (
+
+ );
+}
+
+function ProjectGridCard({
+ project,
+ people,
+ profiles,
+ summary,
+ onDelete,
+ onOpen,
+ deleteDisabled,
+}: {
+ project: Project;
+ people: string[];
+ profiles?: UserProfileLookup;
+ summary: ProjectActivitySummary | undefined;
+ onDelete: (project: Project) => void;
+ onOpen: (project: Project) => void;
+ deleteDisabled: boolean;
+}) {
+ const ownerProfile = profiles?.[normalizePubkey(project.owner)];
+ const ownerLabel = resolveUserLabel({ pubkey: project.owner, profiles });
+
+ return (
+
+
+
+
+
+
+
+
+ {project.name}
+
+
+
+ {project.dtag}
+
+
+
+
+
+
+
+
+ {project.description || "A shared space for internal git work."}
+
+
+
+ {project.defaultBranch}
+
+ {pluralize(people.length, "person", "people")}
+
+
+ {getDiscussionLabel(project)}
+
+
+ {formatCreatedDate(project.createdAt)}
+
+
+
+
+
+
+ {getActivityLabel(summary)}
+
+
+
+
+
+ {getCloneLabel(project)}
+
+
+
+
+ );
+}
+
+function ProjectListRow({
+ project,
+ people,
+ profiles,
+ summary,
+ onDelete,
+ onOpen,
+ deleteDisabled,
+}: {
+ project: Project;
+ people: string[];
+ profiles?: UserProfileLookup;
+ summary: ProjectActivitySummary | undefined;
+ onDelete: (project: Project) => void;
+ onOpen: (project: Project) => void;
+ deleteDisabled: boolean;
+}) {
+ const ownerProfile = profiles?.[normalizePubkey(project.owner)];
+ const ownerLabel = resolveUserLabel({ pubkey: project.owner, profiles });
+
+ return (
+
+
+
+
+
+
+
+ {project.name}
+
+
+
+
+ {project.description || "A shared space for internal git work."}
+
+
+
+
+
+
+
+ {project.defaultBranch}
+
+
+ {pluralize(people.length, "person", "people")}
+
+
+ {getDiscussionLabel(project)}
+
+
+ {formatCreatedDate(project.createdAt)}
+
+
+
+
+ {getCloneLabel(project)}
+
+
+
+
+
+ {getActivityLabel(summary)}
+
+
+
+
+
+
+ );
+}
+
export function ProjectsView() {
const { goProject } = useAppNavigation();
const projectsQuery = useProjectsQuery();
const projects = projectsQuery.data ?? [];
+ const activitySummariesQuery = useProjectActivitySummariesQuery(projects);
+ const [storedViewMode, setStoredViewMode] =
+ React.useState
(() => readStoredViewMode());
+ const viewMode =
+ storedViewMode ??
+ (projects.length > MANY_PROJECTS_THRESHOLD ? "list" : "grid");
+
+ const projectPubkeys = React.useMemo(
+ () => [
+ ...new Set(
+ projects.flatMap((project) =>
+ projectPeople(
+ project,
+ activitySummariesQuery.data?.[project.repoAddress],
+ ),
+ ),
+ ),
+ ],
+ [activitySummariesQuery.data, projects],
+ );
+ const profilesQuery = useUsersBatchQuery(projectPubkeys, {
+ enabled: projectPubkeys.length > 0,
+ });
+ const profiles = profilesQuery.data?.profiles;
+ const deleteProjectMutation = useDeleteProjectMutation();
+
+ const handleViewModeChange = React.useCallback(
+ (nextViewMode: ProjectsViewMode) => {
+ setStoredViewMode(nextViewMode);
+ writeStoredViewMode(nextViewMode);
+ },
+ [],
+ );
+
+ const handleOpenProject = React.useCallback(
+ (project: Project) => {
+ void goProject(project.dtag);
+ },
+ [goProject],
+ );
+
+ const handleDeleteProject = React.useCallback(
+ async (project: Project) => {
+ const confirmed = window.confirm(`Delete ${project.name}?`);
+ if (!confirmed) return;
+
+ try {
+ await deleteProjectMutation.mutateAsync(project);
+ toast.success("Project card deleted");
+ } catch (error) {
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : "Failed to delete project card",
+ );
+ }
+ },
+ [deleteProjectMutation],
+ );
if (projectsQuery.isLoading) {
return null;
@@ -56,65 +591,53 @@ export function ProjectsView() {
topChromeInset.padding,
)}
>
-
-
- {projects.length} {projects.length === 1 ? "project" : "projects"}
-
-
+
-
- {projects.map((project) => (
-
-
-
-
-
-
-
- {project.name}
-
-
- {project.description ? (
-
- {project.description}
-
- ) : null}
-
- {project.cloneUrls.length > 0 ? (
-
-
- {project.cloneUrls[0]}
-
- ) : null}
- {project.contributors.length > 0 ? (
-
-
- {project.contributors.length}
-
- ) : null}
- {project.webUrl ? (
-
-
- Web
-
- ) : null}
-
-
-
-
- ))}
-
+ {viewMode === "grid" ? (
+
+ {projects.map((project) => {
+ const summary = activitySummariesQuery.data?.[project.repoAddress];
+ return (
+
+ void handleDeleteProject(nextProject)
+ }
+ onOpen={handleOpenProject}
+ people={projectPeople(project, summary)}
+ profiles={profiles}
+ project={project}
+ summary={summary}
+ />
+ );
+ })}
+
+ ) : (
+
+ {projects.map((project) => {
+ const summary = activitySummariesQuery.data?.[project.repoAddress];
+ return (
+
+ void handleDeleteProject(nextProject)
+ }
+ onOpen={handleOpenProject}
+ people={projectPeople(project, summary)}
+ profiles={profiles}
+ project={project}
+ summary={summary}
+ />
+ );
+ })}
+
+ )}
);
}
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 04f8bfe98..8ad959d1a 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -6506,6 +6506,47 @@ export function maybeInstallE2eTauriMocks() {
},
activeConfig,
);
+ case "get_project_repo_snapshot":
+ return {
+ latest_commit: {
+ hash: "0123456789abcdef0123456789abcdef01234567",
+ short_hash: "0123456",
+ author_name: "Brain",
+ author_email: "brain@example.com",
+ timestamp: Math.floor(Date.now() / 1000) - 600,
+ subject: "Add Trello board workflow details",
+ },
+ files: [
+ {
+ path: "desktop/src/features/projects/ui/ProjectDetailScreen.tsx",
+ kind: "blob",
+ size: 18420,
+ preview_content:
+ 'export function ProjectDetailScreen() {\n return