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
parent 6b4f9f05d4
commit ef5fdca792
17 changed files with 692 additions and 19 deletions

View File

@@ -33,7 +33,7 @@ const overrides = new Map([
["src-tauri/src/managed_agents/personas.rs", 900], // built-in persona system prompts (Solo + Kit + Scout) + persona pack import/uninstall/list + uninstall safety check
["src-tauri/src/managed_agents/teams.rs", 580], // built-in team registry (Kit & Scout) + merge_teams + validate_team_deletion + JSON export/import + tests
["src-tauri/src/managed_agents/persona_card.rs", 970], // PNG/ZIP/MD persona card codec + pack-zip detection + nested root finder + provider/model/namePool fields + 27 unit tests
["src/app/AppShell.tsx", 770], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + memory-leak safeguards
["src/app/AppShell.tsx", 800], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + projects view + memory-leak safeguards
["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
["src/features/channels/ui/ChannelPane.tsx", 520], // composer/timeline/sidebar orchestration + anchored agent activity footers

View File

@@ -63,7 +63,13 @@ import {
} from "@/shared/ui/sidebar";
import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
type AppView = "home" | "channel" | "agents" | "workflows" | "pulse";
type AppView =
| "home"
| "channel"
| "agents"
| "workflows"
| "pulse"
| "projects";
const LazySettingsScreen = React.lazy(async () => {
const module = await import("@/features/settings/ui/SettingsScreen");
@@ -129,6 +135,13 @@ function deriveShellRoute(pathname: string): {
};
}
if (pathname === "/projects" || pathname.startsWith("/projects/")) {
return {
selectedChannelId: null,
selectedView: "projects",
};
}
if (pathname === "/pulse") {
return {
selectedChannelId: null,
@@ -161,8 +174,15 @@ export function AppShell() {
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
const location = useLocation();
const queryClient = useQueryClient();
const { goAgents, goChannel, goHome, goPulse, goWorkflows, openSearchHit } =
useAppNavigation();
const {
goAgents,
goChannel,
goHome,
goProjects,
goPulse,
goWorkflows,
openSearchHit,
} = useAppNavigation();
const { canGoBack, canGoForward, goBack, goForward } =
useBackForwardControls();
const { selectedChannelId, selectedView } = React.useMemo(
@@ -672,6 +692,9 @@ export function AppShell() {
onSelectHome={() => {
void goHome();
}}
onSelectProjects={() => {
void goProjects();
}}
onSelectPulse={() => {
void goPulse();
}}

View File

@@ -79,6 +79,31 @@ export function useAppNavigation() {
[commitNavigation],
);
const goProjects = React.useCallback(
(behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/projects",
},
behavior,
),
[commitNavigation],
);
const goProject = React.useCallback(
(projectId: string, behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/projects/$projectId",
params: {
projectId,
},
},
behavior,
),
[commitNavigation],
);
const goWorkflows = React.useCallback(
(behavior?: NavigationBehavior) =>
commitNavigation(
@@ -204,6 +229,8 @@ export function useAppNavigation() {
goChannel,
goForumPost,
goHome,
goProject,
goProjects,
goPulse,
goWorkflow,
goWorkflows,

View File

@@ -7,9 +7,11 @@
import { Route as rootRouteImport } from "./routes/root";
import { Route as workflowsRouteImport } from "./routes/workflows";
import { Route as pulseRouteImport } from "./routes/pulse";
import { Route as projectsRouteImport } from "./routes/projects";
import { Route as agentsRouteImport } from "./routes/agents";
import { Route as indexRouteImport } from "./routes/index";
import { Route as workflowsDotworkflowIdRouteImport } from "./routes/workflows.$workflowId";
import { Route as projectsDotprojectIdRouteImport } from "./routes/projects.$projectId";
import { Route as channelsDotchannelIdRouteImport } from "./routes/channels.$channelId";
import { Route as channelsDotchannelIdDotpostsDotpostIdRouteImport } from "./routes/channels.$channelId.posts.$postId";
@@ -23,6 +25,11 @@ const pulseRoute = pulseRouteImport.update({
path: "/pulse",
getParentRoute: () => rootRouteImport,
} as any);
const projectsRoute = projectsRouteImport.update({
id: "/projects",
path: "/projects",
getParentRoute: () => rootRouteImport,
} as any);
const agentsRoute = agentsRouteImport.update({
id: "/agents",
path: "/agents",
@@ -38,6 +45,11 @@ const workflowsDotworkflowIdRoute = workflowsDotworkflowIdRouteImport.update({
path: "/workflows/$workflowId",
getParentRoute: () => rootRouteImport,
} as any);
const projectsDotprojectIdRoute = projectsDotprojectIdRouteImport.update({
id: "/projects/$projectId",
path: "/projects/$projectId",
getParentRoute: () => rootRouteImport,
} as any);
const channelsDotchannelIdRoute = channelsDotchannelIdRouteImport.update({
id: "/channels/$channelId",
path: "/channels/$channelId",
@@ -53,18 +65,22 @@ const channelsDotchannelIdDotpostsDotpostIdRoute =
export interface FileRoutesByFullPath {
"/": typeof indexRoute;
"/agents": typeof agentsRoute;
"/projects": typeof projectsRoute;
"/pulse": typeof pulseRoute;
"/workflows": typeof workflowsRoute;
"/channels/$channelId": typeof channelsDotchannelIdRoute;
"/projects/$projectId": typeof projectsDotprojectIdRoute;
"/workflows/$workflowId": typeof workflowsDotworkflowIdRoute;
"/channels/$channelId/posts/$postId": typeof channelsDotchannelIdDotpostsDotpostIdRoute;
}
export interface FileRoutesByTo {
"/": typeof indexRoute;
"/agents": typeof agentsRoute;
"/projects": typeof projectsRoute;
"/pulse": typeof pulseRoute;
"/workflows": typeof workflowsRoute;
"/channels/$channelId": typeof channelsDotchannelIdRoute;
"/projects/$projectId": typeof projectsDotprojectIdRoute;
"/workflows/$workflowId": typeof workflowsDotworkflowIdRoute;
"/channels/$channelId/posts/$postId": typeof channelsDotchannelIdDotpostsDotpostIdRoute;
}
@@ -72,9 +88,11 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof indexRoute;
"/agents": typeof agentsRoute;
"/projects": typeof projectsRoute;
"/pulse": typeof pulseRoute;
"/workflows": typeof workflowsRoute;
"/channels/$channelId": typeof channelsDotchannelIdRoute;
"/projects/$projectId": typeof projectsDotprojectIdRoute;
"/workflows/$workflowId": typeof workflowsDotworkflowIdRoute;
"/channels/$channelId/posts/$postId": typeof channelsDotchannelIdDotpostsDotpostIdRoute;
}
@@ -83,27 +101,33 @@ export interface FileRouteTypes {
fullPaths:
| "/"
| "/agents"
| "/projects"
| "/pulse"
| "/workflows"
| "/channels/$channelId"
| "/projects/$projectId"
| "/workflows/$workflowId"
| "/channels/$channelId/posts/$postId";
fileRoutesByTo: FileRoutesByTo;
to:
| "/"
| "/agents"
| "/projects"
| "/pulse"
| "/workflows"
| "/channels/$channelId"
| "/projects/$projectId"
| "/workflows/$workflowId"
| "/channels/$channelId/posts/$postId";
id:
| "__root__"
| "/"
| "/agents"
| "/projects"
| "/pulse"
| "/workflows"
| "/channels/$channelId"
| "/projects/$projectId"
| "/workflows/$workflowId"
| "/channels/$channelId/posts/$postId";
fileRoutesById: FileRoutesById;
@@ -111,9 +135,11 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
indexRoute: typeof indexRoute;
agentsRoute: typeof agentsRoute;
projectsRoute: typeof projectsRoute;
pulseRoute: typeof pulseRoute;
workflowsRoute: typeof workflowsRoute;
channelsDotchannelIdRoute: typeof channelsDotchannelIdRoute;
projectsDotprojectIdRoute: typeof projectsDotprojectIdRoute;
workflowsDotworkflowIdRoute: typeof workflowsDotworkflowIdRoute;
channelsDotchannelIdDotpostsDotpostIdRoute: typeof channelsDotchannelIdDotpostsDotpostIdRoute;
}
@@ -134,6 +160,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof pulseRouteImport;
parentRoute: typeof rootRouteImport;
};
"/projects": {
id: "/projects";
path: "/projects";
fullPath: "/projects";
preLoaderRoute: typeof projectsRouteImport;
parentRoute: typeof rootRouteImport;
};
"/agents": {
id: "/agents";
path: "/agents";
@@ -155,6 +188,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof workflowsDotworkflowIdRouteImport;
parentRoute: typeof rootRouteImport;
};
"/projects/$projectId": {
id: "/projects/$projectId";
path: "/projects/$projectId";
fullPath: "/projects/$projectId";
preLoaderRoute: typeof projectsDotprojectIdRouteImport;
parentRoute: typeof rootRouteImport;
};
"/channels/$channelId": {
id: "/channels/$channelId";
path: "/channels/$channelId";
@@ -175,9 +215,11 @@ declare module "@tanstack/react-router" {
const rootRouteChildren: RootRouteChildren = {
indexRoute: indexRoute,
agentsRoute: agentsRoute,
projectsRoute: projectsRoute,
pulseRoute: pulseRoute,
workflowsRoute: workflowsRoute,
channelsDotchannelIdRoute: channelsDotchannelIdRoute,
projectsDotprojectIdRoute: projectsDotprojectIdRoute,
workflowsDotworkflowIdRoute: workflowsDotworkflowIdRoute,
channelsDotchannelIdDotpostsDotpostIdRoute:
channelsDotchannelIdDotpostsDotpostIdRoute,

View File

@@ -6,6 +6,8 @@ export const routes = rootRoute("root.tsx", [
route("/pulse", "pulse.tsx"),
route("/workflows", "workflows.tsx"),
route("/workflows/$workflowId", "workflows.$workflowId.tsx"),
route("/projects", "projects.tsx"),
route("/projects/$projectId", "projects.$projectId.tsx"),
route("/channels/$channelId", "channels.$channelId.tsx"),
route(
"/channels/$channelId/posts/$postId",

View File

@@ -0,0 +1,25 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
const ProjectDetailScreen = React.lazy(async () => {
const module = await import("@/features/projects/ui/ProjectDetailScreen");
return { default: module.ProjectDetailScreen };
});
export const Route = createFileRoute("/projects/$projectId")({
component: ProjectDetailRouteComponent,
});
function ProjectDetailRouteComponent() {
const { projectId } = Route.useParams();
return (
<React.Suspense
fallback={<ViewLoadingFallback includeHeader kind="projects" />}
>
<ProjectDetailScreen projectId={projectId} />
</React.Suspense>
);
}

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
const ProjectsScreen = React.lazy(async () => {
const module = await import("@/features/projects/ui/ProjectsScreen");
return { default: module.ProjectsScreen };
});
export const Route = createFileRoute("/projects")({
component: ProjectsRouteComponent,
});
function ProjectsRouteComponent() {
return (
<React.Suspense
fallback={<ViewLoadingFallback includeHeader kind="projects" />}
>
<ProjectsScreen />
</React.Suspense>
);
}

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} />;
}

View File

@@ -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;

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,
});
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

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"

View File

@@ -19,6 +19,7 @@ export const KIND_TYPING_INDICATOR = 20002;
export const KIND_READ_STATE = 30078;
export const KIND_USER_STATUS = 30315;
export const KIND_AGENT_OBSERVER_FRAME = 24200;
export const KIND_REPO_ANNOUNCEMENT = 30617;
const CHANNEL_MESSAGE_EVENT_KINDS = [
KIND_STREAM_MESSAGE,

View File

@@ -0,0 +1,15 @@
/**
* Returns true when the given string is a valid HTTP or HTTPS URL.
*
* Use this to gate any user-supplied URL before rendering it as an `<a href>`,
* opening it in a browser, or embedding it in an iframe.
*/
export 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;
}
}

View File

@@ -7,6 +7,7 @@ type ViewLoadingFallbackKind =
| "agents"
| "channel"
| "forum"
| "projects"
| "pulse"
| "workflows";
@@ -133,7 +134,7 @@ function AgentsLoadingBody() {
);
}
function WorkflowsLoadingBody() {
function CardListLoadingBody() {
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-4">
<div className="mb-4 flex items-center justify-between gap-3">
@@ -235,7 +236,8 @@ export function ViewLoadingFallback({
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{includeHeader ? <LoadingHeaderSkeleton /> : null}
{kind === "agents" ? <AgentsLoadingBody /> : null}
{kind === "workflows" ? <WorkflowsLoadingBody /> : null}
{kind === "workflows" ? <CardListLoadingBody /> : null}
{kind === "projects" ? <CardListLoadingBody /> : null}
{kind === "channel" ? <ChannelLoadingBody /> : null}
{kind === "forum" ? <ForumLoadingBody /> : null}
{kind === "pulse" ? <ChannelLoadingBody /> : null}