mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
feat(desktop): add channel project features POC
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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?.[
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
137
desktop/src/features/projects/channelProjectFeatures.test.mjs
Normal file
137
desktop/src/features/projects/channelProjectFeatures.test.mjs
Normal 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"]);
|
||||
});
|
||||
201
desktop/src/features/projects/channelProjectFeatures.ts
Normal file
201
desktop/src/features/projects/channelProjectFeatures.ts
Normal 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;
|
||||
}
|
||||
327
desktop/src/features/projects/ui/ChannelProjectFeatureBar.tsx
Normal file
327
desktop/src/features/projects/ui/ChannelProjectFeatureBar.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
206
desktop/src/features/projects/useChannelProjectFeatures.ts
Normal file
206
desktop/src/features/projects/useChannelProjectFeatures.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
183
desktop/tests/e2e/channel-project-features.spec.ts
Normal file
183
desktop/tests/e2e/channel-project-features.spec.ts
Normal 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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user