feat(desktop): add Projects as first-class top-level view (#546)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-05-12 10:51:09 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 6b4f9f05d4
commit ef5fdca792
17 changed files with 692 additions and 19 deletions
+7 -2
View File
@@ -3,6 +3,7 @@ import {
Bot,
CircleDot,
FileText,
FolderGit2,
Hash,
Home,
Lock,
@@ -20,7 +21,7 @@ type ChatHeaderProps = {
description: string;
channelType?: ChannelType;
visibility?: ChannelVisibility;
mode?: "home" | "channel" | "agents" | "workflows" | "pulse";
mode?: "home" | "channel" | "agents" | "workflows" | "pulse" | "projects";
overlaysContent?: boolean;
statusBadge?: React.ReactNode;
};
@@ -34,7 +35,7 @@ function ChannelIcon({
}: {
channelType?: ChannelType;
visibility?: ChannelVisibility;
mode?: "home" | "channel" | "agents" | "workflows" | "pulse";
mode?: "home" | "channel" | "agents" | "workflows" | "pulse" | "projects";
}) {
if (mode === "home") {
return <Home className={HEADER_ICON_CLASS} />;
@@ -52,6 +53,10 @@ function ChannelIcon({
return <Activity className={HEADER_ICON_CLASS} />;
}
if (mode === "projects") {
return <FolderGit2 className={HEADER_ICON_CLASS} />;
}
if (channelType === "dm") {
return <CircleDot className={HEADER_ICON_CLASS} />;
}
@@ -1,5 +1,6 @@
import { FileDiff, Maximize2 } from "lucide-react";
import { isSafeUrl } from "@/shared/lib/url";
import { Button } from "@/shared/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { DiffViewer } from "./DiffViewer";
@@ -14,16 +15,6 @@ type DiffMessageProps = {
onExpand?: () => void;
};
function isSafeUrl(url: string | undefined): url is string {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
function getHostname(url: string): string {
try {
return new URL(url).hostname;
+107
View File
@@ -0,0 +1,107 @@
import { useQuery } from "@tanstack/react-query";
import { relayClient } from "@/shared/api/relayClient";
import { KIND_REPO_ANNOUNCEMENT } from "@/shared/constants/kinds";
import type { RelayEvent } from "@/shared/api/types";
export type Project = {
id: string;
dtag: string;
name: string;
description: string;
cloneUrls: string[];
webUrl: string | null;
owner: string;
contributors: string[];
createdAt: number;
};
function getTag(event: RelayEvent, name: string): string | undefined {
return event.tags.find((t) => t[0] === name)?.[1];
}
function getAllTags(event: RelayEvent, name: string): string[] {
return event.tags.filter((t) => t[0] === name).map((t) => t[1]);
}
function getCloneUrls(event: RelayEvent): string[] {
const tag = event.tags.find((t) => t[0] === "clone");
return tag ? tag.slice(1) : [];
}
function eventToProject(event: RelayEvent): Project {
const d = getTag(event, "d") ?? event.id;
const name = getTag(event, "name") || d;
const description = getTag(event, "description") || event.content || "";
const cloneUrls = getCloneUrls(event);
const webUrl = getTag(event, "web") ?? null;
const contributors = getAllTags(event, "p");
return {
id: `${event.pubkey}:${d}`,
dtag: d,
name,
description,
cloneUrls,
webUrl,
owner: event.pubkey,
contributors,
createdAt: event.created_at,
};
}
function dedup(events: RelayEvent[]): RelayEvent[] {
const best = new Map<string, RelayEvent>();
for (const e of events) {
const d = getTag(e, "d") ?? "";
const key = `${e.pubkey}:${e.kind}:${d}`;
const prev = best.get(key);
if (!prev || e.created_at > prev.created_at) {
best.set(key, e);
}
}
return [...best.values()];
}
async function fetchProjects(): Promise<Project[]> {
const events = await relayClient.fetchEvents({
kinds: [KIND_REPO_ANNOUNCEMENT],
limit: 200,
});
return dedup(events)
.map(eventToProject)
.sort((a, b) => b.createdAt - a.createdAt);
}
async function fetchProject(projectId: string): Promise<Project | null> {
const events = await relayClient.fetchEvents({
kinds: [KIND_REPO_ANNOUNCEMENT],
"#d": [projectId],
limit: 10,
});
const deduped = dedup(events);
return deduped.length > 0 ? eventToProject(deduped[0]) : null;
}
export const projectsQueryKey = ["projects"] as const;
export function useProjectsQuery() {
return useQuery({
queryKey: projectsQueryKey,
queryFn: fetchProjects,
staleTime: 60_000,
});
}
export function useProjectQuery(projectId: string) {
return useQuery({
queryKey: ["project", projectId],
queryFn: () => fetchProject(projectId),
staleTime: 60_000,
});
}
@@ -0,0 +1,257 @@
import {
ArrowLeft,
Check,
Copy,
ExternalLink,
FolderGit2,
GitFork,
Users,
} from "lucide-react";
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import { useProjectQuery } from "@/features/projects/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { resolveUserLabel } from "@/features/profile/lib/identity";
import { isSafeUrl } from "@/shared/lib/url";
import { Button } from "@/shared/ui/button";
import { UserAvatar } from "@/shared/ui/UserAvatar";
function CloneUrlRow({ url }: { url: string }) {
const [copied, setCopied] = React.useState(false);
const handleCopy = React.useCallback(() => {
void navigator.clipboard.writeText(url).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2_000);
});
}, [url]);
return (
<div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-3 py-2">
<GitFork className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<code className="min-w-0 flex-1 truncate text-xs">{url}</code>
<Button
className="h-6 w-6 shrink-0"
onClick={handleCopy}
size="icon"
variant="ghost"
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
);
}
type ProjectDetailScreenProps = {
projectId: string;
};
export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) {
const { goProjects } = useAppNavigation();
const projectQuery = useProjectQuery(projectId);
const project = projectQuery.data;
const allPubkeys = React.useMemo(
() =>
project ? [project.owner, ...project.contributors].filter(Boolean) : [],
[project],
);
const profilesQuery = useUsersBatchQuery(allPubkeys);
const profiles = profilesQuery.data?.profiles;
if (projectQuery.isLoading) {
return null;
}
if (projectQuery.isError) {
return (
<>
<ChatHeader
description=""
mode="projects"
overlaysContent
title="Error"
/>
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-16 text-center">
<FolderGit2 className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm text-red-400">Failed to load project</p>
<div className="flex items-center gap-2">
<Button
onClick={() => void projectQuery.refetch()}
size="sm"
variant="outline"
>
Retry
</Button>
<Button
onClick={() => {
void goProjects();
}}
size="sm"
variant="ghost"
>
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
Back to Projects
</Button>
</div>
</div>
</>
);
}
if (!project) {
return (
<>
<ChatHeader
description=""
mode="projects"
overlaysContent
title="Project not found"
/>
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-16 text-center">
<FolderGit2 className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">
This project could not be found.
</p>
<Button
onClick={() => {
void goProjects();
}}
size="sm"
variant="outline"
>
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
Back to Projects
</Button>
</div>
</>
);
}
const createdDate = new Date(project.createdAt * 1_000).toLocaleDateString(
undefined,
{ year: "numeric", month: "long", day: "numeric" },
);
return (
<>
<ChatHeader
description={project.description}
mode="projects"
overlaysContent
title={project.name}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-4 pt-14">
<div className="mb-4">
<Button
className="gap-1.5 text-muted-foreground"
onClick={() => {
void goProjects();
}}
size="sm"
variant="ghost"
>
<ArrowLeft className="h-3.5 w-3.5" />
Back to Projects
</Button>
</div>
<div className="mx-auto w-full max-w-2xl space-y-6">
<section className="space-y-2">
<div className="flex items-center gap-2">
<FolderGit2 className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold">{project.name}</h2>
</div>
{project.description ? (
<p className="text-sm text-muted-foreground">
{project.description}
</p>
) : null}
</section>
{project.cloneUrls.length > 0 ? (
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Clone
</h3>
<div className="space-y-1.5">
{project.cloneUrls.map((url) => (
<CloneUrlRow key={url} url={url} />
))}
</div>
</section>
) : null}
{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-3.5 w-3.5" />
{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">
<span className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" />
Contributors ({project.contributors.length})
</span>
</h3>
<div className="space-y-1.5">
{project.contributors.map((pubkey) => {
const label = resolveUserLabel({ pubkey, profiles });
const avatarUrl =
profiles?.[pubkey.toLowerCase()]?.avatarUrl ?? null;
return (
<div
className="flex items-center gap-2 rounded-md bg-muted/30 px-3 py-1.5"
key={pubkey}
>
<UserAvatar
avatarUrl={avatarUrl}
displayName={label}
size="xs"
/>
<span className="truncate text-sm text-muted-foreground">
{label}
</span>
</div>
);
})}
</div>
</section>
) : null}
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Details
</h3>
<div className="space-y-1 text-sm text-muted-foreground">
<p>Created: {createdDate}</p>
<p className="truncate">
Owner: {resolveUserLabel({ pubkey: project.owner, profiles })}
</p>
</div>
</section>
</div>
</div>
</>
);
}
@@ -0,0 +1,19 @@
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import { ProjectsView } from "@/features/projects/ui/ProjectsView";
export function ProjectsScreen() {
return (
<>
<ChatHeader
description="Repositories and projects on this relay."
mode="projects"
overlaysContent
title="Projects"
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ProjectsView />
</div>
</>
);
}
@@ -0,0 +1,113 @@
import { ExternalLink, FolderGit2, GitFork, Users } from "lucide-react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useProjectsQuery } from "@/features/projects/hooks";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
function EmptyState() {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-16 text-center">
<FolderGit2 className="h-10 w-10 text-muted-foreground/40" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">No projects yet</p>
<p className="text-sm text-muted-foreground">
Projects published to this relay will appear here.
</p>
</div>
</div>
);
}
export function ProjectsView() {
const { goProject } = useAppNavigation();
const projectsQuery = useProjectsQuery();
const projects = projectsQuery.data ?? [];
if (projectsQuery.isLoading) {
return null;
}
if (projectsQuery.isError) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-muted-foreground">
<p className="text-sm text-red-400">Failed to load projects</p>
<Button
onClick={() => void projectsQuery.refetch()}
size="sm"
variant="outline"
>
Retry
</Button>
</div>
);
}
if (projects.length === 0) {
return <EmptyState />;
}
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-4 pt-14">
<div className="mb-3 flex items-center gap-2">
<h2 className="text-sm font-medium text-muted-foreground">
{projects.length} {projects.length === 1 ? "project" : "projects"}
</h2>
</div>
<div className="space-y-2">
{projects.map((project) => (
<Card
className="relative p-4 transition-colors hover:bg-muted/50"
key={project.id}
>
<button
className="absolute inset-0 rounded-lg"
onClick={() => {
void goProject(project.dtag);
}}
type="button"
>
<span className="sr-only">View {project.name}</span>
</button>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-center gap-2">
<FolderGit2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-semibold">
{project.name}
</span>
</div>
{project.description ? (
<p className="line-clamp-2 text-sm text-muted-foreground">
{project.description}
</p>
) : null}
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground/70">
{project.cloneUrls.length > 0 ? (
<span className="flex items-center gap-1">
<GitFork className="h-3 w-3" />
{project.cloneUrls[0]}
</span>
) : null}
{project.contributors.length > 0 ? (
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{project.contributors.length}
</span>
) : null}
{project.webUrl ? (
<span className="flex items-center gap-1">
<ExternalLink className="h-3 w-3" />
Web
</span>
) : null}
</div>
</div>
</div>
</Card>
))}
</div>
</div>
);
}
+22 -1
View File
@@ -3,6 +3,7 @@ import {
Activity,
Bot,
ChevronDown,
FolderGit2,
Home,
PenSquare,
Plus,
@@ -88,7 +89,13 @@ type AppSidebarProps = {
selfPresenceStatus: PresenceStatus;
errorMessage?: string;
selectedChannelId: string | null;
selectedView: "home" | "channel" | "agents" | "workflows" | "pulse";
selectedView:
| "home"
| "channel"
| "agents"
| "workflows"
| "pulse"
| "projects";
unreadChannelIds: Set<string>;
workspaces: Workspace[];
onAddWorkspace: (workspace: Workspace) => void;
@@ -119,6 +126,7 @@ type AppSidebarProps = {
) => void;
onRemoveWorkspace: (id: string) => void;
onSelectAgents: () => void;
onSelectProjects: () => void;
onSelectPulse: () => void;
onSelectWorkflows: () => void;
onSelectHome: () => void;
@@ -309,6 +317,7 @@ export function AppSidebar({
onUpdateWorkspace,
onRemoveWorkspace,
onSelectAgents,
onSelectProjects,
onSelectPulse,
onSelectWorkflows,
onSelectHome,
@@ -481,6 +490,18 @@ export function AppSidebar({
<span>Pulse</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-projects-view"
isActive={selectedView === "projects"}
onClick={onSelectProjects}
tooltip="Projects"
type="button"
>
<FolderGit2 className="h-4 w-4" />
<span>Projects</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-agents-view"