diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 485eb27233..d277544367 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -23,7 +23,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ | `buzz feed` | `get` | | `buzz social` | `publish`, `notes` | | `buzz repos` | `create`, `get`, `list` | -| `buzz projects` | `create`, `get`, `list`, `add-repo` | +| `buzz projects` | `create`, `get`, `list`, `add-repo`, `add-channel` | | `buzz issues` | `create`, `get`, `list`, `status`, `assign` | | `buzz pr` | `open`, `update`, `get`, `list`, `status` | | `buzz upload` | `file` | @@ -39,6 +39,7 @@ A project is a named grouping (`kind:30621`) with a home channel. Creating a sec - If you are in a project's home channel, or a project with that name/slug already exists, do **not** run `buzz projects create`. `[Context]` includes a Project block when this channel is a project home — tasks, repositories, and files you create belong to that project. - To add a codebase: `buzz repos create --id --name "…" --channel `. `mkdir` in `REPOS/` is not a Buzz repository. - To add tasks: `buzz issues create --channel --subject "…" --content "…"`. That uses this project's repository and creates one bound to the channel if none exists. `--repo-owner` / `--repo-id` remain valid once a repository exists. Session todos and markdown plans do not appear on the project. +- To add another channel to this project: `buzz projects add-channel --home-channel --name "…" [--template "…"]`. This opens an owner-reviewed request in Buzz Desktop and uses the project-aware channel primitive after approval. Do **not** use `buzz channels create` for a channel that should belong to the current project, and do not claim the channel exists until the owner approves it. `buzz pr open`, `buzz issues create`, `buzz repos create`, and `buzz projects create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, repo, or project in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. diff --git a/crates/buzz-cli/src/agent_management.rs b/crates/buzz-cli/src/agent_management.rs index ce4059f821..e5f2513069 100644 --- a/crates/buzz-cli/src/agent_management.rs +++ b/crates/buzz-cli/src/agent_management.rs @@ -6,7 +6,8 @@ use serde::Serialize; use crate::error::CliError; -const REQUEST_KIND: &str = "agent_management_request"; +const AGENT_REQUEST_KIND: &str = "agent_management_request"; +const PROJECT_CHANNEL_REQUEST_KIND: &str = "project_channel_request"; const MAX_NAME_CHARS: usize = 120; const MAX_PROMPT_CHARS: usize = 20_000; @@ -37,6 +38,20 @@ pub struct UpdateAgentDraft { pub respond_to: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateProjectChannelDraft { + pub home_channel_id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub visibility: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ttl_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub template_name: Option, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct ManagementRequest { @@ -88,6 +103,7 @@ fn build( keys: &Keys, owner: &PublicKey, channel_id: String, + request_kind: &'static str, action: &'static str, request: T, ) -> Result { @@ -95,13 +111,13 @@ fn build( let payload = ObserverEvent { seq: 0, timestamp: chrono::Utc::now().to_rfc3339(), - kind: REQUEST_KIND, + kind: request_kind, agent_index: None, channel_id: Some(channel_id), session_id: None, turn_id: None, payload: ManagementRequest { - request_type: REQUEST_KIND, + request_type: request_kind, action, request_id: request_id.clone(), request, @@ -138,7 +154,14 @@ pub fn build_create( display_name: required(draft.display_name, "display name", MAX_NAME_CHARS)?, system_prompt: required(draft.system_prompt, "system prompt", MAX_PROMPT_CHARS)?, }; - build(keys, owner, channel_id, "create", request) + build( + keys, + owner, + channel_id, + AGENT_REQUEST_KIND, + "create", + request, + ) } pub fn build_update( @@ -182,7 +205,50 @@ pub fn build_update( "include at least one field to update".into(), )); } - build(keys, owner, channel_id, "update", request) + build( + keys, + owner, + channel_id, + AGENT_REQUEST_KIND, + "update", + request, + ) +} + +pub fn build_project_channel( + keys: &Keys, + owner: &PublicKey, + draft: CreateProjectChannelDraft, +) -> Result { + let home_channel_id = required(draft.home_channel_id, "home channel", 128)?; + uuid::Uuid::parse_str(&home_channel_id) + .map_err(|_| CliError::Usage(format!("invalid channel UUID: {home_channel_id}")))?; + let visibility = required(draft.visibility, "visibility", 16)?; + if visibility != "open" && visibility != "private" { + return Err(CliError::Usage("visibility must be open or private".into())); + } + if draft.ttl_seconds == Some(0) { + return Err(CliError::Usage("ttl must be greater than zero".into())); + } + let request = CreateProjectChannelDraft { + home_channel_id: home_channel_id.clone(), + name: required(draft.name, "name", MAX_NAME_CHARS)?, + description: draft + .description + .map(|value| required(value, "description", 2_048)) + .transpose()?, + visibility, + ttl_seconds: draft.ttl_seconds, + template_name: optional(draft.template_name, "template")?, + }; + build( + keys, + owner, + home_channel_id, + PROJECT_CHANNEL_REQUEST_KIND, + "create", + request, + ) } #[cfg(test)] @@ -228,9 +294,9 @@ mod tests { .any(|tag| tag.first().map(String::as_str) == Some("h"))); let payload: serde_json::Value = decrypt_observer_payload(&owner, &built.event).unwrap(); - assert_eq!(payload["kind"], REQUEST_KIND); + assert_eq!(payload["kind"], AGENT_REQUEST_KIND); assert_eq!(payload["channelId"], CHANNEL); - assert_eq!(payload["payload"]["type"], REQUEST_KIND); + assert_eq!(payload["payload"]["type"], AGENT_REQUEST_KIND); assert_eq!(payload["payload"]["action"], "create"); assert_eq!( payload["payload"]["request"]["displayName"], @@ -274,4 +340,34 @@ mod tests { .unwrap_err(); assert!(error.to_string().contains("invalid channel UUID")); } + + #[test] + fn project_channel_request_is_owner_encrypted() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let built = build_project_channel( + &agent, + &owner.public_key(), + CreateProjectChannelDraft { + home_channel_id: CHANNEL.into(), + name: "release-planning".into(), + description: Some("Coordinate the next release.".into()), + visibility: "open".into(), + ttl_seconds: None, + template_name: Some("Release team".into()), + }, + ) + .unwrap(); + + let payload: serde_json::Value = decrypt_observer_payload(&owner, &built.event).unwrap(); + assert_eq!(payload["kind"], PROJECT_CHANNEL_REQUEST_KIND); + assert_eq!(payload["channelId"], CHANNEL); + assert_eq!(payload["payload"]["type"], PROJECT_CHANNEL_REQUEST_KIND); + assert_eq!(payload["payload"]["action"], "create"); + assert_eq!(payload["payload"]["request"]["homeChannelId"], CHANNEL); + assert_eq!( + payload["payload"]["request"]["templateName"], + "Release team" + ); + } } diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs index d01f3e68b8..06d8a865db 100644 --- a/crates/buzz-cli/src/commands/projects.rs +++ b/crates/buzz-cli/src/commands/projects.rs @@ -21,14 +21,58 @@ use buzz_sdk::{ build_delete_addressable, build_project, build_project_with_tags, ProjectMemberCoord, PROJECT_D_MAX_LEN, }; -use nostr::{Event, EventBuilder, Tag, Timestamp}; +use nostr::{Event, EventBuilder, PublicKey, Tag, Timestamp}; +use crate::agent_management::{build_project_channel, CreateProjectChannelDraft}; use crate::client::BuzzClient; use crate::commands::parse_write_response; use crate::commands::project_channel::repo_id_from_project_slug; use crate::commands::repos::{build_create_announcement, fetch_own_repo_announcement}; use crate::error::CliError; +async fn cmd_add_channel_draft( + client: &BuzzClient, + home_channel: String, + name: String, + description: Option, + visibility: String, + ttl_seconds: Option, + template_name: Option, +) -> Result<(), CliError> { + let owner_hex = client + .auth_tag_owner_hex() + .ok_or_else(|| CliError::Auth("project channel requests require BUZZ_AUTH_TAG".into()))?; + let owner = PublicKey::parse(&owner_hex) + .map_err(|error| CliError::Auth(format!("invalid owner attestation: {error}")))?; + let built = build_project_channel( + client.keys(), + &owner, + CreateProjectChannelDraft { + home_channel_id: home_channel, + name, + description, + visibility, + ttl_seconds, + template_name, + }, + )?; + let response = client.publish_ephemeral_event(built.event).await?; + let mut output: serde_json::Value = serde_json::from_str(&response) + .map_err(|error| CliError::Other(format!("invalid relay response: {error}")))?; + if let Some(object) = output.as_object_mut() { + object.insert("request_id".into(), built.request_id.into()); + object.insert("action".into(), "add-channel".into()); + object.insert("saved".into(), false.into()); + object.insert( + "message".into(), + "Project channel draft sent to Buzz Desktop for owner review. The channel is not created until the owner approves it." + .into(), + ); + } + println!("{output}"); + Ok(()) +} + // ── Buzz repo-ID grammar (bare --repo shorthand) ───────────────────────────── /// Pattern for a Buzz-hosted repo identifier (bare `--repo` shorthand). @@ -739,6 +783,25 @@ pub async fn dispatch(cmd: crate::ProjectsCmd, client: &BuzzClient) -> Result<() ProjectsCmd::Get { slug, owner } => cmd_get(client, &slug, owner.as_deref()).await, ProjectsCmd::List { owner, limit } => cmd_list(client, owner.as_deref(), limit).await, ProjectsCmd::AddRepo { slug, repo } => cmd_add_repo(client, &slug, &repo).await, + ProjectsCmd::AddChannel { + home_channel, + name, + description, + visibility, + ttl, + template, + } => { + cmd_add_channel_draft( + client, + home_channel, + name, + description, + visibility.to_string(), + ttl, + template, + ) + .await + } ProjectsCmd::RemoveRepo { slug, repo } => cmd_remove_repo(client, &slug, &repo).await, ProjectsCmd::Update { slug, diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index af198f4582..d0155970fa 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1334,6 +1334,28 @@ pub enum ProjectsCmd { #[arg(long = "repo", required = true)] repo: Vec, }, + /// Draft a project-linked channel for owner review in Buzz Desktop + #[command(name = "add-channel")] + AddChannel { + /// Project home channel UUID from the current ACP [Context] + #[arg(long)] + home_channel: String, + /// New channel name + #[arg(long)] + name: String, + /// Optional channel description + #[arg(long)] + description: Option, + /// Channel visibility + #[arg(long, value_enum, default_value = "open")] + visibility: ChannelVisibility, + /// Optional temporary-channel lifetime in seconds + #[arg(long)] + ttl: Option, + /// Optional Desktop channel-template name + #[arg(long)] + template: Option, + }, /// Remove one or more member repositories from a project #[command(name = "remove-repo")] RemoveRepo { @@ -2375,6 +2397,7 @@ mod tests { assert_eq!( names(&cmd, "projects"), vec![ + "add-channel", "add-repo", "create", "delete", @@ -2421,7 +2444,7 @@ mod tests { ("pack", 2), ("patches", 4), ("pr", 5), - ("projects", 7), + ("projects", 8), ("reactions", 3), ("repos", 5), ("social", 7), @@ -2492,6 +2515,25 @@ mod tests { // ── projects update mutation group ──────────────────────────────────────── + /// Project-channel requests accept the owner-review metadata. + #[test] + fn projects_add_channel_accepts_owner_review_fields() { + assert!(Cli::try_parse_from([ + "buzz", + "projects", + "add-channel", + "--home-channel", + "11111111-1111-4111-8111-111111111111", + "--name", + "release-planning", + "--visibility", + "private", + "--template", + "Release team", + ]) + .is_ok()); + } + /// Multiple independent fields must be accepted in the same invocation. #[test] fn projects_update_multi_field_is_accepted() { diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index c4776564e3..e9794cb988 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -108,6 +108,7 @@ export function useAppNavigation() { projectId: string, behavior?: NavigationBehavior & { commitHash?: string; + filePath?: string; pullRequestId?: string; issueId?: string; repositoryId?: string; @@ -128,6 +129,7 @@ export function useAppNavigation() { ...(behavior?.commitHash ? { commitHash: behavior.commitHash } : {}), + ...(behavior?.filePath ? { filePath: behavior.filePath } : {}), ...(behavior?.pullRequestId ? { pullRequestId: behavior.pullRequestId } : {}), diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index d4626d2c6f..93f41cbee3 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -12,6 +12,9 @@ import { isBroadcastReply, } from "@/features/messages/lib/threading"; import { useProfileQuery } from "@/features/profile/hooks"; +import { useProjectsQuery } from "@/features/projects/hooks"; +import { findProjectHomeByChannelId } from "@/features/projects/lib/projectHomeChannel"; +import { ProjectChannelHome } from "@/features/projects/ui/ProjectChannelHome"; import { useIdentityQuery } from "@/shared/api/hooks"; import { getEventById } from "@/shared/api/tauri"; import type { RelayEvent } from "@/shared/api/types"; @@ -108,6 +111,7 @@ export function ChannelRouteScreen({ const isHuddleTranscript = huddleWindowChannelId() !== null; const { closeForumPost, goForumPost } = useAppNavigation(); const channelsQuery = useChannelsQuery(); + const projectsQuery = useProjectsQuery(); const identityQuery = useIdentityQuery(); const profileQuery = useProfileQuery(); const channels = channelsQuery.data ?? []; @@ -126,6 +130,10 @@ export function ChannelRouteScreen({ memberChannel ?? openDirectoryQuery.data?.find((channel) => channel.id === channelId) ?? null; + const projectHome = findProjectHomeByChannelId( + channelId, + projectsQuery.data ?? [], + ); const [targetMessageEvents, setTargetMessageEvents] = React.useState< RelayEvent[] >(() => { @@ -218,6 +226,18 @@ export function ChannelRouteScreen({ ); } + if (projectHome && !isHuddleTranscript) { + return ( + + ); + } + return ( { @@ -34,6 +34,7 @@ function ProjectDetailRouteComponent() { void >(); +const projectChannelRequestListeners = new Set< + (agentPubkey: string, request: ProjectChannelRequest) => void +>(); // Normalized pubkeys of agents we are actively managing. Only events whose // "agent" tag matches an entry here will be decrypted (defense-in-depth). @@ -506,6 +513,12 @@ function processLiveObserverEvents( listener(agentPubkey, managementRequest); } } + const projectChannelRequest = parseProjectChannelRequest(parsed.payload); + if (projectChannelRequest) { + for (const listener of projectChannelRequestListeners) { + listener(agentPubkey, projectChannelRequest); + } + } if (parsed.kind === "session_config_captured") { void putAgentSessionConfig(agentPubkey, parsed.payload); onSessionConfigCaptured?.(agentPubkey); @@ -687,6 +700,15 @@ export function subscribeAgentManagementRequests( }; } +export function subscribeProjectChannelRequests( + listener: (agentPubkey: string, request: ProjectChannelRequest) => void, +) { + projectChannelRequestListeners.add(listener); + return () => { + projectChannelRequestListeners.delete(listener); + }; +} + export function subscribeControlResults( agentPubkey: string, listener: (frame: ControlResultFrame) => void, @@ -919,6 +941,7 @@ export function resetAgentObserverStore() { pendingUnknownAgentFrames.length = 0; latestLiveSessionByAgentChannel.clear(); agentManagementListeners.clear(); + projectChannelRequestListeners.clear(); onSessionConfigCaptured = null; connectionState = "idle"; errorMessage = null; diff --git a/desktop/src/features/agents/ui/AgentManagementDialogs.tsx b/desktop/src/features/agents/ui/AgentManagementDialogs.tsx index 27541889f2..6d6b68d803 100644 --- a/desktop/src/features/agents/ui/AgentManagementDialogs.tsx +++ b/desktop/src/features/agents/ui/AgentManagementDialogs.tsx @@ -1,4 +1,5 @@ import { useAgentManagement } from "@/features/agents/useAgentManagement"; +import { ProjectChannelRequestDialog } from "@/features/projects/ui/ProjectChannelRequestDialog"; import { AgentCardDialogs } from "./AgentCardViewerDialog"; import { AgentDialog } from "./AgentDialog"; @@ -42,6 +43,7 @@ export function AgentManagementDialogs() { title="Edit agent" /> ) : null} + ); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 1fa03c1e2d..531a13724b 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -83,6 +83,7 @@ export const ChannelPane = React.memo(function ChannelPane({ fetchOlder, header, idleAuxiliaryPanel = null, + idleAuxiliaryHeaderActions, idleAuxiliaryTitle = "", hasOlderMessages, historyExhausted, @@ -978,6 +979,7 @@ export const ChannelPane = React.memo(function ChannelPane({ wrapIdlePanel( void; diff --git a/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx b/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx index f254efb098..7a9c454554 100644 --- a/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx +++ b/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx @@ -4,13 +4,21 @@ import { AuxiliaryPanel, AuxiliaryPanelBody, AuxiliaryPanelHeader, + AuxiliaryPanelHeaderActions, AuxiliaryPanelHeaderGroup, AuxiliaryPanelTitle, } from "@/shared/layout/AuxiliaryPanel"; +export type IdleAuxiliaryHeaderControls = { + actions?: React.ReactNode; + backLabel?: string; + onBack?: () => void; +}; + export function IdleAuxiliaryPanel({ canResetWidth, children, + headerControls, isFocusDrawer = false, isSinglePanelView, onClose, @@ -22,6 +30,7 @@ export function IdleAuxiliaryPanel({ }: { canResetWidth: boolean; children: React.ReactNode; + headerControls?: IdleAuxiliaryHeaderControls; isFocusDrawer?: boolean; isSinglePanelView: boolean; onClose: () => void; @@ -50,9 +59,18 @@ export function IdleAuxiliaryPanel({ widthPx={widthPx} header={ - + {title} + {headerControls?.actions ? ( + + {headerControls.actions} + + ) : null} } > diff --git a/desktop/src/features/projects/lib/projectDetailSearch.test.mjs b/desktop/src/features/projects/lib/projectDetailSearch.test.mjs index d9d667d73d..d64cab9829 100644 --- a/desktop/src/features/projects/lib/projectDetailSearch.test.mjs +++ b/desktop/src/features/projects/lib/projectDetailSearch.test.mjs @@ -10,6 +10,7 @@ test("parseProjectDetailSearch keeps forge params and channel panel params", () const search = parseProjectDetailSearch({ repositoryId: "30617:owner:buzz", tab: "files", + filePath: "src/main.ts", thread: "abc123", agentSession: "def456", channelManagement: "1", @@ -18,6 +19,7 @@ test("parseProjectDetailSearch keeps forge params and channel panel params", () assert.equal(search.repositoryId, "30617:owner:buzz"); assert.equal(search.tab, "files"); + assert.equal(search.filePath, "src/main.ts"); assert.equal(search.thread, "abc123"); assert.equal(search.agentSession, "def456"); assert.equal(search.channelManagement, "1"); @@ -60,6 +62,13 @@ test("wantsProjectRepositorySurface is true for repo, tab, or work-item params", }), true, ); + assert.equal( + wantsProjectRepositorySurface({ + filePath: "src/main.ts", + projectId: "30621:owner:platform", + }), + true, + ); }); test("wantsProjectRepositorySurface is true for a legacy kind:30617 project id", () => { diff --git a/desktop/src/features/projects/lib/projectDetailSearch.ts b/desktop/src/features/projects/lib/projectDetailSearch.ts index cfc7ece099..460ade0400 100644 --- a/desktop/src/features/projects/lib/projectDetailSearch.ts +++ b/desktop/src/features/projects/lib/projectDetailSearch.ts @@ -22,6 +22,7 @@ function nonEmptyString(value: unknown): string | undefined { export function parseProjectDetailSearch(search: Record) { return { commitHash: optionalSearchString(search.commitHash), + filePath: optionalSearchString(search.filePath), pullRequestId: optionalSearchString(search.pullRequestId), issueId: optionalSearchString(search.issueId), repositoryId: optionalSearchString(search.repositoryId), @@ -46,6 +47,7 @@ export function parseProjectDetailSearch(search: Record) { */ export function wantsProjectRepositorySurface(input: { commitHash?: string; + filePath?: string; issueId?: string; projectId: string; pullRequestId?: string; @@ -57,7 +59,8 @@ export function wantsProjectRepositorySurface(input: { input.tab || input.issueId || input.pullRequestId || - input.commitHash + input.commitHash || + input.filePath ) { return true; } diff --git a/desktop/src/features/projects/lib/projectHomeChannel.test.mjs b/desktop/src/features/projects/lib/projectHomeChannel.test.mjs index 2903873b40..a7c02ccb6a 100644 --- a/desktop/src/features/projects/lib/projectHomeChannel.test.mjs +++ b/desktop/src/features/projects/lib/projectHomeChannel.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + findProjectHomeByChannelId, hasAuthoritativeHomeBinding, isProjectHomeChannel, } from "./projectHomeChannel.ts"; @@ -78,3 +79,18 @@ test("isProjectHomeChannel is false for unbound channels", () => { assert.equal(isProjectHomeChannel("channel-z", [project()]), false); assert.equal(isProjectHomeChannel(null, [project()]), false); }); + +test("findProjectHomeByChannelId prefers the oldest listed home", () => { + const base = { + createdAt: 0, + legacy: false, + projectChannelId: "channel-a", + visibility: "listed", + }; + const selected = findProjectHomeByChannelId("channel-a", [ + { ...base, createdAt: 200, id: "later" }, + { ...base, createdAt: 50, id: "hidden", visibility: "unlisted" }, + { ...base, createdAt: 100, id: "original" }, + ]); + assert.equal(selected?.id, "original"); +}); diff --git a/desktop/src/features/projects/lib/projectHomeChannel.ts b/desktop/src/features/projects/lib/projectHomeChannel.ts index 7edc9a3b6a..091a4409bf 100644 --- a/desktop/src/features/projects/lib/projectHomeChannel.ts +++ b/desktop/src/features/projects/lib/projectHomeChannel.ts @@ -1,4 +1,23 @@ import { useProjectsQuery } from "@/features/projects/hooks"; +import type { Project } from "@/features/projects/projectModels"; + +/** Resolves the canonical visible project home for a channel. */ +export function findProjectHomeByChannelId( + channelId: string | null | undefined, + projects: readonly Project[], +): Project | null { + if (!channelId) return null; + const matching = projects + .filter( + (project) => !project.legacy && project.projectChannelId === channelId, + ) + .sort((left, right) => left.createdAt - right.createdAt); + return ( + matching.find((project) => project.visibility !== "unlisted") ?? + matching[0] ?? + null + ); +} export type ProjectHomeCandidate = { owner: string; diff --git a/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.test.mjs b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.test.mjs index 89d23eb221..68ee2d8239 100644 --- a/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.test.mjs +++ b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { isProjectHomeWorkspaceSheetTab, + projectHomeWorkspaceSheetExpandTab, projectHomeWorkspaceSheetTitle, } from "./projectHomeWorkspaceSheet.ts"; @@ -20,3 +21,14 @@ test("projectHomeWorkspaceSheetTitle matches overview row labels", () => { assert.equal(projectHomeWorkspaceSheetTitle("files"), "Files"); assert.equal(projectHomeWorkspaceSheetTitle("contributors"), "People"); }); + +test("projectHomeWorkspaceSheetExpandTab keeps the selected repository menu", () => { + assert.equal(projectHomeWorkspaceSheetExpandTab("issues"), "issues"); + assert.equal(projectHomeWorkspaceSheetExpandTab("prs"), "prs"); + assert.equal(projectHomeWorkspaceSheetExpandTab("commits"), "commits"); + assert.equal(projectHomeWorkspaceSheetExpandTab("files"), "files"); + assert.equal( + projectHomeWorkspaceSheetExpandTab("contributors"), + "contributors", + ); +}); diff --git a/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts index 93a6675990..bf3ce4f65d 100644 --- a/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts +++ b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts @@ -31,3 +31,10 @@ export function projectHomeWorkspaceSheetTitle( ): string { return WORKSPACE_SHEET_TITLES[tab]; } + +/** Repository workspace tab to open when expanding a home-channel sheet. */ +export function projectHomeWorkspaceSheetExpandTab( + tab: ProjectHomeWorkspaceSheetTab, +): ProjectHomeWorkspaceSheetTab { + return tab; +} diff --git a/desktop/src/features/projects/projectChannelRequest.test.mjs b/desktop/src/features/projects/projectChannelRequest.test.mjs new file mode 100644 index 0000000000..4d109ffb21 --- /dev/null +++ b/desktop/src/features/projects/projectChannelRequest.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseProjectChannelRequest, + PROJECT_CHANNEL_REQUEST, +} from "./projectChannelRequest.ts"; + +const HOME_CHANNEL = "11111111-1111-4111-8111-111111111111"; + +test("parses a narrow project channel request", () => { + assert.deepEqual( + parseProjectChannelRequest({ + type: PROJECT_CHANNEL_REQUEST, + action: "create", + requestId: "request-1", + request: { + homeChannelId: HOME_CHANNEL, + name: "release-planning", + description: "Coordinate the release.", + visibility: "private", + ttlSeconds: 3600, + templateName: "Release team", + }, + }), + { + type: PROJECT_CHANNEL_REQUEST, + action: "create", + requestId: "request-1", + request: { + homeChannelId: HOME_CHANNEL, + name: "release-planning", + description: "Coordinate the release.", + visibility: "private", + ttlSeconds: 3600, + templateName: "Release team", + }, + }, + ); +}); + +test("rejects unknown fields and invalid values", () => { + assert.equal( + parseProjectChannelRequest({ + type: PROJECT_CHANNEL_REQUEST, + action: "create", + requestId: "request-1", + request: { + homeChannelId: HOME_CHANNEL, + name: "release", + visibility: "public", + }, + }), + null, + ); + assert.equal( + parseProjectChannelRequest({ + type: PROJECT_CHANNEL_REQUEST, + action: "create", + requestId: "request-1", + request: { + homeChannelId: HOME_CHANNEL, + name: "release", + visibility: "open", + secret: "nope", + }, + }), + null, + ); +}); diff --git a/desktop/src/features/projects/projectChannelRequest.ts b/desktop/src/features/projects/projectChannelRequest.ts new file mode 100644 index 0000000000..944dc29335 --- /dev/null +++ b/desktop/src/features/projects/projectChannelRequest.ts @@ -0,0 +1,81 @@ +export const PROJECT_CHANNEL_REQUEST = "project_channel_request" as const; + +export type ProjectChannelRequest = { + type: typeof PROJECT_CHANNEL_REQUEST; + action: "create"; + requestId: string; + request: { + homeChannelId: string; + name: string; + description?: string; + visibility: "open" | "private"; + ttlSeconds?: number; + templateName?: string; + }; +}; + +function isText(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isTextWithin(value: unknown, max: number): value is string { + return isText(value) && value.length <= max; +} + +/** Parses the narrow, no-secret owner-review contract for project channels. */ +export function parseProjectChannelRequest( + value: unknown, +): ProjectChannelRequest | null { + if (typeof value !== "object" || value === null) return null; + const payload = value as Record; + if ( + payload.type !== PROJECT_CHANNEL_REQUEST || + payload.action !== "create" || + !isText(payload.requestId) || + typeof payload.request !== "object" || + payload.request === null + ) { + return null; + } + const request = payload.request as Record; + const allowed = [ + "homeChannelId", + "name", + "description", + "visibility", + "ttlSeconds", + "templateName", + ]; + if ( + Object.keys(request).some((key) => !allowed.includes(key)) || + !isTextWithin(request.homeChannelId, 128) || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + request.homeChannelId, + ) || + !isTextWithin(request.name, 120) || + (request.visibility !== "open" && request.visibility !== "private") || + (request.description !== undefined && + !isTextWithin(request.description, 2_048)) || + (request.templateName !== undefined && + !isTextWithin(request.templateName, 300)) || + (request.ttlSeconds !== undefined && + (typeof request.ttlSeconds !== "number" || + !Number.isSafeInteger(request.ttlSeconds) || + request.ttlSeconds <= 0)) + ) { + return null; + } + return { + type: PROJECT_CHANNEL_REQUEST, + action: "create", + requestId: payload.requestId, + request: { + homeChannelId: request.homeChannelId, + name: request.name, + visibility: request.visibility, + ...(request.description ? { description: request.description } : {}), + ...(request.ttlSeconds ? { ttlSeconds: request.ttlSeconds } : {}), + ...(request.templateName ? { templateName: request.templateName } : {}), + }, + }; +} diff --git a/desktop/src/features/projects/projectModels.ts b/desktop/src/features/projects/projectModels.ts index a89ddcd689..21afa771d7 100644 --- a/desktop/src/features/projects/projectModels.ts +++ b/desktop/src/features/projects/projectModels.ts @@ -49,6 +49,11 @@ export type Project = { legacy: boolean; }; +/** True for an announced NIP-MP project, excluding repository-only read models. */ +export function isExplicitProject(project: Project): boolean { + return !project.legacy; +} + type BuildProjectReadModelsInput = { projectEvents: RelayEvent[]; repositoryEvents: RelayEvent[]; diff --git a/desktop/src/features/projects/ui/ProjectChannelHome.tsx b/desktop/src/features/projects/ui/ProjectChannelHome.tsx index a0b931d9e4..d0c6cbdb08 100644 --- a/desktop/src/features/projects/ui/ProjectChannelHome.tsx +++ b/desktop/src/features/projects/ui/ProjectChannelHome.tsx @@ -1,5 +1,5 @@ import { useSearch } from "@tanstack/react-router"; -import { Info } from "lucide-react"; +import { Maximize2, Plus } from "lucide-react"; import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; @@ -9,9 +9,11 @@ import { useProfileQuery } from "@/features/profile/hooks"; import type { Project } from "@/features/projects/hooks"; import { isProjectHomeWorkspaceSheetTab, + projectHomeWorkspaceSheetExpandTab, projectHomeWorkspaceSheetTitle, type ProjectHomeWorkspaceSheetTab, } from "@/features/projects/lib/projectHomeWorkspaceSheet"; +import { ProjectSelectionProvider } from "@/features/projects/lib/useProjectSelection"; import { useHealProjectHomeRepositories } from "@/features/projects/useHealProjectHomeRepositories"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { RelayEvent } from "@/shared/api/types"; @@ -27,7 +29,11 @@ import { ProjectContextRail } from "./ProjectContextRail"; import { ProjectDetailChrome } from "./ProjectDetailChrome"; import { ProjectHomeColumn } from "./ProjectHomeColumn"; import { ProjectHomeContextPanel } from "./ProjectHomeContextPanel"; -import { ProjectHomeWorkspaceSheet } from "./ProjectHomeWorkspaceSheet"; +import { + ProjectHomeWorkspaceSheet, + type ProjectHomeWorkspaceCreateAction, + type ProjectHomeWorkspaceDetail, +} from "./ProjectHomeWorkspaceSheet"; import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; const EMPTY_TARGET_MESSAGE_EVENTS: RelayEvent[] = []; @@ -78,11 +84,17 @@ function ProjectHomeHeaderToggle({ } export function ProjectChannelHome({ + autoSendDraftKey, project, projects, + targetMessageEvents = EMPTY_TARGET_MESSAGE_EVENTS, + targetMessageId, }: { + autoSendDraftKey?: string | null; project: Project; projects: Project[]; + targetMessageEvents?: RelayEvent[]; + targetMessageId?: string | null; }) { const { goChannel, goProject, goProjects } = useAppNavigation(); const sidebar = useOptionalSidebar(); @@ -100,6 +112,10 @@ export function ProjectChannelHome({ const [workspaceRepositoryId, setWorkspaceRepositoryId] = React.useState< string | null >(null); + const [workspaceCreateAction, setWorkspaceCreateAction] = + React.useState(null); + const [workspaceDetail, setWorkspaceDetail] = + React.useState(null); const summaryWidth = useThreadPanelWidth(undefined, { sessionKey: PROJECT_HOME_SUMMARY_WIDTH_KEY, }); @@ -123,11 +139,15 @@ export function ProjectChannelHome({ if (repositoryId) { setWorkspaceRepositoryId(repositoryId); } + setWorkspaceCreateAction(null); + setWorkspaceDetail(null); setWorkspaceSheetTab((current) => (current === tab ? null : tab)); }, [], ); const closeWorkspaceSheet = React.useCallback(() => { + setWorkspaceCreateAction(null); + setWorkspaceDetail(null); setWorkspaceSheetTab(null); }, []); const handleOpenWorkspace = React.useCallback( @@ -153,16 +173,19 @@ export function ProjectChannelHome({ setAddRepositoryOpen(true); }, []); const handleFilesAdded = React.useCallback((repositoryId: string) => { + setWorkspaceCreateAction(null); + setWorkspaceDetail(null); setWorkspaceRepositoryId(repositoryId); setWorkspaceSheetTab("files"); }, []); - const handleToggleFilesSheet = React.useCallback(() => { - if (!workspaceRepository) { - handleAddFiles(); - return; - } - openWorkspaceSheet("files", workspaceRepository.id); - }, [handleAddFiles, openWorkspaceSheet, workspaceRepository]); + const handleWorkspaceRepositoryChange = React.useCallback( + (repositoryId: string) => { + setWorkspaceCreateAction(null); + setWorkspaceDetail(null); + setWorkspaceRepositoryId(repositoryId); + }, + [], + ); useHealProjectHomeRepositories(project, identityQuery.data?.pubkey); const handleOpenCommit = React.useCallback( (commitHash: string) => { @@ -175,14 +198,33 @@ export function ProjectChannelHome({ }, [goProject, project.id, workspaceRepository], ); + const handleExpandWorkspace = React.useCallback(() => { + if (!workspaceRepository || !workspaceSheetTab) return; + void goProject(project.id, { + ...workspaceDetail?.navigation, + repositoryId: workspaceRepository.id, + tab: projectHomeWorkspaceSheetExpandTab(workspaceSheetTab), + }); + }, [ + goProject, + project.id, + workspaceDetail?.navigation, + workspaceRepository, + workspaceSheetTab, + ]); + const expandLabel = workspaceSheetTab + ? `Open ${projectHomeWorkspaceSheetTitle(workspaceSheetTab)} in repository` + : "Open in repository"; const workspaceSheet = workspaceSheetOpen && workspaceSheetTab && workspaceRepository ? (
- +
+ { @@ -221,116 +267,161 @@ export function ProjectChannelHome({ }} open={summaryVisible} testId="project-home-drawer-toggle" - > - - - - - } - activeTabCrumb={null} - activeWorkItemCrumb={null} - onGoProjectHome={() => undefined} - onGoProjects={() => { - void goProjects(); - }} - project={project} - /> - {waitingForChannel ? ( - - ) : homeChannel ? ( - } - > - undefined} + onGoProjects={() => { + void goProjects(); + }} + project={project} + /> + {waitingForChannel ? ( + + ) : homeChannel ? ( + } - onAddFiles={handleAddFiles} - onCloseIdleAuxiliaryPanel={closeWorkspaceSheet} - onCloseForumPost={ignoreForumPost} - onSelectForumPost={ignoreForumPostSelect} - selectedForumPostId={null} - targetForumReplyId={null} - targetMessageEvents={EMPTY_TARGET_MESSAGE_EVENTS} - targetMessageId={search.messageId ?? null} - /> - - ) : ( -
-

- This project's channel could not be found. -

-
- )} + > + + {workspaceCreateAction ? ( + + + + + + {workspaceCreateAction.label} + + + ) : null} + + + + + {expandLabel} + + + ), + backLabel: workspaceDetail?.backLabel, + onBack: workspaceDetail?.onBack, + }} + idleAuxiliaryTitle={ + workspaceSheetTab + ? projectHomeWorkspaceSheetTitle(workspaceSheetTab) + : "" + } + onAddFiles={handleAddFiles} + onCloseIdleAuxiliaryPanel={closeWorkspaceSheet} + onCloseForumPost={ignoreForumPost} + onSelectForumPost={ignoreForumPostSelect} + selectedForumPostId={null} + targetForumReplyId={null} + targetMessageEvents={targetMessageEvents} + targetMessageId={ + targetMessageId === undefined + ? (search.messageId ?? null) + : targetMessageId + } + /> +
+ ) : ( +
+

+ This project's channel could not be found. +

+
+ )} +
+ + + {summaryVisible ? ( + + { + void goChannel(channelId); + }} + onOpenRepository={handleOpenRepository} + onOpenWorkspace={handleOpenWorkspace} + onRepositoryChange={handleRepositoryChange} + project={project} + projects={projects} + /> + + ) : null} +
- - - {summaryVisible ? ( - setSummaryOpen(false)} - onResetWidth={summaryWidth.onResetWidth} - onResizeStart={summaryWidth.onResizeStart} - testId="project-home-summary-column" - title="Overview" - widthPx={summaryWidth.widthPx} - > - { - void goChannel(channelId); - }} - onOpenRepository={handleOpenRepository} - onOpenWorkspace={handleOpenWorkspace} - onRepositoryChange={handleRepositoryChange} - project={project} - projects={projects} - /> - - ) : null} - - + ); } diff --git a/desktop/src/features/projects/ui/ProjectChannelManagement.tsx b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx index dbb02fd462..71d269a84a 100644 --- a/desktop/src/features/projects/ui/ProjectChannelManagement.tsx +++ b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx @@ -64,7 +64,7 @@ export function ProjectChannelManagement({ />