mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
25
desktop/src/app/routes/projects.$projectId.tsx
Normal file
25
desktop/src/app/routes/projects.$projectId.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
desktop/src/app/routes/projects.tsx
Normal file
23
desktop/src/app/routes/projects.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
desktop/src/features/projects/hooks.ts
Normal file
107
desktop/src/features/projects/hooks.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
257
desktop/src/features/projects/ui/ProjectDetailScreen.tsx
Normal file
257
desktop/src/features/projects/ui/ProjectDetailScreen.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
19
desktop/src/features/projects/ui/ProjectsScreen.tsx
Normal file
19
desktop/src/features/projects/ui/ProjectsScreen.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
113
desktop/src/features/projects/ui/ProjectsView.tsx
Normal file
113
desktop/src/features/projects/ui/ProjectsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
15
desktop/src/shared/lib/url.ts
Normal file
15
desktop/src/shared/lib/url.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user