mirror of
https://github.com/block/buzz.git
synced 2026-11-05 00:41:28 +00:00
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:
@@ -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();
|
||||
|
||||
|
||||
75
desktop/src/features/projects/projectActivity.mjs
Normal file
75
desktop/src/features/projects/projectActivity.mjs
Normal 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);
|
||||
}
|
||||
59
desktop/src/features/projects/projectActivity.test.mjs
Normal file
59
desktop/src/features/projects/projectActivity.test.mjs
Normal 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]);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user