diff --git a/desktop/src/features/channels/lib/channelLifecycle.test.mjs b/desktop/src/features/channels/lib/channelLifecycle.test.mjs
new file mode 100644
index 00000000000..0477aa7b77f
--- /dev/null
+++ b/desktop/src/features/channels/lib/channelLifecycle.test.mjs
@@ -0,0 +1,35 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { channelLifecycle, channelLifecycleLabel } from "./channelLifecycle.ts";
+
+test("channelLifecycle prefers project home over TTL", () => {
+ assert.equal(
+ channelLifecycle({ projectHome: true, temporary: false }),
+ "project",
+ );
+ assert.equal(
+ channelLifecycle({ projectHome: true, temporary: true }),
+ "project",
+ );
+});
+
+test("channelLifecycle maps ongoing and temporary streams", () => {
+ assert.equal(
+ channelLifecycle({ projectHome: false, temporary: false }),
+ "ongoing",
+ );
+ assert.equal(
+ channelLifecycle({ projectHome: false, temporary: true }),
+ "temporary",
+ );
+});
+
+test("channelLifecycleLabel names project, ongoing, and temporary", () => {
+ assert.equal(channelLifecycleLabel("project", null), "Project");
+ assert.equal(channelLifecycleLabel("ongoing", null), "Ongoing");
+ assert.equal(
+ channelLifecycleLabel("temporary", 7 * 24 * 60 * 60),
+ "Temporary · 7d",
+ );
+});
diff --git a/desktop/src/features/channels/lib/channelLifecycle.ts b/desktop/src/features/channels/lib/channelLifecycle.ts
new file mode 100644
index 00000000000..09517675610
--- /dev/null
+++ b/desktop/src/features/channels/lib/channelLifecycle.ts
@@ -0,0 +1,23 @@
+import { formatTtlDuration } from "@/features/channels/lib/ephemeralChannel";
+
+export type ChannelLifecycle = "ongoing" | "temporary" | "project";
+
+export function channelLifecycle(input: {
+ projectHome: boolean;
+ temporary: boolean;
+}): ChannelLifecycle {
+ if (input.projectHome) return "project";
+ return input.temporary ? "temporary" : "ongoing";
+}
+
+export function channelLifecycleLabel(
+ lifecycle: ChannelLifecycle,
+ ttlSeconds: number | null,
+): string {
+ if (lifecycle === "project") return "Project";
+ if (lifecycle === "temporary" && ttlSeconds != null) {
+ return `Temporary · ${formatTtlDuration(ttlSeconds)}`;
+ }
+ if (lifecycle === "temporary") return "Temporary";
+ return "Ongoing";
+}
diff --git a/desktop/src/features/channels/ui/ChannelGlyph.tsx b/desktop/src/features/channels/ui/ChannelGlyph.tsx
new file mode 100644
index 00000000000..fae767426fd
--- /dev/null
+++ b/desktop/src/features/channels/ui/ChannelGlyph.tsx
@@ -0,0 +1,29 @@
+import { FileText, Hash, Lock } from "lucide-react";
+
+import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel";
+import { ProjectChannelIcon } from "@/features/projects/ui/ProjectChannelIcon";
+import type { Channel } from "@/shared/api/types";
+import { cn } from "@/shared/lib/cn";
+
+/** Stream/forum glyph for a channel, using the project mark on project homes. */
+export function ChannelGlyph({
+ channel,
+ className,
+}: {
+ channel: Pick;
+ className?: string;
+}) {
+ const projectHome = useIsProjectHomeChannel(channel.id);
+ const iconClass = cn("size-4 shrink-0", className);
+
+ if (projectHome) {
+ return ;
+ }
+ if (channel.visibility === "private") {
+ return ;
+ }
+ if (channel.channelType === "forum") {
+ return ;
+ }
+ return ;
+}
diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx
index aea0f9323ec..8a1aaf6a02e 100644
--- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx
+++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx
@@ -24,10 +24,7 @@ import {
import { compareMembersByRole } from "@/features/channels/lib/memberUtils";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelWorkflowsQuery } from "@/features/workflows/hooks";
-import {
- DEFAULT_EPHEMERAL_TTL_SECONDS,
- formatTtlDuration,
-} from "@/features/channels/lib/ephemeralChannel";
+import { DEFAULT_EPHEMERAL_TTL_SECONDS } from "@/features/channels/lib/ephemeralChannel";
import type { Channel, ChannelMember, Workflow } from "@/shared/api/types";
import { useWorkflowEditorOverlay } from "@/shared/context/WorkflowEditorOverlayContext";
import { useFeatureEnabled } from "@/shared/features";
@@ -65,7 +62,10 @@ import {
CHANNEL_FORM_FIELD_CONTROL_CLASS,
CHANNEL_FORM_FIELD_SHELL_CLASS,
} from "./channelFormStyles";
-import { ChannelTypeSettings } from "./ChannelTypeSettings";
+import {
+ ChannelTypeDetailRow,
+ ChannelTypeSettings,
+} from "./ChannelTypeSettings";
import { ChannelPermissionsSettings } from "./ChannelPermissionsSettings";
import {
ActionFieldRow,
@@ -555,6 +555,7 @@ export function ChannelManagementSheet({
data-testid="channel-management-lifecycle"
>
{
setIsEphemeralDraft(temporary);
@@ -778,16 +779,10 @@ function ChannelManagementPanelContent({
{resolvedChannel.channelType !== "dm" ? (
<>
-
void;
}) {
- const Icon = getChannelIcon(channel.channelType);
const channelDescription = channel.description.trim();
const description =
channelDescription || (onEdit ? "Add a description" : null);
@@ -42,7 +34,11 @@ export function ChannelHero({
data-testid="channel-management-hero"
>
-
+ {channel.channelType === "dm" ? (
+
+ ) : (
+
+ )}
{channel.channelType !== "dm" && onEdit ? (
@@ -71,15 +97,22 @@ export function ChannelTypePicker({
minWidth: "var(--radix-dropdown-menu-trigger-width)",
}}
>
-
-
+
+ {allowProject ? (
+
+ Project
+
+ ) : null}
+
Ongoing
Temporary
diff --git a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx
index 6883f4cad1a..82fd1c9f7a1 100644
--- a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx
+++ b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx
@@ -1,10 +1,16 @@
import { ChevronDown } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
+import {
+ channelLifecycle,
+ channelLifecycleLabel,
+} from "@/features/channels/lib/channelLifecycle";
import {
DEFAULT_EPHEMERAL_TTL_SECONDS,
formatTtlDuration,
} from "@/features/channels/lib/ephemeralChannel";
+import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel";
+import type { Channel } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
@@ -13,6 +19,7 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
+import { EditableInfoFieldRow } from "./ChannelManagementSheetRows";
import { ChannelTypePicker } from "./ChannelTypePicker";
const EPHEMERAL_TIMEOUT_OPTIONS = [
@@ -32,7 +39,34 @@ const CHANNEL_TYPE_RESIZE_TRANSITION = {
ease: [0.23, 1, 0.32, 1],
} as const;
+export function ChannelTypeDetailRow({
+ canEdit,
+ channel,
+ onEdit,
+}: {
+ canEdit: boolean;
+ channel: Channel;
+ onEdit?: () => void;
+}) {
+ const projectHome = useIsProjectHomeChannel(channel.id);
+ const lifecycle = channelLifecycle({
+ projectHome,
+ temporary: channel.ttlSeconds !== null,
+ });
+
+ return (
+
+ );
+}
+
export function ChannelTypeSettings({
+ channelId,
disabled,
label = "Channel type",
onOpenChange,
@@ -43,6 +77,7 @@ export function ChannelTypeSettings({
testIdPrefix,
ttlSeconds,
}: {
+ channelId?: string | null;
disabled?: boolean;
label?: string;
onOpenChange?: (open: boolean) => void;
@@ -53,6 +88,8 @@ export function ChannelTypeSettings({
testIdPrefix: string;
ttlSeconds: number;
}) {
+ const projectHome = useIsProjectHomeChannel(channelId);
+ const lifecycle = channelLifecycle({ projectHome, temporary });
const shouldReduceMotion = useReducedMotion();
const channelTypeResizeTransition = shouldReduceMotion
? { duration: 0 }
@@ -82,17 +119,18 @@ export function ChannelTypeSettings({
{label}
onTemporaryChange(next === "temporary")}
onOpenChange={onOpenChange}
- onTemporaryChange={onTemporaryChange}
open={open}
- temporary={temporary}
testId={`${testIdPrefix}-channel-type`}
/>
- {temporary ? (
+ {temporary && !projectHome ? (
void;
};
@@ -139,6 +141,7 @@ const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const;
export function FocusThreadDrawer({
channelName,
children,
+ label = "Thread",
onClose,
}: FocusThreadDrawerProps) {
const prefersReducedMotion = useReducedMotion();
@@ -220,7 +223,7 @@ export function FocusThreadDrawer({
// see the token for why a `border-l` cannot.
"absolute inset-y-0 right-0 flex flex-col overflow-hidden rounded-l-2xl bg-background shadow-panel-left",
)}
- aria-label="Thread"
+ aria-label={label}
data-testid="focus-thread-drawer"
ref={drawerRef}
role="complementary"
diff --git a/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx b/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx
new file mode 100644
index 00000000000..f254efb0989
--- /dev/null
+++ b/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx
@@ -0,0 +1,64 @@
+import type * as React from "react";
+
+import {
+ AuxiliaryPanel,
+ AuxiliaryPanelBody,
+ AuxiliaryPanelHeader,
+ AuxiliaryPanelHeaderGroup,
+ AuxiliaryPanelTitle,
+} from "@/shared/layout/AuxiliaryPanel";
+
+export function IdleAuxiliaryPanel({
+ canResetWidth,
+ children,
+ isFocusDrawer = false,
+ isSinglePanelView,
+ onClose,
+ onResetWidth,
+ onResizeStart,
+ title,
+ useSplitAuxiliaryPane,
+ widthPx,
+}: {
+ canResetWidth: boolean;
+ children: React.ReactNode;
+ isFocusDrawer?: boolean;
+ isSinglePanelView: boolean;
+ onClose: () => void;
+ onResetWidth: () => void;
+ onResizeStart: React.PointerEventHandler;
+ title: string;
+ useSplitAuxiliaryPane: boolean;
+ widthPx: number;
+}) {
+ const split = useSplitAuxiliaryPane && !isFocusDrawer;
+ return (
+
+
+ {title}
+
+
+ }
+ >
+
+ {children}
+
+
+ );
+}
diff --git a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx
index 43e4d80e6ba..d46de8eb7d7 100644
--- a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx
+++ b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx
@@ -6,6 +6,7 @@ import { cn } from "@/shared/lib/cn";
type RightAuxiliaryPaneProps = {
canResetWidth: boolean;
children: React.ReactNode;
+ className?: string;
constrainToAvailableSpace?: boolean;
detached?: boolean;
onResetWidth: () => void;
@@ -17,6 +18,7 @@ type RightAuxiliaryPaneProps = {
export function RightAuxiliaryPane({
canResetWidth,
children,
+ className,
constrainToAvailableSpace = true,
detached = false,
onResetWidth,
@@ -31,6 +33,7 @@ export function RightAuxiliaryPane({
detached
? "bg-transparent"
: "before:pointer-events-none before:absolute before:bottom-0 before:left-0 before:top-0 before:z-50 before:w-px before:bg-border/80 before:content-['']",
+ className,
)}
data-testid={testId}
style={{
diff --git a/desktop/src/features/channels/ui/useChannelIntro.tsx b/desktop/src/features/channels/ui/useChannelIntro.tsx
index 19f2da0edbc..910d836ff08 100644
--- a/desktop/src/features/channels/ui/useChannelIntro.tsx
+++ b/desktop/src/features/channels/ui/useChannelIntro.tsx
@@ -1,5 +1,5 @@
import * as React from "react";
-import { Bot, Plus, Sparkles, UserPlus } from "lucide-react";
+import { Bot, FolderPlus, Plus, Sparkles, UserPlus } from "lucide-react";
import {
getChannelIntroDescription,
@@ -29,6 +29,7 @@ type ChannelIntroAction = {
export function useChannelIntro({
activeChannel,
onAddAgent,
+ onAddFiles,
onBrowseChannels,
onCreateChannel,
onOpenMembers,
@@ -36,6 +37,7 @@ export function useChannelIntro({
}: {
activeChannel: Channel | null;
onAddAgent?: (options?: { beforeSend?: () => void }) => void;
+ onAddFiles?: () => void;
onBrowseChannels?: () => void;
onCreateChannel?: () => void;
onOpenMembers?: () => void;
@@ -89,6 +91,16 @@ export function useChannelIntro({
}
if (!activeChannel.archivedAt && activeChannel.isMember) {
+ if (onAddFiles) {
+ actions.push({
+ description: "Add a repo.",
+ icon: ,
+ label: "Add files",
+ onClick: onAddFiles,
+ testId: "channel-intro-action-add-files",
+ });
+ }
+
if (onAddAgent) {
actions.push({
description: "Bring them in.",
@@ -119,6 +131,7 @@ export function useChannelIntro({
}, [
activeChannel,
onAddAgent,
+ onAddFiles,
onBrowseChannels,
onCreateChannel,
onOpenMembers,
diff --git a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs
index a0374fbe2b0..ad09e4e6f40 100644
--- a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs
+++ b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs
@@ -423,7 +423,7 @@ test("timeline-body-surface: loading and deferred-pending both paint the single
test("timeline-body-surface: first authoritative rows wait for deferred paint", () => {
// A newly selected populated channel has already resolved live rows, but the
// deferred snapshot is still empty. It has never committed a settled empty
- // surface, so showing its intro here would flash Create agent / Add people.
+ // surface, so showing its intro here would flash Add agent / Add people.
assert.equal(
selectTimelineBodySurface({
deferredCount: 0,
diff --git a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx
index 76d1960b966..c69fd46a32d 100644
--- a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx
+++ b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx
@@ -63,7 +63,7 @@ export function ChannelIntroBlock({
) : null}
{intro.actions?.length ? (
-
+
{intro.actions.map((action) => {
const hasDescription = Boolean(action.description);
@@ -72,8 +72,8 @@ export function ChannelIntroBlock({
className={cn(
"flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
hasDescription
- ? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
- : "h-28 w-64 flex-col rounded-2xl p-4",
+ ? "h-52 w-48 flex-col rounded-2xl p-3"
+ : "h-24 w-56 flex-col rounded-2xl p-3",
)}
data-testid={action.testId}
key={action.label}
@@ -84,8 +84,8 @@ export function ChannelIntroBlock({
className={cn(
"flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground",
hasDescription
- ? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
- : "h-10 w-10 [&_svg]:h-4 [&_svg]:w-4",
+ ? "h-10 w-10 [&_svg]:h-5 [&_svg]:w-5"
+ : "h-9 w-9 [&_svg]:h-4 [&_svg]:w-4",
)}
data-testid={
action.testId ? `${action.testId}-icon` : undefined
@@ -95,7 +95,7 @@ export function ChannelIntroBlock({
;
+ projectIds: Set;
+};
+
+function formatAgentFailures(
+ failures: ReadonlyArray<{ name: string; error: string }>,
+) {
+ if (failures.length === 1) {
+ const [failure] = failures;
+ return `The project was created, but adding ${failure.name} failed: ${failure.error}`;
+ }
+ return `The project was created, but adding agents failed: ${failures
+ .map((failure) => `${failure.name}: ${failure.error}`)
+ .join("; ")}`;
+}
+
+async function publishProjectEvent(event: RelayEvent) {
+ try {
+ await relayClient.publishEvent(
+ event,
+ "Timed out creating project.",
+ "Failed to create project.",
+ );
+ } catch (error) {
+ if (isUnsupportedProjectKindError(error)) {
+ throw new Error(
+ "This relay does not support projects yet, so a project channel cannot be published here.",
+ );
+ }
+ throw error;
+ }
+}
+
+async function publishRepositoryEvent(event: RelayEvent) {
+ await relayClient.publishEvent(
+ event,
+ "Timed out creating the project repository.",
+ "Failed to create the project repository.",
+ );
+}
+
+function readCreatedProject(
+ projectEvent: RelayEvent,
+ repositoryEvent: RelayEvent | null,
+): Project {
+ const [project] = buildProjectReadModels({
+ projectEvents: [projectEvent],
+ repositoryEvents: repositoryEvent ? [repositoryEvent] : [],
+ relayOrigin: getCachedRelayOrigin(),
+ });
+ if (!project) {
+ throw new Error("The project was created but could not be read.");
+ }
+ return project;
+}
+
+async function addRequestedAgents(
+ channelId: string,
+ agents: readonly CreateChannelManagedAgentInput[] | undefined,
+) {
+ if (!agents || agents.length === 0) return;
+ const result = await createChannelManagedAgents(channelId, agents);
+ if (result.failures.length > 0) {
+ throw new Error(formatAgentFailures(result.failures));
+ }
+}
+
+async function fetchOwnHead(
+ kind: number,
+ ownerPubkey: string,
+ dtag: string,
+): Promise {
+ const events = await relayClient.fetchEvents({
+ kinds: [kind],
+ authors: [ownerPubkey],
+ "#d": [dtag],
+ limit: 1,
+ });
+ return events[0] ?? null;
+}
+
+async function ensureDefaultRepository({
+ channelId,
+ input,
+ ownerPubkey,
+ project,
+}: {
+ channelId: string;
+ input: CreateProjectInput;
+ ownerPubkey: string;
+ project: Project;
+}): Promise {
+ const repositoryTemplate = buildDefaultProjectRepositoryTemplate({
+ description: input.description,
+ name: input.name,
+ ownerPubkey,
+ projectChannelId: channelId,
+ });
+ const existingRepository =
+ project.repositories.find(
+ (repository) =>
+ repository.repoAddress === repositoryTemplate.repositoryAddress,
+ ) ?? null;
+ if (existingRepository) return project;
+
+ let repositoryEvent = await fetchOwnHead(
+ KIND_REPO_ANNOUNCEMENT,
+ ownerPubkey,
+ repositoryTemplate.dtag,
+ );
+ if (!repositoryEvent) {
+ repositoryEvent = await signRelayEvent(repositoryTemplate.repository);
+ await publishRepositoryEvent(repositoryEvent);
+ }
+
+ if (
+ project.repositoryAddresses.includes(repositoryTemplate.repositoryAddress)
+ ) {
+ const liveProject =
+ (await fetchOwnHead(
+ KIND_PROJECT_ANNOUNCEMENT,
+ ownerPubkey,
+ project.dtag,
+ )) ?? null;
+ if (!liveProject) {
+ throw new Error("The project was created but could not be read.");
+ }
+ return readCreatedProject(liveProject, repositoryEvent);
+ }
+
+ const liveHead = await fetchOwnHead(
+ KIND_PROJECT_ANNOUNCEMENT,
+ ownerPubkey,
+ project.dtag,
+ );
+ if (!liveHead) {
+ throw new Error(
+ "Could not find this project on the relay. Refresh and try again.",
+ );
+ }
+ const patched = buildProjectPatchTemplate({
+ liveHead,
+ ownerPubkey,
+ repositoryAddresses: [
+ ...new Set([
+ ...project.repositoryAddresses,
+ repositoryTemplate.repositoryAddress,
+ ]),
+ ].sort(),
+ });
+ const projectEvent = await signRelayEvent(patched);
+ await publishProjectEvent(projectEvent);
+ return readCreatedProject(projectEvent, repositoryEvent);
+}
+
+async function finishCreate(
+ channel: Channel | null,
+ project: Project,
+ input: CreateProjectInput,
+ resume: CreateProjectResumeState,
+ projectId: string,
+): Promise {
+ const agentChannelId = channel?.id ?? project.projectChannelId;
+ if (agentChannelId && input.agents && input.agents.length > 0) {
+ await addRequestedAgents(agentChannelId, input.agents);
+ }
+ resume.projectIds.delete(projectId);
+ resume.channels.delete(projectId);
+ return { channel, project };
+}
+
+/** Creates the home channel, a bound default repository, and the NIP-MP project. */
+export async function createProject(
+ input: CreateProjectInput,
+ resume: CreateProjectResumeState,
+): Promise {
+ const identity = await getIdentity();
+ const dtagPreview = projectDtagFromName(input.name);
+ if (!dtagPreview) {
+ throw new Error("Project name must include letters or numbers.");
+ }
+ const existing = await fetchProjects();
+ const ownerPubkey = identity.pubkey.toLowerCase();
+ const existingProject = existing.find(
+ (project) =>
+ project.owner.toLowerCase() === ownerPubkey &&
+ project.dtag === dtagPreview,
+ );
+ const projectId = `${ownerPubkey}:${dtagPreview}`;
+ const canResume = resume.projectIds.has(projectId);
+ if (existingProject && !canResume) {
+ throw new Error(`You already have a project named "${dtagPreview}".`);
+ }
+ if (existingProject && !existingProject.legacy) {
+ const cachedChannel = resume.channels.get(projectId) ?? null;
+ const channelId =
+ cachedChannel?.id ?? existingProject.projectChannelId ?? "";
+ const project = channelId
+ ? await ensureDefaultRepository({
+ channelId,
+ input,
+ ownerPubkey,
+ project: existingProject,
+ })
+ : existingProject;
+ return finishCreate(cachedChannel, project, input, resume, projectId);
+ }
+ const conflict = conflictingListedProject(existing, {
+ dtag: dtagPreview,
+ name: input.name,
+ ownerPubkey,
+ });
+ if (conflict) {
+ throw new Error(
+ `A project named "${conflict.name}" already exists. Open that one instead of creating another.`,
+ );
+ }
+
+ resume.projectIds.add(projectId);
+ let channel = resume.channels.get(projectId);
+ if (!channel) {
+ channel = await createChannel({
+ channelType: "stream",
+ description: input.description,
+ name: input.name.trim(),
+ visibility: input.channelVisibility ?? "open",
+ });
+ resume.channels.set(projectId, channel);
+ }
+
+ const templates = buildProjectBootstrapTemplates({
+ description: input.description,
+ name: input.name,
+ ownerPubkey: identity.pubkey,
+ projectChannelId: channel.id,
+ projectVisibility: input.projectVisibility ?? "listed",
+ });
+ const existingRepositoryEvent = await fetchOwnHead(
+ KIND_REPO_ANNOUNCEMENT,
+ ownerPubkey,
+ templates.dtag,
+ );
+ const projectEvent = await signRelayEvent(templates.project);
+ await publishProjectEvent(projectEvent);
+
+ let repositoryEvent = existingRepositoryEvent;
+ if (!repositoryEvent) {
+ repositoryEvent = await signRelayEvent(templates.repository);
+ await publishRepositoryEvent(repositoryEvent);
+ }
+
+ const project = readCreatedProject(projectEvent, repositoryEvent);
+ return finishCreate(channel, project, input, resume, projectId);
+}
diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts
index ebfc15a083e..4e26287f65e 100644
--- a/desktop/src/features/projects/hooks.ts
+++ b/desktop/src/features/projects/hooks.ts
@@ -172,9 +172,11 @@ export async function fetchProjects(
signal?: AbortSignal,
): Promise {
// Delegates to `buildProjectsFromFetcher` in `projectEnumeration.ts`, which
- // is the pure, Tauri-free core of this operation. That helper's javadoc
- // explains the fail-closed tombstone contract and the NIP-OA owner-deletion
- // relay-side-suppression decision.
+ // is the pure, Tauri-free core of this operation. Its javadoc explains
+ // fail-closed tombstones and NIP-OA owner-deletion suppression.
+ const viewerPubkey = await getIdentity()
+ .then((identity) => identity.pubkey)
+ .catch(() => undefined);
const fetcher: FetchProjectEventsExhaustively =
fetchExhaustively ??
((kinds, extraFilter) =>
@@ -182,6 +184,7 @@ export async function fetchProjects(
return buildProjectsFromFetcher(fetcher, {
relayOrigin: getCachedRelayOrigin(),
hiddenAddresses: new Set(readHiddenProjectCards()),
+ viewerPubkey,
});
}
diff --git a/desktop/src/features/projects/lib/projectCollection.test.mjs b/desktop/src/features/projects/lib/projectCollection.test.mjs
new file mode 100644
index 00000000000..d504234e131
--- /dev/null
+++ b/desktop/src/features/projects/lib/projectCollection.test.mjs
@@ -0,0 +1,135 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ absorbStandaloneProjectRepositories,
+ homeRepositoriesToBind,
+} from "./projectCollection.ts";
+
+const OWNER = "a".repeat(64);
+const AGENT = "b".repeat(64);
+const CHANNEL = "11111111-1111-4111-8111-111111111111";
+
+function explicitProject(overrides = {}) {
+ return {
+ id: `30621:${OWNER}:space-invaders-3d`,
+ dtag: "space-invaders-3d",
+ name: "Space Invaders 3D",
+ description: "Recreating Space Invaders the Game but in 3D",
+ owner: OWNER,
+ createdAt: 100,
+ projectChannelId: CHANNEL,
+ relatedChannelIds: [],
+ status: "active",
+ projectAddress: `30621:${OWNER}:space-invaders-3d`,
+ primaryRepositoryAddress: null,
+ repositoryAddresses: [],
+ repositoryRelayHints: {},
+ repositories: [],
+ unavailableRepositoryAddresses: [],
+ visibility: "listed",
+ legacy: false,
+ ...overrides,
+ };
+}
+
+function standaloneRepo(overrides = {}) {
+ const owner = overrides.owner ?? AGENT;
+ const dtag = overrides.dtag ?? "space-invaders-3d";
+ const repoAddress = `30617:${owner}:${dtag}`;
+ const repository = {
+ id: `${owner}:${dtag}`,
+ dtag,
+ name: "Space Invaders 3D",
+ description: "A 3D remake of Space Invaders built with three.js",
+ cloneUrls: [],
+ webUrl: null,
+ owner,
+ contributors: [owner],
+ createdAt: 200,
+ status: "active",
+ defaultBranch: "main",
+ repoAddress,
+ channelId: CHANNEL,
+ ...overrides.repository,
+ };
+ return {
+ id: repoAddress,
+ dtag,
+ name: repository.name,
+ description: repository.description,
+ owner,
+ createdAt: 200,
+ projectChannelId: null,
+ relatedChannelIds: [],
+ status: "active",
+ projectAddress: repoAddress,
+ primaryRepositoryAddress: repoAddress,
+ repositoryAddresses: [repoAddress],
+ repositoryRelayHints: {},
+ repositories: [repository],
+ unavailableRepositoryAddresses: [],
+ visibility: "listed",
+ legacy: true,
+ ...overrides.card,
+ };
+}
+
+test("absorbStandaloneProjectRepositories folds a home-channel repo into the project", () => {
+ const project = explicitProject();
+ const repoCard = standaloneRepo();
+ const folded = absorbStandaloneProjectRepositories([project, repoCard]);
+
+ assert.equal(folded.length, 1);
+ assert.equal(folded[0].legacy, false);
+ assert.equal(folded[0].repositories.length, 1);
+ assert.equal(folded[0].repositories[0].repoAddress, repoCard.projectAddress);
+ assert.equal(folded[0].repositoryAddresses[0], repoCard.projectAddress);
+});
+
+test("absorbStandaloneProjectRepositories folds the owner's same-slug repo", () => {
+ const project = explicitProject();
+ const repoCard = standaloneRepo({
+ owner: OWNER,
+ repository: { channelId: null },
+ });
+ const folded = absorbStandaloneProjectRepositories([project, repoCard]);
+
+ assert.equal(folded.length, 1);
+ assert.equal(folded[0].repositories[0].owner, OWNER);
+});
+
+test("absorbStandaloneProjectRepositories keeps an unrelated standalone repo", () => {
+ const project = explicitProject();
+ const repoCard = standaloneRepo({
+ dtag: "other-game",
+ owner: AGENT,
+ repository: { channelId: null, dtag: "other-game" },
+ });
+ const folded = absorbStandaloneProjectRepositories([project, repoCard]);
+
+ assert.equal(folded.length, 2);
+ assert.equal(
+ folded.some((item) => item.legacy),
+ true,
+ );
+});
+
+test("homeRepositoriesToBind lists absorbed channel repos missing from the signed project", () => {
+ const project = explicitProject({
+ repositories: [standaloneRepo().repositories[0]],
+ repositoryAddresses: [standaloneRepo().projectAddress],
+ });
+ const pending = homeRepositoriesToBind(project, []);
+ assert.equal(pending.length, 1);
+ assert.equal(pending[0].repoAddress, standaloneRepo().projectAddress);
+});
+
+test("homeRepositoriesToBind ignores repos already on the signed project", () => {
+ const repo = standaloneRepo().repositories[0];
+ const project = explicitProject({
+ repositories: [repo],
+ repositoryAddresses: [repo.repoAddress],
+ });
+ assert.equal(homeRepositoriesToBind(project, [repo.repoAddress]).length, 0);
+});
diff --git a/desktop/src/features/projects/lib/projectCollection.ts b/desktop/src/features/projects/lib/projectCollection.ts
new file mode 100644
index 00000000000..73924c06f4a
--- /dev/null
+++ b/desktop/src/features/projects/lib/projectCollection.ts
@@ -0,0 +1,103 @@
+import type { Project, Repository } from "@/features/projects/projectModels";
+
+function withAbsorbedRepository(
+ project: Project,
+ repository: Repository,
+): Project {
+ if (project.repositoryAddresses.includes(repository.repoAddress)) {
+ return project;
+ }
+ return {
+ ...project,
+ primaryRepositoryAddress:
+ project.primaryRepositoryAddress ?? repository.repoAddress,
+ repositories: [...project.repositories, repository],
+ repositoryAddresses: [
+ ...project.repositoryAddresses,
+ repository.repoAddress,
+ ],
+ };
+}
+
+function hostForStandaloneRepository(
+ explicitProjects: Project[],
+ repository: Repository,
+): Project | undefined {
+ const channelHost = repository.channelId
+ ? explicitProjects.find(
+ (project) => project.projectChannelId === repository.channelId,
+ )
+ : undefined;
+ if (channelHost) return channelHost;
+ return explicitProjects.find(
+ (project) =>
+ project.owner === repository.owner && project.dtag === repository.dtag,
+ );
+}
+
+function repositoryBelongsOnProjectHome(
+ project: Project,
+ repository: Repository,
+): boolean {
+ return Boolean(
+ (repository.channelId &&
+ repository.channelId === project.projectChannelId) ||
+ (repository.owner === project.owner && repository.dtag === project.dtag),
+ );
+}
+
+/**
+ * Repositories already shown on the project (after absorb) that are not yet
+ * on the signed `kind:30621` `a` tag set. The owner should bind them so
+ * other clients see the same grouping.
+ */
+export function homeRepositoriesToBind(
+ project: Project,
+ signedAddresses: ReadonlyArray | ReadonlySet,
+): Repository[] {
+ const signed = new Set(signedAddresses);
+ return project.repositories.filter(
+ (repository) =>
+ !signed.has(repository.repoAddress) &&
+ repositoryBelongsOnProjectHome(project, repository),
+ );
+}
+
+/**
+ * After the NIP-MP fold, keep a repository off the standalone-project list
+ * when it already belongs to a listing-eligible project's home channel, or
+ * when the same owner already has an explicit project with that slug.
+ *
+ * Agents often announce a repo (`repos create --channel`) without
+ * `projects add-repo`. Without this, the same work shows up as a second card.
+ */
+export function absorbStandaloneProjectRepositories(
+ projects: Project[],
+): Project[] {
+ const explicitProjects = projects.filter((project) => !project.legacy);
+ if (explicitProjects.length === 0) return projects;
+
+ const absorbed = new Set();
+ let nextExplicit = explicitProjects;
+ for (const card of projects) {
+ if (!card.legacy) continue;
+ const repository = card.repositories[0];
+ if (!repository) continue;
+ const host = hostForStandaloneRepository(nextExplicit, repository);
+ if (!host) continue;
+ absorbed.add(card.projectAddress);
+ nextExplicit = nextExplicit.map((project) =>
+ project.projectAddress === host.projectAddress
+ ? withAbsorbedRepository(project, repository)
+ : project,
+ );
+ }
+
+ if (absorbed.size === 0) return projects;
+ return [
+ ...nextExplicit,
+ ...projects.filter(
+ (project) => project.legacy && !absorbed.has(project.projectAddress),
+ ),
+ ];
+}
diff --git a/desktop/src/features/projects/lib/projectHomeChannel.test.mjs b/desktop/src/features/projects/lib/projectHomeChannel.test.mjs
new file mode 100644
index 00000000000..5c7eeb7335c
--- /dev/null
+++ b/desktop/src/features/projects/lib/projectHomeChannel.test.mjs
@@ -0,0 +1,70 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { isProjectHomeChannel } from "./projectHomeChannel.ts";
+
+const OWNER = "a".repeat(64);
+const MAINTAINER = "b".repeat(64);
+
+function project(overrides = {}) {
+ return {
+ owner: OWNER,
+ projectChannelId: "channel-a",
+ repositories: [
+ {
+ channelId: "channel-a",
+ owner: OWNER,
+ },
+ ],
+ ...overrides,
+ };
+}
+
+test("isProjectHomeChannel accepts an owner-bound repository", () => {
+ assert.equal(isProjectHomeChannel("channel-a", [project()]), true);
+});
+
+test("isProjectHomeChannel accepts a repository that authorizes the project owner", () => {
+ assert.equal(
+ isProjectHomeChannel("channel-a", [
+ project({
+ owner: MAINTAINER.toUpperCase(),
+ repositories: [
+ {
+ channelId: "channel-a",
+ maintainers: [OWNER, MAINTAINER],
+ owner: OWNER,
+ },
+ ],
+ }),
+ ]),
+ true,
+ );
+});
+
+test("isProjectHomeChannel rejects a bare project channel assertion", () => {
+ assert.equal(
+ isProjectHomeChannel("channel-a", [project({ repositories: [] })]),
+ false,
+ );
+});
+
+test("isProjectHomeChannel rejects unauthorized and mismatched repository bindings", () => {
+ assert.equal(
+ isProjectHomeChannel("channel-a", [
+ project({
+ owner: MAINTAINER,
+ repositories: [{ channelId: "channel-a", owner: OWNER }],
+ }),
+ project({
+ repositories: [{ channelId: "channel-b", owner: OWNER }],
+ }),
+ ]),
+ false,
+ );
+});
+
+test("isProjectHomeChannel is false for unbound channels", () => {
+ assert.equal(isProjectHomeChannel("channel-z", [project()]), false);
+ assert.equal(isProjectHomeChannel(null, [project()]), false);
+});
diff --git a/desktop/src/features/projects/lib/projectHomeChannel.ts b/desktop/src/features/projects/lib/projectHomeChannel.ts
new file mode 100644
index 00000000000..fa408467fe4
--- /dev/null
+++ b/desktop/src/features/projects/lib/projectHomeChannel.ts
@@ -0,0 +1,42 @@
+import { useProjectsQuery } from "@/features/projects/hooks";
+
+export type ProjectHomeCandidate = {
+ owner: string;
+ projectChannelId: string | null;
+ repositories: ReadonlyArray<{
+ channelId?: string | null;
+ maintainers?: ReadonlyArray;
+ owner: string;
+ }>;
+};
+
+function hasAuthoritativeHomeBinding(project: ProjectHomeCandidate): boolean {
+ const channelId = project.projectChannelId;
+ if (!channelId) return false;
+
+ const projectOwner = project.owner.toLowerCase();
+ return project.repositories.some((repository) => {
+ if (repository.channelId !== channelId) return false;
+ if (repository.owner.toLowerCase() === projectOwner) return true;
+ return repository.maintainers?.some(
+ (maintainer) => maintainer.toLowerCase() === projectOwner,
+ );
+ });
+}
+
+export function isProjectHomeChannel(
+ channelId: string | null | undefined,
+ projects: ReadonlyArray,
+): boolean {
+ if (!channelId) return false;
+ return projects.some(
+ (project) =>
+ project.projectChannelId === channelId &&
+ hasAuthoritativeHomeBinding(project),
+ );
+}
+
+export function useIsProjectHomeChannel(channelId: string | null | undefined) {
+ const projectsQuery = useProjectsQuery();
+ return isProjectHomeChannel(channelId, projectsQuery.data ?? []);
+}
diff --git a/desktop/src/features/projects/projectChannelCreation.test.mjs b/desktop/src/features/projects/projectChannelCreation.test.mjs
new file mode 100644
index 00000000000..3782a919051
--- /dev/null
+++ b/desktop/src/features/projects/projectChannelCreation.test.mjs
@@ -0,0 +1,105 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { buildProjectRelatedChannelPatchTemplate } from "./projectChannelCreation.ts";
+import {
+ MAX_PROJECT_RELATED_CHANNELS,
+ PROJECT_RELATED_CHANNEL_TAG,
+} from "./projectModels.ts";
+
+const OWNER = "a".repeat(64);
+const OTHER = "b".repeat(64);
+const CHANNEL_A = "11111111-1111-4111-8111-111111111111";
+const CHANNEL_B = "22222222-2222-4222-8222-222222222222";
+
+function liveHead(tags = []) {
+ return {
+ content: "",
+ created_at: 100,
+ id: "project-head",
+ kind: 30621,
+ pubkey: OWNER,
+ sig: "sig",
+ tags: [
+ ["d", "sprout"],
+ ["name", "Sprout"],
+ ["buzz-channel", CHANNEL_A],
+ ...tags,
+ ],
+ };
+}
+
+test("appends a related channel tag and preserves the live head", () => {
+ const patched = buildProjectRelatedChannelPatchTemplate({
+ channelId: CHANNEL_B,
+ liveHead: liveHead([["description", "A project"]]),
+ ownerPubkey: OWNER,
+ });
+
+ assert.equal(patched.alreadyBound, false);
+ assert.deepEqual(patched.project.tags, [
+ ["d", "sprout"],
+ ["name", "Sprout"],
+ ["buzz-channel", CHANNEL_A],
+ ["description", "A project"],
+ [PROJECT_RELATED_CHANNEL_TAG, CHANNEL_B],
+ ]);
+});
+
+test("is idempotent when the related channel is already tagged", () => {
+ const patched = buildProjectRelatedChannelPatchTemplate({
+ channelId: CHANNEL_B,
+ liveHead: liveHead([[PROJECT_RELATED_CHANNEL_TAG, CHANNEL_B]]),
+ ownerPubkey: OWNER,
+ });
+
+ assert.equal(patched.alreadyBound, true);
+ assert.equal(
+ patched.project.tags.filter((tag) => tag[0] === PROJECT_RELATED_CHANNEL_TAG)
+ .length,
+ 1,
+ );
+});
+
+test("refuses to bind the home channel as a related channel", () => {
+ assert.throws(
+ () =>
+ buildProjectRelatedChannelPatchTemplate({
+ channelId: CHANNEL_A,
+ liveHead: liveHead(),
+ ownerPubkey: OWNER,
+ }),
+ /already this project's home/,
+ );
+});
+
+test("only the project owner can add related channels", () => {
+ assert.throws(
+ () =>
+ buildProjectRelatedChannelPatchTemplate({
+ channelId: CHANNEL_B,
+ liveHead: liveHead(),
+ ownerPubkey: OTHER,
+ }),
+ /Only the project owner/,
+ );
+});
+
+test("caps extra related channels", () => {
+ const tags = Array.from(
+ { length: MAX_PROJECT_RELATED_CHANNELS },
+ (_, index) => {
+ const suffix = String(index + 1).padStart(12, "0");
+ return [PROJECT_RELATED_CHANNEL_TAG, `33333333-3333-4333-8333-${suffix}`];
+ },
+ );
+ assert.throws(
+ () =>
+ buildProjectRelatedChannelPatchTemplate({
+ channelId: CHANNEL_B,
+ liveHead: liveHead(tags),
+ ownerPubkey: OWNER,
+ }),
+ /extra channels/,
+ );
+});
diff --git a/desktop/src/features/projects/projectChannelCreation.ts b/desktop/src/features/projects/projectChannelCreation.ts
new file mode 100644
index 00000000000..a75425c23be
--- /dev/null
+++ b/desktop/src/features/projects/projectChannelCreation.ts
@@ -0,0 +1,71 @@
+import type { RelayEvent } from "@/shared/api/types";
+import { KIND_PROJECT_ANNOUNCEMENT } from "@/shared/constants/kinds";
+import {
+ isValidProjectChannelId,
+ MAX_PROJECT_RELATED_CHANNELS,
+ PROJECT_RELATED_CHANNEL_TAG,
+ validateProjectEventEnvelope,
+} from "@/features/projects/projectModels";
+import type { ProjectEventTemplate } from "./projectCreation";
+
+/**
+ * Appends a `buzz-related-channel` tag to a live project head. Every other
+ * tag is preserved so adding a stream cannot erase unknown metadata.
+ */
+export function buildProjectRelatedChannelPatchTemplate({
+ channelId,
+ liveHead,
+ ownerPubkey,
+}: {
+ channelId: string;
+ liveHead: RelayEvent;
+ ownerPubkey: string;
+}): { alreadyBound: boolean; project: ProjectEventTemplate } {
+ const normalizedOwner = ownerPubkey.trim().toLowerCase();
+ if (normalizedOwner !== liveHead.pubkey.toLowerCase()) {
+ throw new Error("Only the project owner can add channels.");
+ }
+ const normalizedChannelId = channelId.trim();
+ if (!isValidProjectChannelId(normalizedChannelId)) {
+ throw new Error("Project channel is invalid.");
+ }
+ const homeChannelId = liveHead.tags.find(
+ (tag) => tag[0] === "buzz-channel",
+ )?.[1];
+ if (homeChannelId === normalizedChannelId) {
+ throw new Error("That channel is already this project's home.");
+ }
+ const existingRelated = liveHead.tags
+ .filter((tag) => tag[0] === PROJECT_RELATED_CHANNEL_TAG)
+ .map((tag) => tag[1])
+ .filter((value): value is string => Boolean(value));
+ if (existingRelated.includes(normalizedChannelId)) {
+ validateProjectEventEnvelope(liveHead.tags, liveHead.content);
+ return {
+ alreadyBound: true,
+ project: {
+ kind: KIND_PROJECT_ANNOUNCEMENT,
+ content: liveHead.content,
+ tags: liveHead.tags.map((tag) => [...tag]),
+ },
+ };
+ }
+ if (existingRelated.length >= MAX_PROJECT_RELATED_CHANNELS) {
+ throw new Error(
+ `A project cannot contain more than ${MAX_PROJECT_RELATED_CHANNELS} extra channels.`,
+ );
+ }
+ const tags = [
+ ...liveHead.tags.map((tag) => [...tag]),
+ [PROJECT_RELATED_CHANNEL_TAG, normalizedChannelId],
+ ];
+ validateProjectEventEnvelope(tags, liveHead.content);
+ return {
+ alreadyBound: false,
+ project: {
+ kind: KIND_PROJECT_ANNOUNCEMENT,
+ content: liveHead.content,
+ tags,
+ },
+ };
+}
diff --git a/desktop/src/features/projects/projectCreation.test.mjs b/desktop/src/features/projects/projectCreation.test.mjs
index ed6e9328cd2..9a3cf0212ba 100644
--- a/desktop/src/features/projects/projectCreation.test.mjs
+++ b/desktop/src/features/projects/projectCreation.test.mjs
@@ -2,77 +2,198 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
- buildInitialProjectEventTemplates,
+ buildDefaultProjectRepositoryTemplate,
+ buildProjectAnnouncementTemplate,
+ buildProjectBootstrapTemplates,
+ conflictingListedProject,
isUnsupportedProjectKindError,
} from "./projectCreation.ts";
const OWNER = "a".repeat(64);
const CHANNEL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
-test("buildInitialProjectEventTemplates emits a NIP-MP project", () => {
- const templates = buildInitialProjectEventTemplates({
- accessChannelId: CHANNEL,
- cloneUrl: "https://relay.example/git/owner/sprout.git",
- description: "A multi-repository workspace",
+test("buildProjectAnnouncementTemplate emits a channel-first NIP-MP project", () => {
+ const templates = buildProjectAnnouncementTemplate({
+ description: "A workspace that starts as a conversation",
name: "Sprout",
ownerPubkey: OWNER,
- webUrl: "https://example.com/sprout",
+ projectChannelId: CHANNEL,
});
assert.equal(templates.dtag, "sprout");
assert.equal(templates.project.kind, 30621);
- assert.equal(templates.repository.kind, 30617);
assert.deepEqual(templates.project.tags, [
["d", "sprout"],
["name", "Sprout"],
["buzz-channel", CHANNEL],
- ["description", "A multi-repository workspace"],
- ["a", `30617:${OWNER}:sprout`],
+ ["description", "A workspace that starts as a conversation"],
]);
assert.equal(templates.project.content, "");
+ assert.equal(
+ templates.project.tags.some((tag) => tag[0] === "a"),
+ false,
+ );
+});
+
+test("buildProjectAnnouncementTemplate records unlisted visibility and members", () => {
+ const address = `30617:${OWNER}:sprout`;
+ const templates = buildProjectAnnouncementTemplate({
+ name: "Sprout",
+ ownerPubkey: OWNER,
+ projectChannelId: CHANNEL,
+ projectVisibility: "unlisted",
+ repositoryAddresses: [address],
+ });
+
+ assert.deepEqual(templates.project.tags, [
+ ["d", "sprout"],
+ ["name", "Sprout"],
+ ["buzz-channel", CHANNEL],
+ ["buzz-visibility", "unlisted"],
+ ["a", address],
+ ]);
+});
+
+test("buildProjectBootstrapTemplates binds a default repository to the home channel", () => {
+ const templates = buildProjectBootstrapTemplates({
+ description: "A workspace that starts as a conversation",
+ name: "Sprout",
+ ownerPubkey: OWNER,
+ projectChannelId: CHANNEL,
+ });
+ const repositoryAddress = `30617:${OWNER}:sprout`;
+
+ assert.equal(templates.dtag, "sprout");
+ assert.equal(templates.repositoryAddress, repositoryAddress);
+ assert.equal(templates.project.kind, 30621);
+ assert.equal(templates.repository.kind, 30617);
+ assert.deepEqual(templates.project.tags, [
+ ["d", "sprout"],
+ ["name", "Sprout"],
+ ["buzz-channel", CHANNEL],
+ ["description", "A workspace that starts as a conversation"],
+ ["a", repositoryAddress],
+ ]);
assert.deepEqual(templates.repository.tags, [
["d", "sprout"],
["name", "Sprout"],
["buzz-channel", CHANNEL],
- ["description", "A multi-repository workspace"],
- ["clone", "https://relay.example/git/owner/sprout.git"],
- ["web", "https://example.com/sprout"],
+ ["description", "A workspace that starts as a conversation"],
]);
});
-test("buildInitialProjectEventTemplates rejects names without an identifier", () => {
+test("buildDefaultProjectRepositoryTemplate uses the project slug as the repo id", () => {
+ const template = buildDefaultProjectRepositoryTemplate({
+ name: "Space Invaders 3D",
+ ownerPubkey: OWNER,
+ projectChannelId: CHANNEL,
+ });
+
+ assert.equal(template.dtag, "space-invaders-3d");
+ assert.equal(template.repositoryAddress, `30617:${OWNER}:space-invaders-3d`);
+});
+
+test("buildProjectAnnouncementTemplate rejects names without an identifier", () => {
assert.throws(
() =>
- buildInitialProjectEventTemplates({
- accessChannelId: CHANNEL,
+ buildProjectAnnouncementTemplate({
name: "!!!",
ownerPubkey: OWNER,
+ projectChannelId: CHANNEL,
}),
/letters or numbers/,
);
});
-test("buildInitialProjectEventTemplates enforces the description tag byte limit", () => {
+test("buildProjectAnnouncementTemplate enforces the description tag byte limit", () => {
assert.doesNotThrow(() =>
- buildInitialProjectEventTemplates({
- accessChannelId: CHANNEL,
+ buildProjectAnnouncementTemplate({
description: "🙂".repeat(512),
name: "Sprout",
ownerPubkey: OWNER,
+ projectChannelId: CHANNEL,
}),
);
assert.throws(
() =>
- buildInitialProjectEventTemplates({
- accessChannelId: CHANNEL,
+ buildProjectAnnouncementTemplate({
description: "🙂".repeat(513),
name: "Sprout",
ownerPubkey: OWNER,
+ projectChannelId: CHANNEL,
}),
/2,048 bytes/,
);
});
+test("buildProjectAnnouncementTemplate rejects an invalid project channel", () => {
+ assert.throws(
+ () =>
+ buildProjectAnnouncementTemplate({
+ name: "Sprout",
+ ownerPubkey: OWNER,
+ projectChannelId: "not-a-channel",
+ }),
+ /Project channel is invalid/,
+ );
+});
+
+test("conflictingListedProject ignores the caller's own slug and legacy cards", () => {
+ assert.equal(
+ conflictingListedProject(
+ [
+ {
+ dtag: "sprout",
+ legacy: false,
+ name: "Sprout",
+ owner: OWNER,
+ },
+ ],
+ { dtag: "sprout", name: "Sprout", ownerPubkey: OWNER },
+ ),
+ null,
+ );
+ assert.equal(
+ conflictingListedProject(
+ [
+ {
+ dtag: "sprout",
+ legacy: true,
+ name: "Sprout",
+ owner: "b".repeat(64),
+ },
+ ],
+ { dtag: "sprout", name: "Sprout", ownerPubkey: OWNER },
+ ),
+ null,
+ );
+});
+
+test("conflictingListedProject blocks another listed project with the same name or slug", () => {
+ const other = {
+ dtag: "space-invaders-3d",
+ legacy: false,
+ name: "Space Invaders 3D",
+ owner: "b".repeat(64),
+ };
+ assert.deepEqual(
+ conflictingListedProject([other], {
+ dtag: "space-invaders-3d",
+ name: "Space Invaders 3D",
+ ownerPubkey: OWNER,
+ }),
+ other,
+ );
+ assert.deepEqual(
+ conflictingListedProject([other], {
+ dtag: "space-invaders-3d-remake",
+ name: "Space Invaders 3D",
+ ownerPubkey: OWNER,
+ }),
+ other,
+ );
+});
+
test("isUnsupportedProjectKindError recognizes relay kind compatibility failures", () => {
assert.equal(
isUnsupportedProjectKindError(
diff --git a/desktop/src/features/projects/projectCreation.ts b/desktop/src/features/projects/projectCreation.ts
index a42cf7bfd79..38bd4521e0e 100644
--- a/desktop/src/features/projects/projectCreation.ts
+++ b/desktop/src/features/projects/projectCreation.ts
@@ -10,13 +10,18 @@ export type ProjectEventTemplate = {
tags: string[][];
};
-export type InitialProjectEventTemplates = {
+export type ProjectAnnouncementTemplate = {
dtag: string;
project: ProjectEventTemplate;
+};
+
+export type ProjectBootstrapTemplates = ProjectAnnouncementTemplate & {
repository: ProjectEventTemplate;
repositoryAddress: string;
};
+export type ProjectListingVisibility = "listed" | "unlisted";
+
export function isUnsupportedProjectKindError(error: unknown): boolean {
return (
error instanceof Error &&
@@ -24,28 +29,64 @@ export function isUnsupportedProjectKindError(error: unknown): boolean {
);
}
-function projectDtagFromName(name: string): string {
+export function projectDtagFromName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
-export function buildInitialProjectEventTemplates({
- accessChannelId,
- cloneUrl,
+export type ListedProjectIdentity = {
+ dtag: string;
+ legacy: boolean;
+ name: string;
+ owner: string;
+};
+
+/**
+ * A second listed project with the same slug or display name is the duplicate
+ * card users see when an agent runs `projects create` inside an existing
+ * project. Same-owner + same-slug is handled as resume/idempotent create by
+ * the caller; this finds a *different* listed project that should block create.
+ */
+export function conflictingListedProject(
+ projects: readonly ListedProjectIdentity[],
+ input: { dtag: string; name: string; ownerPubkey: string },
+): ListedProjectIdentity | null {
+ const ownerPubkey = input.ownerPubkey.toLowerCase();
+ const normalizedName = input.name.trim().toLowerCase();
+ return (
+ projects.find((project) => {
+ if (project.legacy) return false;
+ const sameOwnerSlug =
+ project.owner.toLowerCase() === ownerPubkey &&
+ project.dtag === input.dtag;
+ if (sameOwnerSlug) return false;
+ return (
+ project.dtag === input.dtag ||
+ project.name.trim().toLowerCase() === normalizedName
+ );
+ }) ?? null
+ );
+}
+
+function normalizeProjectAnnouncementInput({
description,
name,
ownerPubkey,
- webUrl,
+ projectChannelId,
}: {
- accessChannelId: string;
- cloneUrl?: string;
description?: string;
name: string;
ownerPubkey: string;
- webUrl?: string;
-}): InitialProjectEventTemplates {
+ projectChannelId: string;
+}): {
+ dtag: string;
+ normalizedDescription: string;
+ normalizedName: string;
+ normalizedOwner: string;
+ normalizedProjectChannelId: string;
+} {
const normalizedName = name.trim();
if (!normalizedName) {
throw new Error("Project name is required.");
@@ -66,36 +107,74 @@ export function buildInitialProjectEventTemplates({
if (new TextEncoder().encode(normalizedDescription).byteLength > 2_048) {
throw new Error("Project description must not exceed 2,048 bytes.");
}
- const repositoryTags: string[][] = [
- ["d", dtag],
- ["name", normalizedName],
- ];
+ const normalizedProjectChannelId = projectChannelId.trim();
+ if (!isValidProjectChannelId(normalizedProjectChannelId)) {
+ throw new Error("Project channel is invalid.");
+ }
+
+ return {
+ dtag,
+ normalizedDescription,
+ normalizedName,
+ normalizedOwner,
+ normalizedProjectChannelId,
+ };
+}
+
+/** Channel-first NIP-MP project: metadata, home channel, optional members. */
+export function buildProjectAnnouncementTemplate({
+ description,
+ name,
+ ownerPubkey,
+ projectChannelId,
+ projectVisibility = "listed",
+ repositoryAddresses = [],
+}: {
+ description?: string;
+ name: string;
+ ownerPubkey: string;
+ projectChannelId: string;
+ projectVisibility?: ProjectListingVisibility;
+ repositoryAddresses?: readonly string[];
+}): ProjectAnnouncementTemplate {
+ const {
+ dtag,
+ normalizedDescription,
+ normalizedName,
+ normalizedProjectChannelId,
+ } = normalizeProjectAnnouncementInput({
+ description,
+ name,
+ ownerPubkey,
+ projectChannelId,
+ });
+
+ if (new Set(repositoryAddresses).size !== repositoryAddresses.length) {
+ throw new Error("A project cannot contain duplicate repositories.");
+ }
+ if (
+ repositoryAddresses.some(
+ (address) => !/^30617:[0-9a-f]{64}:.+$/.test(address),
+ )
+ ) {
+ throw new Error("Repository address is invalid.");
+ }
+
const projectTags: string[][] = [
["d", dtag],
["name", normalizedName],
+ ["buzz-channel", normalizedProjectChannelId],
];
- const normalizedAccessChannelId = accessChannelId.trim();
- if (!isValidProjectChannelId(normalizedAccessChannelId)) {
- throw new Error("Repository access channel is invalid.");
- }
- repositoryTags.push(["buzz-channel", normalizedAccessChannelId]);
- projectTags.push(["buzz-channel", normalizedAccessChannelId]);
if (normalizedDescription) {
- repositoryTags.push(["description", normalizedDescription]);
projectTags.push(["description", normalizedDescription]);
}
- const normalizedCloneUrl = cloneUrl?.trim();
- if (normalizedCloneUrl) {
- repositoryTags.push(["clone", normalizedCloneUrl]);
+ if (projectVisibility === "unlisted") {
+ projectTags.push(["buzz-visibility", "unlisted"]);
}
- const normalizedWebUrl = webUrl?.trim();
- if (normalizedWebUrl) {
- repositoryTags.push(["web", normalizedWebUrl]);
+ for (const address of [...repositoryAddresses].sort()) {
+ projectTags.push(["a", address]);
}
- const repositoryAddress = `${KIND_REPO_ANNOUNCEMENT}:${normalizedOwner}:${dtag}`;
- projectTags.push(["a", repositoryAddress]);
-
return {
dtag,
project: {
@@ -103,11 +182,88 @@ export function buildInitialProjectEventTemplates({
content: "",
tags: projectTags,
},
+ };
+}
+
+/** Default 30617 bound to the project home channel, using the project slug. */
+export function buildDefaultProjectRepositoryTemplate({
+ description,
+ name,
+ ownerPubkey,
+ projectChannelId,
+}: {
+ description?: string;
+ name: string;
+ ownerPubkey: string;
+ projectChannelId: string;
+}): {
+ dtag: string;
+ repository: ProjectEventTemplate;
+ repositoryAddress: string;
+} {
+ const {
+ dtag,
+ normalizedDescription,
+ normalizedName,
+ normalizedOwner,
+ normalizedProjectChannelId,
+ } = normalizeProjectAnnouncementInput({
+ description,
+ name,
+ ownerPubkey,
+ projectChannelId,
+ });
+ const repositoryAddress = `${KIND_REPO_ANNOUNCEMENT}:${normalizedOwner}:${dtag}`;
+ const repositoryTags: string[][] = [
+ ["d", dtag],
+ ["name", normalizedName],
+ ["buzz-channel", normalizedProjectChannelId],
+ ];
+ if (normalizedDescription) {
+ repositoryTags.push(["description", normalizedDescription]);
+ }
+ return {
+ dtag,
+ repositoryAddress,
repository: {
kind: KIND_REPO_ANNOUNCEMENT,
content: normalizedDescription,
tags: repositoryTags,
},
- repositoryAddress,
+ };
+}
+
+/** Home channel + default repository already listed on the project. */
+export function buildProjectBootstrapTemplates({
+ description,
+ name,
+ ownerPubkey,
+ projectChannelId,
+ projectVisibility = "listed",
+}: {
+ description?: string;
+ name: string;
+ ownerPubkey: string;
+ projectChannelId: string;
+ projectVisibility?: ProjectListingVisibility;
+}): ProjectBootstrapTemplates {
+ const repository = buildDefaultProjectRepositoryTemplate({
+ description,
+ name,
+ ownerPubkey,
+ projectChannelId,
+ });
+ const announcement = buildProjectAnnouncementTemplate({
+ description,
+ name,
+ ownerPubkey,
+ projectChannelId,
+ projectVisibility,
+ repositoryAddresses: [repository.repositoryAddress],
+ });
+ return {
+ ...announcement,
+ repository: repository.repository,
+ repositoryAddress: repository.repositoryAddress,
};
}
diff --git a/desktop/src/features/projects/projectEnumeration.ts b/desktop/src/features/projects/projectEnumeration.ts
index ac494321042..6a1929fc2c6 100644
--- a/desktop/src/features/projects/projectEnumeration.ts
+++ b/desktop/src/features/projects/projectEnumeration.ts
@@ -5,6 +5,7 @@ import {
KIND_PROJECT_ANNOUNCEMENT,
KIND_REPO_ANNOUNCEMENT,
} from "@/shared/constants/kinds";
+import { absorbStandaloneProjectRepositories } from "./lib/projectCollection";
import { buildProjectReadModels, type Project } from "./projectModels";
const PROJECT_ENUMERATION_PAGE_SIZE = 500;
@@ -173,6 +174,7 @@ export async function buildProjectsFromFetcher(
options: {
relayOrigin?: string | null;
hiddenAddresses?: ReadonlySet;
+ viewerPubkey?: string | null;
} = {},
): Promise {
const [projectEvents, repositoryEvents] = await Promise.all([
@@ -200,11 +202,14 @@ export async function buildProjectsFromFetcher(
);
}
- return buildProjectReadModels({
- projectEvents,
- repositoryEvents,
- deletionEvents: tombstoneResult.events,
- relayOrigin: options.relayOrigin ?? null,
- hiddenAddresses: options.hiddenAddresses ?? new Set(),
- }).sort((a, b) => b.createdAt - a.createdAt);
+ return absorbStandaloneProjectRepositories(
+ buildProjectReadModels({
+ projectEvents,
+ repositoryEvents,
+ deletionEvents: tombstoneResult.events,
+ relayOrigin: options.relayOrigin ?? null,
+ hiddenAddresses: options.hiddenAddresses ?? new Set(),
+ viewerPubkey: options.viewerPubkey,
+ }),
+ ).sort((a, b) => b.createdAt - a.createdAt);
}
diff --git a/desktop/src/features/projects/projectModels.test.mjs b/desktop/src/features/projects/projectModels.test.mjs
index 7be105584a9..837a0deb531 100644
--- a/desktop/src/features/projects/projectModels.test.mjs
+++ b/desktop/src/features/projects/projectModels.test.mjs
@@ -90,6 +90,31 @@ test("buildProjectReadModels resolves repositories with a deterministic selectio
projects[0].repositoryRelayHints[backendAddress],
"wss://relay.example",
);
+ assert.equal(
+ projects[0].projectChannelId,
+ "11111111-1111-4111-8111-111111111111",
+ );
+ assert.deepEqual(projects[0].relatedChannelIds, []);
+});
+
+test("buildProjectReadModels keeps extra related channel ids", () => {
+ const relatedA = "22222222-2222-4222-8222-222222222222";
+ const relatedB = "33333333-3333-4333-8333-333333333333";
+ const home = "11111111-1111-4111-8111-111111111111";
+ const projects = buildProjectReadModels({
+ projectEvents: [
+ projectEvent([
+ ["buzz-related-channel", relatedA],
+ ["buzz-related-channel", home],
+ ["buzz-related-channel", relatedB],
+ ["buzz-related-channel", relatedA],
+ ]),
+ ],
+ repositoryEvents: [],
+ relayOrigin: RELAY_ORIGIN,
+ });
+
+ assert.deepEqual(projects[0].relatedChannelIds, [relatedA, relatedB]);
});
test("buildProjectReadModels keeps unclaimed repositories as implicit projects", () => {
@@ -194,6 +219,44 @@ test("selectProjectRepository honors a request and falls back to primary", () =>
assert.equal(selectProjectRepository(projects[0], null)?.dtag, "backend");
});
+test("buildProjectReadModels keeps the viewer's own unlisted project", () => {
+ const repoAddress = `30617:${PROJECT_OWNER}:secret`;
+ const unlisted = {
+ ...projectEvent([["a", repoAddress]]),
+ tags: [
+ ["d", "secret"],
+ ["name", "Secret"],
+ ["buzz-channel", "11111111-1111-4111-8111-111111111111"],
+ ["buzz-visibility", "unlisted"],
+ ["a", repoAddress],
+ ],
+ };
+ const asStranger = buildProjectReadModels({
+ projectEvents: [unlisted],
+ repositoryEvents: [repositoryEvent(PROJECT_OWNER, "secret")],
+ relayOrigin: RELAY_ORIGIN,
+ });
+ const asOwner = buildProjectReadModels({
+ projectEvents: [unlisted],
+ repositoryEvents: [repositoryEvent(PROJECT_OWNER, "secret")],
+ relayOrigin: RELAY_ORIGIN,
+ viewerPubkey: PROJECT_OWNER,
+ });
+
+ assert.equal(
+ asStranger.some((project) => project.dtag === "secret" && !project.legacy),
+ false,
+ );
+ assert.equal(
+ asStranger.some((project) => project.legacy && project.dtag === "secret"),
+ true,
+ );
+ assert.equal(asOwner.length, 1);
+ assert.equal(asOwner[0]?.legacy, false);
+ assert.equal(asOwner[0]?.dtag, "secret");
+ assert.equal(asOwner[0]?.visibility, "unlisted");
+});
+
function coordinateParts(coordinate) {
const first = coordinate.indexOf(":");
const second = coordinate.indexOf(":", first + 1);
diff --git a/desktop/src/features/projects/projectModels.ts b/desktop/src/features/projects/projectModels.ts
index 6d539b54d9e..a89ddcd689e 100644
--- a/desktop/src/features/projects/projectModels.ts
+++ b/desktop/src/features/projects/projectModels.ts
@@ -32,6 +32,12 @@ export type Project = {
owner: string;
createdAt: number;
projectChannelId: string | null;
+ /**
+ * Extra streams linked to this project via repeatable
+ * `buzz-related-channel` tags. Client convention: NIP-MP treats the tag as
+ * unrecognized metadata, so older readers ignore it.
+ */
+ relatedChannelIds: string[];
status: string;
projectAddress: string;
primaryRepositoryAddress: string | null;
@@ -50,6 +56,12 @@ type BuildProjectReadModelsInput = {
deletionEvents?: RelayEvent[];
relayOrigin?: string | null;
hiddenAddresses?: ReadonlySet;
+ /**
+ * When set, the viewer's own unlisted projects stay in the collection so the
+ * creator can still open them. Other viewers keep the NIP-MP fold: unlisted
+ * projects are absent and do not claim members.
+ */
+ viewerPubkey?: string | null;
};
const MAX_D_TAG_BYTES = 1_024;
@@ -109,6 +121,12 @@ export function isValidProjectChannelId(value: string): boolean {
);
}
+/** Repeatable project tag naming an extra stream besides `buzz-channel`. */
+export const PROJECT_RELATED_CHANNEL_TAG = "buzz-related-channel";
+
+/** Cap extra project streams so a tag list cannot grow without bound. */
+export const MAX_PROJECT_RELATED_CHANNELS = 64;
+
const SINGLETON_METADATA_TAGS = [
"name",
"description",
@@ -339,6 +357,16 @@ export function eventToExplicitProject(
const visibility =
rawVisibility === "unlisted" ? ("unlisted" as const) : ("listed" as const);
const channel = getTag(event, "buzz-channel");
+ const projectChannelId =
+ channel && isValidProjectChannelId(channel) ? channel : null;
+ const relatedChannelIds = [
+ ...new Set(
+ getAllTags(event, PROJECT_RELATED_CHANNEL_TAG).filter(
+ (channelId) =>
+ isValidProjectChannelId(channelId) && channelId !== projectChannelId,
+ ),
+ ),
+ ].slice(0, MAX_PROJECT_RELATED_CHANNELS);
return {
id: projectAddress,
dtag,
@@ -346,8 +374,8 @@ export function eventToExplicitProject(
description: getTag(event, "description") ?? "",
owner,
createdAt: event.created_at,
- projectChannelId:
- channel && isValidProjectChannelId(channel) ? channel : null,
+ projectChannelId,
+ relatedChannelIds,
status: visibility === "listed" ? "active" : "unlisted",
projectAddress,
primaryRepositoryAddress,
@@ -374,6 +402,7 @@ function repositoryToLegacyProject(repository: Repository): Project {
owner: repository.owner,
createdAt: repository.createdAt,
projectChannelId: null,
+ relatedChannelIds: [],
status: repository.status,
projectAddress: repository.repoAddress,
primaryRepositoryAddress: repository.repoAddress,
@@ -417,12 +446,23 @@ function buildDeletionThresholds(
return thresholds;
}
+function projectIsListingEligible(
+ project: Project,
+ viewerPubkey: string | null | undefined,
+): boolean {
+ if (project.visibility !== "unlisted") return true;
+ return Boolean(
+ viewerPubkey && project.owner === viewerPubkey.trim().toLowerCase(),
+ );
+}
+
export function buildProjectReadModels({
projectEvents,
repositoryEvents,
deletionEvents = [],
relayOrigin,
hiddenAddresses = new Set(),
+ viewerPubkey,
}: BuildProjectReadModelsInput): Project[] {
const deletionThresholds = buildDeletionThresholds(deletionEvents);
@@ -463,7 +503,7 @@ export function buildProjectReadModels({
visibleRepositoriesByAddress,
);
return project &&
- project.visibility === "listed" &&
+ projectIsListingEligible(project, viewerPubkey) &&
!hiddenAddresses.has(project.projectAddress)
? [project]
: [];
@@ -548,3 +588,21 @@ export function addRepositoryToProject(
) ?? [],
};
}
+
+/** Returns the optimistic read model after linking an extra project stream. */
+export function addRelatedChannelToProject(
+ project: Project,
+ channelId: string,
+ createdAt: number,
+): Project {
+ const relatedChannelIds = [
+ ...new Set([...(project.relatedChannelIds ?? []), channelId]),
+ ].filter(
+ (id) => id !== project.projectChannelId && isValidProjectChannelId(id),
+ );
+ return {
+ ...project,
+ createdAt,
+ relatedChannelIds: relatedChannelIds.slice(0, MAX_PROJECT_RELATED_CHANNELS),
+ };
+}
diff --git a/desktop/src/features/projects/ui/CreateProjectDialog.tsx b/desktop/src/features/projects/ui/CreateProjectDialog.tsx
index d3ac4dfe84b..7f75a45f7dd 100644
--- a/desktop/src/features/projects/ui/CreateProjectDialog.tsx
+++ b/desktop/src/features/projects/ui/CreateProjectDialog.tsx
@@ -9,7 +9,7 @@ type CreateProjectDialogProps = {
open: boolean;
};
-/** Modal for publishing a project with its initial NIP-34 repository. */
+/** Modal for creating a project channel. */
export function CreateProjectDialog({
isCreating,
onCreate,
diff --git a/desktop/src/features/projects/ui/CreateProjectFormContent.tsx b/desktop/src/features/projects/ui/CreateProjectFormContent.tsx
index df825d83f01..c685187ff51 100644
--- a/desktop/src/features/projects/ui/CreateProjectFormContent.tsx
+++ b/desktop/src/features/projects/ui/CreateProjectFormContent.tsx
@@ -1,8 +1,9 @@
import { ArrowLeft } from "lucide-react";
import * as React from "react";
-import { useChannelsQuery } from "@/features/channels/hooks";
import type { CreateProjectInput } from "@/features/projects/useCreateProject";
+import { CreateProjectFormSettings } from "@/features/projects/ui/CreateProjectFormSettings";
+import { useCreateProjectFormSettings } from "@/features/projects/ui/useCreateProjectFormSettings";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
@@ -33,51 +34,35 @@ export function CreateProjectFormContent({
}) {
const [name, setName] = React.useState("");
const [description, setDescription] = React.useState("");
- const [cloneUrl, setCloneUrl] = React.useState("");
- const [webUrl, setWebUrl] = React.useState("");
- const [accessChannelId, setAccessChannelId] = React.useState("");
const [errorMessage, setErrorMessage] = React.useState(null);
const nameInputRef = React.useRef(null);
- const channelsQuery = useChannelsQuery({ enabled: active });
- const accessChannels = React.useMemo(
- () =>
- (channelsQuery.data ?? []).filter(
- (channel) =>
- channel.isMember &&
- !channel.archivedAt &&
- channel.channelType !== "dm",
- ),
- [channelsQuery.data],
- );
+ const settings = useCreateProjectFormSettings(active);
React.useEffect(() => {
if (!active) return;
setName(initialName);
setDescription("");
- setCloneUrl("");
- setWebUrl("");
- setAccessChannelId(accessChannels[0]?.id ?? "");
setErrorMessage(null);
const timerId = globalThis.setTimeout(() => {
nameInputRef.current?.focus();
}, 50);
return () => globalThis.clearTimeout(timerId);
- }, [accessChannels, active, initialName]);
+ }, [active, initialName]);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const trimmedName = name.trim();
- if (!trimmedName || !accessChannelId) return;
+ if (!trimmedName) return;
setErrorMessage(null);
try {
await onCreate({
- accessChannelId,
name: trimmedName,
description: description.trim() || undefined,
- cloneUrl: cloneUrl.trim() || undefined,
- webUrl: webUrl.trim() || undefined,
+ channelVisibility: settings.channelVisibility,
+ projectVisibility: settings.projectVisibility,
+ agents: settings.buildAgents(),
});
onCreated();
} catch (error) {
@@ -92,14 +77,12 @@ export function CreateProjectFormContent({
className="max-w-lg"
contentClassName="pt-3"
data-testid="create-project-dialog"
- description="Projects group one or more repositories published to this workspace's relay."
+ headerSubtitle="A project starts as a channel with a repository. People in the channel can talk, clone, and open tasks here."
footer={
@@ -167,44 +150,6 @@ export function CreateProjectFormContent({
-
-
-
-
-
-
- Members of this channel can access project repositories.
-
-
-
-
-
-
- {
- setCloneUrl(event.target.value);
- setErrorMessage(null);
- }}
- placeholder="https://relay.example.com/git/bee-garden-game.git"
- spellCheck={false}
- value={cloneUrl}
- />
-
-
-
-
-
-
- {
- setWebUrl(event.target.value);
- setErrorMessage(null);
- }}
- placeholder="https://github.com/owner/repo"
- spellCheck={false}
- value={webUrl}
- />
-
-
+
{errorMessage ? (
{errorMessage}
diff --git a/desktop/src/features/projects/ui/CreateProjectFormSettings.tsx b/desktop/src/features/projects/ui/CreateProjectFormSettings.tsx
new file mode 100644
index 00000000000..2f90dc8be1d
--- /dev/null
+++ b/desktop/src/features/projects/ui/CreateProjectFormSettings.tsx
@@ -0,0 +1,149 @@
+import { ChevronDown } from "lucide-react";
+
+import { ChannelPermissionsSettings } from "@/features/channels/ui/ChannelPermissionsSettings";
+import type { CreateProjectFormSettingsState } from "@/features/projects/ui/useCreateProjectFormSettings";
+import { Button } from "@/shared/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+} from "@/shared/ui/dropdown-menu";
+import { cn } from "@/shared/lib/cn";
+
+const NONE_AGENT_VALUE = "__none__";
+
+const SETTINGS_ROW_CLASS =
+ "flex min-h-12 items-center justify-between gap-4 rounded-xl border border-input bg-background px-3 py-3";
+
+export function CreateProjectFormSettings({
+ agentPersonaId,
+ disabled,
+ personas,
+ projectVisibility,
+ runtimesAvailable,
+ setAgentPersonaId,
+ setChannelVisibility,
+ setProjectVisibility,
+ channelVisibility,
+}: CreateProjectFormSettingsState & { disabled: boolean }) {
+ const selectedPersona = personas.find(
+ (persona) => persona.id === agentPersonaId,
+ );
+ const listingLabel = projectVisibility === "unlisted" ? "Unlisted" : "Listed";
+ const agentLabel = selectedPersona?.displayName ?? "None";
+ const agentDisabled = disabled || (!runtimesAvailable && personas.length > 0);
+
+ return (
+ <>
+
+
+
+
+ Project list
+
+
+
+
+ {listingLabel}
+
+
+
+ event.preventDefault()}
+ style={{
+ minWidth: "var(--radix-dropdown-menu-trigger-width)",
+ }}
+ >
+
+ setProjectVisibility(
+ value === "unlisted" ? "unlisted" : "listed",
+ )
+ }
+ value={projectVisibility}
+ >
+
+ Listed
+
+
+ Unlisted
+
+
+
+
+
+
+
+
+ Coding agent
+
+
+
+
+ {agentLabel}
+
+
+
+ event.preventDefault()}
+ style={{
+ minWidth: "var(--radix-dropdown-menu-trigger-width)",
+ }}
+ >
+
+ setAgentPersonaId(value === NONE_AGENT_VALUE ? "" : value)
+ }
+ value={agentPersonaId || NONE_AGENT_VALUE}
+ >
+
+ None
+
+ {personas.map((persona) => (
+
+ {persona.displayName}
+
+ ))}
+
+
+
+
+ >
+ );
+}
diff --git a/desktop/src/features/projects/ui/ProjectChannelIcon.tsx b/desktop/src/features/projects/ui/ProjectChannelIcon.tsx
new file mode 100644
index 00000000000..524003ad1eb
--- /dev/null
+++ b/desktop/src/features/projects/ui/ProjectChannelIcon.tsx
@@ -0,0 +1,20 @@
+import { Folders, Hash } from "lucide-react";
+
+import { cn } from "@/shared/lib/cn";
+
+/** Projects glyph with a small channel hash nested in the lower right. */
+export function ProjectChannelIcon({ className }: { className?: string }) {
+ return (
+
+
+
+
+ );
+}
diff --git a/desktop/src/features/projects/ui/useCreateProjectFormSettings.ts b/desktop/src/features/projects/ui/useCreateProjectFormSettings.ts
new file mode 100644
index 00000000000..00a70c587a5
--- /dev/null
+++ b/desktop/src/features/projects/ui/useCreateProjectFormSettings.ts
@@ -0,0 +1,93 @@
+import * as React from "react";
+
+import type { CreateChannelManagedAgentInput } from "@/features/agents/channelAgents";
+import {
+ useAvailableAcpRuntimes,
+ usePersonasQuery,
+} from "@/features/agents/hooks";
+import { getActivePersonas } from "@/features/agents/lib/catalog";
+import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
+import type { ProjectListingVisibility } from "@/features/projects/projectCreation";
+import type { ChannelVisibility } from "@/shared/api/types";
+
+export function useCreateProjectFormSettings(active: boolean) {
+ const personasQuery = usePersonasQuery({ enabled: active });
+ const runtimesQuery = useAvailableAcpRuntimes({ enabled: active });
+ const [channelVisibility, setChannelVisibility] =
+ React.useState("open");
+ const [projectVisibility, setProjectVisibility] =
+ React.useState("listed");
+ const [agentPersonaId, setAgentPersonaId] = React.useState("");
+
+ const personas = React.useMemo(
+ () => getActivePersonas(personasQuery.data ?? []),
+ [personasQuery.data],
+ );
+
+ React.useEffect(() => {
+ if (!active) return;
+ setChannelVisibility("open");
+ setProjectVisibility("listed");
+ setAgentPersonaId("");
+ }, [active]);
+
+ React.useEffect(() => {
+ if (
+ agentPersonaId &&
+ !personas.some((persona) => persona.id === agentPersonaId)
+ ) {
+ setAgentPersonaId("");
+ }
+ }, [agentPersonaId, personas]);
+
+ const buildAgents =
+ React.useCallback((): CreateChannelManagedAgentInput[] => {
+ if (!agentPersonaId) return [];
+ const persona = personas.find((entry) => entry.id === agentPersonaId);
+ if (!persona) {
+ throw new Error("Choose an agent that still exists.");
+ }
+ const defaultRuntime = runtimesQuery.data[0] ?? null;
+ const resolved = resolvePersonaRuntime(
+ persona.runtime,
+ runtimesQuery.data,
+ defaultRuntime,
+ false,
+ );
+ if (!resolved.runtime) {
+ throw new Error(
+ resolved.warnings[0] ??
+ "No agent runtimes are available. Install a runtime to add an agent.",
+ );
+ }
+ return [
+ {
+ runtime: resolved.runtime,
+ name: persona.displayName,
+ personaId: persona.id,
+ harnessOverride: false,
+ systemPrompt: persona.systemPrompt,
+ avatarUrl: persona.avatarUrl ?? undefined,
+ model: persona.model ?? undefined,
+ role: "bot",
+ backend: { type: "local" },
+ },
+ ];
+ }, [agentPersonaId, personas, runtimesQuery.data]);
+
+ return {
+ agentPersonaId,
+ buildAgents,
+ channelVisibility,
+ personas,
+ projectVisibility,
+ runtimesAvailable: runtimesQuery.data.length > 0,
+ setAgentPersonaId,
+ setChannelVisibility,
+ setProjectVisibility,
+ };
+}
+
+export type CreateProjectFormSettingsState = ReturnType<
+ typeof useCreateProjectFormSettings
+>;
diff --git a/desktop/src/features/projects/useCreateProject.ts b/desktop/src/features/projects/useCreateProject.ts
index f4f548683fb..8526e0317d6 100644
--- a/desktop/src/features/projects/useCreateProject.ts
+++ b/desktop/src/features/projects/useCreateProject.ts
@@ -2,135 +2,34 @@ import * as React from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
- fetchProjects,
- type Project,
- projectsQueryKey,
-} from "@/features/projects/hooks";
+ channelsQueryKey,
+ upsertCachedChannel,
+} from "@/features/channels/hooks";
+import { type Project, projectsQueryKey } from "@/features/projects/hooks";
import {
- buildInitialProjectEventTemplates,
- isUnsupportedProjectKindError,
-} from "@/features/projects/projectCreation";
+ createProject,
+ type CreateProjectInput,
+ type CreateProjectResult,
+ type CreateProjectResumeState,
+} from "@/features/projects/createProject";
import { addProjectToSidebar } from "@/features/projects/lib/projectSidebarMembership";
-import { buildProjectReadModels } from "@/features/projects/projectModels";
-import { relayClient } from "@/shared/api/relayClient";
+import type { Channel } from "@/shared/api/types";
import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl";
-import { signRelayEvent } from "@/shared/api/tauri";
-import { getIdentity } from "@/shared/api/tauriIdentity";
-export type CreateProjectInput = {
- accessChannelId: string;
- name: string;
- description?: string;
- cloneUrl?: string;
- webUrl?: string;
-};
+export type { CreateProjectInput, CreateProjectResult };
-export type CreateProjectResult = {
- project: Project;
- compatibilityWarning?: string;
-};
-
-/** Publishes a project announcement and its initial NIP-34 repository. */
-async function createProject(
- input: CreateProjectInput,
- resumableProjectIds: Set,
-): Promise {
- const identity = await getIdentity();
- const templates = buildInitialProjectEventTemplates({
- ...input,
- ownerPubkey: identity.pubkey,
- });
- const existing = await fetchProjects();
- const ownerPubkey = identity.pubkey.toLowerCase();
- const existingProject = existing.find(
- (project) =>
- project.owner.toLowerCase() === ownerPubkey &&
- project.dtag === templates.dtag,
- );
- const projectId = `${ownerPubkey}:${templates.dtag}`;
- const canResume = resumableProjectIds.has(projectId);
- if (existingProject && !canResume) {
- throw new Error(`You already have a project named "${templates.dtag}".`);
- }
- if (existingProject && !existingProject.legacy) {
- if (
- existingProject.repositories.some(
- (repository) => repository.repoAddress === templates.repositoryAddress,
- )
- ) {
- resumableProjectIds.delete(projectId);
- return { project: existingProject };
- }
- throw new Error(`You already have a project named "${templates.dtag}".`);
- }
-
- resumableProjectIds.add(projectId);
- const projectEvent = await signRelayEvent(templates.project);
-
- let repositoryEvent = null;
- if (!existingProject) {
- repositoryEvent = await signRelayEvent(templates.repository);
- await relayClient.publishEvent(
- repositoryEvent,
- "Timed out creating the initial repository.",
- "Failed to create the initial repository.",
- );
- }
-
- try {
- await relayClient.publishEvent(
- projectEvent,
- "Timed out creating project.",
- "Failed to create project.",
- );
- } catch (error) {
- if (!isUnsupportedProjectKindError(error)) throw error;
-
- const [legacyProject] = existingProject?.legacy
- ? [existingProject]
- : buildProjectReadModels({
- projectEvents: [],
- repositoryEvents: repositoryEvent ? [repositoryEvent] : [],
- relayOrigin: getCachedRelayOrigin(),
- });
- if (!legacyProject) throw error;
-
- resumableProjectIds.delete(projectId);
- return {
- project: legacyProject,
- compatibilityWarning:
- "The repository was created, but this relay does not support multi-repository projects yet. It will appear as a standalone project.",
- };
- }
-
- const [project] = repositoryEvent
- ? buildProjectReadModels({
- projectEvents: [projectEvent],
- repositoryEvents: [repositoryEvent],
- relayOrigin: getCachedRelayOrigin(),
- })
- : (await fetchProjects()).filter(
- (candidate) =>
- candidate.owner.toLowerCase() === ownerPubkey &&
- candidate.dtag === templates.dtag &&
- !candidate.legacy,
- );
- if (!project) {
- throw new Error("The project was created but could not be read.");
- }
- resumableProjectIds.delete(projectId);
- return { project };
-}
-
-/** Mutation that creates a project and inserts it into the projects cache. */
+/** Mutation that creates a project home and inserts it into the caches. */
export function useCreateProjectMutation() {
const queryClient = useQueryClient();
- const resumableProjectIdsRef = React.useRef(new Set());
+ const resumeRef = React.useRef({
+ channels: new Map(),
+ projectIds: new Set(),
+ });
return useMutation({
mutationFn: (input: CreateProjectInput) =>
- createProject(input, resumableProjectIdsRef.current),
- onSuccess: ({ project }) => {
+ createProject(input, resumeRef.current),
+ onSuccess: ({ channel, project }) => {
addProjectToSidebar(
project.projectAddress,
getCachedRelayOrigin(),
@@ -148,6 +47,17 @@ export function useCreateProjectMutation() {
),
),
]);
+ if (channel) {
+ queryClient.setQueryData(
+ channelsQueryKey,
+ (current: Channel[] | undefined) =>
+ upsertCachedChannel(current, channel),
+ );
+ void queryClient.invalidateQueries({
+ queryKey: channelsQueryKey,
+ refetchType: "none",
+ });
+ }
void queryClient.invalidateQueries({ queryKey: projectsQueryKey });
},
});
diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts
index 52d91dea3ed..25513f6ad75 100644
--- a/desktop/tests/e2e/channels.spec.ts
+++ b/desktop/tests/e2e/channels.spec.ts
@@ -450,9 +450,9 @@ async function expectIntroActionCardLayout(
}
expect(actionBox.height).toBeGreaterThan(actionBox.width);
- expect(Math.round(actionBox.width)).toBe(220);
- expect(Math.round(iconBox.width)).toBe(48);
- expect(Math.round(iconBox.height)).toBe(48);
+ expect(Math.round(actionBox.width)).toBe(192);
+ expect(Math.round(iconBox.width)).toBe(40);
+ expect(Math.round(iconBox.height)).toBe(40);
const introIconRadius = await page
.getByTestId("message-channel-intro-icon")
.evaluate((element) => window.getComputedStyle(element).borderRadius);
diff --git a/desktop/tests/e2e/project-commit-detail.spec.ts b/desktop/tests/e2e/project-commit-detail.spec.ts
index 53804bf85ae..a6f10ccb7fc 100644
--- a/desktop/tests/e2e/project-commit-detail.spec.ts
+++ b/desktop/tests/e2e/project-commit-detail.spec.ts
@@ -260,9 +260,6 @@ test("creating a project publishes its initial repository grouping", async ({
await page
.getByTestId("create-project-description")
.fill("A grouped project created through the desktop app.");
- await page
- .getByTestId("create-project-clone-url")
- .fill("https://relay.example.com/git/owner/multi-repo-demo.git");
await page.getByTestId("create-project-submit").click();
await expect(page.getByTestId("create-project-dialog")).toBeHidden();
@@ -314,7 +311,7 @@ test("creating a project publishes its initial repository grouping", async ({
.toBe(2);
});
-test("unsupported relays keep the initial repository accessible", async ({
+test("unsupported relays cannot create a channel-first project", async ({
page,
}) => {
await enableProjectsFeature(page);
@@ -329,26 +326,10 @@ test("unsupported relays keep the initial repository accessible", async ({
await page.getByTestId("create-project-name").fill("legacy-fallback");
await page.getByTestId("create-project-submit").click();
- await expect(page.getByTestId("create-project-dialog")).toBeHidden();
- await expect(page.getByText("Created as a standalone project")).toBeVisible();
- await waitForAnimations(page);
- const projectEntry = page
- .locator(
- '[data-testid="project-card-legacy-fallback"], [data-testid="project-row-legacy-fallback"]',
- )
- .first();
- await expect(projectEntry).toBeVisible();
- await projectEntry
- .getByRole("button", { name: "View legacy-fallback" })
- .click();
- const repositoryRow = page.getByTestId(
- "sidebar-project-repository-legacy-fallback",
- );
- await expect(repositoryRow).toBeVisible();
- await waitForAnimations(page);
- await page.screenshot({
- path: `${SHOTS}/06-single-repository-add.png`,
- });
+ await expect(page.getByTestId("create-project-dialog")).toBeVisible();
+ await expect(
+ page.getByText("This relay does not support projects yet"),
+ ).toBeVisible();
const acceptedKinds = await page.evaluate(
() =>
@@ -360,7 +341,7 @@ test("unsupported relays keep the initial repository accessible", async ({
)
.map((event) => event.kind) ?? [],
);
- expect(acceptedKinds).toEqual([30617]);
+ expect(acceptedKinds).toEqual([]);
});
test("project creation can retry after its repository publication fails", async ({