diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index 8f88564908..8a05145ddd 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -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 { - 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 { - 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 { return projectIssueEventsToIssues(issueEvents, statusEvents); } +async function fetchProjectActivitySummaries( + projects: Project[], +): Promise> { + 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 { 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(); diff --git a/desktop/src/features/projects/projectActivity.mjs b/desktop/src/features/projects/projectActivity.mjs new file mode 100644 index 0000000000..0695461d9a --- /dev/null +++ b/desktop/src/features/projects/projectActivity.mjs @@ -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); +} diff --git a/desktop/src/features/projects/projectActivity.test.mjs b/desktop/src/features/projects/projectActivity.test.mjs new file mode 100644 index 0000000000..fe4ad3a82e --- /dev/null +++ b/desktop/src/features/projects/projectActivity.test.mjs @@ -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]); +}); diff --git a/desktop/src/features/projects/projectEvents.mjs b/desktop/src/features/projects/projectEvents.mjs index a25f858d28..0bbcc259ea 100644 --- a/desktop/src/features/projects/projectEvents.mjs +++ b/desktop/src/features/projects/projectEvents.mjs @@ -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(); diff --git a/desktop/src/features/projects/projectEvents.test.mjs b/desktop/src/features/projects/projectEvents.test.mjs index 484184cd3f..fca43ea2b4 100644 --- a/desktop/src/features/projects/projectEvents.test.mjs +++ b/desktop/src/features/projects/projectEvents.test.mjs @@ -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", diff --git a/desktop/src/features/projects/ui/CreateProjectDialog.tsx b/desktop/src/features/projects/ui/CreateProjectDialog.tsx index 060c85f2e4..31a77e7327 100644 --- a/desktop/src/features/projects/ui/CreateProjectDialog.tsx +++ b/desktop/src/features/projects/ui/CreateProjectDialog.tsx @@ -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={
@@ -194,7 +194,7 @@ export function CreateProjectDialog({ />

- Used in the Buzz Git clone URL. + Used in the BizzHub clone URL.

diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index 03e12cc9a6..d3cdb57339 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -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) { - {project.webUrl && isSafeUrl(project.webUrl) ? ( -
-

- Web -

- - - {project.webUrl} - -
- ) : null} - {project.contributors.length > 0 ? (

diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index 1ba8c23d5d..376cd69304 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -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(); + + 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; +}) { + 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 ( + + {status} + + ); +} + +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 ( +
+
+ + Agent involvement +
+ {visibleAgents.length > 0 ? ( +
+ {visibleAgents.map((agent) => ( + + {agent.name} + + ))} + {hiddenCount > 0 ? ( + + +{hiddenCount} + + ) : null} +
+ ) : ( +

+ {isLoading + ? "Checking project activity..." + : summary && summary.activityCount > 0 + ? "No known agents in activity" + : "No agent activity yet"} +

+ )} +
+ ); +} + +function MetadataItem({ + icon: Icon, + children, +}: { + icon: React.ComponentType<{ className?: string }>; + children: React.ReactNode; +}) { + return ( + + + {children} + + ); +} + function EmptyState({ onCreate }: { onCreate: () => void }) { return (
@@ -26,7 +213,7 @@ function EmptyState({ onCreate }: { onCreate: () => void }) {

No projects yet

- Create a Buzz-hosted Git project with a linked discussion channel. + Create a BizzHub project with a linked discussion channel.

+ + + ); +} + +function ProjectsToolbar({ + projectCount, + viewMode, + onCreate, + onViewModeChange, +}: { + projectCount: number; + viewMode: ProjectsViewMode; + onCreate: () => void; + onViewModeChange: (viewMode: ProjectsViewMode) => void; +}) { + return ( +
+
+
+

Projects

+ + {pluralize(projectCount, "project")} + +
+

+ BizzHub projects bring code, discussion, and agent work into one + shared space. +

+
+
+ + +
+
+ ); +} + +function ProjectCardButton({ + project, + onOpen, +}: { + project: Project; + onOpen: (project: Project) => void; +}) { + return ( + + ); +} + +function ProjectGridCard({ + project, + activitySummary, + involvedAgents, + isActivityLoading, + onOpen, +}: { + project: Project; + activitySummary: ProjectActivitySummary | undefined; + involvedAgents: ProjectAgentInfo[]; + isActivityLoading: boolean; + onOpen: (project: Project) => void; +}) { + return ( + + +
+
+
+
+ + + {project.name} + +
+

+ {project.dtag} +

+
+ +
+ +

+ {project.description || "A shared BizzHub space for code work."} +

+ +
+ {project.defaultBranch} + + {pluralize(project.contributors.length, "contributor")} + + + {getDiscussionLabel(project)} + + + {formatCreatedDate(project.createdAt)} + +
+ +
+ +
+ + + {getProjectCloneUrl(project)} + +
+
+
+
+ ); +} + +function ProjectListRow({ + project, + activitySummary, + involvedAgents, + isActivityLoading, + onOpen, +}: { + project: Project; + activitySummary: ProjectActivitySummary | undefined; + involvedAgents: ProjectAgentInfo[]; + isActivityLoading: boolean; + onOpen: (project: Project) => void; +}) { + return ( + + +
+
+
+ + + {project.name} + + +
+

+ {project.description || "A shared BizzHub space for code work."} +

+
+ +
+
+ + {project.defaultBranch} + + + {pluralize(project.contributors.length, "contributor")} + + + {getDiscussionLabel(project)} + + + {getActivityLabel(activitySummary)} + +
+
+ + + {getProjectCloneUrl(project)} + +
+
+ +
+ +

+ Created {formatCreatedDate(project.createdAt)} +

+
+
+
+ ); +} + export function ProjectsView() { const { goProject } = useAppNavigation(); const projectsQuery = useProjectsQuery(); const createProjectMutation = useCreateProjectMutation(); const [createOpen, setCreateOpen] = React.useState(false); + const [storedViewMode, setStoredViewMode] = + React.useState(() => 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, )} > -
-

- {projects.length} {projects.length === 1 ? "project" : "projects"} -

- -
+ setCreateOpen(true)} + onViewModeChange={handleViewModeChange} + projectCount={projects.length} + viewMode={viewMode} + /> -
- {projects.map((project) => ( - - -
-
-
- - - {project.name} - - - {project.status} - -
- {project.description ? ( -

- {project.description} -

- ) : null} -
- {project.cloneUrls.length > 0 ? ( - - - {project.cloneUrls[0]} - - ) : null} - - - {project.defaultBranch} - - {project.contributors.length > 0 ? ( - - - {project.contributors.length} - - ) : null} - {project.webUrl ? ( - - - Web - - ) : null} -
-
-
-
- ))} -
+ {viewMode === "grid" ? ( +
+ {projects.map((project) => ( + + ))} +
+ ) : ( +
+ {projects.map((project) => ( + + ))} +
+ )}