diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index be15c75587..c233f466af 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -28,6 +28,7 @@ export default defineConfig({ "**/key-import-reveal.spec.ts", "**/navigation.spec.ts", "**/channels.spec.ts", + "**/channel-project-features.spec.ts", "**/channel-shared-header-backdrop.spec.ts", "**/channel-composer-overflow.spec.ts", "**/badge.spec.ts", diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index e111f93ca0..b9b96bf17a 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -146,7 +146,6 @@ export function AppShell() { goChannel, goHome, goNewMessage, - goProjects, goPulse, goSettings, goWorkflows, @@ -880,7 +879,6 @@ export function AppShell() { scopeSearchFocusRequest, ]} onSelectHome={() => void goHome()} - onSelectProjects={() => void goProjects()} onSelectPulse={() => void goPulse()} onSelectSettings={handleOpenSettings} onSelectWorkflows={() => void goWorkflows()} @@ -897,9 +895,6 @@ export function AppShell() { }) } profile={profileQuery.data} - projectsOverviewActive={ - location.pathname === "/projects" - } selfUserStatus={ deferredPubkey ? (selfStatusQuery.data?.[ diff --git a/desktop/src/app/routes/projects.tsx b/desktop/src/app/routes/projects.tsx index cc9f215490..75871c03b4 100644 --- a/desktop/src/app/routes/projects.tsx +++ b/desktop/src/app/routes/projects.tsx @@ -1,23 +1,7 @@ -import * as React from "react"; -import { createFileRoute } from "@tanstack/react-router"; - -import { usePreviewFeatureWarning } from "@/shared/features"; -import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; - -const ProjectsScreen = React.lazy(async () => { - const module = await import("@/features/projects/ui/ProjectsScreen"); - return { default: module.ProjectsScreen }; -}); +import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/projects")({ - component: ProjectsRouteComponent, + beforeLoad: () => { + throw redirect({ to: "/" }); + }, }); - -function ProjectsRouteComponent() { - usePreviewFeatureWarning("projects"); - return ( - }> - - - ); -} diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx index aea0f9323e..4f21bc72fc 100644 --- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx +++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx @@ -82,6 +82,7 @@ import { useChannelModerationCapabilities, } from "./ChannelManagementModerationActions"; import { ChannelMemberAvatarStack } from "./ChannelMemberAvatarStack"; +import { ChannelProjectFeaturesSettings } from "@/features/projects/ui/ChannelProjectFeaturesSettings"; type ChannelManagementSheetProps = { channel: Channel | null; @@ -207,14 +208,10 @@ export function ChannelManagementSheet({ setActiveView("summary"); return; } - if (!detail) { - return; - } + if (!detail) return; const key = detail.id; - if (syncedForRef.current === key) { - return; - } + if (syncedForRef.current === key) return; syncedForRef.current = key; setNameDraft(detail.name); @@ -226,9 +223,7 @@ export function ChannelManagementSheet({ setActiveView("summary"); }, [cancelDeferredModalOpen, detail, open]); - if (!channel) { - return null; - } + if (!channel) return null; function handleDeleteDialogOpenChange(next: boolean) { deleteChannelMutation.reset(); @@ -247,9 +242,7 @@ export function ChannelManagementSheet({ } function handlePanelOpenChange(next: boolean) { - if (!next) { - handleDeleteDialogOpenChange(false); - } + if (!next) handleDeleteDialogOpenChange(false); onOpenChange(next); } @@ -821,6 +814,13 @@ function ChannelManagementPanelContent({ /> + {canEditChannel && resolvedChannel.channelType !== "dm" ? ( + + ) : null} + {canOpenCanvas ? (
} + secondaryNavigation={ + activeChannel ? ( + + ) : null + } title={activeChannelTitle} transparentChrome={transparentChrome} visibility={activeChannel?.visibility} diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 9ced506751..7357953a3f 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -32,6 +32,7 @@ type ChatHeaderProps = { leadingContent?: React.ReactNode; mode?: "home" | "channel" | "agents" | "workflows" | "pulse" | "projects"; overlaysContent?: boolean; + secondaryNavigation?: React.ReactNode; statusBadge?: React.ReactNode; /** Render the chrome wrapper without an individual backdrop when a parent supplies shared blur. */ transparentChrome?: boolean; @@ -95,6 +96,7 @@ export function ChatHeader({ leadingContent, mode = "channel", overlaysContent = false, + secondaryNavigation, statusBadge, transparentChrome = false, }: ChatHeaderProps) { @@ -186,6 +188,7 @@ export function ChatHeader({ )} > {header} + {secondaryNavigation}
); } diff --git a/desktop/src/features/projects/channelProjectFeatures.test.mjs b/desktop/src/features/projects/channelProjectFeatures.test.mjs new file mode 100644 index 0000000000..c7b505a97a --- /dev/null +++ b/desktop/src/features/projects/channelProjectFeatures.test.mjs @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import { after, before, beforeEach, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +import { + channelProjectFeatureEnabled, + findChannelProject, + parseChannelProjectFeatureStore, + projectPrimaryRepository, + projectRelatedChannelIds, + projectRelatedRepositories, + readChannelProjectFeaturePreferences, + writeChannelProjectFeaturePreferences, +} from "./channelProjectFeatures.ts"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + globalThis.window = dom.window; +}); +beforeEach(() => dom.window.localStorage.clear()); +after(() => dom.window.close()); + +test("feature preferences are scoped by viewer, relay, and channel", () => { + writeChannelProjectFeaturePreferences( + "viewer-a", + "wss://relay-a.example/", + "channel-a", + { tasks: true }, + ); + + assert.deepEqual( + readChannelProjectFeaturePreferences( + "viewer-a", + "wss://relay-a.example", + "channel-a", + ), + { tasks: true }, + ); + assert.deepEqual( + readChannelProjectFeaturePreferences( + "viewer-b", + "wss://relay-a.example", + "channel-a", + ), + {}, + ); + assert.deepEqual( + readChannelProjectFeaturePreferences( + "viewer-a", + "wss://relay-b.example", + "channel-a", + ), + {}, + ); + assert.deepEqual( + readChannelProjectFeaturePreferences( + "viewer-a", + "wss://relay-a.example", + "channel-b", + ), + {}, + ); +}); + +test("malformed feature storage fails closed", () => { + assert.deepEqual(parseChannelProjectFeatureStore(null), { + version: 1, + channels: {}, + }); + assert.deepEqual( + parseChannelProjectFeatureStore({ + version: 1, + channels: { + valid: { tasks: true, repositories: "yes" }, + empty: null, + }, + }), + { + version: 1, + channels: { valid: { tasks: true }, empty: {} }, + }, + ); +}); + +test("existing data keeps a locally disabled feature enabled", () => { + assert.equal( + channelProjectFeatureEnabled({ + feature: "tasks", + hasExistingData: true, + preferences: { tasks: false }, + }), + true, + ); + assert.equal( + channelProjectFeatureEnabled({ + feature: "tasks", + hasExistingData: false, + preferences: { tasks: false }, + }), + false, + ); +}); + +test("channel project helpers hide the primary repository and dedupe breakout channels", () => { + const primary = { + id: "primary", + repoAddress: "30617:owner:primary", + channelId: "root", + }; + const related = { + id: "related", + repoAddress: "30617:owner:related", + channelId: "breakout", + }; + const project = { + id: "project", + legacy: false, + projectChannelId: "root", + primaryRepositoryAddress: primary.repoAddress, + repositories: [primary, related, { ...related, id: "duplicate" }], + }; + + assert.equal(findChannelProject([project], "root"), project); + assert.equal(projectPrimaryRepository(project), primary); + assert.deepEqual(projectRelatedRepositories(project), [ + related, + { + ...related, + id: "duplicate", + }, + ]); + assert.deepEqual(projectRelatedChannelIds(project, "root"), ["breakout"]); +}); diff --git a/desktop/src/features/projects/channelProjectFeatures.ts b/desktop/src/features/projects/channelProjectFeatures.ts new file mode 100644 index 0000000000..04b81111db --- /dev/null +++ b/desktop/src/features/projects/channelProjectFeatures.ts @@ -0,0 +1,201 @@ +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; + +import type { Project, Repository } from "./projectModels"; + +const STORAGE_KEY_PREFIX = "buzz-channel-project-features.v1"; +const MAX_CHANNEL_PREFERENCES = 1_000; +export const CHANNEL_PROJECT_FEATURES_CHANGED_EVENT = + "buzz:channel-project-features-changed"; + +export type ChannelProjectFeature = "tasks" | "breakouts" | "repositories"; + +export type ChannelProjectFeaturePreferences = { + tasks?: boolean; + breakouts?: boolean; + repositories?: boolean; + breakoutSectionId?: string; +}; + +type ChannelProjectFeatureStore = { + version: 1; + channels: Record; +}; + +const EMPTY_PREFERENCES: ChannelProjectFeaturePreferences = Object.freeze({}); + +export function channelProjectFeatureStorageKey( + pubkey: string, + relayUrl: string, +) { + return `${STORAGE_KEY_PREFIX}:${pubkey.toLowerCase()}:${encodeURIComponent( + normalizeRelayUrl(relayUrl), + )}`; +} + +function parsePreferences(value: unknown): ChannelProjectFeaturePreferences { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const candidate = value as Record; + return { + ...(typeof candidate.tasks === "boolean" ? { tasks: candidate.tasks } : {}), + ...(typeof candidate.breakouts === "boolean" + ? { breakouts: candidate.breakouts } + : {}), + ...(typeof candidate.repositories === "boolean" + ? { repositories: candidate.repositories } + : {}), + ...(typeof candidate.breakoutSectionId === "string" && + candidate.breakoutSectionId.length > 0 + ? { breakoutSectionId: candidate.breakoutSectionId } + : {}), + }; +} + +export function parseChannelProjectFeatureStore( + value: unknown, +): ChannelProjectFeatureStore { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { version: 1, channels: {} }; + } + const candidate = value as Record; + if ( + candidate.version !== 1 || + !candidate.channels || + typeof candidate.channels !== "object" || + Array.isArray(candidate.channels) + ) { + return { version: 1, channels: {} }; + } + return { + version: 1, + channels: Object.fromEntries( + Object.entries(candidate.channels as Record) + .filter(([channelId]) => channelId.length > 0) + .slice(-MAX_CHANNEL_PREFERENCES) + .map(([channelId, preferences]) => [ + channelId, + parsePreferences(preferences), + ]), + ), + }; +} + +function readStore(pubkey: string, relayUrl: string) { + try { + const raw = window.localStorage.getItem( + channelProjectFeatureStorageKey(pubkey, relayUrl), + ); + return parseChannelProjectFeatureStore(raw ? JSON.parse(raw) : null); + } catch { + return { version: 1, channels: {} } satisfies ChannelProjectFeatureStore; + } +} + +export function readChannelProjectFeaturePreferences( + pubkey: string | undefined, + relayUrl: string | undefined, + channelId: string, +) { + if (!pubkey || !relayUrl) return EMPTY_PREFERENCES; + return readStore(pubkey, relayUrl).channels[channelId] ?? EMPTY_PREFERENCES; +} + +export function writeChannelProjectFeaturePreferences( + pubkey: string, + relayUrl: string, + channelId: string, + patch: Partial, +) { + const key = channelProjectFeatureStorageKey(pubkey, relayUrl); + try { + const store = readStore(pubkey, relayUrl); + const next = parsePreferences({ + ...store.channels[channelId], + ...patch, + }); + // TODO: Replace this browser-local POC state with shared persisted + // capability metadata if the channel-first model is validated. + window.localStorage.setItem( + key, + JSON.stringify( + parseChannelProjectFeatureStore({ + version: 1, + channels: { ...store.channels, [channelId]: next }, + }), + ), + ); + window.dispatchEvent( + new window.CustomEvent(CHANNEL_PROJECT_FEATURES_CHANGED_EVENT, { + detail: { key }, + }), + ); + return next; + } catch { + return null; + } +} + +export function findChannelProject( + projects: readonly Project[], + channelId: string, +) { + return ( + projects.find((project) => project.projectChannelId === channelId) ?? + projects.find( + (project) => + project.legacy && + project.repositories.some( + (repository) => repository.channelId === channelId, + ), + ) ?? + null + ); +} + +export function projectPrimaryRepository(project: Project | null) { + if (!project) return null; + return ( + project.repositories.find( + (repository) => + repository.repoAddress === project.primaryRepositoryAddress, + ) ?? + project.repositories[0] ?? + null + ); +} + +export function projectRelatedRepositories(project: Project | null) { + if (!project) return []; + const primary = projectPrimaryRepository(project); + return project.repositories.filter( + (repository) => repository.repoAddress !== primary?.repoAddress, + ); +} + +export function projectRelatedChannelIds( + project: Project | null, + rootChannelId: string, +) { + if (!project) return []; + return [ + ...new Set( + project.repositories + .map((repository: Repository) => repository.channelId?.trim()) + .filter( + (channelId): channelId is string => + Boolean(channelId) && channelId !== rootChannelId, + ), + ), + ]; +} + +export function channelProjectFeatureEnabled({ + feature, + hasExistingData, + preferences, +}: { + feature: ChannelProjectFeature; + hasExistingData: boolean; + preferences: ChannelProjectFeaturePreferences; +}) { + return hasExistingData || preferences[feature] === true; +} diff --git a/desktop/src/features/projects/ui/ChannelProjectFeatureBar.tsx b/desktop/src/features/projects/ui/ChannelProjectFeatureBar.tsx new file mode 100644 index 0000000000..6990ca4b23 --- /dev/null +++ b/desktop/src/features/projects/ui/ChannelProjectFeatureBar.tsx @@ -0,0 +1,327 @@ +import { + ArrowLeft, + GitBranch, + Hash, + ListTodo, + MessagesSquare, + Plus, +} from "lucide-react"; +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useCreateChannelMutation } from "@/features/channels/hooks"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog"; +import type { Channel } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; + +import { useCreateProjectIssueMutation } from "../issueMutations"; +import { useChannelProjectFeatures } from "../useChannelProjectFeatures"; +import { CreateProjectWorkItemDialog } from "./CreateProjectWorkItemDialog"; +import { ProjectIssuesPanel } from "./ProjectIssuesPanel"; +import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; + +type OpenTool = "tasks" | "breakouts" | "repositories" | null; + +export function ChannelProjectFeatureBar({ + channel, + currentPubkey, +}: { + channel: Channel; + currentPubkey?: string; +}) { + const { activeCommunity } = useCommunities(); + const { goChannel } = useAppNavigation(); + const context = useChannelProjectFeatures({ + channel, + currentPubkey, + relayUrl: activeCommunity?.relayUrl, + }); + const createChannelMutation = useCreateChannelMutation(); + const createTaskMutation = useCreateProjectIssueMutation( + context.primaryRepository, + ); + const [openTool, setOpenTool] = React.useState(null); + const [createTaskOpen, setCreateTaskOpen] = React.useState(false); + const [createChannelOpen, setCreateChannelOpen] = React.useState(false); + const [selectedIssueId, setSelectedIssueId] = React.useState( + null, + ); + + if ( + !context.project || + !Object.values(context.enabled).some(Boolean) || + channel.channelType === "dm" + ) { + return null; + } + + const breakoutChannels = context.breakoutChannelIds.flatMap((channelId) => { + const result = context.channels.find( + (candidate) => candidate.id === channelId, + ); + return result ? [result] : []; + }); + + return ( + <> + + + + {selectedIssueId ? ( + + ) : null} + + + } + onOpenChange={(open) => { + setOpenTool(open ? "tasks" : null); + if (!open) setSelectedIssueId(null); + }} + open={openTool === "tasks"} + testId="channel-tasks-dialog" + title="Tasks" + > + {context.primaryRepository ? ( + + ) : ( + Tasks are unavailable. + )} + + + { + await createTaskMutation.mutateAsync(input); + context.setFeatureEnabled("tasks", true); + }} + onOpenChange={setCreateTaskOpen} + open={createTaskOpen} + submitDisabled={!context.primaryRepository} + title="Create task" + titlePlaceholder="Task title" + /> + + setCreateChannelOpen(true)} + size="icon" + type="button" + variant="outline" + > + + + } + onOpenChange={(open) => setOpenTool(open ? "breakouts" : null)} + open={openTool === "breakouts"} + testId="channel-breakouts-dialog" + title="Breakout channels" + > + {breakoutChannels.length > 0 ? ( +
+ {breakoutChannels.map((breakoutChannel) => ( + + ))} +
+ ) : ( + No breakout channels yet. + )} +
+ + { + const createdChannel = await createChannelMutation.mutateAsync({ + ...input, + channelType: "stream", + }); + const section = context.ensureBreakoutSection(); + if (!section) throw new Error("Could not create the channel group."); + context.channelSections.assignChannel(createdChannel.id, section.id); + context.setFeatureEnabled("breakouts", true); + }} + onOpenChange={setCreateChannelOpen} + /> + + context.setFeatureEnabled("repositories", true)} + project={context.project} + projects={context.projects} + repository={context.primaryRepository} + showAccessManagement={false} + /> + ) : null + } + onOpenChange={(open) => setOpenTool(open ? "repositories" : null)} + open={openTool === "repositories"} + testId="channel-repositories-dialog" + title="Related repositories" + > + {context.relatedRepositories.length > 0 ? ( +
+ {context.relatedRepositories.map((repository) => ( +
+ + {repository.name} +
+ ))} +
+ ) : ( + No related repositories yet. + )} +
+ + ); +} + +function FeatureButton({ + icon: Icon, + label, + onClick, + testId, +}: { + icon: typeof ListTodo; + label: string; + onClick: () => void; + testId: string; +}) { + return ( + + ); +} + +function ToolDialog({ + actions, + children, + onOpenChange, + open, + testId, + title, +}: { + actions?: React.ReactNode; + children: React.ReactNode; + onOpenChange: (open: boolean) => void; + open: boolean; + testId: string; + title: string; +}) { + return ( + + + +
+ {title} + + {title} for this channel + +
+
{actions}
+
+
{children}
+
+
+ ); +} + +function EmptyState({ children }: { children: React.ReactNode }) { + return

{children}

; +} diff --git a/desktop/src/features/projects/ui/ChannelProjectFeaturesSettings.tsx b/desktop/src/features/projects/ui/ChannelProjectFeaturesSettings.tsx new file mode 100644 index 0000000000..aeb56f49d4 --- /dev/null +++ b/desktop/src/features/projects/ui/ChannelProjectFeaturesSettings.tsx @@ -0,0 +1,131 @@ +import { + GitBranch, + ListTodo, + MessagesSquare, + type LucideIcon, +} from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { useCommunities } from "@/features/communities/useCommunities"; +import type { Channel } from "@/shared/api/types"; +import { Switch } from "@/shared/ui/switch"; + +import type { ChannelProjectFeature } from "../channelProjectFeatures"; +import { useChannelProjectFeatures } from "../useChannelProjectFeatures"; +import { useCreateProjectMutation } from "../useCreateProject"; +import { FieldGroup } from "@/features/channels/ui/ChannelManagementSheetRows"; + +const FEATURES: Array<{ + feature: ChannelProjectFeature; + icon: LucideIcon; + label: string; +}> = [ + { feature: "tasks", icon: ListTodo, label: "Tasks" }, + { feature: "breakouts", icon: MessagesSquare, label: "Breakout channels" }, + { feature: "repositories", icon: GitBranch, label: "Related repositories" }, +]; + +export function ChannelProjectFeaturesSettings({ + channel, + currentPubkey, +}: { + channel: Channel; + currentPubkey?: string; +}) { + const { activeCommunity } = useCommunities(); + const context = useChannelProjectFeatures({ + channel, + currentPubkey, + relayUrl: activeCommunity?.relayUrl, + }); + const createProjectMutation = useCreateProjectMutation(); + const [pendingFeature, setPendingFeature] = + React.useState(null); + + async function ensureProject() { + if (context.project) return context.project; + const input = { + accessChannelId: channel.id, + description: channel.description, + name: channel.name, + }; + try { + return (await createProjectMutation.mutateAsync(input)).project; + } catch (error) { + if ( + !(error instanceof Error) || + !/already have a project/i.test(error.message) + ) { + throw error; + } + return ( + await createProjectMutation.mutateAsync({ + ...input, + name: `${channel.name} ${channel.id.slice(0, 8)}`, + }) + ).project; + } + } + + async function handleFeatureChange( + feature: ChannelProjectFeature, + checked: boolean, + ) { + setPendingFeature(feature); + try { + if (checked) await ensureProject(); + context.setFeatureEnabled(feature, checked); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Could not update channel features.", + ); + } finally { + setPendingFeature(null); + } + } + + if (!currentPubkey || !activeCommunity?.relayUrl) return null; + + return ( + + {FEATURES.map(({ feature, icon: Icon, label }) => { + const forcedOn = context.existing[feature]; + const labelId = `channel-feature-${feature}-label`; + return ( +
+ + + {label} + + {forcedOn ? ( + In use + ) : null} + { + void handleFeatureChange(feature, checked); + }} + /> +
+ ); + })} +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectDetailChrome.tsx b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx index f9740b8aa3..ee9bcbaeda 100644 --- a/desktop/src/features/projects/ui/ProjectDetailChrome.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx @@ -1,4 +1,4 @@ -import { ChevronRight, Folders } from "lucide-react"; +import { ChevronRight, Folders, Hash } from "lucide-react"; import type * as React from "react"; import { AppTopChromePortal } from "@/app/AppTopChromePortal"; @@ -15,7 +15,7 @@ export function ProjectDetailChrome({ activeTabCrumb, activeWorkItemCrumb, onGoProjectHome, - onGoProjects, + onGoRootChannel, project, repository, }: { @@ -24,7 +24,7 @@ export function ProjectDetailChrome({ activeTabCrumb: string | null; activeWorkItemCrumb: ProjectDetailWorkItemCrumb | null; onGoProjectHome: () => void; - onGoProjects: () => void; + onGoRootChannel: () => void; project: Project; repository: Repository; }) { @@ -44,11 +44,11 @@ export function ProjectDetailChrome({ > @@ -49,7 +49,7 @@ export function ProjectDetailUnavailableState(

); diff --git a/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx index fd08d49051..126eef9ecf 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx @@ -28,6 +28,7 @@ export function ProjectRepositoryManagement({ project, projects, repository, + showAccessManagement = true, }: { compact?: boolean; identityPubkey?: string; @@ -35,6 +36,7 @@ export function ProjectRepositoryManagement({ project: Project; projects: Project[]; repository: Repository; + showAccessManagement?: boolean; }) { const [createOpen, setCreateOpen] = React.useState(false); const [attachOpen, setAttachOpen] = React.useState(false); @@ -169,7 +171,7 @@ export function ProjectRepositoryManagement({ ) : null} - {canManageAccess ? ( + {showAccessManagement && canManageAccess ? (