From ca960b99c49d4c103e5cadfb5249b6373b73e366 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Tue, 25 Aug 2026 12:52:31 +1000
Subject: [PATCH] feat(desktop): add channel project features POC
Signed-off-by: Matt Toohey
---
desktop/playwright.config.ts | 1 +
desktop/src/app/AppShell.tsx | 5 -
desktop/src/app/routes/projects.tsx | 24 +-
.../channels/ui/ChannelManagementSheet.tsx | 24 +-
.../channels/ui/ChannelScreenHeader.tsx | 10 +
desktop/src/features/chat/ui/ChatHeader.tsx | 3 +
.../projects/channelProjectFeatures.test.mjs | 137 ++++++++
.../projects/channelProjectFeatures.ts | 201 +++++++++++
.../projects/ui/ChannelProjectFeatureBar.tsx | 327 ++++++++++++++++++
.../ui/ChannelProjectFeaturesSettings.tsx | 131 +++++++
.../projects/ui/ProjectDetailChrome.tsx | 12 +-
.../projects/ui/ProjectDetailScreen.tsx | 16 +-
.../ui/ProjectDetailUnavailableState.tsx | 4 +-
.../ui/ProjectRepositoryManagement.tsx | 4 +-
.../projects/useChannelProjectFeatures.ts | 206 +++++++++++
.../sidebar/lib/channelSectionsStorage.ts | 17 +-
.../sidebar/lib/useChannelSections.test.mjs | 40 +++
.../sidebar/lib/useChannelSections.ts | 13 +-
.../src/features/sidebar/ui/AppSidebar.tsx | 4 -
.../features/sidebar/ui/AppSidebar.types.ts | 2 -
.../sidebar/ui/AppSidebarPinnedHeader.tsx | 138 ++++----
desktop/src/shared/features/store.test.mjs | 10 +-
.../e2e/channel-project-features.spec.ts | 183 ++++++++++
preview-features.json | 6 -
24 files changed, 1367 insertions(+), 151 deletions(-)
create mode 100644 desktop/src/features/projects/channelProjectFeatures.test.mjs
create mode 100644 desktop/src/features/projects/channelProjectFeatures.ts
create mode 100644 desktop/src/features/projects/ui/ChannelProjectFeatureBar.tsx
create mode 100644 desktop/src/features/projects/ui/ChannelProjectFeaturesSettings.tsx
create mode 100644 desktop/src/features/projects/useChannelProjectFeatures.ts
create mode 100644 desktop/tests/e2e/channel-project-features.spec.ts
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 (
+
+ );
+}
+
+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 ? (