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 <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-22 21:09:43 -04:00
parent e236329413
commit 510dc6a8f3
16 changed files with 1468 additions and 322 deletions

View File

@@ -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<string, Channel>;
projectIds: Set<string>;
};
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<RelayEvent | null> {
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<Project> {
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<CreateProjectResult> {
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<CreateProjectResult> {
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);
}

View File

@@ -172,9 +172,11 @@ export async function fetchProjects(
signal?: AbortSignal,
): Promise<Project[]> {
// 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,
});
}

View File

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

View File

@@ -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<string> | ReadonlySet<string>,
): 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<string>();
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),
),
];
}

View File

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

View File

@@ -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,
},
};
}

View File

@@ -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.deepEqual(templates.project.tags, [
["d", "sprout"],
["name", "Sprout"],
["buzz-channel", CHANNEL],
["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 multi-repository workspace"],
["a", `30617:${OWNER}:sprout`],
["description", "A workspace that starts as a conversation"],
["a", repositoryAddress],
]);
assert.equal(templates.project.content, "");
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(

View File

@@ -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,
};
}

View File

@@ -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<string>;
viewerPubkey?: string | null;
} = {},
): Promise<Project[]> {
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);
}

View File

@@ -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);

View File

@@ -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<string>;
/**
* 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),
};
}

View File

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

View File

@@ -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<string | null>(null);
const nameInputRef = React.useRef<HTMLInputElement>(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<HTMLFormElement>) {
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={
<div className="flex w-full items-center justify-end gap-3">
<Button
data-testid="create-project-submit"
disabled={
isCreating || name.trim().length === 0 || !accessChannelId
}
disabled={isCreating || name.trim().length === 0}
form="create-project-form"
type="submit"
>
@@ -167,44 +150,6 @@ export function CreateProjectFormContent({
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-project-access-channel"
>
Repository access channel
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
CREATE_FIELD_SHELL_CLASS,
)}
>
<select
className={cn("h-8 w-full px-0 py-0", CREATE_FIELD_CONTROL_CLASS)}
data-testid="create-project-access-channel"
disabled={isCreating}
id="create-project-access-channel"
onChange={(event) => {
setAccessChannelId(event.target.value);
setErrorMessage(null);
}}
required
value={accessChannelId}
>
<option value="">Select a channel</option>
{accessChannels.map((channel) => (
<option key={channel.id} value={channel.id}>
{channel.name}
</option>
))}
</select>
</div>
<p className="text-xs text-muted-foreground">
Members of this channel can access project repositories.
</p>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
@@ -226,84 +171,14 @@ export function CreateProjectFormContent({
setDescription(event.target.value);
setErrorMessage(null);
}}
placeholder="What this project is about"
placeholder="What this project should become"
rows={2}
value={description}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-project-clone-url"
>
Initial repository clone URL
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
CREATE_FIELD_SHELL_CLASS,
)}
>
<Input
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-project-clone-url"
disabled={isCreating}
id="create-project-clone-url"
onChange={(event) => {
setCloneUrl(event.target.value);
setErrorMessage(null);
}}
placeholder="https://relay.example.com/git/bee-garden-game.git"
spellCheck={false}
value={cloneUrl}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-project-web-url"
>
Initial repository web URL
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
CREATE_FIELD_SHELL_CLASS,
)}
>
<Input
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-project-web-url"
disabled={isCreating}
id="create-project-web-url"
onChange={(event) => {
setWebUrl(event.target.value);
setErrorMessage(null);
}}
placeholder="https://github.com/owner/repo"
spellCheck={false}
value={webUrl}
/>
</div>
</div>
<CreateProjectFormSettings disabled={isCreating} {...settings} />
{errorMessage ? (
<p className="text-sm text-destructive">{errorMessage}</p>

View File

@@ -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 (
<>
<ChannelPermissionsSettings
disabled={disabled}
onVisibilityChange={setChannelVisibility}
testIdPrefix="create-project-channel"
visibility={channelVisibility}
/>
<div className={cn(SETTINGS_ROW_CLASS, disabled && "opacity-50")}>
<span className="text-sm font-medium text-foreground">
Project list
</span>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label={`Project list: ${listingLabel}`}
className="-mr-2.5 ml-auto h-9 w-fit justify-end px-2.5 text-right text-sm font-medium text-foreground hover:bg-muted/50"
data-testid="create-project-listing"
disabled={disabled}
type="button"
variant="ghost"
>
<span className="text-right">{listingLabel}</span>
<ChevronDown className="size-4 shrink-0 text-muted-foreground/70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
style={{
minWidth: "var(--radix-dropdown-menu-trigger-width)",
}}
>
<DropdownMenuRadioGroup
onValueChange={(value) =>
setProjectVisibility(
value === "unlisted" ? "unlisted" : "listed",
)
}
value={projectVisibility}
>
<DropdownMenuRadioItem
data-testid="create-project-listing-option-listed"
value="listed"
>
Listed
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
data-testid="create-project-listing-option-unlisted"
value="unlisted"
>
Unlisted
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className={cn(SETTINGS_ROW_CLASS, agentDisabled && "opacity-50")}>
<span className="text-sm font-medium text-foreground">
Coding agent
</span>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label={`Coding agent: ${agentLabel}`}
className="-mr-2.5 ml-auto h-9 min-w-0 max-w-[60%] justify-end px-2.5 text-right text-sm font-medium text-foreground hover:bg-muted/50"
data-testid="create-project-agent"
disabled={agentDisabled}
type="button"
variant="ghost"
>
<span className="truncate text-right">{agentLabel}</span>
<ChevronDown className="size-4 shrink-0 text-muted-foreground/70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
style={{
minWidth: "var(--radix-dropdown-menu-trigger-width)",
}}
>
<DropdownMenuRadioGroup
onValueChange={(value) =>
setAgentPersonaId(value === NONE_AGENT_VALUE ? "" : value)
}
value={agentPersonaId || NONE_AGENT_VALUE}
>
<DropdownMenuRadioItem
data-testid="create-project-agent-option-none"
value={NONE_AGENT_VALUE}
>
None
</DropdownMenuRadioItem>
{personas.map((persona) => (
<DropdownMenuRadioItem
data-testid={`create-project-agent-option-${persona.id}`}
key={persona.id}
value={persona.id}
>
{persona.displayName}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
);
}

View File

@@ -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<ChannelVisibility>("open");
const [projectVisibility, setProjectVisibility] =
React.useState<ProjectListingVisibility>("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
>;

View File

@@ -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<string>,
): Promise<CreateProjectResult> {
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<string>());
const resumeRef = React.useRef<CreateProjectResumeState>({
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 });
},
});