From 510dc6a8f38e72cf9fefea864705dd5057d08bb4 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Sat, 22 Aug 2026 21:09:43 -0400 Subject: [PATCH 1/2] feat(projects): make project creation channel-first Create an explicit project with a home channel and default repository while preserving NIP-MP folding, deletion, and retry semantics. Signed-off-by: Thomas Petersen --- .../src/features/projects/createProject.ts | 299 ++++++++++++++++++ desktop/src/features/projects/hooks.ts | 9 +- .../projects/lib/projectCollection.test.mjs | 135 ++++++++ .../projects/lib/projectCollection.ts | 103 ++++++ .../projects/projectChannelCreation.test.mjs | 105 ++++++ .../projects/projectChannelCreation.ts | 71 +++++ .../projects/projectCreation.test.mjs | 163 ++++++++-- .../src/features/projects/projectCreation.ts | 218 +++++++++++-- .../features/projects/projectEnumeration.ts | 19 +- .../features/projects/projectModels.test.mjs | 63 ++++ .../src/features/projects/projectModels.ts | 64 +++- .../projects/ui/CreateProjectDialog.tsx | 2 +- .../projects/ui/CreateProjectFormContent.tsx | 149 +-------- .../projects/ui/CreateProjectFormSettings.tsx | 149 +++++++++ .../ui/useCreateProjectFormSettings.ts | 93 ++++++ .../src/features/projects/useCreateProject.ts | 148 ++------- 16 files changed, 1468 insertions(+), 322 deletions(-) create mode 100644 desktop/src/features/projects/createProject.ts create mode 100644 desktop/src/features/projects/lib/projectCollection.test.mjs create mode 100644 desktop/src/features/projects/lib/projectCollection.ts create mode 100644 desktop/src/features/projects/projectChannelCreation.test.mjs create mode 100644 desktop/src/features/projects/projectChannelCreation.ts create mode 100644 desktop/src/features/projects/ui/CreateProjectFormSettings.tsx create mode 100644 desktop/src/features/projects/ui/useCreateProjectFormSettings.ts diff --git a/desktop/src/features/projects/createProject.ts b/desktop/src/features/projects/createProject.ts new file mode 100644 index 00000000000..f11a7905303 --- /dev/null +++ b/desktop/src/features/projects/createProject.ts @@ -0,0 +1,299 @@ +import { + createChannelManagedAgents, + type CreateChannelManagedAgentInput, +} from "@/features/agents/channelAgents"; +import { fetchProjects, type Project } from "@/features/projects/hooks"; +import { + buildDefaultProjectRepositoryTemplate, + buildProjectBootstrapTemplates, + conflictingListedProject, + isUnsupportedProjectKindError, + projectDtagFromName, + type ProjectListingVisibility, +} from "@/features/projects/projectCreation"; +import { buildProjectPatchTemplate } from "@/features/projects/projectRepositoryCreation"; +import { buildProjectReadModels } from "@/features/projects/projectModels"; +import { relayClient } from "@/shared/api/relayClient"; +import { createChannel, signRelayEvent } from "@/shared/api/tauri"; +import { getIdentity } from "@/shared/api/tauriIdentity"; +import type { + Channel, + ChannelVisibility, + RelayEvent, +} from "@/shared/api/types"; +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl"; + +export type CreateProjectInput = { + name: string; + description?: string; + channelVisibility?: ChannelVisibility; + projectVisibility?: ProjectListingVisibility; + agents?: readonly CreateChannelManagedAgentInput[]; +}; + +export type CreateProjectResult = { + channel: Channel | null; + /** Retained for callers shared with legacy repository-only creation. */ + compatibilityWarning?: string; + project: Project; +}; + +export type CreateProjectResumeState = { + channels: Map; + 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/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={
-
- -
- -
-

- 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 + + + + + + event.preventDefault()} + style={{ + minWidth: "var(--radix-dropdown-menu-trigger-width)", + }} + > + + setProjectVisibility( + value === "unlisted" ? "unlisted" : "listed", + ) + } + value={projectVisibility} + > + + Listed + + + Unlisted + + + + +
+ +
+ + Coding agent + + + + + + 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/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 }); }, }); From f1dd8a89040f57db1b1a2819120dc2e852dbc65a Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Sun, 23 Aug 2026 09:13:35 -0400 Subject: [PATCH 2/2] test(projects): align creation smoke coverage Assert the channel-first creation contract instead of the removed clone-URL and standalone fallback behavior. Signed-off-by: Thomas Petersen --- .../tests/e2e/project-commit-detail.spec.ts | 31 ++++--------------- 1 file changed, 6 insertions(+), 25 deletions(-) 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 ({