feat(projects): refine BizzHub project hub

Present projects as a BizzHub-first collaboration surface with denser grid/list layouts, canonical relay clone URLs, and inferred agent involvement from existing project activity.
This commit is contained in:
Thomas Petersen
2026-06-25 07:10:35 -04:00
parent 9d7458a5aa
commit 38a5e6022e
9 changed files with 772 additions and 111 deletions

View File

@@ -1,8 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { relayClient } from "@/shared/api/relayClient";
import {
KIND_GIT_ISSUE,
KIND_GIT_PATCH,
KIND_GIT_PR_UPDATE,
KIND_GIT_PULL_REQUEST,
KIND_GIT_STATUS_CLOSED,
KIND_GIT_STATUS_DRAFT,
KIND_GIT_STATUS_MERGED,
@@ -23,7 +27,9 @@ import {
eventToProject,
getTag,
projectEventsToProjects,
withCanonicalProjectCloneUrl,
} from "./projectEvents.mjs";
import { summarizeProjectActivityEvents } from "./projectActivity.mjs";
import {
buildGitIssueTags,
buildGitStatusTags,
@@ -62,6 +68,14 @@ export type RepoState = {
updatedAt: number;
};
export type ProjectActivitySummary = {
repoAddress: string;
issueCount: number;
activityCount: number;
updatedAt: number;
participantPubkeys: string[];
};
export type CreateProjectIssueInput = {
title: string;
content?: string;
@@ -75,23 +89,33 @@ export type ProjectIssueStatusInput = {
};
async function fetchProjects(): Promise<Project[]> {
const events = await relayClient.fetchEvents({
kinds: [KIND_REPO_ANNOUNCEMENT],
limit: 200,
});
const [events, relayHttpUrl] = await Promise.all([
relayClient.fetchEvents({
kinds: [KIND_REPO_ANNOUNCEMENT],
limit: 200,
}),
getRelayHttpUrl(),
]);
return projectEventsToProjects(events) as Project[];
return (projectEventsToProjects(events) as Project[]).map((project) =>
withCanonicalProjectCloneUrl(project, relayHttpUrl),
);
}
async function fetchProject(projectId: string): Promise<Project | null> {
const events = await relayClient.fetchEvents({
kinds: [KIND_REPO_ANNOUNCEMENT],
"#d": [projectId],
limit: 10,
});
const [events, relayHttpUrl] = await Promise.all([
relayClient.fetchEvents({
kinds: [KIND_REPO_ANNOUNCEMENT],
"#d": [projectId],
limit: 10,
}),
getRelayHttpUrl(),
]);
const projects = projectEventsToProjects(events) as Project[];
return projects.length > 0 ? projects[0] : null;
return projects.length > 0
? withCanonicalProjectCloneUrl(projects[0], relayHttpUrl)
: null;
}
function eventToRepoState(event: RelayEvent): RepoState {
@@ -155,6 +179,34 @@ async function fetchProjectIssues(project: Project): Promise<ProjectIssue[]> {
return projectIssueEventsToIssues(issueEvents, statusEvents);
}
async function fetchProjectActivitySummaries(
projects: Project[],
): Promise<Record<string, ProjectActivitySummary>> {
if (projects.length === 0) {
return {};
}
const events = await relayClient.fetchEvents({
kinds: [
KIND_GIT_ISSUE,
KIND_GIT_STATUS_OPEN,
KIND_GIT_STATUS_MERGED,
KIND_GIT_STATUS_CLOSED,
KIND_GIT_STATUS_DRAFT,
KIND_GIT_PATCH,
KIND_GIT_PULL_REQUEST,
KIND_GIT_PR_UPDATE,
],
"#a": projects.map((project) => project.repoAddress),
limit: 1_000,
});
return summarizeProjectActivityEvents(events, projects) as Record<
string,
ProjectActivitySummary
>;
}
async function createProject(input: CreateProjectInput): Promise<Project> {
const name = input.name.trim();
const repoId = input.repoId.trim();
@@ -250,6 +302,20 @@ export function useProjectIssuesQuery(project: Project | null | undefined) {
});
}
export function useProjectActivitySummariesQuery(projects: Project[]) {
const repoAddresses = React.useMemo(
() => projects.map((project) => project.repoAddress).sort(),
[projects],
);
return useQuery({
enabled: repoAddresses.length > 0,
queryKey: ["projects", "activity-summaries", repoAddresses],
queryFn: () => fetchProjectActivitySummaries(projects),
staleTime: 30_000,
});
}
export function useCreateProjectMutation() {
const queryClient = useQueryClient();

View File

@@ -0,0 +1,75 @@
function getAllTags(event, name) {
return event.tags.filter((tag) => tag[0] === name).map((tag) => tag[1]);
}
function getTag(event, name) {
return event.tags.find((tag) => tag[0] === name)?.[1];
}
function normalizePubkey(pubkey) {
return /^[a-fA-F0-9]{64}$/.test(pubkey) ? pubkey.toLowerCase() : null;
}
function ensureSummary(summaryByRepoAddress, repoAddress) {
const existing = summaryByRepoAddress.get(repoAddress);
if (existing) return existing;
const summary = {
repoAddress,
issueCount: 0,
activityCount: 0,
updatedAt: 0,
participantPubkeys: [],
};
summaryByRepoAddress.set(repoAddress, summary);
return summary;
}
export function summarizeProjectActivityEvents(events, projects) {
const repoAddresses = new Set(projects.map((project) => project.repoAddress));
const summaryByRepoAddress = new Map();
const participantsByRepoAddress = new Map();
for (const project of projects) {
ensureSummary(summaryByRepoAddress, project.repoAddress);
participantsByRepoAddress.set(project.repoAddress, new Set());
}
for (const event of events) {
const repoAddress = getTag(event, "a");
if (!repoAddress || !repoAddresses.has(repoAddress)) {
continue;
}
const summary = ensureSummary(summaryByRepoAddress, repoAddress);
const participants =
participantsByRepoAddress.get(repoAddress) ?? new Set();
participantsByRepoAddress.set(repoAddress, participants);
summary.activityCount += 1;
summary.updatedAt = Math.max(summary.updatedAt, event.created_at);
if (event.kind === 1621) {
summary.issueCount += 1;
}
const author = normalizePubkey(event.pubkey);
if (author) {
participants.add(author);
}
for (const pubkey of getAllTags(event, "p")) {
const normalized = normalizePubkey(pubkey);
if (normalized) {
participants.add(normalized);
}
}
}
for (const [repoAddress, participants] of participantsByRepoAddress) {
const summary = ensureSummary(summaryByRepoAddress, repoAddress);
summary.participantPubkeys = [...participants].sort();
}
return Object.fromEntries(summaryByRepoAddress);
}

View File

@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import test from "node:test";
import { summarizeProjectActivityEvents } from "./projectActivity.mjs";
const OWNER = "a".repeat(64);
const AGENT = "b".repeat(64);
const REVIEWER = "c".repeat(64);
const OTHER = "d".repeat(64);
const REPO_ADDRESS = `30617:${OWNER}:demo`;
function event(overrides = {}) {
return {
id: overrides.id ?? "event-id",
pubkey: overrides.pubkey ?? AGENT,
created_at: overrides.created_at ?? 100,
kind: overrides.kind ?? 1621,
content: overrides.content ?? "",
sig: "",
tags: overrides.tags ?? [["a", REPO_ADDRESS]],
};
}
test("summarizes project activity participants by repo address", () => {
const summary = summarizeProjectActivityEvents(
[
event({
id: "issue",
kind: 1621,
pubkey: AGENT,
created_at: 100,
tags: [
["a", REPO_ADDRESS],
["p", REVIEWER],
],
}),
event({
id: "status",
kind: 1630,
pubkey: REVIEWER,
created_at: 200,
tags: [["a", REPO_ADDRESS]],
}),
event({
id: "other-repo",
kind: 1621,
pubkey: OTHER,
created_at: 300,
tags: [["a", `30617:${OWNER}:other`]],
}),
],
[{ repoAddress: REPO_ADDRESS }],
);
assert.equal(summary[REPO_ADDRESS].issueCount, 1);
assert.equal(summary[REPO_ADDRESS].activityCount, 2);
assert.equal(summary[REPO_ADDRESS].updatedAt, 200);
assert.deepEqual(summary[REPO_ADDRESS].participantPubkeys, [AGENT, REVIEWER]);
});

View File

@@ -146,6 +146,28 @@ export function eventToProject(event) {
};
}
export function withCanonicalProjectCloneUrl(project, relayHttpUrl) {
try {
return {
...project,
cloneUrls: [
buildProjectCloneUrl({
relayHttpUrl,
owner: project.owner,
repoId: project.dtag,
}),
],
};
} catch {
return {
...project,
cloneUrls: project.cloneUrls.filter(
(url) => !/github\.com/i.test(String(url)),
),
};
}
}
export function dedupProjectEvents(events) {
const best = new Map();

View File

@@ -8,6 +8,7 @@ import {
isValidRepoId,
normalizeProjectSlug,
projectEventsToProjects,
withCanonicalProjectCloneUrl,
} from "./projectEvents.mjs";
const OWNER =
@@ -46,7 +47,7 @@ test("normalizes project names into repo slugs", () => {
assert.equal(normalizeProjectSlug("A".repeat(80)), "a".repeat(64));
});
test("builds canonical Buzz git clone URL", () => {
test("builds canonical BizzHub git clone URL", () => {
assert.equal(
buildProjectCloneUrl({
relayHttpUrl: "https://relay.example.com/",
@@ -57,6 +58,24 @@ test("builds canonical Buzz git clone URL", () => {
);
});
test("projects display the canonical BizzHub clone URL", () => {
const project = eventToProject(
repoEvent({
tags: [
["d", "block-buzz"],
["name", "block/buzz"],
["clone", "https://github.com/block/buzz.git"],
],
}),
);
assert.deepEqual(
withCanonicalProjectCloneUrl(project, "https://relay.example.com")
.cloneUrls,
[`https://relay.example.com/git/${OWNER}/block-buzz`],
);
});
test("builds repo announcement tags with project channel metadata", () => {
const tags = buildRepoAnnouncementTags({
repoId: "my-repo",

View File

@@ -100,7 +100,7 @@ export function CreateProjectDialog({
footerClassName="border-t-0 pt-0"
headerClassName="pb-2"
title="Create project"
description="Create a Buzz-hosted Git repo with a linked channel for project and code discussion."
description="Create a BizzHub repo with a linked channel for project and code discussion."
footer={
<div className="flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -194,7 +194,7 @@ export function CreateProjectDialog({
/>
</div>
<p className="text-xs text-muted-foreground/70">
Used in the Buzz Git clone URL.
Used in the BizzHub clone URL.
</p>
</div>

View File

@@ -5,7 +5,6 @@ import {
CheckCircle2,
CircleDot,
Copy,
ExternalLink,
FileDiff,
FolderGit2,
GitBranch,
@@ -31,7 +30,6 @@ 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 { isSafeUrl } from "@/shared/lib/url";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { Textarea } from "@/shared/ui/textarea";
@@ -581,23 +579,6 @@ export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) {
</Card>
</section>
{project.webUrl && isSafeUrl(project.webUrl) ? (
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Web
</h3>
<a
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
href={project.webUrl}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLink className="h-4 w-4" />
{project.webUrl}
</a>
</section>
) : null}
{project.contributors.length > 0 ? (
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">

View File

@@ -1,8 +1,12 @@
import {
ExternalLink,
Bot,
CalendarDays,
FolderGit2,
GitBranch,
GitFork,
LayoutGrid,
List,
MessageSquare,
Plus,
Users,
} from "lucide-react";
@@ -10,15 +14,198 @@ import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import {
useManagedAgentsQuery,
useRelayAgentsQuery,
} from "@/features/agents/hooks";
import {
type Project,
type ProjectActivitySummary,
useProjectActivitySummariesQuery,
useCreateProjectMutation,
useProjectsQuery,
} from "@/features/projects/hooks";
import type { ManagedAgent, RelayAgent } from "@/shared/api/types";
import { topChromeInset } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { CreateProjectDialog } from "./CreateProjectDialog";
type ProjectsViewMode = "grid" | "list";
type ProjectAgentInfo = {
pubkey: string;
name: string;
status: string;
};
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 a convenience only; the toggle should still work.
}
}
function formatCreatedDate(createdAt: number) {
return new Date(createdAt * 1_000).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
function pluralize(count: number, singular: string, plural = `${singular}s`) {
return `${count} ${count === 1 ? singular : plural}`;
}
function getDiscussionLabel(project: Project) {
return project.projectChannelId ? "Discussion linked" : "No discussion";
}
function getProjectCloneUrl(project: Project) {
return project.cloneUrls[0] ?? "BizzHub clone URL pending";
}
function getActivityLabel(summary: ProjectActivitySummary | undefined) {
if (!summary || summary.activityCount === 0) {
return "No project activity yet";
}
return `${pluralize(summary.issueCount, "issue")} · ${pluralize(
summary.activityCount,
"event",
)}`;
}
function buildAgentDirectory({
managedAgents,
relayAgents,
}: {
managedAgents: ManagedAgent[] | undefined;
relayAgents: RelayAgent[] | undefined;
}) {
const agents = new Map<string, ProjectAgentInfo>();
for (const agent of relayAgents ?? []) {
agents.set(agent.pubkey.toLowerCase(), {
pubkey: agent.pubkey,
name: agent.name,
status: agent.status,
});
}
for (const agent of managedAgents ?? []) {
agents.set(agent.pubkey.toLowerCase(), {
pubkey: agent.pubkey,
name: agent.name,
status: agent.status,
});
}
return agents;
}
function getInvolvedAgents({
summary,
agentDirectory,
}: {
summary: ProjectActivitySummary | undefined;
agentDirectory: Map<string, ProjectAgentInfo>;
}) {
if (!summary) return [];
return summary.participantPubkeys
.map((pubkey) => agentDirectory.get(pubkey.toLowerCase()))
.filter((agent): agent is ProjectAgentInfo => Boolean(agent))
.sort((left, right) => left.name.localeCompare(right.name));
}
function StatusPill({ status }: { status: string }) {
return (
<span className="rounded-full bg-muted px-2 py-0.5 text-2xs font-medium uppercase tracking-wide text-muted-foreground">
{status}
</span>
);
}
function AgentInvolvement({
agents,
summary,
isLoading,
compact = false,
}: {
agents: ProjectAgentInfo[];
summary: ProjectActivitySummary | undefined;
isLoading: boolean;
compact?: boolean;
}) {
const visibleAgents = agents.slice(0, compact ? 1 : 2);
const hiddenCount = agents.length - visibleAgents.length;
return (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-2xs font-medium uppercase tracking-wide text-muted-foreground">
<Bot className="h-3.5 w-3.5" />
Agent involvement
</div>
{visibleAgents.length > 0 ? (
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
{visibleAgents.map((agent) => (
<span
className="min-w-0 max-w-32 truncate rounded-full border border-border/70 bg-background/70 px-2 py-0.5 text-2xs font-medium text-foreground"
key={agent.pubkey}
title={`${agent.name} · ${agent.status}`}
>
{agent.name}
</span>
))}
{hiddenCount > 0 ? (
<span className="text-2xs text-muted-foreground">
+{hiddenCount}
</span>
) : null}
</div>
) : (
<p className="truncate text-xs text-muted-foreground/80">
{isLoading
? "Checking project activity..."
: summary && summary.activityCount > 0
? "No known agents in activity"
: "No agent activity yet"}
</p>
)}
</div>
);
}
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-4 w-4 shrink-0 text-muted-foreground/70" />
<span className="min-w-0 truncate">{children}</span>
</span>
);
}
function EmptyState({ onCreate }: { onCreate: () => void }) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-16 text-center">
@@ -26,7 +213,7 @@ function EmptyState({ onCreate }: { onCreate: () => void }) {
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">No projects yet</p>
<p className="text-sm text-muted-foreground">
Create a Buzz-hosted Git project with a linked discussion channel.
Create a BizzHub project with a linked discussion channel.
</p>
</div>
<Button
@@ -41,12 +228,287 @@ function EmptyState({ onCreate }: { onCreate: () => void }) {
);
}
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 ProjectsToolbar({
projectCount,
viewMode,
onCreate,
onViewModeChange,
}: {
projectCount: number;
viewMode: ProjectsViewMode;
onCreate: () => void;
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">
BizzHub projects bring code, discussion, and agent work into one
shared space.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<ProjectsViewModeToggle
onViewModeChange={onViewModeChange}
viewMode={viewMode}
/>
<Button
className="gap-1.5"
data-testid="create-project-open"
onClick={onCreate}
size="sm"
>
<Plus className="h-4 w-4" />
Create Project
</Button>
</div>
</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 ProjectGridCard({
project,
activitySummary,
involvedAgents,
isActivityLoading,
onOpen,
}: {
project: Project;
activitySummary: ProjectActivitySummary | undefined;
involvedAgents: ProjectAgentInfo[];
isActivityLoading: boolean;
onOpen: (project: Project) => void;
}) {
return (
<Card
className="group relative flex min-h-48 flex-col p-3 transition-colors hover:bg-muted/45"
data-testid={`project-card-${project.dtag}`}
>
<ProjectCardButton onOpen={onOpen} project={project} />
<div className="pointer-events-none 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-semibold">
{project.name}
</span>
</div>
<p className="truncate font-mono text-2xs text-muted-foreground/70">
{project.dtag}
</p>
</div>
<StatusPill status={project.status} />
</div>
<p className="line-clamp-3 min-h-12 text-sm text-muted-foreground">
{project.description || "A shared BizzHub space for code 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(project.contributors.length, "contributor")}
</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">
<AgentInvolvement
agents={involvedAgents}
isLoading={isActivityLoading}
summary={activitySummary}
/>
<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">
{getProjectCloneUrl(project)}
</span>
</div>
</div>
</div>
</Card>
);
}
function ProjectListRow({
project,
activitySummary,
involvedAgents,
isActivityLoading,
onOpen,
}: {
project: Project;
activitySummary: ProjectActivitySummary | undefined;
involvedAgents: ProjectAgentInfo[];
isActivityLoading: boolean;
onOpen: (project: Project) => void;
}) {
return (
<Card
className="group relative p-3 transition-colors hover:bg-muted/45"
data-testid={`project-row-${project.dtag}`}
>
<ProjectCardButton onOpen={onOpen} project={project} />
<div className="pointer-events-none 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-semibold">
{project.name}
</span>
<StatusPill status={project.status} />
</div>
<p className="line-clamp-1 text-sm text-muted-foreground">
{project.description || "A shared BizzHub space for code work."}
</p>
</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(project.contributors.length, "contributor")}
</MetadataItem>
<MetadataItem icon={MessageSquare}>
{getDiscussionLabel(project)}
</MetadataItem>
<MetadataItem icon={Bot}>
{getActivityLabel(activitySummary)}
</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">
{getProjectCloneUrl(project)}
</span>
</div>
</div>
<div className="min-w-0 space-y-1 text-left lg:text-right">
<AgentInvolvement
agents={involvedAgents}
compact
isLoading={isActivityLoading}
summary={activitySummary}
/>
<p className="text-2xs font-medium uppercase tracking-wide text-muted-foreground">
Created {formatCreatedDate(project.createdAt)}
</p>
</div>
</div>
</Card>
);
}
export function ProjectsView() {
const { goProject } = useAppNavigation();
const projectsQuery = useProjectsQuery();
const createProjectMutation = useCreateProjectMutation();
const [createOpen, setCreateOpen] = React.useState(false);
const [storedViewMode, setStoredViewMode] =
React.useState<ProjectsViewMode | null>(() => readStoredViewMode());
const projects = projectsQuery.data ?? [];
const activitySummariesQuery = useProjectActivitySummariesQuery(projects);
const relayAgentsQuery = useRelayAgentsQuery({
enabled: projects.length > 0,
});
const managedAgentsQuery = useManagedAgentsQuery({
enabled: projects.length > 0,
});
const agentDirectory = React.useMemo(
() =>
buildAgentDirectory({
managedAgents: managedAgentsQuery.data,
relayAgents: relayAgentsQuery.data,
}),
[managedAgentsQuery.data, relayAgentsQuery.data],
);
const viewMode =
storedViewMode ??
(projects.length > MANY_PROJECTS_THRESHOLD ? "list" : "grid");
const handleViewModeChange = React.useCallback(
(nextViewMode: ProjectsViewMode) => {
setStoredViewMode(nextViewMode);
writeStoredViewMode(nextViewMode);
},
[],
);
const handleOpenProject = React.useCallback(
(project: Project) => {
void goProject(project.dtag);
},
[goProject],
);
if (projectsQuery.isLoading) {
return null;
@@ -92,81 +554,58 @@ export function ProjectsView() {
topChromeInset.padding,
)}
>
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-sm font-medium text-muted-foreground">
{projects.length} {projects.length === 1 ? "project" : "projects"}
</h2>
<Button
className="gap-1.5"
data-testid="create-project-open"
onClick={() => setCreateOpen(true)}
size="sm"
>
<Plus className="h-4 w-4" />
Create Project
</Button>
</div>
<ProjectsToolbar
onCreate={() => setCreateOpen(true)}
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>
<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>
{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 min-w-0 items-center gap-1">
<GitFork className="h-4 w-4 shrink-0" />
<span className="truncate">{project.cloneUrls[0]}</span>
</span>
) : null}
<span className="flex items-center gap-1">
<GitBranch className="h-4 w-4" />
{project.defaultBranch}
</span>
{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) => (
<ProjectGridCard
activitySummary={
activitySummariesQuery.data?.[project.repoAddress]
}
involvedAgents={getInvolvedAgents({
agentDirectory,
summary: activitySummariesQuery.data?.[project.repoAddress],
})}
isActivityLoading={
activitySummariesQuery.isLoading ||
relayAgentsQuery.isLoading ||
managedAgentsQuery.isLoading
}
key={project.id}
onOpen={handleOpenProject}
project={project}
/>
))}
</div>
) : (
<div className="space-y-2">
{projects.map((project) => (
<ProjectListRow
activitySummary={
activitySummariesQuery.data?.[project.repoAddress]
}
involvedAgents={getInvolvedAgents({
agentDirectory,
summary: activitySummariesQuery.data?.[project.repoAddress],
})}
isActivityLoading={
activitySummariesQuery.isLoading ||
relayAgentsQuery.isLoading ||
managedAgentsQuery.isLoading
}
key={project.id}
onOpen={handleOpenProject}
project={project}
/>
))}
</div>
)}
</div>
<CreateProjectDialog
isCreating={createProjectMutation.isPending}

View File

@@ -2871,13 +2871,13 @@ function createMockProjectEvents(): RelayEvent[] {
return [
createMockEvent(
KIND_REPO_ANNOUNCEMENT,
"A Buzz-hosted Git project for testing project workflows, code discussion, and agent progress.",
"A BizzHub project for testing project workflows, code discussion, and agent progress.",
[
["d", MOCK_PROJECT_REPO_ID],
["name", "Sprout Invaders"],
[
"description",
"A Buzz-hosted Git project for testing project workflows, code discussion, and agent progress.",
"A BizzHub project for testing project workflows, code discussion, and agent progress.",
],
[
"clone",