diff --git a/desktop/src/features/channels/lib/channelLifecycle.test.mjs b/desktop/src/features/channels/lib/channelLifecycle.test.mjs new file mode 100644 index 00000000000..0477aa7b77f --- /dev/null +++ b/desktop/src/features/channels/lib/channelLifecycle.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { channelLifecycle, channelLifecycleLabel } from "./channelLifecycle.ts"; + +test("channelLifecycle prefers project home over TTL", () => { + assert.equal( + channelLifecycle({ projectHome: true, temporary: false }), + "project", + ); + assert.equal( + channelLifecycle({ projectHome: true, temporary: true }), + "project", + ); +}); + +test("channelLifecycle maps ongoing and temporary streams", () => { + assert.equal( + channelLifecycle({ projectHome: false, temporary: false }), + "ongoing", + ); + assert.equal( + channelLifecycle({ projectHome: false, temporary: true }), + "temporary", + ); +}); + +test("channelLifecycleLabel names project, ongoing, and temporary", () => { + assert.equal(channelLifecycleLabel("project", null), "Project"); + assert.equal(channelLifecycleLabel("ongoing", null), "Ongoing"); + assert.equal( + channelLifecycleLabel("temporary", 7 * 24 * 60 * 60), + "Temporary · 7d", + ); +}); diff --git a/desktop/src/features/channels/lib/channelLifecycle.ts b/desktop/src/features/channels/lib/channelLifecycle.ts new file mode 100644 index 00000000000..09517675610 --- /dev/null +++ b/desktop/src/features/channels/lib/channelLifecycle.ts @@ -0,0 +1,23 @@ +import { formatTtlDuration } from "@/features/channels/lib/ephemeralChannel"; + +export type ChannelLifecycle = "ongoing" | "temporary" | "project"; + +export function channelLifecycle(input: { + projectHome: boolean; + temporary: boolean; +}): ChannelLifecycle { + if (input.projectHome) return "project"; + return input.temporary ? "temporary" : "ongoing"; +} + +export function channelLifecycleLabel( + lifecycle: ChannelLifecycle, + ttlSeconds: number | null, +): string { + if (lifecycle === "project") return "Project"; + if (lifecycle === "temporary" && ttlSeconds != null) { + return `Temporary · ${formatTtlDuration(ttlSeconds)}`; + } + if (lifecycle === "temporary") return "Temporary"; + return "Ongoing"; +} diff --git a/desktop/src/features/channels/ui/ChannelGlyph.tsx b/desktop/src/features/channels/ui/ChannelGlyph.tsx new file mode 100644 index 00000000000..fae767426fd --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelGlyph.tsx @@ -0,0 +1,29 @@ +import { FileText, Hash, Lock } from "lucide-react"; + +import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel"; +import { ProjectChannelIcon } from "@/features/projects/ui/ProjectChannelIcon"; +import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; + +/** Stream/forum glyph for a channel, using the project mark on project homes. */ +export function ChannelGlyph({ + channel, + className, +}: { + channel: Pick; + className?: string; +}) { + const projectHome = useIsProjectHomeChannel(channel.id); + const iconClass = cn("size-4 shrink-0", className); + + if (projectHome) { + return ; + } + if (channel.visibility === "private") { + return ; + } + if (channel.channelType === "forum") { + return ; + } + return ; +} diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx index aea0f9323ec..8a1aaf6a02e 100644 --- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx +++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx @@ -24,10 +24,7 @@ import { import { compareMembersByRole } from "@/features/channels/lib/memberUtils"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelWorkflowsQuery } from "@/features/workflows/hooks"; -import { - DEFAULT_EPHEMERAL_TTL_SECONDS, - formatTtlDuration, -} from "@/features/channels/lib/ephemeralChannel"; +import { DEFAULT_EPHEMERAL_TTL_SECONDS } from "@/features/channels/lib/ephemeralChannel"; import type { Channel, ChannelMember, Workflow } from "@/shared/api/types"; import { useWorkflowEditorOverlay } from "@/shared/context/WorkflowEditorOverlayContext"; import { useFeatureEnabled } from "@/shared/features"; @@ -65,7 +62,10 @@ import { CHANNEL_FORM_FIELD_CONTROL_CLASS, CHANNEL_FORM_FIELD_SHELL_CLASS, } from "./channelFormStyles"; -import { ChannelTypeSettings } from "./ChannelTypeSettings"; +import { + ChannelTypeDetailRow, + ChannelTypeSettings, +} from "./ChannelTypeSettings"; import { ChannelPermissionsSettings } from "./ChannelPermissionsSettings"; import { ActionFieldRow, @@ -555,6 +555,7 @@ export function ChannelManagementSheet({ data-testid="channel-management-lifecycle" > { setIsEphemeralDraft(temporary); @@ -778,16 +779,10 @@ function ChannelManagementPanelContent({ {resolvedChannel.channelType !== "dm" ? ( <> - void; }) { - const Icon = getChannelIcon(channel.channelType); const channelDescription = channel.description.trim(); const description = channelDescription || (onEdit ? "Add a description" : null); @@ -42,7 +34,11 @@ export function ChannelHero({ data-testid="channel-management-hero" >
- + {channel.channelType === "dm" ? ( + + ) : ( + + )}
{channel.channelType !== "dm" && onEdit ? ( - - - - - Members - - {memberCount} - - - {huddleIndicator} - - - Manage channel - - - +
+ + + + + + + + Members + + {memberCount} + + + {huddleIndicator} + + + Manage channel + + + + {endActions} +
) : (
@@ -260,6 +265,8 @@ export function ChannelMembersBar({ Channel settings + + {endActions}
); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs new file mode 100644 index 00000000000..e4f8aa65e18 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getChannelIntroKind } from "./ChannelPane.helpers.ts"; + +function channel(overrides = {}) { + return { + ttlDeadline: null, + ttlSeconds: null, + visibility: "open", + ...overrides, + }; +} + +test("getChannelIntroKind names project homes ahead of regular streams", () => { + assert.equal(getChannelIntroKind(channel(), true), "project channel"); + assert.equal(getChannelIntroKind(channel(), false), "regular channel"); +}); + +test("getChannelIntroKind keeps private and ephemeral labels for other streams", () => { + assert.equal( + getChannelIntroKind(channel({ visibility: "private" })), + "private channel", + ); + assert.equal( + getChannelIntroKind(channel({ ttlSeconds: 3600 })), + "ephemeral channel", + ); +}); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index cb0600a28ae..2a645ba3876 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -3,7 +3,14 @@ import type { TimelineMessage } from "@/features/messages/types"; import type { Channel } from "@/shared/api/types"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; -export function getChannelIntroKind(channel: Channel): string { +export function getChannelIntroKind( + channel: Channel, + projectHome = false, +): string { + if (projectHome) { + return "project channel"; + } + const isPrivate = channel.visibility === "private"; const isEphemeral = isEphemeralChannel(channel); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index bccc163ed40..beb9a992bc1 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Hash, LogIn } from "lucide-react"; +import { LogIn } from "lucide-react"; import { AnimatePresence } from "motion/react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; @@ -27,6 +27,7 @@ import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeig import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel"; +import { IdleAuxiliaryPanel } from "@/features/channels/ui/IdleAuxiliaryPanel"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; import { ThreadViewModeToggle } from "@/features/channels/ui/ThreadViewModeToggle"; import { FocusThreadDrawer } from "@/features/channels/ui/FocusThreadDrawer"; @@ -46,6 +47,7 @@ import { import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers"; import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; +import { ChannelGlyph } from "@/features/channels/ui/ChannelGlyph"; import { useChannelIntro } from "@/features/channels/ui/useChannelIntro"; import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection"; @@ -77,6 +79,8 @@ export const ChannelPane = React.memo(function ChannelPane({ editTarget = null, fetchOlder, header, + idleAuxiliaryPanel = null, + idleAuxiliaryTitle = "", hasOlderMessages, historyExhausted, isFetchingOlder, @@ -104,8 +108,10 @@ export const ChannelPane = React.memo(function ChannelPane({ onCloseAgentSession, onCloseChannelManagement, onChannelManagementDeleted, + onCloseIdleAuxiliaryPanel, onCloseProfilePanel, onAddAgent, + onAddFiles, onBrowseChannels, onCreateChannel, onCloseThread, @@ -396,6 +402,7 @@ export const ChannelPane = React.memo(function ChannelPane({ const standardChannelIntro = useChannelIntro({ activeChannel, onAddAgent, + onAddFiles, onBrowseChannels, onCreateChannel, onOpenMembers, @@ -457,9 +464,15 @@ export const ChannelPane = React.memo(function ChannelPane({ threadViewMode === "focus" && useSplitAuxiliaryPane && (Boolean(threadHeadMessage) || shouldShowThreadSkeleton); + const useFocusIdleDrawer = + useSplitAuxiliaryPane && + Boolean(idleAuxiliaryPanel) && + Boolean(onCloseIdleAuxiliaryPanel); const { channelIsCovered, markExitComplete } = useFocusDrawerPresence( - useFocusThreadDrawer, - onCloseThread, + useFocusThreadDrawer || useFocusIdleDrawer, + useFocusThreadDrawer + ? onCloseThread + : (onCloseIdleAuxiliaryPanel ?? onCloseThread), ); const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } = useThreadViewModeSwitch({ @@ -516,6 +529,19 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : ( wrapAux(panel, "message-thread-panel", { key: THREAD_SURFACE_KEY }) ); + const wrapIdlePanel = (panel: React.ReactNode) => + useFocusIdleDrawer && onCloseIdleAuxiliaryPanel ? ( + + {panel} + + ) : ( + wrapAux(panel, "idle-auxiliary-panel") + ); const threadHeaderLeading = useSplitAuxiliaryPane ? ( ) : undefined; @@ -643,7 +669,12 @@ export const ChannelPane = React.memo(function ChannelPane({ className="flex items-center gap-3 border-t border-border/80 bg-card/50 px-5 py-3" >
- + {activeChannel ? ( + + ) : null} Viewing{" "} @@ -935,6 +966,22 @@ export const ChannelPane = React.memo(function ChannelPane({ ); return wrapAux(panel, "user-profile-panel"); })() + ) : idleAuxiliaryPanel && onCloseIdleAuxiliaryPanel ? ( + wrapIdlePanel( + + {idleAuxiliaryPanel} + , + ) ) : null}
diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 760ef58073b..68fab10d487 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -46,6 +46,13 @@ export type ChannelPaneProps = { } | null; fetchOlder?: () => Promise; header?: React.ReactNode; + /** + * Idle-state body for the right auxiliary pane (project extras, etc.). + * Shown only when no thread, profile, agent session, or channel-management + * panel is open — the same slot as those panels. + */ + idleAuxiliaryPanel?: React.ReactNode; + idleAuxiliaryTitle?: string; hasOlderMessages?: boolean; /** True when the loaded window provably starts at the channel's beginning. */ historyExhausted?: boolean; @@ -79,8 +86,10 @@ export type ChannelPaneProps = { onCloseAgentSession: () => void; onCloseChannelManagement?: () => void; onChannelManagementDeleted?: () => void; + onCloseIdleAuxiliaryPanel?: () => void; onCloseProfilePanel: () => void; onAddAgent?: (options?: { beforeSend?: () => void }) => void; + onAddFiles?: () => void; onBrowseChannels?: () => void; onCreateChannel?: () => void; onCloseThread: () => void; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 6254afd8c71..b01e9f9d265 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -88,6 +88,11 @@ export function ChannelScreen({ autoSendDraftKey, currentIdentity, currentProfile, + headerEndActions, + idleAuxiliaryPanel, + idleAuxiliaryTitle, + onAddFiles, + onCloseIdleAuxiliaryPanel, onCloseForumPost, onSelectForumPost, selectedForumPostId, @@ -742,6 +747,7 @@ export function ChannelScreen({ activeDmPresenceStatus={activeDmPresenceStatus} chromeWrapperRef={channelHeaderChromeRef} currentPubkey={currentPubkey} + headerEndActions={headerEndActions} isAddBotOpen={isAddBotOpen} isJoining={joinChannelMutation.isPending} onAddBotOpenChange={setIsAddBotOpen} @@ -762,6 +768,7 @@ export function ChannelScreen({ activeDmPresenceStatus, channelHeaderChromeRef, currentPubkey, + headerEndActions, isAddBotOpen, joinChannelMutation.isPending, joinChannelMutation.mutateAsync, @@ -845,9 +852,12 @@ export function ChannelScreen({ canResetThreadPanelWidth={canResetThreadPanelWidth} fetchOlder={fetchOlder} header={channelHeader} + idleAuxiliaryPanel={idleAuxiliaryPanel} + idleAuxiliaryTitle={idleAuxiliaryTitle} hasOlderMessages={hasOlderMessages} historyExhausted={historyExhausted} onAddAgent={handleOpenAddBot} + onAddFiles={onAddFiles} onBrowseChannels={openBrowseChannels} onCreateChannel={openCreateChannel} onOpenMembers={handleOpenMembersSidebar} @@ -900,6 +910,7 @@ export function ChannelScreen({ : undefined } onCloseChannelManagement={handleCloseChannelManagement} + onCloseIdleAuxiliaryPanel={onCloseIdleAuxiliaryPanel} onCloseThread={handleCloseThread} onDelete={ activeChannel?.archivedAt ? undefined : handleDelete diff --git a/desktop/src/features/channels/ui/ChannelScreen.types.ts b/desktop/src/features/channels/ui/ChannelScreen.types.ts index 371af6faf5d..223b69b8092 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.types.ts +++ b/desktop/src/features/channels/ui/ChannelScreen.types.ts @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; + import type { Channel, Identity, @@ -16,6 +18,11 @@ export type ChannelScreenProps = { autoSendDraftKey: string | null; currentIdentity?: Identity; currentProfile?: Profile; + idleAuxiliaryPanel?: ReactNode; + idleAuxiliaryTitle?: string; + headerEndActions?: ReactNode; + onAddFiles?: () => void; + onCloseIdleAuxiliaryPanel?: () => void; onCloseForumPost: () => void; onSelectForumPost: (postId: string) => void; selectedForumPostId: string | null; diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 358a0e637bb..44e4d891dc1 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -6,6 +6,7 @@ import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralC import type { ActiveDmHeaderParticipant } from "@/features/channels/useActiveChannelHeader"; import { getChannelDescription } from "@/features/channels/lib/channelDescription"; import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay"; +import { ChannelGlyph } from "@/features/channels/ui/ChannelGlyph"; import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge"; import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar"; import { @@ -38,6 +39,7 @@ type ChannelScreenHeaderProps = { activeDmPresenceStatus: PresenceStatus | null; chromeWrapperRef?: React.Ref; currentPubkey?: string; + headerEndActions?: React.ReactNode; isAddBotOpen?: boolean; isJoining?: boolean; showHeaderContent?: boolean; @@ -58,6 +60,7 @@ export function ChannelScreenHeader({ activeDmPresenceStatus, chromeWrapperRef, currentPubkey, + headerEndActions, isAddBotOpen, isJoining = false, onAddBotOpenChange, @@ -95,19 +98,23 @@ export function ChannelScreenHeader({ ) : null; const channelActions = activeChannel ? ( showJoinButton ? ( - +
+ + {headerEndActions} +
) : ( ) - ) : null; - const actions = activeChannel ? ( -
- {terminalButton} - {channelActions} -
- ) : null; + ) : ( + headerEndActions + ); + const actions = + terminalButton || channelActions ? ( +
+ {terminalButton} + {channelActions} +
+ ) : null; if (!showHeaderContent) { return null; @@ -173,6 +183,11 @@ export function ChannelScreenHeader({ testId="chat-header-dm-avatar" /> ) + ) : activeChannel ? ( + ) : undefined } statusBadge={ diff --git a/desktop/src/features/channels/ui/ChannelTypePicker.tsx b/desktop/src/features/channels/ui/ChannelTypePicker.tsx index 78e2c1080bb..b69e382bbb2 100644 --- a/desktop/src/features/channels/ui/ChannelTypePicker.tsx +++ b/desktop/src/features/channels/ui/ChannelTypePicker.tsx @@ -1,6 +1,8 @@ import { ChevronDown, ClockFading, Hash } from "lucide-react"; import * as React from "react"; +import type { ChannelLifecycle } from "@/features/channels/lib/channelLifecycle"; +import { ProjectChannelIcon } from "@/features/projects/ui/ProjectChannelIcon"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { @@ -11,37 +13,57 @@ import { DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +const LIFECYCLE_LABEL: Record = { + ongoing: "Ongoing", + project: "Project", + temporary: "Temporary", +}; + +const LIFECYCLE_ICON = { + ongoing: Hash, + temporary: ClockFading, +} as const; + export function ChannelTypePicker({ align = "start", + allowProject = false, ariaLabel, className, disabled, + lifecycle, + onLifecycleChange, onOpenChange, - onTemporaryChange, open, - temporary, temporaryOptionAriaLabel = "Temporary channel", testId, }: { align?: React.ComponentProps["align"]; + allowProject?: boolean; ariaLabel?: string; className?: string; disabled?: boolean; + lifecycle: ChannelLifecycle; + onLifecycleChange: (lifecycle: Exclude) => void; onOpenChange?: (open: boolean) => void; - onTemporaryChange: (temporary: boolean) => void; open?: boolean; - temporary: boolean; temporaryOptionAriaLabel?: string; testId?: string; }) { const [internalOpen, setInternalOpen] = React.useState(false); const pickerOpen = open ?? internalOpen; const setPickerOpen = onOpenChange ?? setInternalOpen; - const label = temporary ? "Temporary" : "Ongoing"; - const Icon = temporary ? ClockFading : Hash; + const label = LIFECYCLE_LABEL[lifecycle]; + const Icon = lifecycle === "project" ? null : LIFECYCLE_ICON[lifecycle]; + const projectLocked = lifecycle === "project"; function selectType(nextType: string) { - onTemporaryChange(nextType === "temporary"); + if (nextType === "project" || projectLocked) { + setPickerOpen(false); + return; + } + if (nextType === "temporary" || nextType === "ongoing") { + onLifecycleChange(nextType); + } setPickerOpen(false); } @@ -59,7 +81,11 @@ export function ChannelTypePicker({ type="button" variant="ghost" > - + {Icon ? ( + + ) : ( + + )} {label} @@ -71,15 +97,22 @@ export function ChannelTypePicker({ minWidth: "var(--radix-dropdown-menu-trigger-width)", }} > - - + + {allowProject ? ( + + Project + + ) : null} + Ongoing Temporary diff --git a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx index 6883f4cad1a..82fd1c9f7a1 100644 --- a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx +++ b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx @@ -1,10 +1,16 @@ import { ChevronDown } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { + channelLifecycle, + channelLifecycleLabel, +} from "@/features/channels/lib/channelLifecycle"; import { DEFAULT_EPHEMERAL_TTL_SECONDS, formatTtlDuration, } from "@/features/channels/lib/ephemeralChannel"; +import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel"; +import type { Channel } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -13,6 +19,7 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { EditableInfoFieldRow } from "./ChannelManagementSheetRows"; import { ChannelTypePicker } from "./ChannelTypePicker"; const EPHEMERAL_TIMEOUT_OPTIONS = [ @@ -32,7 +39,34 @@ const CHANNEL_TYPE_RESIZE_TRANSITION = { ease: [0.23, 1, 0.32, 1], } as const; +export function ChannelTypeDetailRow({ + canEdit, + channel, + onEdit, +}: { + canEdit: boolean; + channel: Channel; + onEdit?: () => void; +}) { + const projectHome = useIsProjectHomeChannel(channel.id); + const lifecycle = channelLifecycle({ + projectHome, + temporary: channel.ttlSeconds !== null, + }); + + return ( + + ); +} + export function ChannelTypeSettings({ + channelId, disabled, label = "Channel type", onOpenChange, @@ -43,6 +77,7 @@ export function ChannelTypeSettings({ testIdPrefix, ttlSeconds, }: { + channelId?: string | null; disabled?: boolean; label?: string; onOpenChange?: (open: boolean) => void; @@ -53,6 +88,8 @@ export function ChannelTypeSettings({ testIdPrefix: string; ttlSeconds: number; }) { + const projectHome = useIsProjectHomeChannel(channelId); + const lifecycle = channelLifecycle({ projectHome, temporary }); const shouldReduceMotion = useReducedMotion(); const channelTypeResizeTransition = shouldReduceMotion ? { duration: 0 } @@ -82,17 +119,18 @@ export function ChannelTypeSettings({ {label} onTemporaryChange(next === "temporary")} onOpenChange={onOpenChange} - onTemporaryChange={onTemporaryChange} open={open} - temporary={temporary} testId={`${testIdPrefix}-channel-type`} /> - {temporary ? ( + {temporary && !projectHome ? ( void; }; @@ -139,6 +141,7 @@ const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const; export function FocusThreadDrawer({ channelName, children, + label = "Thread", onClose, }: FocusThreadDrawerProps) { const prefersReducedMotion = useReducedMotion(); @@ -220,7 +223,7 @@ export function FocusThreadDrawer({ // see the token for why a `border-l` cannot. "absolute inset-y-0 right-0 flex flex-col overflow-hidden rounded-l-2xl bg-background shadow-panel-left", )} - aria-label="Thread" + aria-label={label} data-testid="focus-thread-drawer" ref={drawerRef} role="complementary" diff --git a/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx b/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx new file mode 100644 index 00000000000..f254efb0989 --- /dev/null +++ b/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx @@ -0,0 +1,64 @@ +import type * as React from "react"; + +import { + AuxiliaryPanel, + AuxiliaryPanelBody, + AuxiliaryPanelHeader, + AuxiliaryPanelHeaderGroup, + AuxiliaryPanelTitle, +} from "@/shared/layout/AuxiliaryPanel"; + +export function IdleAuxiliaryPanel({ + canResetWidth, + children, + isFocusDrawer = false, + isSinglePanelView, + onClose, + onResetWidth, + onResizeStart, + title, + useSplitAuxiliaryPane, + widthPx, +}: { + canResetWidth: boolean; + children: React.ReactNode; + isFocusDrawer?: boolean; + isSinglePanelView: boolean; + onClose: () => void; + onResetWidth: () => void; + onResizeStart: React.PointerEventHandler; + title: string; + useSplitAuxiliaryPane: boolean; + widthPx: number; +}) { + const split = useSplitAuxiliaryPane && !isFocusDrawer; + return ( + + + {title} + + + } + > + + {children} + + + ); +} diff --git a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx index 43e4d80e6ba..d46de8eb7d7 100644 --- a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx +++ b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx @@ -6,6 +6,7 @@ import { cn } from "@/shared/lib/cn"; type RightAuxiliaryPaneProps = { canResetWidth: boolean; children: React.ReactNode; + className?: string; constrainToAvailableSpace?: boolean; detached?: boolean; onResetWidth: () => void; @@ -17,6 +18,7 @@ type RightAuxiliaryPaneProps = { export function RightAuxiliaryPane({ canResetWidth, children, + className, constrainToAvailableSpace = true, detached = false, onResetWidth, @@ -31,6 +33,7 @@ export function RightAuxiliaryPane({ detached ? "bg-transparent" : "before:pointer-events-none before:absolute before:bottom-0 before:left-0 before:top-0 before:z-50 before:w-px before:bg-border/80 before:content-['']", + className, )} data-testid={testId} style={{ diff --git a/desktop/src/features/channels/ui/useChannelIntro.tsx b/desktop/src/features/channels/ui/useChannelIntro.tsx index 19f2da0edbc..910d836ff08 100644 --- a/desktop/src/features/channels/ui/useChannelIntro.tsx +++ b/desktop/src/features/channels/ui/useChannelIntro.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Bot, Plus, Sparkles, UserPlus } from "lucide-react"; +import { Bot, FolderPlus, Plus, Sparkles, UserPlus } from "lucide-react"; import { getChannelIntroDescription, @@ -29,6 +29,7 @@ type ChannelIntroAction = { export function useChannelIntro({ activeChannel, onAddAgent, + onAddFiles, onBrowseChannels, onCreateChannel, onOpenMembers, @@ -36,6 +37,7 @@ export function useChannelIntro({ }: { activeChannel: Channel | null; onAddAgent?: (options?: { beforeSend?: () => void }) => void; + onAddFiles?: () => void; onBrowseChannels?: () => void; onCreateChannel?: () => void; onOpenMembers?: () => void; @@ -89,6 +91,16 @@ export function useChannelIntro({ } if (!activeChannel.archivedAt && activeChannel.isMember) { + if (onAddFiles) { + actions.push({ + description: "Add a repo.", + icon: , + label: "Add files", + onClick: onAddFiles, + testId: "channel-intro-action-add-files", + }); + } + if (onAddAgent) { actions.push({ description: "Bring them in.", @@ -119,6 +131,7 @@ export function useChannelIntro({ }, [ activeChannel, onAddAgent, + onAddFiles, onBrowseChannels, onCreateChannel, onOpenMembers, diff --git a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs index a0374fbe2b0..ad09e4e6f40 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs +++ b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs @@ -423,7 +423,7 @@ test("timeline-body-surface: loading and deferred-pending both paint the single test("timeline-body-surface: first authoritative rows wait for deferred paint", () => { // A newly selected populated channel has already resolved live rows, but the // deferred snapshot is still empty. It has never committed a settled empty - // surface, so showing its intro here would flash Create agent / Add people. + // surface, so showing its intro here would flash Add agent / Add people. assert.equal( selectTimelineBodySurface({ deferredCount: 0, diff --git a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx index 76d1960b966..c69fd46a32d 100644 --- a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx +++ b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx @@ -63,7 +63,7 @@ export function ChannelIntroBlock({

) : null} {intro.actions?.length ? ( -
+
{intro.actions.map((action) => { const hasDescription = Boolean(action.description); @@ -72,8 +72,8 @@ export function ChannelIntroBlock({ className={cn( "flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring", hasDescription - ? "h-56 w-[13.75rem] flex-col rounded-2xl p-4" - : "h-28 w-64 flex-col rounded-2xl p-4", + ? "h-52 w-48 flex-col rounded-2xl p-3" + : "h-24 w-56 flex-col rounded-2xl p-3", )} data-testid={action.testId} key={action.label} @@ -84,8 +84,8 @@ export function ChannelIntroBlock({ className={cn( "flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground", hasDescription - ? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6" - : "h-10 w-10 [&_svg]:h-4 [&_svg]:w-4", + ? "h-10 w-10 [&_svg]:h-5 [&_svg]:w-5" + : "h-9 w-9 [&_svg]:h-4 [&_svg]:w-4", )} data-testid={ action.testId ? `${action.testId}-icon` : undefined @@ -95,7 +95,7 @@ export function ChannelIntroBlock({ ; + projectIds: Set; +}; + +function formatAgentFailures( + failures: ReadonlyArray<{ name: string; error: string }>, +) { + if (failures.length === 1) { + const [failure] = failures; + return `The project was created, but adding ${failure.name} failed: ${failure.error}`; + } + return `The project was created, but adding agents failed: ${failures + .map((failure) => `${failure.name}: ${failure.error}`) + .join("; ")}`; +} + +async function publishProjectEvent(event: RelayEvent) { + try { + await relayClient.publishEvent( + event, + "Timed out creating project.", + "Failed to create project.", + ); + } catch (error) { + if (isUnsupportedProjectKindError(error)) { + throw new Error( + "This relay does not support projects yet, so a project channel cannot be published here.", + ); + } + throw error; + } +} + +async function publishRepositoryEvent(event: RelayEvent) { + await relayClient.publishEvent( + event, + "Timed out creating the project repository.", + "Failed to create the project repository.", + ); +} + +function readCreatedProject( + projectEvent: RelayEvent, + repositoryEvent: RelayEvent | null, +): Project { + const [project] = buildProjectReadModels({ + projectEvents: [projectEvent], + repositoryEvents: repositoryEvent ? [repositoryEvent] : [], + relayOrigin: getCachedRelayOrigin(), + }); + if (!project) { + throw new Error("The project was created but could not be read."); + } + return project; +} + +async function addRequestedAgents( + channelId: string, + agents: readonly CreateChannelManagedAgentInput[] | undefined, +) { + if (!agents || agents.length === 0) return; + const result = await createChannelManagedAgents(channelId, agents); + if (result.failures.length > 0) { + throw new Error(formatAgentFailures(result.failures)); + } +} + +async function fetchOwnHead( + kind: number, + ownerPubkey: string, + dtag: string, +): Promise { + const events = await relayClient.fetchEvents({ + kinds: [kind], + authors: [ownerPubkey], + "#d": [dtag], + limit: 1, + }); + return events[0] ?? null; +} + +async function ensureDefaultRepository({ + channelId, + input, + ownerPubkey, + project, +}: { + channelId: string; + input: CreateProjectInput; + ownerPubkey: string; + project: Project; +}): Promise { + const repositoryTemplate = buildDefaultProjectRepositoryTemplate({ + description: input.description, + name: input.name, + ownerPubkey, + projectChannelId: channelId, + }); + const existingRepository = + project.repositories.find( + (repository) => + repository.repoAddress === repositoryTemplate.repositoryAddress, + ) ?? null; + if (existingRepository) return project; + + let repositoryEvent = await fetchOwnHead( + KIND_REPO_ANNOUNCEMENT, + ownerPubkey, + repositoryTemplate.dtag, + ); + if (!repositoryEvent) { + repositoryEvent = await signRelayEvent(repositoryTemplate.repository); + await publishRepositoryEvent(repositoryEvent); + } + + if ( + project.repositoryAddresses.includes(repositoryTemplate.repositoryAddress) + ) { + const liveProject = + (await fetchOwnHead( + KIND_PROJECT_ANNOUNCEMENT, + ownerPubkey, + project.dtag, + )) ?? null; + if (!liveProject) { + throw new Error("The project was created but could not be read."); + } + return readCreatedProject(liveProject, repositoryEvent); + } + + const liveHead = await fetchOwnHead( + KIND_PROJECT_ANNOUNCEMENT, + ownerPubkey, + project.dtag, + ); + if (!liveHead) { + throw new Error( + "Could not find this project on the relay. Refresh and try again.", + ); + } + const patched = buildProjectPatchTemplate({ + liveHead, + ownerPubkey, + repositoryAddresses: [ + ...new Set([ + ...project.repositoryAddresses, + repositoryTemplate.repositoryAddress, + ]), + ].sort(), + }); + const projectEvent = await signRelayEvent(patched); + await publishProjectEvent(projectEvent); + return readCreatedProject(projectEvent, repositoryEvent); +} + +async function finishCreate( + channel: Channel | null, + project: Project, + input: CreateProjectInput, + resume: CreateProjectResumeState, + projectId: string, +): Promise { + const agentChannelId = channel?.id ?? project.projectChannelId; + if (agentChannelId && input.agents && input.agents.length > 0) { + await addRequestedAgents(agentChannelId, input.agents); + } + resume.projectIds.delete(projectId); + resume.channels.delete(projectId); + return { channel, project }; +} + +/** Creates the home channel, a bound default repository, and the NIP-MP project. */ +export async function createProject( + input: CreateProjectInput, + resume: CreateProjectResumeState, +): Promise { + const identity = await getIdentity(); + const dtagPreview = projectDtagFromName(input.name); + if (!dtagPreview) { + throw new Error("Project name must include letters or numbers."); + } + const existing = await fetchProjects(); + const ownerPubkey = identity.pubkey.toLowerCase(); + const existingProject = existing.find( + (project) => + project.owner.toLowerCase() === ownerPubkey && + project.dtag === dtagPreview, + ); + const projectId = `${ownerPubkey}:${dtagPreview}`; + const canResume = resume.projectIds.has(projectId); + if (existingProject && !canResume) { + throw new Error(`You already have a project named "${dtagPreview}".`); + } + if (existingProject && !existingProject.legacy) { + const cachedChannel = resume.channels.get(projectId) ?? null; + const channelId = + cachedChannel?.id ?? existingProject.projectChannelId ?? ""; + const project = channelId + ? await ensureDefaultRepository({ + channelId, + input, + ownerPubkey, + project: existingProject, + }) + : existingProject; + return finishCreate(cachedChannel, project, input, resume, projectId); + } + const conflict = conflictingListedProject(existing, { + dtag: dtagPreview, + name: input.name, + ownerPubkey, + }); + if (conflict) { + throw new Error( + `A project named "${conflict.name}" already exists. Open that one instead of creating another.`, + ); + } + + resume.projectIds.add(projectId); + let channel = resume.channels.get(projectId); + if (!channel) { + channel = await createChannel({ + channelType: "stream", + description: input.description, + name: input.name.trim(), + visibility: input.channelVisibility ?? "open", + }); + resume.channels.set(projectId, channel); + } + + const templates = buildProjectBootstrapTemplates({ + description: input.description, + name: input.name, + ownerPubkey: identity.pubkey, + projectChannelId: channel.id, + projectVisibility: input.projectVisibility ?? "listed", + }); + const existingRepositoryEvent = await fetchOwnHead( + KIND_REPO_ANNOUNCEMENT, + ownerPubkey, + templates.dtag, + ); + const projectEvent = await signRelayEvent(templates.project); + await publishProjectEvent(projectEvent); + + let repositoryEvent = existingRepositoryEvent; + if (!repositoryEvent) { + repositoryEvent = await signRelayEvent(templates.repository); + await publishRepositoryEvent(repositoryEvent); + } + + const project = readCreatedProject(projectEvent, repositoryEvent); + return finishCreate(channel, project, input, resume, projectId); +} diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index ebfc15a083e..4e26287f65e 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -172,9 +172,11 @@ export async function fetchProjects( signal?: AbortSignal, ): Promise { // Delegates to `buildProjectsFromFetcher` in `projectEnumeration.ts`, which - // is the pure, Tauri-free core of this operation. That helper's javadoc - // explains the fail-closed tombstone contract and the NIP-OA owner-deletion - // relay-side-suppression decision. + // is the pure, Tauri-free core of this operation. Its javadoc explains + // fail-closed tombstones and NIP-OA owner-deletion suppression. + const viewerPubkey = await getIdentity() + .then((identity) => identity.pubkey) + .catch(() => undefined); const fetcher: FetchProjectEventsExhaustively = fetchExhaustively ?? ((kinds, extraFilter) => @@ -182,6 +184,7 @@ export async function fetchProjects( return buildProjectsFromFetcher(fetcher, { relayOrigin: getCachedRelayOrigin(), hiddenAddresses: new Set(readHiddenProjectCards()), + viewerPubkey, }); } diff --git a/desktop/src/features/projects/lib/projectCollection.test.mjs b/desktop/src/features/projects/lib/projectCollection.test.mjs new file mode 100644 index 00000000000..d504234e131 --- /dev/null +++ b/desktop/src/features/projects/lib/projectCollection.test.mjs @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + absorbStandaloneProjectRepositories, + homeRepositoriesToBind, +} from "./projectCollection.ts"; + +const OWNER = "a".repeat(64); +const AGENT = "b".repeat(64); +const CHANNEL = "11111111-1111-4111-8111-111111111111"; + +function explicitProject(overrides = {}) { + return { + id: `30621:${OWNER}:space-invaders-3d`, + dtag: "space-invaders-3d", + name: "Space Invaders 3D", + description: "Recreating Space Invaders the Game but in 3D", + owner: OWNER, + createdAt: 100, + projectChannelId: CHANNEL, + relatedChannelIds: [], + status: "active", + projectAddress: `30621:${OWNER}:space-invaders-3d`, + primaryRepositoryAddress: null, + repositoryAddresses: [], + repositoryRelayHints: {}, + repositories: [], + unavailableRepositoryAddresses: [], + visibility: "listed", + legacy: false, + ...overrides, + }; +} + +function standaloneRepo(overrides = {}) { + const owner = overrides.owner ?? AGENT; + const dtag = overrides.dtag ?? "space-invaders-3d"; + const repoAddress = `30617:${owner}:${dtag}`; + const repository = { + id: `${owner}:${dtag}`, + dtag, + name: "Space Invaders 3D", + description: "A 3D remake of Space Invaders built with three.js", + cloneUrls: [], + webUrl: null, + owner, + contributors: [owner], + createdAt: 200, + status: "active", + defaultBranch: "main", + repoAddress, + channelId: CHANNEL, + ...overrides.repository, + }; + return { + id: repoAddress, + dtag, + name: repository.name, + description: repository.description, + owner, + createdAt: 200, + projectChannelId: null, + relatedChannelIds: [], + status: "active", + projectAddress: repoAddress, + primaryRepositoryAddress: repoAddress, + repositoryAddresses: [repoAddress], + repositoryRelayHints: {}, + repositories: [repository], + unavailableRepositoryAddresses: [], + visibility: "listed", + legacy: true, + ...overrides.card, + }; +} + +test("absorbStandaloneProjectRepositories folds a home-channel repo into the project", () => { + const project = explicitProject(); + const repoCard = standaloneRepo(); + const folded = absorbStandaloneProjectRepositories([project, repoCard]); + + assert.equal(folded.length, 1); + assert.equal(folded[0].legacy, false); + assert.equal(folded[0].repositories.length, 1); + assert.equal(folded[0].repositories[0].repoAddress, repoCard.projectAddress); + assert.equal(folded[0].repositoryAddresses[0], repoCard.projectAddress); +}); + +test("absorbStandaloneProjectRepositories folds the owner's same-slug repo", () => { + const project = explicitProject(); + const repoCard = standaloneRepo({ + owner: OWNER, + repository: { channelId: null }, + }); + const folded = absorbStandaloneProjectRepositories([project, repoCard]); + + assert.equal(folded.length, 1); + assert.equal(folded[0].repositories[0].owner, OWNER); +}); + +test("absorbStandaloneProjectRepositories keeps an unrelated standalone repo", () => { + const project = explicitProject(); + const repoCard = standaloneRepo({ + dtag: "other-game", + owner: AGENT, + repository: { channelId: null, dtag: "other-game" }, + }); + const folded = absorbStandaloneProjectRepositories([project, repoCard]); + + assert.equal(folded.length, 2); + assert.equal( + folded.some((item) => item.legacy), + true, + ); +}); + +test("homeRepositoriesToBind lists absorbed channel repos missing from the signed project", () => { + const project = explicitProject({ + repositories: [standaloneRepo().repositories[0]], + repositoryAddresses: [standaloneRepo().projectAddress], + }); + const pending = homeRepositoriesToBind(project, []); + assert.equal(pending.length, 1); + assert.equal(pending[0].repoAddress, standaloneRepo().projectAddress); +}); + +test("homeRepositoriesToBind ignores repos already on the signed project", () => { + const repo = standaloneRepo().repositories[0]; + const project = explicitProject({ + repositories: [repo], + repositoryAddresses: [repo.repoAddress], + }); + assert.equal(homeRepositoriesToBind(project, [repo.repoAddress]).length, 0); +}); diff --git a/desktop/src/features/projects/lib/projectCollection.ts b/desktop/src/features/projects/lib/projectCollection.ts new file mode 100644 index 00000000000..73924c06f4a --- /dev/null +++ b/desktop/src/features/projects/lib/projectCollection.ts @@ -0,0 +1,103 @@ +import type { Project, Repository } from "@/features/projects/projectModels"; + +function withAbsorbedRepository( + project: Project, + repository: Repository, +): Project { + if (project.repositoryAddresses.includes(repository.repoAddress)) { + return project; + } + return { + ...project, + primaryRepositoryAddress: + project.primaryRepositoryAddress ?? repository.repoAddress, + repositories: [...project.repositories, repository], + repositoryAddresses: [ + ...project.repositoryAddresses, + repository.repoAddress, + ], + }; +} + +function hostForStandaloneRepository( + explicitProjects: Project[], + repository: Repository, +): Project | undefined { + const channelHost = repository.channelId + ? explicitProjects.find( + (project) => project.projectChannelId === repository.channelId, + ) + : undefined; + if (channelHost) return channelHost; + return explicitProjects.find( + (project) => + project.owner === repository.owner && project.dtag === repository.dtag, + ); +} + +function repositoryBelongsOnProjectHome( + project: Project, + repository: Repository, +): boolean { + return Boolean( + (repository.channelId && + repository.channelId === project.projectChannelId) || + (repository.owner === project.owner && repository.dtag === project.dtag), + ); +} + +/** + * Repositories already shown on the project (after absorb) that are not yet + * on the signed `kind:30621` `a` tag set. The owner should bind them so + * other clients see the same grouping. + */ +export function homeRepositoriesToBind( + project: Project, + signedAddresses: ReadonlyArray | ReadonlySet, +): Repository[] { + const signed = new Set(signedAddresses); + return project.repositories.filter( + (repository) => + !signed.has(repository.repoAddress) && + repositoryBelongsOnProjectHome(project, repository), + ); +} + +/** + * After the NIP-MP fold, keep a repository off the standalone-project list + * when it already belongs to a listing-eligible project's home channel, or + * when the same owner already has an explicit project with that slug. + * + * Agents often announce a repo (`repos create --channel`) without + * `projects add-repo`. Without this, the same work shows up as a second card. + */ +export function absorbStandaloneProjectRepositories( + projects: Project[], +): Project[] { + const explicitProjects = projects.filter((project) => !project.legacy); + if (explicitProjects.length === 0) return projects; + + const absorbed = new Set(); + let nextExplicit = explicitProjects; + for (const card of projects) { + if (!card.legacy) continue; + const repository = card.repositories[0]; + if (!repository) continue; + const host = hostForStandaloneRepository(nextExplicit, repository); + if (!host) continue; + absorbed.add(card.projectAddress); + nextExplicit = nextExplicit.map((project) => + project.projectAddress === host.projectAddress + ? withAbsorbedRepository(project, repository) + : project, + ); + } + + if (absorbed.size === 0) return projects; + return [ + ...nextExplicit, + ...projects.filter( + (project) => project.legacy && !absorbed.has(project.projectAddress), + ), + ]; +} diff --git a/desktop/src/features/projects/lib/projectHomeChannel.test.mjs b/desktop/src/features/projects/lib/projectHomeChannel.test.mjs new file mode 100644 index 00000000000..5c7eeb7335c --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeChannel.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isProjectHomeChannel } from "./projectHomeChannel.ts"; + +const OWNER = "a".repeat(64); +const MAINTAINER = "b".repeat(64); + +function project(overrides = {}) { + return { + owner: OWNER, + projectChannelId: "channel-a", + repositories: [ + { + channelId: "channel-a", + owner: OWNER, + }, + ], + ...overrides, + }; +} + +test("isProjectHomeChannel accepts an owner-bound repository", () => { + assert.equal(isProjectHomeChannel("channel-a", [project()]), true); +}); + +test("isProjectHomeChannel accepts a repository that authorizes the project owner", () => { + assert.equal( + isProjectHomeChannel("channel-a", [ + project({ + owner: MAINTAINER.toUpperCase(), + repositories: [ + { + channelId: "channel-a", + maintainers: [OWNER, MAINTAINER], + owner: OWNER, + }, + ], + }), + ]), + true, + ); +}); + +test("isProjectHomeChannel rejects a bare project channel assertion", () => { + assert.equal( + isProjectHomeChannel("channel-a", [project({ repositories: [] })]), + false, + ); +}); + +test("isProjectHomeChannel rejects unauthorized and mismatched repository bindings", () => { + assert.equal( + isProjectHomeChannel("channel-a", [ + project({ + owner: MAINTAINER, + repositories: [{ channelId: "channel-a", owner: OWNER }], + }), + project({ + repositories: [{ channelId: "channel-b", owner: OWNER }], + }), + ]), + false, + ); +}); + +test("isProjectHomeChannel is false for unbound channels", () => { + assert.equal(isProjectHomeChannel("channel-z", [project()]), false); + assert.equal(isProjectHomeChannel(null, [project()]), false); +}); diff --git a/desktop/src/features/projects/lib/projectHomeChannel.ts b/desktop/src/features/projects/lib/projectHomeChannel.ts new file mode 100644 index 00000000000..fa408467fe4 --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeChannel.ts @@ -0,0 +1,42 @@ +import { useProjectsQuery } from "@/features/projects/hooks"; + +export type ProjectHomeCandidate = { + owner: string; + projectChannelId: string | null; + repositories: ReadonlyArray<{ + channelId?: string | null; + maintainers?: ReadonlyArray; + owner: string; + }>; +}; + +function hasAuthoritativeHomeBinding(project: ProjectHomeCandidate): boolean { + const channelId = project.projectChannelId; + if (!channelId) return false; + + const projectOwner = project.owner.toLowerCase(); + return project.repositories.some((repository) => { + if (repository.channelId !== channelId) return false; + if (repository.owner.toLowerCase() === projectOwner) return true; + return repository.maintainers?.some( + (maintainer) => maintainer.toLowerCase() === projectOwner, + ); + }); +} + +export function isProjectHomeChannel( + channelId: string | null | undefined, + projects: ReadonlyArray, +): boolean { + if (!channelId) return false; + return projects.some( + (project) => + project.projectChannelId === channelId && + hasAuthoritativeHomeBinding(project), + ); +} + +export function useIsProjectHomeChannel(channelId: string | null | undefined) { + const projectsQuery = useProjectsQuery(); + return isProjectHomeChannel(channelId, projectsQuery.data ?? []); +} diff --git a/desktop/src/features/projects/projectChannelCreation.test.mjs b/desktop/src/features/projects/projectChannelCreation.test.mjs new file mode 100644 index 00000000000..3782a919051 --- /dev/null +++ b/desktop/src/features/projects/projectChannelCreation.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { buildProjectRelatedChannelPatchTemplate } from "./projectChannelCreation.ts"; +import { + MAX_PROJECT_RELATED_CHANNELS, + PROJECT_RELATED_CHANNEL_TAG, +} from "./projectModels.ts"; + +const OWNER = "a".repeat(64); +const OTHER = "b".repeat(64); +const CHANNEL_A = "11111111-1111-4111-8111-111111111111"; +const CHANNEL_B = "22222222-2222-4222-8222-222222222222"; + +function liveHead(tags = []) { + return { + content: "", + created_at: 100, + id: "project-head", + kind: 30621, + pubkey: OWNER, + sig: "sig", + tags: [ + ["d", "sprout"], + ["name", "Sprout"], + ["buzz-channel", CHANNEL_A], + ...tags, + ], + }; +} + +test("appends a related channel tag and preserves the live head", () => { + const patched = buildProjectRelatedChannelPatchTemplate({ + channelId: CHANNEL_B, + liveHead: liveHead([["description", "A project"]]), + ownerPubkey: OWNER, + }); + + assert.equal(patched.alreadyBound, false); + assert.deepEqual(patched.project.tags, [ + ["d", "sprout"], + ["name", "Sprout"], + ["buzz-channel", CHANNEL_A], + ["description", "A project"], + [PROJECT_RELATED_CHANNEL_TAG, CHANNEL_B], + ]); +}); + +test("is idempotent when the related channel is already tagged", () => { + const patched = buildProjectRelatedChannelPatchTemplate({ + channelId: CHANNEL_B, + liveHead: liveHead([[PROJECT_RELATED_CHANNEL_TAG, CHANNEL_B]]), + ownerPubkey: OWNER, + }); + + assert.equal(patched.alreadyBound, true); + assert.equal( + patched.project.tags.filter((tag) => tag[0] === PROJECT_RELATED_CHANNEL_TAG) + .length, + 1, + ); +}); + +test("refuses to bind the home channel as a related channel", () => { + assert.throws( + () => + buildProjectRelatedChannelPatchTemplate({ + channelId: CHANNEL_A, + liveHead: liveHead(), + ownerPubkey: OWNER, + }), + /already this project's home/, + ); +}); + +test("only the project owner can add related channels", () => { + assert.throws( + () => + buildProjectRelatedChannelPatchTemplate({ + channelId: CHANNEL_B, + liveHead: liveHead(), + ownerPubkey: OTHER, + }), + /Only the project owner/, + ); +}); + +test("caps extra related channels", () => { + const tags = Array.from( + { length: MAX_PROJECT_RELATED_CHANNELS }, + (_, index) => { + const suffix = String(index + 1).padStart(12, "0"); + return [PROJECT_RELATED_CHANNEL_TAG, `33333333-3333-4333-8333-${suffix}`]; + }, + ); + assert.throws( + () => + buildProjectRelatedChannelPatchTemplate({ + channelId: CHANNEL_B, + liveHead: liveHead(tags), + ownerPubkey: OWNER, + }), + /extra channels/, + ); +}); diff --git a/desktop/src/features/projects/projectChannelCreation.ts b/desktop/src/features/projects/projectChannelCreation.ts new file mode 100644 index 00000000000..a75425c23be --- /dev/null +++ b/desktop/src/features/projects/projectChannelCreation.ts @@ -0,0 +1,71 @@ +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_PROJECT_ANNOUNCEMENT } from "@/shared/constants/kinds"; +import { + isValidProjectChannelId, + MAX_PROJECT_RELATED_CHANNELS, + PROJECT_RELATED_CHANNEL_TAG, + validateProjectEventEnvelope, +} from "@/features/projects/projectModels"; +import type { ProjectEventTemplate } from "./projectCreation"; + +/** + * Appends a `buzz-related-channel` tag to a live project head. Every other + * tag is preserved so adding a stream cannot erase unknown metadata. + */ +export function buildProjectRelatedChannelPatchTemplate({ + channelId, + liveHead, + ownerPubkey, +}: { + channelId: string; + liveHead: RelayEvent; + ownerPubkey: string; +}): { alreadyBound: boolean; project: ProjectEventTemplate } { + const normalizedOwner = ownerPubkey.trim().toLowerCase(); + if (normalizedOwner !== liveHead.pubkey.toLowerCase()) { + throw new Error("Only the project owner can add channels."); + } + const normalizedChannelId = channelId.trim(); + if (!isValidProjectChannelId(normalizedChannelId)) { + throw new Error("Project channel is invalid."); + } + const homeChannelId = liveHead.tags.find( + (tag) => tag[0] === "buzz-channel", + )?.[1]; + if (homeChannelId === normalizedChannelId) { + throw new Error("That channel is already this project's home."); + } + const existingRelated = liveHead.tags + .filter((tag) => tag[0] === PROJECT_RELATED_CHANNEL_TAG) + .map((tag) => tag[1]) + .filter((value): value is string => Boolean(value)); + if (existingRelated.includes(normalizedChannelId)) { + validateProjectEventEnvelope(liveHead.tags, liveHead.content); + return { + alreadyBound: true, + project: { + kind: KIND_PROJECT_ANNOUNCEMENT, + content: liveHead.content, + tags: liveHead.tags.map((tag) => [...tag]), + }, + }; + } + if (existingRelated.length >= MAX_PROJECT_RELATED_CHANNELS) { + throw new Error( + `A project cannot contain more than ${MAX_PROJECT_RELATED_CHANNELS} extra channels.`, + ); + } + const tags = [ + ...liveHead.tags.map((tag) => [...tag]), + [PROJECT_RELATED_CHANNEL_TAG, normalizedChannelId], + ]; + validateProjectEventEnvelope(tags, liveHead.content); + return { + alreadyBound: false, + project: { + kind: KIND_PROJECT_ANNOUNCEMENT, + content: liveHead.content, + tags, + }, + }; +} diff --git a/desktop/src/features/projects/projectCreation.test.mjs b/desktop/src/features/projects/projectCreation.test.mjs index ed6e9328cd2..9a3cf0212ba 100644 --- a/desktop/src/features/projects/projectCreation.test.mjs +++ b/desktop/src/features/projects/projectCreation.test.mjs @@ -2,77 +2,198 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - buildInitialProjectEventTemplates, + buildDefaultProjectRepositoryTemplate, + buildProjectAnnouncementTemplate, + buildProjectBootstrapTemplates, + conflictingListedProject, isUnsupportedProjectKindError, } from "./projectCreation.ts"; const OWNER = "a".repeat(64); const CHANNEL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; -test("buildInitialProjectEventTemplates emits a NIP-MP project", () => { - const templates = buildInitialProjectEventTemplates({ - accessChannelId: CHANNEL, - cloneUrl: "https://relay.example/git/owner/sprout.git", - description: "A multi-repository workspace", +test("buildProjectAnnouncementTemplate emits a channel-first NIP-MP project", () => { + const templates = buildProjectAnnouncementTemplate({ + description: "A workspace that starts as a conversation", name: "Sprout", ownerPubkey: OWNER, - webUrl: "https://example.com/sprout", + projectChannelId: CHANNEL, }); assert.equal(templates.dtag, "sprout"); assert.equal(templates.project.kind, 30621); - assert.equal(templates.repository.kind, 30617); assert.deepEqual(templates.project.tags, [ ["d", "sprout"], ["name", "Sprout"], ["buzz-channel", CHANNEL], - ["description", "A multi-repository workspace"], - ["a", `30617:${OWNER}:sprout`], + ["description", "A workspace that starts as a conversation"], ]); assert.equal(templates.project.content, ""); + assert.equal( + templates.project.tags.some((tag) => tag[0] === "a"), + false, + ); +}); + +test("buildProjectAnnouncementTemplate records unlisted visibility and members", () => { + const address = `30617:${OWNER}:sprout`; + const templates = buildProjectAnnouncementTemplate({ + name: "Sprout", + ownerPubkey: OWNER, + projectChannelId: CHANNEL, + projectVisibility: "unlisted", + repositoryAddresses: [address], + }); + + assert.deepEqual(templates.project.tags, [ + ["d", "sprout"], + ["name", "Sprout"], + ["buzz-channel", CHANNEL], + ["buzz-visibility", "unlisted"], + ["a", address], + ]); +}); + +test("buildProjectBootstrapTemplates binds a default repository to the home channel", () => { + const templates = buildProjectBootstrapTemplates({ + description: "A workspace that starts as a conversation", + name: "Sprout", + ownerPubkey: OWNER, + projectChannelId: CHANNEL, + }); + const repositoryAddress = `30617:${OWNER}:sprout`; + + assert.equal(templates.dtag, "sprout"); + assert.equal(templates.repositoryAddress, repositoryAddress); + assert.equal(templates.project.kind, 30621); + assert.equal(templates.repository.kind, 30617); + assert.deepEqual(templates.project.tags, [ + ["d", "sprout"], + ["name", "Sprout"], + ["buzz-channel", CHANNEL], + ["description", "A workspace that starts as a conversation"], + ["a", repositoryAddress], + ]); assert.deepEqual(templates.repository.tags, [ ["d", "sprout"], ["name", "Sprout"], ["buzz-channel", CHANNEL], - ["description", "A multi-repository workspace"], - ["clone", "https://relay.example/git/owner/sprout.git"], - ["web", "https://example.com/sprout"], + ["description", "A workspace that starts as a conversation"], ]); }); -test("buildInitialProjectEventTemplates rejects names without an identifier", () => { +test("buildDefaultProjectRepositoryTemplate uses the project slug as the repo id", () => { + const template = buildDefaultProjectRepositoryTemplate({ + name: "Space Invaders 3D", + ownerPubkey: OWNER, + projectChannelId: CHANNEL, + }); + + assert.equal(template.dtag, "space-invaders-3d"); + assert.equal(template.repositoryAddress, `30617:${OWNER}:space-invaders-3d`); +}); + +test("buildProjectAnnouncementTemplate rejects names without an identifier", () => { assert.throws( () => - buildInitialProjectEventTemplates({ - accessChannelId: CHANNEL, + buildProjectAnnouncementTemplate({ name: "!!!", ownerPubkey: OWNER, + projectChannelId: CHANNEL, }), /letters or numbers/, ); }); -test("buildInitialProjectEventTemplates enforces the description tag byte limit", () => { +test("buildProjectAnnouncementTemplate enforces the description tag byte limit", () => { assert.doesNotThrow(() => - buildInitialProjectEventTemplates({ - accessChannelId: CHANNEL, + buildProjectAnnouncementTemplate({ description: "🙂".repeat(512), name: "Sprout", ownerPubkey: OWNER, + projectChannelId: CHANNEL, }), ); assert.throws( () => - buildInitialProjectEventTemplates({ - accessChannelId: CHANNEL, + buildProjectAnnouncementTemplate({ description: "🙂".repeat(513), name: "Sprout", ownerPubkey: OWNER, + projectChannelId: CHANNEL, }), /2,048 bytes/, ); }); +test("buildProjectAnnouncementTemplate rejects an invalid project channel", () => { + assert.throws( + () => + buildProjectAnnouncementTemplate({ + name: "Sprout", + ownerPubkey: OWNER, + projectChannelId: "not-a-channel", + }), + /Project channel is invalid/, + ); +}); + +test("conflictingListedProject ignores the caller's own slug and legacy cards", () => { + assert.equal( + conflictingListedProject( + [ + { + dtag: "sprout", + legacy: false, + name: "Sprout", + owner: OWNER, + }, + ], + { dtag: "sprout", name: "Sprout", ownerPubkey: OWNER }, + ), + null, + ); + assert.equal( + conflictingListedProject( + [ + { + dtag: "sprout", + legacy: true, + name: "Sprout", + owner: "b".repeat(64), + }, + ], + { dtag: "sprout", name: "Sprout", ownerPubkey: OWNER }, + ), + null, + ); +}); + +test("conflictingListedProject blocks another listed project with the same name or slug", () => { + const other = { + dtag: "space-invaders-3d", + legacy: false, + name: "Space Invaders 3D", + owner: "b".repeat(64), + }; + assert.deepEqual( + conflictingListedProject([other], { + dtag: "space-invaders-3d", + name: "Space Invaders 3D", + ownerPubkey: OWNER, + }), + other, + ); + assert.deepEqual( + conflictingListedProject([other], { + dtag: "space-invaders-3d-remake", + name: "Space Invaders 3D", + ownerPubkey: OWNER, + }), + other, + ); +}); + test("isUnsupportedProjectKindError recognizes relay kind compatibility failures", () => { assert.equal( isUnsupportedProjectKindError( diff --git a/desktop/src/features/projects/projectCreation.ts b/desktop/src/features/projects/projectCreation.ts index a42cf7bfd79..38bd4521e0e 100644 --- a/desktop/src/features/projects/projectCreation.ts +++ b/desktop/src/features/projects/projectCreation.ts @@ -10,13 +10,18 @@ export type ProjectEventTemplate = { tags: string[][]; }; -export type InitialProjectEventTemplates = { +export type ProjectAnnouncementTemplate = { dtag: string; project: ProjectEventTemplate; +}; + +export type ProjectBootstrapTemplates = ProjectAnnouncementTemplate & { repository: ProjectEventTemplate; repositoryAddress: string; }; +export type ProjectListingVisibility = "listed" | "unlisted"; + export function isUnsupportedProjectKindError(error: unknown): boolean { return ( error instanceof Error && @@ -24,28 +29,64 @@ export function isUnsupportedProjectKindError(error: unknown): boolean { ); } -function projectDtagFromName(name: string): string { +export function projectDtagFromName(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } -export function buildInitialProjectEventTemplates({ - accessChannelId, - cloneUrl, +export type ListedProjectIdentity = { + dtag: string; + legacy: boolean; + name: string; + owner: string; +}; + +/** + * A second listed project with the same slug or display name is the duplicate + * card users see when an agent runs `projects create` inside an existing + * project. Same-owner + same-slug is handled as resume/idempotent create by + * the caller; this finds a *different* listed project that should block create. + */ +export function conflictingListedProject( + projects: readonly ListedProjectIdentity[], + input: { dtag: string; name: string; ownerPubkey: string }, +): ListedProjectIdentity | null { + const ownerPubkey = input.ownerPubkey.toLowerCase(); + const normalizedName = input.name.trim().toLowerCase(); + return ( + projects.find((project) => { + if (project.legacy) return false; + const sameOwnerSlug = + project.owner.toLowerCase() === ownerPubkey && + project.dtag === input.dtag; + if (sameOwnerSlug) return false; + return ( + project.dtag === input.dtag || + project.name.trim().toLowerCase() === normalizedName + ); + }) ?? null + ); +} + +function normalizeProjectAnnouncementInput({ description, name, ownerPubkey, - webUrl, + projectChannelId, }: { - accessChannelId: string; - cloneUrl?: string; description?: string; name: string; ownerPubkey: string; - webUrl?: string; -}): InitialProjectEventTemplates { + projectChannelId: string; +}): { + dtag: string; + normalizedDescription: string; + normalizedName: string; + normalizedOwner: string; + normalizedProjectChannelId: string; +} { const normalizedName = name.trim(); if (!normalizedName) { throw new Error("Project name is required."); @@ -66,36 +107,74 @@ export function buildInitialProjectEventTemplates({ if (new TextEncoder().encode(normalizedDescription).byteLength > 2_048) { throw new Error("Project description must not exceed 2,048 bytes."); } - const repositoryTags: string[][] = [ - ["d", dtag], - ["name", normalizedName], - ]; + const normalizedProjectChannelId = projectChannelId.trim(); + if (!isValidProjectChannelId(normalizedProjectChannelId)) { + throw new Error("Project channel is invalid."); + } + + return { + dtag, + normalizedDescription, + normalizedName, + normalizedOwner, + normalizedProjectChannelId, + }; +} + +/** Channel-first NIP-MP project: metadata, home channel, optional members. */ +export function buildProjectAnnouncementTemplate({ + description, + name, + ownerPubkey, + projectChannelId, + projectVisibility = "listed", + repositoryAddresses = [], +}: { + description?: string; + name: string; + ownerPubkey: string; + projectChannelId: string; + projectVisibility?: ProjectListingVisibility; + repositoryAddresses?: readonly string[]; +}): ProjectAnnouncementTemplate { + const { + dtag, + normalizedDescription, + normalizedName, + normalizedProjectChannelId, + } = normalizeProjectAnnouncementInput({ + description, + name, + ownerPubkey, + projectChannelId, + }); + + if (new Set(repositoryAddresses).size !== repositoryAddresses.length) { + throw new Error("A project cannot contain duplicate repositories."); + } + if ( + repositoryAddresses.some( + (address) => !/^30617:[0-9a-f]{64}:.+$/.test(address), + ) + ) { + throw new Error("Repository address is invalid."); + } + const projectTags: string[][] = [ ["d", dtag], ["name", normalizedName], + ["buzz-channel", normalizedProjectChannelId], ]; - const normalizedAccessChannelId = accessChannelId.trim(); - if (!isValidProjectChannelId(normalizedAccessChannelId)) { - throw new Error("Repository access channel is invalid."); - } - repositoryTags.push(["buzz-channel", normalizedAccessChannelId]); - projectTags.push(["buzz-channel", normalizedAccessChannelId]); if (normalizedDescription) { - repositoryTags.push(["description", normalizedDescription]); projectTags.push(["description", normalizedDescription]); } - const normalizedCloneUrl = cloneUrl?.trim(); - if (normalizedCloneUrl) { - repositoryTags.push(["clone", normalizedCloneUrl]); + if (projectVisibility === "unlisted") { + projectTags.push(["buzz-visibility", "unlisted"]); } - const normalizedWebUrl = webUrl?.trim(); - if (normalizedWebUrl) { - repositoryTags.push(["web", normalizedWebUrl]); + for (const address of [...repositoryAddresses].sort()) { + projectTags.push(["a", address]); } - const repositoryAddress = `${KIND_REPO_ANNOUNCEMENT}:${normalizedOwner}:${dtag}`; - projectTags.push(["a", repositoryAddress]); - return { dtag, project: { @@ -103,11 +182,88 @@ export function buildInitialProjectEventTemplates({ content: "", tags: projectTags, }, + }; +} + +/** Default 30617 bound to the project home channel, using the project slug. */ +export function buildDefaultProjectRepositoryTemplate({ + description, + name, + ownerPubkey, + projectChannelId, +}: { + description?: string; + name: string; + ownerPubkey: string; + projectChannelId: string; +}): { + dtag: string; + repository: ProjectEventTemplate; + repositoryAddress: string; +} { + const { + dtag, + normalizedDescription, + normalizedName, + normalizedOwner, + normalizedProjectChannelId, + } = normalizeProjectAnnouncementInput({ + description, + name, + ownerPubkey, + projectChannelId, + }); + const repositoryAddress = `${KIND_REPO_ANNOUNCEMENT}:${normalizedOwner}:${dtag}`; + const repositoryTags: string[][] = [ + ["d", dtag], + ["name", normalizedName], + ["buzz-channel", normalizedProjectChannelId], + ]; + if (normalizedDescription) { + repositoryTags.push(["description", normalizedDescription]); + } + return { + dtag, + repositoryAddress, repository: { kind: KIND_REPO_ANNOUNCEMENT, content: normalizedDescription, tags: repositoryTags, }, - repositoryAddress, + }; +} + +/** Home channel + default repository already listed on the project. */ +export function buildProjectBootstrapTemplates({ + description, + name, + ownerPubkey, + projectChannelId, + projectVisibility = "listed", +}: { + description?: string; + name: string; + ownerPubkey: string; + projectChannelId: string; + projectVisibility?: ProjectListingVisibility; +}): ProjectBootstrapTemplates { + const repository = buildDefaultProjectRepositoryTemplate({ + description, + name, + ownerPubkey, + projectChannelId, + }); + const announcement = buildProjectAnnouncementTemplate({ + description, + name, + ownerPubkey, + projectChannelId, + projectVisibility, + repositoryAddresses: [repository.repositoryAddress], + }); + return { + ...announcement, + repository: repository.repository, + repositoryAddress: repository.repositoryAddress, }; } diff --git a/desktop/src/features/projects/projectEnumeration.ts b/desktop/src/features/projects/projectEnumeration.ts index ac494321042..6a1929fc2c6 100644 --- a/desktop/src/features/projects/projectEnumeration.ts +++ b/desktop/src/features/projects/projectEnumeration.ts @@ -5,6 +5,7 @@ import { KIND_PROJECT_ANNOUNCEMENT, KIND_REPO_ANNOUNCEMENT, } from "@/shared/constants/kinds"; +import { absorbStandaloneProjectRepositories } from "./lib/projectCollection"; import { buildProjectReadModels, type Project } from "./projectModels"; const PROJECT_ENUMERATION_PAGE_SIZE = 500; @@ -173,6 +174,7 @@ export async function buildProjectsFromFetcher( options: { relayOrigin?: string | null; hiddenAddresses?: ReadonlySet; + viewerPubkey?: string | null; } = {}, ): Promise { const [projectEvents, repositoryEvents] = await Promise.all([ @@ -200,11 +202,14 @@ export async function buildProjectsFromFetcher( ); } - return buildProjectReadModels({ - projectEvents, - repositoryEvents, - deletionEvents: tombstoneResult.events, - relayOrigin: options.relayOrigin ?? null, - hiddenAddresses: options.hiddenAddresses ?? new Set(), - }).sort((a, b) => b.createdAt - a.createdAt); + return absorbStandaloneProjectRepositories( + buildProjectReadModels({ + projectEvents, + repositoryEvents, + deletionEvents: tombstoneResult.events, + relayOrigin: options.relayOrigin ?? null, + hiddenAddresses: options.hiddenAddresses ?? new Set(), + viewerPubkey: options.viewerPubkey, + }), + ).sort((a, b) => b.createdAt - a.createdAt); } diff --git a/desktop/src/features/projects/projectModels.test.mjs b/desktop/src/features/projects/projectModels.test.mjs index 7be105584a9..837a0deb531 100644 --- a/desktop/src/features/projects/projectModels.test.mjs +++ b/desktop/src/features/projects/projectModels.test.mjs @@ -90,6 +90,31 @@ test("buildProjectReadModels resolves repositories with a deterministic selectio projects[0].repositoryRelayHints[backendAddress], "wss://relay.example", ); + assert.equal( + projects[0].projectChannelId, + "11111111-1111-4111-8111-111111111111", + ); + assert.deepEqual(projects[0].relatedChannelIds, []); +}); + +test("buildProjectReadModels keeps extra related channel ids", () => { + const relatedA = "22222222-2222-4222-8222-222222222222"; + const relatedB = "33333333-3333-4333-8333-333333333333"; + const home = "11111111-1111-4111-8111-111111111111"; + const projects = buildProjectReadModels({ + projectEvents: [ + projectEvent([ + ["buzz-related-channel", relatedA], + ["buzz-related-channel", home], + ["buzz-related-channel", relatedB], + ["buzz-related-channel", relatedA], + ]), + ], + repositoryEvents: [], + relayOrigin: RELAY_ORIGIN, + }); + + assert.deepEqual(projects[0].relatedChannelIds, [relatedA, relatedB]); }); test("buildProjectReadModels keeps unclaimed repositories as implicit projects", () => { @@ -194,6 +219,44 @@ test("selectProjectRepository honors a request and falls back to primary", () => assert.equal(selectProjectRepository(projects[0], null)?.dtag, "backend"); }); +test("buildProjectReadModels keeps the viewer's own unlisted project", () => { + const repoAddress = `30617:${PROJECT_OWNER}:secret`; + const unlisted = { + ...projectEvent([["a", repoAddress]]), + tags: [ + ["d", "secret"], + ["name", "Secret"], + ["buzz-channel", "11111111-1111-4111-8111-111111111111"], + ["buzz-visibility", "unlisted"], + ["a", repoAddress], + ], + }; + const asStranger = buildProjectReadModels({ + projectEvents: [unlisted], + repositoryEvents: [repositoryEvent(PROJECT_OWNER, "secret")], + relayOrigin: RELAY_ORIGIN, + }); + const asOwner = buildProjectReadModels({ + projectEvents: [unlisted], + repositoryEvents: [repositoryEvent(PROJECT_OWNER, "secret")], + relayOrigin: RELAY_ORIGIN, + viewerPubkey: PROJECT_OWNER, + }); + + assert.equal( + asStranger.some((project) => project.dtag === "secret" && !project.legacy), + false, + ); + assert.equal( + asStranger.some((project) => project.legacy && project.dtag === "secret"), + true, + ); + assert.equal(asOwner.length, 1); + assert.equal(asOwner[0]?.legacy, false); + assert.equal(asOwner[0]?.dtag, "secret"); + assert.equal(asOwner[0]?.visibility, "unlisted"); +}); + function coordinateParts(coordinate) { const first = coordinate.indexOf(":"); const second = coordinate.indexOf(":", first + 1); diff --git a/desktop/src/features/projects/projectModels.ts b/desktop/src/features/projects/projectModels.ts index 6d539b54d9e..a89ddcd689e 100644 --- a/desktop/src/features/projects/projectModels.ts +++ b/desktop/src/features/projects/projectModels.ts @@ -32,6 +32,12 @@ export type Project = { owner: string; createdAt: number; projectChannelId: string | null; + /** + * Extra streams linked to this project via repeatable + * `buzz-related-channel` tags. Client convention: NIP-MP treats the tag as + * unrecognized metadata, so older readers ignore it. + */ + relatedChannelIds: string[]; status: string; projectAddress: string; primaryRepositoryAddress: string | null; @@ -50,6 +56,12 @@ type BuildProjectReadModelsInput = { deletionEvents?: RelayEvent[]; relayOrigin?: string | null; hiddenAddresses?: ReadonlySet; + /** + * When set, the viewer's own unlisted projects stay in the collection so the + * creator can still open them. Other viewers keep the NIP-MP fold: unlisted + * projects are absent and do not claim members. + */ + viewerPubkey?: string | null; }; const MAX_D_TAG_BYTES = 1_024; @@ -109,6 +121,12 @@ export function isValidProjectChannelId(value: string): boolean { ); } +/** Repeatable project tag naming an extra stream besides `buzz-channel`. */ +export const PROJECT_RELATED_CHANNEL_TAG = "buzz-related-channel"; + +/** Cap extra project streams so a tag list cannot grow without bound. */ +export const MAX_PROJECT_RELATED_CHANNELS = 64; + const SINGLETON_METADATA_TAGS = [ "name", "description", @@ -339,6 +357,16 @@ export function eventToExplicitProject( const visibility = rawVisibility === "unlisted" ? ("unlisted" as const) : ("listed" as const); const channel = getTag(event, "buzz-channel"); + const projectChannelId = + channel && isValidProjectChannelId(channel) ? channel : null; + const relatedChannelIds = [ + ...new Set( + getAllTags(event, PROJECT_RELATED_CHANNEL_TAG).filter( + (channelId) => + isValidProjectChannelId(channelId) && channelId !== projectChannelId, + ), + ), + ].slice(0, MAX_PROJECT_RELATED_CHANNELS); return { id: projectAddress, dtag, @@ -346,8 +374,8 @@ export function eventToExplicitProject( description: getTag(event, "description") ?? "", owner, createdAt: event.created_at, - projectChannelId: - channel && isValidProjectChannelId(channel) ? channel : null, + projectChannelId, + relatedChannelIds, status: visibility === "listed" ? "active" : "unlisted", projectAddress, primaryRepositoryAddress, @@ -374,6 +402,7 @@ function repositoryToLegacyProject(repository: Repository): Project { owner: repository.owner, createdAt: repository.createdAt, projectChannelId: null, + relatedChannelIds: [], status: repository.status, projectAddress: repository.repoAddress, primaryRepositoryAddress: repository.repoAddress, @@ -417,12 +446,23 @@ function buildDeletionThresholds( return thresholds; } +function projectIsListingEligible( + project: Project, + viewerPubkey: string | null | undefined, +): boolean { + if (project.visibility !== "unlisted") return true; + return Boolean( + viewerPubkey && project.owner === viewerPubkey.trim().toLowerCase(), + ); +} + export function buildProjectReadModels({ projectEvents, repositoryEvents, deletionEvents = [], relayOrigin, hiddenAddresses = new Set(), + viewerPubkey, }: BuildProjectReadModelsInput): Project[] { const deletionThresholds = buildDeletionThresholds(deletionEvents); @@ -463,7 +503,7 @@ export function buildProjectReadModels({ visibleRepositoriesByAddress, ); return project && - project.visibility === "listed" && + projectIsListingEligible(project, viewerPubkey) && !hiddenAddresses.has(project.projectAddress) ? [project] : []; @@ -548,3 +588,21 @@ export function addRepositoryToProject( ) ?? [], }; } + +/** Returns the optimistic read model after linking an extra project stream. */ +export function addRelatedChannelToProject( + project: Project, + channelId: string, + createdAt: number, +): Project { + const relatedChannelIds = [ + ...new Set([...(project.relatedChannelIds ?? []), channelId]), + ].filter( + (id) => id !== project.projectChannelId && isValidProjectChannelId(id), + ); + return { + ...project, + createdAt, + relatedChannelIds: relatedChannelIds.slice(0, MAX_PROJECT_RELATED_CHANNELS), + }; +} diff --git a/desktop/src/features/projects/ui/CreateProjectDialog.tsx b/desktop/src/features/projects/ui/CreateProjectDialog.tsx index d3ac4dfe84b..7f75a45f7dd 100644 --- a/desktop/src/features/projects/ui/CreateProjectDialog.tsx +++ b/desktop/src/features/projects/ui/CreateProjectDialog.tsx @@ -9,7 +9,7 @@ type CreateProjectDialogProps = { open: boolean; }; -/** Modal for publishing a project with its initial NIP-34 repository. */ +/** Modal for creating a project channel. */ export function CreateProjectDialog({ isCreating, onCreate, diff --git a/desktop/src/features/projects/ui/CreateProjectFormContent.tsx b/desktop/src/features/projects/ui/CreateProjectFormContent.tsx index df825d83f01..c685187ff51 100644 --- a/desktop/src/features/projects/ui/CreateProjectFormContent.tsx +++ b/desktop/src/features/projects/ui/CreateProjectFormContent.tsx @@ -1,8 +1,9 @@ import { ArrowLeft } from "lucide-react"; import * as React from "react"; -import { useChannelsQuery } from "@/features/channels/hooks"; import type { CreateProjectInput } from "@/features/projects/useCreateProject"; +import { CreateProjectFormSettings } from "@/features/projects/ui/CreateProjectFormSettings"; +import { useCreateProjectFormSettings } from "@/features/projects/ui/useCreateProjectFormSettings"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; @@ -33,51 +34,35 @@ export function CreateProjectFormContent({ }) { const [name, setName] = React.useState(""); const [description, setDescription] = React.useState(""); - const [cloneUrl, setCloneUrl] = React.useState(""); - const [webUrl, setWebUrl] = React.useState(""); - const [accessChannelId, setAccessChannelId] = React.useState(""); const [errorMessage, setErrorMessage] = React.useState(null); const nameInputRef = React.useRef(null); - const channelsQuery = useChannelsQuery({ enabled: active }); - const accessChannels = React.useMemo( - () => - (channelsQuery.data ?? []).filter( - (channel) => - channel.isMember && - !channel.archivedAt && - channel.channelType !== "dm", - ), - [channelsQuery.data], - ); + const settings = useCreateProjectFormSettings(active); React.useEffect(() => { if (!active) return; setName(initialName); setDescription(""); - setCloneUrl(""); - setWebUrl(""); - setAccessChannelId(accessChannels[0]?.id ?? ""); setErrorMessage(null); const timerId = globalThis.setTimeout(() => { nameInputRef.current?.focus(); }, 50); return () => globalThis.clearTimeout(timerId); - }, [accessChannels, active, initialName]); + }, [active, initialName]); async function handleSubmit(event: React.FormEvent) { event.preventDefault(); const trimmedName = name.trim(); - if (!trimmedName || !accessChannelId) return; + if (!trimmedName) return; setErrorMessage(null); try { await onCreate({ - accessChannelId, name: trimmedName, description: description.trim() || undefined, - cloneUrl: cloneUrl.trim() || undefined, - webUrl: webUrl.trim() || undefined, + channelVisibility: settings.channelVisibility, + projectVisibility: settings.projectVisibility, + agents: settings.buildAgents(), }); onCreated(); } catch (error) { @@ -92,14 +77,12 @@ export function CreateProjectFormContent({ className="max-w-lg" contentClassName="pt-3" data-testid="create-project-dialog" - description="Projects group one or more repositories published to this workspace's relay." + headerSubtitle="A project starts as a channel with a repository. People in the channel can talk, clone, and open tasks here." footer={
-
- -
- -
-

- 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/ProjectChannelIcon.tsx b/desktop/src/features/projects/ui/ProjectChannelIcon.tsx new file mode 100644 index 00000000000..524003ad1eb --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectChannelIcon.tsx @@ -0,0 +1,20 @@ +import { Folders, Hash } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; + +/** Projects glyph with a small channel hash nested in the lower right. */ +export function ProjectChannelIcon({ className }: { className?: string }) { + return ( + + ); +} diff --git a/desktop/src/features/projects/ui/useCreateProjectFormSettings.ts b/desktop/src/features/projects/ui/useCreateProjectFormSettings.ts new file mode 100644 index 00000000000..00a70c587a5 --- /dev/null +++ b/desktop/src/features/projects/ui/useCreateProjectFormSettings.ts @@ -0,0 +1,93 @@ +import * as React from "react"; + +import type { CreateChannelManagedAgentInput } from "@/features/agents/channelAgents"; +import { + useAvailableAcpRuntimes, + usePersonasQuery, +} from "@/features/agents/hooks"; +import { getActivePersonas } from "@/features/agents/lib/catalog"; +import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; +import type { ProjectListingVisibility } from "@/features/projects/projectCreation"; +import type { ChannelVisibility } from "@/shared/api/types"; + +export function useCreateProjectFormSettings(active: boolean) { + const personasQuery = usePersonasQuery({ enabled: active }); + const runtimesQuery = useAvailableAcpRuntimes({ enabled: active }); + const [channelVisibility, setChannelVisibility] = + React.useState("open"); + const [projectVisibility, setProjectVisibility] = + React.useState("listed"); + const [agentPersonaId, setAgentPersonaId] = React.useState(""); + + const personas = React.useMemo( + () => getActivePersonas(personasQuery.data ?? []), + [personasQuery.data], + ); + + React.useEffect(() => { + if (!active) return; + setChannelVisibility("open"); + setProjectVisibility("listed"); + setAgentPersonaId(""); + }, [active]); + + React.useEffect(() => { + if ( + agentPersonaId && + !personas.some((persona) => persona.id === agentPersonaId) + ) { + setAgentPersonaId(""); + } + }, [agentPersonaId, personas]); + + const buildAgents = + React.useCallback((): CreateChannelManagedAgentInput[] => { + if (!agentPersonaId) return []; + const persona = personas.find((entry) => entry.id === agentPersonaId); + if (!persona) { + throw new Error("Choose an agent that still exists."); + } + const defaultRuntime = runtimesQuery.data[0] ?? null; + const resolved = resolvePersonaRuntime( + persona.runtime, + runtimesQuery.data, + defaultRuntime, + false, + ); + if (!resolved.runtime) { + throw new Error( + resolved.warnings[0] ?? + "No agent runtimes are available. Install a runtime to add an agent.", + ); + } + return [ + { + runtime: resolved.runtime, + name: persona.displayName, + personaId: persona.id, + harnessOverride: false, + systemPrompt: persona.systemPrompt, + avatarUrl: persona.avatarUrl ?? undefined, + model: persona.model ?? undefined, + role: "bot", + backend: { type: "local" }, + }, + ]; + }, [agentPersonaId, personas, runtimesQuery.data]); + + return { + agentPersonaId, + buildAgents, + channelVisibility, + personas, + projectVisibility, + runtimesAvailable: runtimesQuery.data.length > 0, + setAgentPersonaId, + setChannelVisibility, + setProjectVisibility, + }; +} + +export type CreateProjectFormSettingsState = ReturnType< + typeof useCreateProjectFormSettings +>; diff --git a/desktop/src/features/projects/useCreateProject.ts b/desktop/src/features/projects/useCreateProject.ts index f4f548683fb..8526e0317d6 100644 --- a/desktop/src/features/projects/useCreateProject.ts +++ b/desktop/src/features/projects/useCreateProject.ts @@ -2,135 +2,34 @@ import * as React from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { - fetchProjects, - type Project, - projectsQueryKey, -} from "@/features/projects/hooks"; + channelsQueryKey, + upsertCachedChannel, +} from "@/features/channels/hooks"; +import { type Project, projectsQueryKey } from "@/features/projects/hooks"; import { - buildInitialProjectEventTemplates, - isUnsupportedProjectKindError, -} from "@/features/projects/projectCreation"; + createProject, + type CreateProjectInput, + type CreateProjectResult, + type CreateProjectResumeState, +} from "@/features/projects/createProject"; import { addProjectToSidebar } from "@/features/projects/lib/projectSidebarMembership"; -import { buildProjectReadModels } from "@/features/projects/projectModels"; -import { relayClient } from "@/shared/api/relayClient"; +import type { Channel } from "@/shared/api/types"; import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl"; -import { signRelayEvent } from "@/shared/api/tauri"; -import { getIdentity } from "@/shared/api/tauriIdentity"; -export type CreateProjectInput = { - accessChannelId: string; - name: string; - description?: string; - cloneUrl?: string; - webUrl?: string; -}; +export type { CreateProjectInput, CreateProjectResult }; -export type CreateProjectResult = { - project: Project; - compatibilityWarning?: string; -}; - -/** Publishes a project announcement and its initial NIP-34 repository. */ -async function createProject( - input: CreateProjectInput, - resumableProjectIds: Set, -): Promise { - const identity = await getIdentity(); - const templates = buildInitialProjectEventTemplates({ - ...input, - ownerPubkey: identity.pubkey, - }); - const existing = await fetchProjects(); - const ownerPubkey = identity.pubkey.toLowerCase(); - const existingProject = existing.find( - (project) => - project.owner.toLowerCase() === ownerPubkey && - project.dtag === templates.dtag, - ); - const projectId = `${ownerPubkey}:${templates.dtag}`; - const canResume = resumableProjectIds.has(projectId); - if (existingProject && !canResume) { - throw new Error(`You already have a project named "${templates.dtag}".`); - } - if (existingProject && !existingProject.legacy) { - if ( - existingProject.repositories.some( - (repository) => repository.repoAddress === templates.repositoryAddress, - ) - ) { - resumableProjectIds.delete(projectId); - return { project: existingProject }; - } - throw new Error(`You already have a project named "${templates.dtag}".`); - } - - resumableProjectIds.add(projectId); - const projectEvent = await signRelayEvent(templates.project); - - let repositoryEvent = null; - if (!existingProject) { - repositoryEvent = await signRelayEvent(templates.repository); - await relayClient.publishEvent( - repositoryEvent, - "Timed out creating the initial repository.", - "Failed to create the initial repository.", - ); - } - - try { - await relayClient.publishEvent( - projectEvent, - "Timed out creating project.", - "Failed to create project.", - ); - } catch (error) { - if (!isUnsupportedProjectKindError(error)) throw error; - - const [legacyProject] = existingProject?.legacy - ? [existingProject] - : buildProjectReadModels({ - projectEvents: [], - repositoryEvents: repositoryEvent ? [repositoryEvent] : [], - relayOrigin: getCachedRelayOrigin(), - }); - if (!legacyProject) throw error; - - resumableProjectIds.delete(projectId); - return { - project: legacyProject, - compatibilityWarning: - "The repository was created, but this relay does not support multi-repository projects yet. It will appear as a standalone project.", - }; - } - - const [project] = repositoryEvent - ? buildProjectReadModels({ - projectEvents: [projectEvent], - repositoryEvents: [repositoryEvent], - relayOrigin: getCachedRelayOrigin(), - }) - : (await fetchProjects()).filter( - (candidate) => - candidate.owner.toLowerCase() === ownerPubkey && - candidate.dtag === templates.dtag && - !candidate.legacy, - ); - if (!project) { - throw new Error("The project was created but could not be read."); - } - resumableProjectIds.delete(projectId); - return { project }; -} - -/** Mutation that creates a project and inserts it into the projects cache. */ +/** Mutation that creates a project home and inserts it into the caches. */ export function useCreateProjectMutation() { const queryClient = useQueryClient(); - const resumableProjectIdsRef = React.useRef(new Set()); + const resumeRef = React.useRef({ + channels: new Map(), + projectIds: new Set(), + }); return useMutation({ mutationFn: (input: CreateProjectInput) => - createProject(input, resumableProjectIdsRef.current), - onSuccess: ({ project }) => { + createProject(input, resumeRef.current), + onSuccess: ({ channel, project }) => { addProjectToSidebar( project.projectAddress, getCachedRelayOrigin(), @@ -148,6 +47,17 @@ export function useCreateProjectMutation() { ), ), ]); + if (channel) { + queryClient.setQueryData( + channelsQueryKey, + (current: Channel[] | undefined) => + upsertCachedChannel(current, channel), + ); + void queryClient.invalidateQueries({ + queryKey: channelsQueryKey, + refetchType: "none", + }); + } void queryClient.invalidateQueries({ queryKey: projectsQueryKey }); }, }); diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 52d91dea3ed..25513f6ad75 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -450,9 +450,9 @@ async function expectIntroActionCardLayout( } expect(actionBox.height).toBeGreaterThan(actionBox.width); - expect(Math.round(actionBox.width)).toBe(220); - expect(Math.round(iconBox.width)).toBe(48); - expect(Math.round(iconBox.height)).toBe(48); + expect(Math.round(actionBox.width)).toBe(192); + expect(Math.round(iconBox.width)).toBe(40); + expect(Math.round(iconBox.height)).toBe(40); const introIconRadius = await page .getByTestId("message-channel-intro-icon") .evaluate((element) => window.getComputedStyle(element).borderRadius); diff --git a/desktop/tests/e2e/project-commit-detail.spec.ts b/desktop/tests/e2e/project-commit-detail.spec.ts index 53804bf85ae..a6f10ccb7fc 100644 --- a/desktop/tests/e2e/project-commit-detail.spec.ts +++ b/desktop/tests/e2e/project-commit-detail.spec.ts @@ -260,9 +260,6 @@ test("creating a project publishes its initial repository grouping", async ({ await page .getByTestId("create-project-description") .fill("A grouped project created through the desktop app."); - await page - .getByTestId("create-project-clone-url") - .fill("https://relay.example.com/git/owner/multi-repo-demo.git"); await page.getByTestId("create-project-submit").click(); await expect(page.getByTestId("create-project-dialog")).toBeHidden(); @@ -314,7 +311,7 @@ test("creating a project publishes its initial repository grouping", async ({ .toBe(2); }); -test("unsupported relays keep the initial repository accessible", async ({ +test("unsupported relays cannot create a channel-first project", async ({ page, }) => { await enableProjectsFeature(page); @@ -329,26 +326,10 @@ test("unsupported relays keep the initial repository accessible", async ({ await page.getByTestId("create-project-name").fill("legacy-fallback"); await page.getByTestId("create-project-submit").click(); - await expect(page.getByTestId("create-project-dialog")).toBeHidden(); - await expect(page.getByText("Created as a standalone project")).toBeVisible(); - await waitForAnimations(page); - const projectEntry = page - .locator( - '[data-testid="project-card-legacy-fallback"], [data-testid="project-row-legacy-fallback"]', - ) - .first(); - await expect(projectEntry).toBeVisible(); - await projectEntry - .getByRole("button", { name: "View legacy-fallback" }) - .click(); - const repositoryRow = page.getByTestId( - "sidebar-project-repository-legacy-fallback", - ); - await expect(repositoryRow).toBeVisible(); - await waitForAnimations(page); - await page.screenshot({ - path: `${SHOTS}/06-single-repository-add.png`, - }); + await expect(page.getByTestId("create-project-dialog")).toBeVisible(); + await expect( + page.getByText("This relay does not support projects yet"), + ).toBeVisible(); const acceptedKinds = await page.evaluate( () => @@ -360,7 +341,7 @@ test("unsupported relays keep the initial repository accessible", async ({ ) .map((event) => event.kind) ?? [], ); - expect(acceptedKinds).toEqual([30617]); + expect(acceptedKinds).toEqual([]); }); test("project creation can retry after its repository publication fails", async ({