feat(desktop): add channel project features POC

Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-08-25 12:52:31 +10:00
parent 8d2d0ff5ad
commit ca960b99c4
24 changed files with 1367 additions and 151 deletions

View File

@@ -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",

View File

@@ -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?.[

View File

@@ -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 (
<React.Suspense fallback={<ViewLoadingFallback kind="projects" />}>
<ProjectsScreen />
</React.Suspense>
);
}

View File

@@ -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({
/>
</FieldGroup>
{canEditChannel && resolvedChannel.channelType !== "dm" ? (
<ChannelProjectFeaturesSettings
channel={resolvedChannel}
currentPubkey={currentPubkey}
/>
) : null}
{canOpenCanvas ? (
<div className="space-y-3">
<IngressRow

View File

@@ -8,6 +8,7 @@ import { getChannelDescription } from "@/features/channels/lib/channelDescriptio
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge";
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
import { ChannelProjectFeatureBar } from "@/features/projects/ui/ChannelProjectFeatureBar";
import {
DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY,
ProfileAvatarWithStatus,
@@ -180,6 +181,15 @@ export function ChannelScreenHeader({
ephemeralDisplay={activeChannelEphemeralDisplay}
/>
}
secondaryNavigation={
activeChannel ? (
<ChannelProjectFeatureBar
channel={activeChannel}
currentPubkey={currentPubkey}
key={activeChannel.id}
/>
) : null
}
title={activeChannelTitle}
transparentChrome={transparentChrome}
visibility={activeChannel?.visibility}

View File

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

View File

@@ -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("<!doctype html><html><body></body></html>", {
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"]);
});

View File

@@ -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<string, ChannelProjectFeaturePreferences>;
};
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<string, unknown>;
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<string, unknown>;
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<string, unknown>)
.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<ChannelProjectFeaturePreferences>,
) {
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;
}

View File

@@ -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<OpenTool>(null);
const [createTaskOpen, setCreateTaskOpen] = React.useState(false);
const [createChannelOpen, setCreateChannelOpen] = React.useState(false);
const [selectedIssueId, setSelectedIssueId] = React.useState<string | null>(
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 (
<>
<nav
aria-label="Channel features"
className="pointer-events-auto flex h-10 shrink-0 items-center gap-1 overflow-x-auto border-b border-border/60 px-4"
data-testid="channel-project-feature-bar"
>
{context.enabled.tasks ? (
<FeatureButton
icon={ListTodo}
label="Tasks"
onClick={() => setOpenTool("tasks")}
testId="open-channel-tasks"
/>
) : null}
{context.enabled.breakouts ? (
<FeatureButton
icon={MessagesSquare}
label="Breakout channels"
onClick={() => setOpenTool("breakouts")}
testId="open-channel-breakouts"
/>
) : null}
{context.enabled.repositories ? (
<FeatureButton
icon={GitBranch}
label="Repositories"
onClick={() => setOpenTool("repositories")}
testId="open-channel-repositories"
/>
) : null}
</nav>
<ToolDialog
actions={
<>
{selectedIssueId ? (
<Button
aria-label="Back to tasks"
onClick={() => setSelectedIssueId(null)}
size="icon"
type="button"
variant="ghost"
>
<ArrowLeft className="h-4 w-4" />
</Button>
) : null}
<Button
aria-label="Create task"
data-testid="create-channel-task"
disabled={!context.primaryRepository}
onClick={() => setCreateTaskOpen(true)}
size="icon"
type="button"
variant="outline"
>
<Plus className="h-4 w-4" />
</Button>
</>
}
onOpenChange={(open) => {
setOpenTool(open ? "tasks" : null);
if (!open) setSelectedIssueId(null);
}}
open={openTool === "tasks"}
testId="channel-tasks-dialog"
title="Tasks"
>
{context.primaryRepository ? (
<ProjectIssuesPanel
onSelectedIssueIdChange={setSelectedIssueId}
project={context.primaryRepository}
selectedIssueId={selectedIssueId}
/>
) : (
<EmptyState>Tasks are unavailable.</EmptyState>
)}
</ToolDialog>
<CreateProjectWorkItemDialog
bodyPlaceholder="Add details"
description={`Create a task for #${channel.name}.`}
isCreating={createTaskMutation.isPending}
itemName="issue"
onCreate={async (input) => {
await createTaskMutation.mutateAsync(input);
context.setFeatureEnabled("tasks", true);
}}
onOpenChange={setCreateTaskOpen}
open={createTaskOpen}
submitDisabled={!context.primaryRepository}
title="Create task"
titlePlaceholder="Task title"
/>
<ToolDialog
actions={
<Button
aria-label="Create breakout channel"
data-testid="create-breakout-channel"
onClick={() => setCreateChannelOpen(true)}
size="icon"
type="button"
variant="outline"
>
<Plus className="h-4 w-4" />
</Button>
}
onOpenChange={(open) => setOpenTool(open ? "breakouts" : null)}
open={openTool === "breakouts"}
testId="channel-breakouts-dialog"
title="Breakout channels"
>
{breakoutChannels.length > 0 ? (
<div className="divide-y divide-border/60">
{breakoutChannels.map((breakoutChannel) => (
<button
className="flex min-h-12 w-full items-center gap-3 px-5 py-3 text-left text-sm hover:bg-muted/40"
key={breakoutChannel.id}
onClick={() => {
setOpenTool(null);
void goChannel(breakoutChannel.id);
}}
type="button"
>
<Hash className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{breakoutChannel.name}</span>
</button>
))}
</div>
) : (
<EmptyState>No breakout channels yet.</EmptyState>
)}
</ToolDialog>
<CreateChannelDialog
channelKind={createChannelOpen ? "stream" : null}
isCreating={createChannelMutation.isPending}
onCreate={async (input) => {
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}
/>
<ToolDialog
actions={
context.primaryRepository ? (
<ProjectRepositoryManagement
compact
identityPubkey={currentPubkey}
onChange={() => 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 ? (
<div className="divide-y divide-border/60">
{context.relatedRepositories.map((repository) => (
<div
className="flex min-h-12 items-center gap-3 px-5 py-3 text-sm"
key={repository.repoAddress}
>
<GitBranch className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{repository.name}</span>
</div>
))}
</div>
) : (
<EmptyState>No related repositories yet.</EmptyState>
)}
</ToolDialog>
</>
);
}
function FeatureButton({
icon: Icon,
label,
onClick,
testId,
}: {
icon: typeof ListTodo;
label: string;
onClick: () => void;
testId: string;
}) {
return (
<Button
className="h-7 shrink-0 gap-1.5 px-2 text-xs"
data-testid={testId}
onClick={onClick}
size="sm"
type="button"
variant="ghost"
>
<Icon className="h-3.5 w-3.5" />
{label}
</Button>
);
}
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 (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
className="flex max-h-[80vh] min-h-72 max-w-3xl flex-col gap-0 overflow-hidden p-0"
data-testid={testId}
>
<DialogHeader className="flex-row items-center justify-between border-b border-border/60 px-5 py-4 pr-14">
<div>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="sr-only">
{title} for this channel
</DialogDescription>
</div>
<div className="flex items-center gap-1">{actions}</div>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
</DialogContent>
</Dialog>
);
}
function EmptyState({ children }: { children: React.ReactNode }) {
return <p className="p-5 text-sm text-muted-foreground">{children}</p>;
}

View File

@@ -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<ChannelProjectFeature | null>(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 (
<FieldGroup testId="channel-project-features" title="Features">
{FEATURES.map(({ feature, icon: Icon, label }) => {
const forcedOn = context.existing[feature];
const labelId = `channel-feature-${feature}-label`;
return (
<div
className="flex min-h-14 w-full items-center gap-3 px-4 py-3"
data-testid={`channel-feature-${feature}`}
key={feature}
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span
className="min-w-0 flex-1 text-sm font-medium text-foreground"
id={labelId}
>
{label}
</span>
{forcedOn ? (
<span className="text-xs text-muted-foreground">In use</span>
) : null}
<Switch
aria-labelledby={labelId}
checked={context.enabled[feature]}
data-testid={`channel-feature-${feature}-switch`}
disabled={
forcedOn ||
pendingFeature !== null ||
context.projectsQuery.isPending
}
onCheckedChange={(checked) => {
void handleFeatureChange(feature, checked);
}}
/>
</div>
);
})}
</FieldGroup>
);
}

View File

@@ -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({
>
<button
className="flex shrink-0 items-center gap-1.5 rounded-md px-1 py-1 font-medium transition-colors hover:text-sidebar-accent-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={onGoProjects}
onClick={onGoRootChannel}
type="button"
>
<Folders className="h-3.5 w-3.5" />
Projects
<Hash className="h-3.5 w-3.5" />
Channel
</button>
<ChevronRight className="h-3 w-3 shrink-0 opacity-60" />
<button

View File

@@ -90,7 +90,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
repositoryId,
tab,
} = props;
const { goProject, goProjects } = useAppNavigation();
const { goChannel, goHome, goProject } = useAppNavigation();
const { activeCommunity } = useCommunities();
const projectQuery = useProjectQuery(projectId);
const projectsQuery = useProjectsQuery();
@@ -660,7 +660,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
return (
<ProjectDetailUnavailableState
kind="load-error"
onBack={() => void goProjects()}
onBack={() => void goHome()}
onRetry={() => void projectQuery.refetch()}
/>
);
@@ -669,7 +669,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
return (
<ProjectDetailUnavailableState
kind="not-found"
onBack={() => void goProjects()}
onBack={() => void goHome()}
/>
);
}
@@ -835,8 +835,14 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
activeTabCrumb={activeTabCrumb}
activeWorkItemCrumb={activeWorkItemCrumb}
onGoProjectHome={handleGoToProjectHome}
onGoProjects={() => {
void goProjects();
onGoRootChannel={() => {
const rootChannelId =
project.projectChannelId ?? repository.channelId;
if (rootChannelId) {
void goChannel(rootChannelId);
} else {
void goHome();
}
}}
project={project}
repository={repository}

View File

@@ -33,7 +33,7 @@ export function ProjectDetailUnavailableState(
</Button>
<Button onClick={props.onBack} size="sm" variant="ghost">
<ArrowLeft className="mr-1.5 h-4 w-4" />
Back to Projects
Back to Inbox
</Button>
</div>
</div>
@@ -49,7 +49,7 @@ export function ProjectDetailUnavailableState(
</p>
<Button onClick={props.onBack} size="sm" variant="outline">
<ArrowLeft className="mr-1.5 h-4 w-4" />
Back to Projects
Back to Inbox
</Button>
</div>
);

View File

@@ -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({
</DropdownMenuContent>
</DropdownMenu>
) : null}
{canManageAccess ? (
{showAccessManagement && canManageAccess ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button

View File

@@ -0,0 +1,206 @@
import * as React from "react";
import { useChannelsQuery } from "@/features/channels/hooks";
import { useChannelSections } from "@/features/sidebar/lib/useChannelSections";
import type { Channel } from "@/shared/api/types";
import { useProjectIssuesQuery, useProjectsQuery } from "./hooks";
import {
CHANNEL_PROJECT_FEATURES_CHANGED_EVENT,
channelProjectFeatureEnabled,
channelProjectFeatureStorageKey,
findChannelProject,
projectPrimaryRepository,
projectRelatedChannelIds,
projectRelatedRepositories,
readChannelProjectFeaturePreferences,
type ChannelProjectFeature,
type ChannelProjectFeaturePreferences,
writeChannelProjectFeaturePreferences,
} from "./channelProjectFeatures";
export function useChannelProjectFeatures({
channel,
currentPubkey,
relayUrl,
}: {
channel: Channel;
currentPubkey?: string;
relayUrl?: string;
}) {
const [preferences, setPreferences] =
React.useState<ChannelProjectFeaturePreferences>(() =>
readChannelProjectFeaturePreferences(currentPubkey, relayUrl, channel.id),
);
const projectsQuery = useProjectsQuery(Boolean(currentPubkey && relayUrl));
const channelsQuery = useChannelsQuery();
const channelSections = useChannelSections(currentPubkey, relayUrl);
const project = findChannelProject(projectsQuery.data ?? [], channel.id);
const primaryRepository = projectPrimaryRepository(project);
const relatedRepositories = projectRelatedRepositories(project);
const relatedChannelIds = projectRelatedChannelIds(project, channel.id);
const issuesQuery = useProjectIssuesQuery(primaryRepository);
const breakoutSection =
channelSections.sections.find(
(section) => section.id === preferences.breakoutSectionId,
) ?? null;
const localBreakoutChannelIds = breakoutSection
? Object.entries(channelSections.assignments)
.filter(
([channelId, sectionId]) =>
sectionId === breakoutSection.id && channelId !== channel.id,
)
.map(([channelId]) => channelId)
: [];
const breakoutChannelIds = [
...new Set([...relatedChannelIds, ...localBreakoutChannelIds]),
];
const existing = {
tasks: (issuesQuery.data?.length ?? 0) > 0,
breakouts: breakoutChannelIds.length > 0,
repositories: relatedRepositories.length > 0,
};
const enabled = {
tasks: channelProjectFeatureEnabled({
feature: "tasks",
hasExistingData: existing.tasks,
preferences,
}),
breakouts: channelProjectFeatureEnabled({
feature: "breakouts",
hasExistingData: existing.breakouts,
preferences,
}),
repositories: channelProjectFeatureEnabled({
feature: "repositories",
hasExistingData: existing.repositories,
preferences,
}),
};
React.useEffect(() => {
setPreferences(
readChannelProjectFeaturePreferences(currentPubkey, relayUrl, channel.id),
);
}, [channel.id, currentPubkey, relayUrl]);
React.useEffect(() => {
if (!currentPubkey || !relayUrl) return;
const key = channelProjectFeatureStorageKey(currentPubkey, relayUrl);
const handleChange = (event: Event) => {
if (
event.type === CHANNEL_PROJECT_FEATURES_CHANGED_EVENT &&
(event as CustomEvent<{ key?: string }>).detail?.key !== key
) {
return;
}
if (event.type === "storage" && (event as StorageEvent).key !== key) {
return;
}
setPreferences(
readChannelProjectFeaturePreferences(
currentPubkey,
relayUrl,
channel.id,
),
);
};
window.addEventListener("storage", handleChange);
window.addEventListener(
CHANNEL_PROJECT_FEATURES_CHANGED_EVENT,
handleChange,
);
return () => {
window.removeEventListener("storage", handleChange);
window.removeEventListener(
CHANNEL_PROJECT_FEATURES_CHANGED_EVENT,
handleChange,
);
};
}, [channel.id, currentPubkey, relayUrl]);
const updatePreferences = React.useCallback(
(patch: Partial<ChannelProjectFeaturePreferences>) => {
if (!currentPubkey || !relayUrl) return null;
const next = writeChannelProjectFeaturePreferences(
currentPubkey,
relayUrl,
channel.id,
patch,
);
if (next) setPreferences(next);
return next;
},
[channel.id, currentPubkey, relayUrl],
);
const ensureBreakoutSection = React.useCallback(() => {
let section =
channelSections.sections.find(
(candidate) => candidate.id === preferences.breakoutSectionId,
) ?? null;
if (!section) {
section = channelSections.createSection(channel.name);
}
if (!section) return null;
if (channelSections.assignments[channel.id] !== section.id) {
channelSections.assignChannel(channel.id, section.id);
}
for (const channelId of relatedChannelIds) {
if (channelSections.assignments[channelId] !== section.id) {
channelSections.assignChannel(channelId, section.id);
}
}
if (preferences.breakoutSectionId !== section.id) {
updatePreferences({ breakoutSectionId: section.id });
}
return section;
}, [
channel.id,
channel.name,
channelSections,
preferences.breakoutSectionId,
relatedChannelIds,
updatePreferences,
]);
const setFeatureEnabled = React.useCallback(
(feature: ChannelProjectFeature, value: boolean) => {
updatePreferences({ [feature]: value });
if (feature === "breakouts") {
if (value) {
ensureBreakoutSection();
} else if (breakoutSection && !existing.breakouts) {
channelSections.deleteSection(breakoutSection.id);
updatePreferences({ breakoutSectionId: undefined });
}
}
},
[
breakoutSection,
channelSections,
ensureBreakoutSection,
existing.breakouts,
updatePreferences,
],
);
return {
breakoutChannelIds,
breakoutSection,
channelSections,
channels: channelsQuery.data ?? [],
enabled,
existing,
issuesQuery,
preferences,
primaryRepository,
project,
projects: projectsQuery.data ?? [],
projectsQuery,
relatedChannelIds,
relatedRepositories,
ensureBreakoutSection,
setFeatureEnabled,
};
}

View File

@@ -1,6 +1,7 @@
import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl";
const STORAGE_KEY_PREFIX = "buzz-channel-sections.v1";
export const CHANNEL_SECTIONS_CHANGED_EVENT = "buzz:channel-sections-changed";
export const MAX_CHANNEL_SECTIONS = 100;
export const MAX_CHANNEL_SECTION_ASSIGNMENTS = 1_000;
@@ -185,10 +186,24 @@ export function writeChannelSectionsStore(
relayUrl?: string,
): boolean {
try {
const key = storageKey(pubkey, relayUrl);
window.localStorage.setItem(
storageKey(pubkey, relayUrl),
key,
JSON.stringify(boundChannelSectionsStore(store)),
);
globalThis.queueMicrotask(() => {
if (
typeof window.dispatchEvent !== "function" ||
typeof window.CustomEvent !== "function"
) {
return;
}
window.dispatchEvent(
new window.CustomEvent(CHANNEL_SECTIONS_CHANGED_EVENT, {
detail: { key },
}),
);
});
return true;
} catch {
return false;

View File

@@ -76,3 +76,43 @@ test("assignChannel refreshes an existing assignment before the next eviction",
relayClient.subscribeToReconnects = originalSubscribeToReconnects;
}
});
test("separate hook instances observe same-window section changes", async () => {
const { act, cleanup, renderHook, waitFor } = await import(
"@testing-library/react"
);
const { relayClient } = await import("@/shared/api/relayClient");
const { useChannelSections } = await import("./useChannelSections.ts");
const originalFetchEvents = relayClient.fetchEvents;
const originalSubscribeLive = relayClient.subscribeLive;
const originalSubscribeToReconnects = relayClient.subscribeToReconnects;
relayClient.fetchEvents = async () => [];
relayClient.subscribeLive = async () => async () => {};
relayClient.subscribeToReconnects = () => () => {};
try {
const first = renderHook(() =>
useChannelSections("same-window", "wss://relay.example"),
);
const second = renderHook(() =>
useChannelSections("same-window", "wss://relay.example"),
);
let section;
act(() => {
section = first.result.current.createSection("Project");
});
assert.ok(section);
act(() => first.result.current.assignChannel("child", section.id));
await waitFor(() => {
assert.equal(second.result.current.assignments.child, section.id);
});
first.unmount();
second.unmount();
} finally {
cleanup();
relayClient.fetchEvents = originalFetchEvents;
relayClient.subscribeLive = originalSubscribeLive;
relayClient.subscribeToReconnects = originalSubscribeToReconnects;
}
});

View File

@@ -3,6 +3,7 @@ import * as React from "react";
import { relayClient } from "@/shared/api/relayClient";
import {
boundChannelSectionsStore,
CHANNEL_SECTIONS_CHANGED_EVENT,
DEFAULT_STORE,
readChannelSectionsStore,
storageKey,
@@ -67,15 +68,23 @@ export function useChannelSections(
return;
}
const key = storageKey(pubkey, relayUrl);
const handler = (e: StorageEvent) => {
if (e.key !== key) {
const handler = (e: StorageEvent | Event) => {
if (
e.type === CHANNEL_SECTIONS_CHANGED_EVENT &&
(e as CustomEvent<{ key?: string }>).detail?.key !== key
) {
return;
}
if (e.type === "storage" && (e as StorageEvent).key !== key) {
return;
}
setStore(readChannelSectionsStore(pubkey, relayUrl));
};
window.addEventListener("storage", handler);
window.addEventListener(CHANNEL_SECTIONS_CHANGED_EVENT, handler);
return () => {
window.removeEventListener("storage", handler);
window.removeEventListener(CHANNEL_SECTIONS_CHANGED_EVENT, handler);
};
}, [pubkey, relayUrl]);

View File

@@ -82,7 +82,6 @@ export function AppSidebar({
isCreatingChannel,
isCreatingForum,
profile,
projectsOverviewActive,
relayConnectionCard,
selfPresenceStatus,
errorMessage,
@@ -108,7 +107,6 @@ export function AppSidebar({
onRemoveCommunity,
onCreateAgent,
onSelectAgents,
onSelectProjects,
onSelectPulse,
onSelectWorkflows,
onSelectHome,
@@ -508,10 +506,8 @@ export function AppSidebar({
homeBadgeCount={homeBadgeCount}
onSelectAgents={onSelectAgents}
onSelectHome={onSelectHome}
onSelectProjects={onSelectProjects}
onSelectPulse={onSelectPulse}
onSelectWorkflows={onSelectWorkflows}
projectsOverviewActive={projectsOverviewActive}
selectedView={selectedView}
/>

View File

@@ -32,7 +32,6 @@ export type AppSidebarProps = {
isCreatingChannel: boolean;
isCreatingForum: boolean;
profile?: Profile;
projectsOverviewActive: boolean;
relayConnectionCard: ReturnType<typeof useSidebarRelayConnectionCard>;
selfPresenceStatus: PresenceStatus;
errorMessage?: string;
@@ -83,7 +82,6 @@ export type AppSidebarProps = {
onRemoveCommunity: (id: string) => Promise<LeaveCommunityResult | undefined>;
onCreateAgent: () => void;
onSelectAgents: () => void;
onSelectProjects: () => void;
onSelectPulse: () => void;
onSelectWorkflows: () => void;
onSelectHome: () => void;

View File

@@ -1,7 +1,6 @@
import { Activity, Bot, Folders, Inbox, Zap } from "lucide-react";
import { Activity, Bot, Inbox, Zap } from "lucide-react";
import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
import { SidebarProjectsSection } from "@/features/sidebar/ui/SidebarProjectsSection";
import { FeatureGate } from "@/shared/features";
import type { Channel, SearchHit } from "@/shared/api/types";
import {
@@ -42,10 +41,8 @@ type AppSidebarPrimaryMenuProps = {
homeBadgeCount: number;
onSelectAgents: () => void;
onSelectHome: () => void;
onSelectProjects: () => void;
onSelectPulse: () => void;
onSelectWorkflows: () => void;
projectsOverviewActive: boolean;
selectedView: SidebarSelectedView;
};
@@ -92,98 +89,79 @@ export function AppSidebarPrimaryMenu({
homeBadgeCount,
onSelectAgents,
onSelectHome,
onSelectProjects,
onSelectPulse,
onSelectWorkflows,
projectsOverviewActive,
selectedView,
}: AppSidebarPrimaryMenuProps) {
return (
<>
<SidebarHeader
className="relative z-40 cursor-default select-none px-2 pb-0 pt-0"
data-tauri-drag-region
data-testid="sidebar-primary-menu"
>
<SidebarMenu className="sidebar-primary-menu pb-2">
<SidebarHeader
className="relative z-40 cursor-default select-none px-2 pb-0 pt-0"
data-tauri-drag-region
data-testid="sidebar-primary-menu"
>
<SidebarMenu className="sidebar-primary-menu pb-2">
<SidebarMenuItem>
<SidebarMenuButton
className="data-[active=true]:font-normal"
isActive={selectedView === "home"}
onClick={onSelectHome}
tooltip="Inbox"
type="button"
>
<Inbox className="h-4 w-4" />
<SidebarMenuLabel>Inbox</SidebarMenuLabel>
</SidebarMenuButton>
{homeBadgeCount > 0 ? (
<SidebarMenuBadge
className="right-2 rounded-full bg-primary/15 px-1.5 text-2xs text-primary peer-data-[active=true]/menu-button:bg-sidebar-active-foreground/20 peer-data-[active=true]/menu-button:text-sidebar-active-foreground"
data-testid="sidebar-home-count"
>
{Math.min(homeBadgeCount, 99)}
</SidebarMenuBadge>
) : null}
</SidebarMenuItem>
<FeatureGate feature="pulse">
<SidebarMenuItem>
<SidebarMenuButton
className="data-[active=true]:font-normal"
isActive={selectedView === "home"}
onClick={onSelectHome}
tooltip="Inbox"
data-testid="open-pulse-view"
isActive={selectedView === "pulse"}
onClick={onSelectPulse}
tooltip="Pulse"
type="button"
>
<Inbox className="h-4 w-4" />
<SidebarMenuLabel>Inbox</SidebarMenuLabel>
<Activity className="h-4 w-4" />
<SidebarMenuLabel>Pulse</SidebarMenuLabel>
</SidebarMenuButton>
{homeBadgeCount > 0 ? (
<SidebarMenuBadge
className="right-2 rounded-full bg-primary/15 px-1.5 text-2xs text-primary peer-data-[active=true]/menu-button:bg-sidebar-active-foreground/20 peer-data-[active=true]/menu-button:text-sidebar-active-foreground"
data-testid="sidebar-home-count"
>
{Math.min(homeBadgeCount, 99)}
</SidebarMenuBadge>
) : null}
</SidebarMenuItem>
<FeatureGate feature="pulse">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-pulse-view"
isActive={selectedView === "pulse"}
onClick={onSelectPulse}
tooltip="Pulse"
type="button"
>
<Activity className="h-4 w-4" />
<SidebarMenuLabel>Pulse</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
</FeatureGate>
<FeatureGate feature="projects">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-projects-view"
isActive={selectedView === "projects" && projectsOverviewActive}
onClick={onSelectProjects}
tooltip="Projects"
type="button"
>
<Folders className="h-4 w-4" />
<SidebarMenuLabel>Projects</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
</FeatureGate>
</FeatureGate>
<SidebarMenuItem>
<SidebarMenuButton
className="data-[active=true]:font-normal"
data-testid="open-agents-view"
isActive={selectedView === "agents"}
onClick={onSelectAgents}
tooltip="Agents"
type="button"
>
<Bot className="h-4 w-4" />
<SidebarMenuLabel>Agents</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
<FeatureGate feature="workflows">
<SidebarMenuItem>
<SidebarMenuButton
className="data-[active=true]:font-normal"
data-testid="open-agents-view"
isActive={selectedView === "agents"}
onClick={onSelectAgents}
tooltip="Agents"
data-testid="open-workflows-view"
isActive={selectedView === "workflows"}
onClick={onSelectWorkflows}
tooltip="Workflows"
type="button"
>
<Bot className="h-4 w-4" />
<SidebarMenuLabel>Agents</SidebarMenuLabel>
<Zap className="h-4 w-4" />
<SidebarMenuLabel>Workflows</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
<FeatureGate feature="workflows">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-workflows-view"
isActive={selectedView === "workflows"}
onClick={onSelectWorkflows}
tooltip="Workflows"
type="button"
>
<Zap className="h-4 w-4" />
<SidebarMenuLabel>Workflows</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
</FeatureGate>
</SidebarMenu>
</SidebarHeader>
<SidebarProjectsSection />
</>
</FeatureGate>
</SidebarMenu>
</SidebarHeader>
);
}

View File

@@ -32,22 +32,22 @@ test("getOverrides drops unknown feature IDs without writing to storage", () =>
);
});
test("setOverride persists filtered overrides", () => {
test("setOverride persists stable IDs but unknown IDs stay filtered on read", () => {
const { values } = installStorage({
workflows: true,
removedFeature: false,
});
setOverride("projects", true);
setOverride("removedFeature", true);
assert.equal(
values.get(OVERRIDES_KEY),
JSON.stringify({ workflows: true, projects: true }),
JSON.stringify({ workflows: true, removedFeature: true }),
);
});
test("getOverrides drops non-boolean values", () => {
installStorage({ workflows: "yes", projects: false });
installStorage({ workflows: "yes", pulse: false });
assert.deepEqual(getOverrides(), { projects: false });
assert.deepEqual(getOverrides(), { pulse: false });
});

View File

@@ -0,0 +1,183 @@
import { expect, test, type Page } from "@playwright/test";
import { installMockBridge, openCreateChannelDialog } from "../helpers/bridge";
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const OWNER = "deadbeef".repeat(8);
async function openGeneralChannelSettings(page: Page) {
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-project-features")).toBeVisible();
}
async function createPocRootChannel(page: Page) {
const name = "channel-project-poc";
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(name);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText(name);
const channelId = page.url().match(/\/channels\/([^/?]+)/)?.[1];
if (!channelId)
throw new Error("Created channel route did not include an ID.");
return { channelId, name };
}
async function acceptedProjectEvents(page: Page) {
return page.evaluate(() => window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__ ?? []);
}
test("first channel feature quietly creates one backing project", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
const rootChannel = await createPocRootChannel(page);
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-project-features")).toBeVisible();
await page.getByTestId("channel-feature-tasks-switch").click();
await expect
.poll(async () => (await acceptedProjectEvents(page)).length)
.toBe(2);
const events = await acceptedProjectEvents(page);
expect(events.map((event) => event.kind).sort()).toEqual([30617, 30621]);
for (const event of events) {
expect(event.tags).toContainEqual(["buzz-channel", rootChannel.channelId]);
}
const repository = events.find((event) => event.kind === 30617);
const project = events.find((event) => event.kind === 30621);
const repositoryDtag = repository?.tags.find((tag) => tag[0] === "d")?.[1];
expect(project?.tags).toContainEqual([
"a",
`30617:${OWNER}:${repositoryDtag}`,
]);
await page.getByTestId("channel-feature-repositories-switch").click();
await page.getByTestId("channel-feature-breakouts-switch").click();
await expect
.poll(async () => (await acceptedProjectEvents(page)).length)
.toBe(2);
await page.getByTestId("auxiliary-panel-close").click();
await page.getByTestId(`channel-${rootChannel.name}`).click();
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-feature-tasks-switch")).toBeChecked();
await expect(
page.getByTestId("channel-feature-repositories-switch"),
).toBeChecked();
await expect(
page.getByTestId("channel-feature-breakouts-switch"),
).toBeChecked();
await page.getByTestId("auxiliary-panel-close").click();
await page.getByTestId("open-channel-tasks").click();
const tasksDialog = page.getByTestId("channel-tasks-dialog");
await expect(tasksDialog).toContainText("No tasks yet.");
await tasksDialog.getByTestId("create-channel-task").click();
await page.getByTestId("create-issue-title").fill("POC task");
await page.getByTestId("create-issue-submit").click();
await expect(
tasksDialog.getByText("POC task", { exact: true }),
).toBeVisible();
await tasksDialog.getByRole("button", { name: "Close" }).click();
await page.getByTestId("open-channel-breakouts").click();
const breakoutsDialog = page.getByTestId("channel-breakouts-dialog");
await expect(breakoutsDialog).toContainText("No breakout channels yet.");
await breakoutsDialog.getByTestId("create-breakout-channel").click();
await page.getByTestId("create-channel-name").fill("poc-breakout");
await page.getByTestId("create-channel-submit").click();
await expect(
breakoutsDialog.getByText("poc-breakout", { exact: true }),
).toBeVisible();
await breakoutsDialog.getByRole("button", { name: "Close" }).click();
await page.getByTestId("open-channel-repositories").click();
const repositoriesDialog = page.getByTestId("channel-repositories-dialog");
await expect(repositoriesDialog).toContainText(
"No related repositories yet.",
);
await expect(
repositoriesDialog.getByTestId("add-project-repository"),
).toBeVisible();
await page.setViewportSize({ height: 844, width: 390 });
await expect(repositoriesDialog).toBeVisible();
});
test("existing channel project data infers features without standalone Projects UI", async ({
page,
}) => {
const dtag = "general-root";
const repositoryAddress = `30617:${OWNER}:${dtag}`;
await page.addInitScript(
({ channelId, owner, repoAddress, projectDtag }) => {
const createdAt = Math.floor(Date.now() / 1_000) - 30;
window.__BUZZ_E2E_EXTRA_PROJECT_EVENTS__ = [
{
id: "a1".padEnd(64, "0"),
kind: 30617,
pubkey: owner,
created_at: createdAt,
content: "",
tags: [
["d", projectDtag],
["name", "general"],
["buzz-channel", channelId],
],
},
{
id: "a2".padEnd(64, "0"),
kind: 30621,
pubkey: owner,
created_at: createdAt,
content: "",
tags: [
["d", projectDtag],
["name", "general"],
["buzz-channel", channelId],
["a", repoAddress],
],
},
{
id: "a3".padEnd(64, "0"),
kind: 1621,
pubkey: owner,
created_at: createdAt,
content: "Seeded task",
tags: [
["a", repoAddress],
["subject", "Seeded task"],
["t", "issue"],
],
},
];
},
{
channelId: GENERAL_CHANNEL_ID,
owner: OWNER,
repoAddress: repositoryAddress,
projectDtag: dtag,
},
);
await installMockBridge(page);
await page.goto("/");
await openGeneralChannelSettings(page);
await expect(page.getByTestId("channel-feature-tasks-switch")).toBeChecked();
await expect(page.getByText("In use", { exact: true })).toBeVisible();
await page.getByTestId("channel-feature-repositories-switch").click();
await expect
.poll(async () => (await acceptedProjectEvents(page)).length)
.toBe(0);
await expect(page.getByTestId("open-projects-view")).toHaveCount(0);
await expect(page.getByTestId("sidebar-projects-section")).toHaveCount(0);
await page.getByTestId("auxiliary-panel-close").click();
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await page.getByTestId("settings-nav-experimental").click();
await expect(page.getByTestId("feature-toggle-projects")).toHaveCount(0);
});

View File

@@ -7,12 +7,6 @@
"description": "YAML-defined automations with approval gates",
"platforms": ["desktop"]
},
{
"id": "projects",
"name": "Projects",
"description": "Git repository browser and collaboration",
"platforms": ["desktop"]
},
{
"id": "pulse",
"name": "Pulse",