diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 7a979b62e0c..d2775443671 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -23,6 +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`, `add-channel` | | `buzz issues` | `create`, `get`, `list`, `status`, `assign` | | `buzz pr` | `open`, `update`, `get`, `list`, `status` | | `buzz upload` | `file` | @@ -31,6 +32,15 @@ Run `buzz --help` or `buzz --help` for full usage. For multiline message When opening a pull request in response to channel work, always pass `--channel ` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation. +## Projects + +A project is a named grouping (`kind:30621`) with a home channel. Creating a second project with the same name produces a duplicate card in Buzz Desktop — never do that for work that already has a project. + +- 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. To assign an issue to someone, run `buzz issues assign --issue --repo-owner --repo-id --assignee --label ` after creating it. Remove an assignment with the matching `buzz issues unassign` arguments. Writing assignee names in the issue body or adding recipients with `issues create --to` is notification/presentation only — Buzz Desktop's Assignees rail and the "Assigned to me" filter read the signed assignment operations. Only operations signed by the issue author or repo owner are trusted for other people; anyone may assign or unassign themselves. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 146214197a8..ec3a906c146 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -7,6 +7,7 @@ mod filter; mod observer; mod pool; mod pool_lifecycle; +mod prompt_project; mod queue; mod relay; mod setup_mode; @@ -4475,6 +4476,14 @@ mod agent_draft_prompt_tests { assert!(prompt.contains("update the team's shared guidance")); } + #[test] + fn shared_base_prompt_teaches_not_to_duplicate_projects() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("do **not** run `buzz projects create`")); + assert!(prompt.contains("buzz issues create --channel")); + assert!(prompt.contains("is not a Buzz repository")); + } + #[test] fn shared_base_prompt_teaches_single_command_mentions_and_preflight() { let prompt = include_str!("base_prompt.md"); @@ -5666,8 +5675,8 @@ mod author_gate_tests { assert!(is_dm_channel(id, &resolver).await); assert_eq!( requests.load(Ordering::SeqCst), - 1, - "second resolution uses cache" + 2, + "channel metadata and project context each resolve once, then cache" ); server.abort(); } diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 38749577398..7b5bdccb452 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -36,6 +36,7 @@ use crate::acp::{ }; use crate::config::{compose_session_title, DedupMode, PermissionMode}; use crate::observer; +use crate::prompt_project::{pick_authoritative_project_home, PromptProjectInfo}; use crate::queue::{ CancelReason, ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo, PromptProfile, PromptProfileLookup, ThreadTags, @@ -543,6 +544,9 @@ pub enum PromptOutcome { #[derive(Debug, Clone)] pub struct ChannelInfoResolver { cache: std::sync::Arc>>, + projects: std::sync::Arc< + std::sync::RwLock>>, + >, rest_client: RestClient, } @@ -560,31 +564,51 @@ impl ChannelInfoResolver { name: info.name, channel_type: info.channel_type, description: info.description, + project: None, }, )) }) .collect(); Self { cache: std::sync::Arc::new(std::sync::RwLock::new(cache)), + projects: std::sync::Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())), rest_client, } } pub async fn resolve(&self, channel_id: Uuid) -> Option { - if let Some(info) = self + let mut info = if let Some(info) = self .cache .read() .ok() .and_then(|cache| cache.get(&channel_id).cloned()) { - return Some(info); - } + info + } else { + let info = fetch_channel_info(channel_id, &self.rest_client).await?; + if let Ok(mut cache) = self.cache.write() { + cache.insert(channel_id, info.clone()); + } + info + }; + info.project = self.lookup_project(channel_id).await; + Some(info) + } - let info = fetch_channel_info(channel_id, &self.rest_client).await?; - if let Ok(mut cache) = self.cache.write() { - cache.insert(channel_id, info.clone()); + async fn lookup_project(&self, channel_id: Uuid) -> Option { + if let Some(cached) = self + .projects + .read() + .ok() + .and_then(|cache| cache.get(&channel_id).cloned()) + { + return cached; } - Some(info) + let fetched = fetch_project_home_for_channel(channel_id, &self.rest_client).await; + if let Ok(mut cache) = self.projects.write() { + cache.insert(channel_id, fetched.clone()); + } + fetched } } @@ -2937,6 +2961,7 @@ pub(crate) async fn fetch_channel_info( name: name.unwrap_or(UNKNOWN_CHANNEL_NAME).to_string(), channel_type, description, + project: None, }) } Ok(Err(e)) => { @@ -2958,6 +2983,53 @@ pub(crate) async fn fetch_channel_info( .await } +/// Resolve the listed NIP-MP project whose home channel is `channel_id`. +/// +/// Empty results are not retried: most channels are not project homes. +pub(crate) async fn fetch_project_home_for_channel( + channel_id: Uuid, + rest: &RestClient, +) -> Option { + let filters = [ + serde_json::json!({ + "kinds": [buzz_core::kind::KIND_PROJECT], + "limit": 1000, + }), + serde_json::json!({ + "kinds": [buzz_core::kind::KIND_GIT_REPO_ANNOUNCEMENT], + "#buzz-channel": [channel_id.to_string()], + "limit": 1000, + }), + ]; + + let json = fetch_with_retry(|| async { + match timeout(CONTEXT_FETCH_TIMEOUT, rest.query_raw(&filters)).await { + Ok(Ok(json)) => Some(json), + Ok(Err(e)) => { + tracing::debug!( + channel_id = %channel_id, + "project home fetch failed: {e} — will retry" + ); + None + } + Err(_) => { + tracing::debug!( + channel_id = %channel_id, + "project home fetch timed out — will retry" + ); + None + } + } + }) + .await?; + let events = json.as_array()?; + let (projects, repos): (Vec<_>, Vec<_>) = events.iter().cloned().partition(|event| { + event.get("kind").and_then(serde_json::Value::as_u64) + == Some(buzz_core::kind::KIND_PROJECT as u64) + }); + pick_authoritative_project_home(&projects, &repos, &channel_id.to_string()) +} + /// Fetch owner-signed huddle instructions for a new channel session. /// /// The event is promoted into the system role, so accepting any channel member's @@ -8373,7 +8445,8 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" } /// A normal channel yields a non-DM (canvas allowed) and its name for the - /// title suffix — and the second consumer reads it from cache, not the wire. + /// title suffix. Channel metadata and project context resolve once each; + /// the second consumer reads both from cache. #[tokio::test] async fn test_new_session_channel_context_qualifies_a_normal_channel() { use std::sync::atomic::Ordering; @@ -8387,14 +8460,14 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" assert!(!is_dm, "a stream channel is not a DM"); assert_eq!(title_channel.as_deref(), Some("buzz-dev")); assert_eq!(channel_type.as_deref(), Some("stream")); - assert_eq!(requests.load(Ordering::SeqCst), 1); + assert_eq!(requests.load(Ordering::SeqCst), 2); let (_, again, _) = resolve_new_session_channel_context(&resolver, id).await; assert_eq!(again.as_deref(), Some("buzz-dev")); assert_eq!( requests.load(Ordering::SeqCst), - 1, - "a resolved channel is cached — no second lookup" + 2, + "resolved channel metadata and project context are cached" ); server.abort(); } diff --git a/crates/buzz-acp/src/prompt_project.rs b/crates/buzz-acp/src/prompt_project.rs new file mode 100644 index 00000000000..0f7e3e40951 --- /dev/null +++ b/crates/buzz-acp/src/prompt_project.rs @@ -0,0 +1,264 @@ +//! Parse a channel's authoritative NIP-MP project home for ACP `[Context]`. + +use std::collections::HashMap; + +use serde_json::Value; + +/// Project identity attached to a home channel in agent prompts. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PromptProjectInfo { + pub name: String, + pub slug: String, + pub owner: String, + pub coordinate: String, + pub default_repo_owner: Option, + pub default_repo_id: Option, +} + +/// Resolve one listed project whose member repository authoritatively binds the channel. +/// +/// A project's own `buzz-channel` is presentation metadata and cannot establish +/// authority. A candidate is accepted only when one of its `a` members resolves +/// to a live `kind:30617` whose first `buzz-channel` is `channel_id` and whose +/// owner (or `maintainers`) authorizes the project signer. Ambiguity fails closed. +pub fn pick_authoritative_project_home( + project_events: &[Value], + repo_events: &[Value], + channel_id: &str, +) -> Option { + let repos = authoritative_channel_repos(repo_events, channel_id); + let mut matches = project_events.iter().filter_map(|event| { + if event_is_unlisted(event) || !event_has_tag_value(event, "buzz-channel", channel_id) { + return None; + } + let mut project = parse_prompt_project(event)?; + let signer = project.owner.as_str(); + let authoritative_member = event + .get("tags")? + .as_array()? + .iter() + .filter_map(|tag| tag.as_array()) + .filter(|tag| tag.first().and_then(Value::as_str) == Some("a")) + .filter_map(|tag| tag.get(1).and_then(Value::as_str)) + .filter_map(parse_repo_coord) + .find(|(owner, id)| { + repos + .get(&(owner.clone(), id.clone())) + .is_some_and(|maintainers| { + owner.eq_ignore_ascii_case(signer) + || maintainers.iter().any(|m| m.eq_ignore_ascii_case(signer)) + }) + })?; + project.default_repo_owner = Some(authoritative_member.0); + project.default_repo_id = Some(authoritative_member.1); + Some(project) + }); + let home = matches.next()?; + matches.next().is_none().then_some(home) +} + +fn authoritative_channel_repos( + events: &[Value], + channel_id: &str, +) -> HashMap<(String, String), Vec> { + events + .iter() + .filter_map(|event| { + if event.get("kind").and_then(Value::as_u64) != Some(30617) + || event_is_unlisted(event) + || first_tag_value(event, "buzz-channel") != Some(channel_id) + { + return None; + } + let owner = event.get("pubkey")?.as_str()?.trim().to_ascii_lowercase(); + if owner.len() != 64 { + return None; + } + let id = first_tag_value(event, "d")?.trim(); + if id.is_empty() { + return None; + } + let maintainers = multi_tag_values(event, "maintainers") + .map(str::to_ascii_lowercase) + .collect(); + Some(((owner, id.to_string()), maintainers)) + }) + .collect() +} + +fn first_tag_value<'a>(event: &'a Value, name: &'static str) -> Option<&'a str> { + tag_values(event, name).next() +} + +fn tag_values<'a>(event: &'a Value, name: &'static str) -> impl Iterator { + event + .get("tags") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_array) + .filter(move |tag| tag.first().and_then(Value::as_str) == Some(name)) + .filter_map(|tag| tag.get(1).and_then(Value::as_str)) +} + +fn multi_tag_values<'a>(event: &'a Value, name: &'static str) -> impl Iterator { + event + .get("tags") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_array) + .filter(move |tag| tag.first().and_then(Value::as_str) == Some(name)) + .flat_map(|tag| tag.iter().skip(1).filter_map(Value::as_str)) +} + +fn event_has_tag_value(event: &Value, name: &'static str, value: &str) -> bool { + tag_values(event, name).any(|candidate| candidate == value) +} + +fn event_is_unlisted(event: &Value) -> bool { + event_has_tag_value(event, "buzz-visibility", "unlisted") +} + +fn parse_prompt_project(event: &Value) -> Option { + if event.get("kind").and_then(Value::as_u64) != Some(30621) { + return None; + } + let owner = event.get("pubkey")?.as_str()?.trim().to_ascii_lowercase(); + if owner.len() != 64 { + return None; + } + let slug = first_tag_value(event, "d")?.trim().to_string(); + if slug.is_empty() { + return None; + } + let name = first_tag_value(event, "name") + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&slug) + .to_string(); + Some(PromptProjectInfo { + name, + coordinate: format!("30621:{owner}:{slug}"), + slug, + owner, + default_repo_owner: None, + default_repo_id: None, + }) +} + +fn parse_repo_coord(value: &str) -> Option<(String, String)> { + let mut parts = value.splitn(3, ':'); + let kind = parts.next()?; + let owner = parts.next()?.trim().to_ascii_lowercase(); + let id = parts.next()?.trim(); + if kind != "30617" || owner.len() != 64 || id.is_empty() { + return None; + } + Some((owner, id.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const CHANNEL_ID: &str = "11111111-1111-4111-8111-111111111111"; + + fn project(owner: &str, slug: &str, repo: &str) -> Value { + json!({"pubkey": owner, "kind": 30621, "tags": [ + ["d", slug], ["name", slug], ["buzz-channel", CHANNEL_ID], ["a", repo] + ]}) + } + + fn repo(owner: &str, id: &str, channel: &str, extra: Vec) -> Value { + let mut tags = vec![json!(["d", id]), json!(["buzz-channel", channel])]; + tags.extend(extra); + json!({"pubkey": owner, "kind": 30617, "tags": tags}) + } + + #[test] + fn requires_repo_owned_channel_binding() { + let owner = "a".repeat(64); + let coord = format!("30617:{owner}:game"); + let home = pick_authoritative_project_home( + &[project(&owner, "game", &coord)], + &[repo(&owner, "game", CHANNEL_ID, vec![])], + CHANNEL_ID, + ) + .unwrap(); + assert_eq!(home.default_repo_id.as_deref(), Some("game")); + + assert!(pick_authoritative_project_home( + &[project(&owner, "game", &coord)], + &[], + CHANNEL_ID + ) + .is_none()); + } + + #[test] + fn hostile_project_cannot_claim_foreign_repo() { + let owner = "a".repeat(64); + let attacker = "b".repeat(64); + let coord = format!("30617:{owner}:game"); + assert!(pick_authoritative_project_home( + &[project(&attacker, "spoof", &coord)], + &[repo(&owner, "game", CHANNEL_ID, vec![])], + CHANNEL_ID, + ) + .is_none()); + } + + #[test] + fn repo_maintainer_can_authorize_project() { + let owner = "a".repeat(64); + let maintainer = "b".repeat(64); + let coord = format!("30617:{owner}:game"); + let home = pick_authoritative_project_home( + &[project(&maintainer, "suite", &coord)], + &[repo( + &owner, + "game", + CHANNEL_ID, + vec![json!(["maintainers", "c".repeat(64), maintainer])], + )], + CHANNEL_ID, + ) + .unwrap(); + assert_eq!(home.owner, maintainer); + } + + #[test] + fn ambiguous_authoritative_projects_fail_closed() { + let owner = "a".repeat(64); + let coord = format!("30617:{owner}:game"); + assert!(pick_authoritative_project_home( + &[ + project(&owner, "one", &coord), + project(&owner, "two", &coord) + ], + &[repo(&owner, "game", CHANNEL_ID, vec![])], + CHANNEL_ID, + ) + .is_none()); + } + + #[test] + fn first_repo_channel_binding_is_authoritative() { + let owner = "a".repeat(64); + let coord = format!("30617:{owner}:game"); + let other = "22222222-2222-4222-8222-222222222222"; + let mut announcement = repo(&owner, "game", other, vec![]); + announcement["tags"] + .as_array_mut() + .unwrap() + .push(json!(["buzz-channel", CHANNEL_ID])); + assert!(pick_authoritative_project_home( + &[project(&owner, "game", &coord)], + &[announcement], + CHANNEL_ID + ) + .is_none()); + } +} diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 60866518bad..89fb268ea2e 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -18,6 +18,8 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::time::{Duration, Instant}; use uuid::Uuid; +use crate::prompt_project::PromptProjectInfo; + use crate::config::DedupMode; /// Maximum events queued per channel before oldest events are dropped. @@ -1015,12 +1017,14 @@ pub struct ContextMessage { } /// Channel metadata for prompt formatting. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct PromptChannelInfo { pub name: String, pub channel_type: String, /// Channel description from the kind-39000 `about` tag, if present. pub description: Option, + /// Listed NIP-MP project whose home channel this is, when one exists. + pub project: Option, } /// Minimal profile fields needed to label users in ACP prompts. @@ -1255,42 +1259,84 @@ fn resolve_reply_anchor( /// in a description must not be able to spoof another `[Context]` field, so /// multiline text is collapsed to single-space-joined lines before truncation. const MAX_DESCRIPTION_LEN: usize = 500; +const MAX_PROJECT_NAME_LEN: usize = 160; -/// Append a `Description: …` line to a `[Context]` block when non-empty. -/// -/// Collapses internal newlines (any `\r\n`, `\r`, or `\n`) to a single space -/// so a multi-line description cannot inject a fake `[Context]` field line. -/// Truncates at [`MAX_DESCRIPTION_LEN`] characters with a `…` marker. -fn append_channel_description(s: &mut String, channel_info: Option<&PromptChannelInfo>) { - let desc = match channel_info.and_then(|ci| ci.description.as_deref()) { - Some(d) if !d.is_empty() => d, - _ => return, - }; - // Collapse newlines to spaces so the description can never spoof another field. - let collapsed: String = desc +fn collapse_prompt_line(raw: &str, max_chars: usize) -> Option { + let collapsed: String = raw .split(['\n', '\r']) .map(str::trim) .filter(|s| !s.is_empty()) .collect::>() .join(" "); if collapsed.is_empty() { - return; + return None; } - // Truncate at a character boundary (not byte boundary) to avoid splitting - // multi-byte sequences. - let truncated = if collapsed.chars().count() > MAX_DESCRIPTION_LEN { + let truncated = if collapsed.chars().count() > max_chars { let end = collapsed .char_indices() - .nth(MAX_DESCRIPTION_LEN) + .nth(max_chars) .map(|(i, _)| i) .unwrap_or(collapsed.len()); format!("{}…", &collapsed[..end]) } else { collapsed }; + Some(truncated) +} + +/// Append a `Description: …` line to a `[Context]` block when non-empty. +/// +/// Collapses internal newlines (any `\r\n`, `\r`, or `\n`) to a single space +/// so a multi-line description cannot inject a fake `[Context]` field line. +/// Truncates at [`MAX_DESCRIPTION_LEN`] characters with a `…` marker. +fn append_channel_description(s: &mut String, channel_info: Option<&PromptChannelInfo>) { + let desc = match channel_info.and_then(|ci| ci.description.as_deref()) { + Some(d) if !d.is_empty() => d, + _ => return, + }; + let Some(truncated) = collapse_prompt_line(desc, MAX_DESCRIPTION_LEN) else { + return; + }; s.push_str(&format!("\nDescription: {truncated}")); } +/// Append project-home identity so create operations target this project. +fn append_project_home(s: &mut String, channel_info: Option<&PromptChannelInfo>, channel_id: Uuid) { + let Some(project) = channel_info.and_then(|ci| ci.project.as_ref()) else { + return; + }; + let Some(slug) = collapse_prompt_line(&project.slug, 64) else { + return; + }; + let name = + collapse_prompt_line(&project.name, MAX_PROJECT_NAME_LEN).unwrap_or_else(|| slug.clone()); + let owner = collapse_prompt_line(&project.owner, 64).unwrap_or_default(); + let coordinate = collapse_prompt_line(&project.coordinate, 200).unwrap_or_default(); + s.push_str(&format!( + "\nProject: {name}\nProject slug: {slug}\nProject owner: {owner}\nProject coordinate: {coordinate}" + )); + match ( + project + .default_repo_owner + .as_deref() + .and_then(|value| collapse_prompt_line(value, 64)), + project + .default_repo_id + .as_deref() + .and_then(|value| collapse_prompt_line(value, 64)), + ) { + (Some(repo_owner), Some(repo_id)) => { + s.push_str(&format!( + "\nDefault repository: {repo_id} (owner {repo_owner})" + )); + } + _ => s.push_str("\nDefault repository: none yet"), + } + s.push_str(&format!( + "\nThis channel is that project's home. Tasks, repositories, and files created here belong to this project. Do not run `buzz projects create`. Create a repository with `buzz repos create --id --name \"…\" --channel {channel_id}`. Create tasks with `buzz issues create --channel {channel_id} --subject \"…\" --content \"…\"`." + )); +} + /// Format a `[Context]` hints section based on event scope. /// /// `reply_anchor` is the pre-resolved `--reply-to` target for this turn (see @@ -1364,6 +1410,7 @@ fn format_context_hints( Channel: {channel_display}" ); append_channel_description(&mut s, channel_info); + append_project_home(&mut s, channel_info, channel_id); s.push_str(&format!("\nThread root: {root}")); if let Some(ref parent) = thread_tags.parent_event_id { if parent != root { @@ -1382,6 +1429,7 @@ fn format_context_hints( Channel: {channel_display}" ); append_channel_description(&mut s, channel_info); + append_project_home(&mut s, channel_info, channel_id); s.push_str( "\nHint: Use `buzz messages get --channel ` for recent messages if needed.", ); @@ -3270,6 +3318,7 @@ mod tests { name: "engineering".into(), channel_type: "stream".into(), description: None, + project: None, }; let prompt = format_prompt( @@ -3302,6 +3351,7 @@ mod tests { name: "DM".into(), channel_type: "dm".into(), description: None, + project: None, }; let prompt = format_prompt( @@ -3417,6 +3467,7 @@ mod tests { name: "DM".into(), channel_type: "dm".into(), description: None, + project: None, }; let ctx = ConversationContext::Dm { messages: vec![ContextMessage { @@ -3676,6 +3727,7 @@ mod tests { name: "DM".into(), channel_type: "dm".into(), description: None, + project: None, }; // Thread context fetched (as the fetch path does for DM replies). let ctx = ConversationContext::Thread { @@ -3778,6 +3830,7 @@ mod tests { name: "DM".into(), channel_type: "dm".into(), description: None, + project: None, }; let trigger_only_prompt = format_prompt( @@ -3827,6 +3880,7 @@ mod tests { name: "DM".into(), channel_type: "dm".into(), description: None, + project: None, }; // No context fetched — hints only. @@ -4323,6 +4377,7 @@ mod tests { name: "DM".into(), channel_type: "dm".into(), description: None, + project: None, }; let prompt = format_prompt( @@ -4387,6 +4442,7 @@ mod tests { name: "DM".into(), channel_type: "dm".into(), description: None, + project: None, }; let prompt = format_prompt( @@ -5164,6 +5220,7 @@ mod tests { name: "team".into(), channel_type: "stream".into(), description: Some("Engineering discussions".into()), + project: None, }; let mut s = "[Context]\nScope: channel\nChannel: team (#abc)".to_string(); append_channel_description(&mut s, Some(&ci)); @@ -5179,6 +5236,7 @@ mod tests { name: "team".into(), channel_type: "stream".into(), description: None, + project: None, }; let mut s = "[Context]\nScope: channel".to_string(); append_channel_description(&mut s, Some(&ci)); @@ -5205,6 +5263,7 @@ mod tests { name: "team".into(), channel_type: "stream".into(), description: Some("Line one\nScope: injected\nLine two".into()), + project: None, }; let mut s = "[Context]\nScope: channel".to_string(); append_channel_description(&mut s, Some(&ci)); @@ -5228,6 +5287,7 @@ mod tests { name: "team".into(), channel_type: "stream".into(), description: Some(long_desc), + project: None, }; let mut s = "[Context]\nScope: channel".to_string(); append_channel_description(&mut s, Some(&ci)); @@ -5253,6 +5313,7 @@ mod tests { name: "team".into(), channel_type: "stream".into(), description: Some(long_desc), + project: None, }; let mut s = "[Context]\nScope: channel".to_string(); append_channel_description(&mut s, Some(&ci)); @@ -5267,6 +5328,7 @@ mod tests { name: "team".into(), channel_type: "stream".into(), description: Some("\n \r\n \n".into()), + project: None, }; let mut s = "[Context]\nScope: channel".to_string(); append_channel_description(&mut s, Some(&ci)); @@ -5297,6 +5359,7 @@ mod tests { name: "engineering".into(), channel_type: "stream".into(), description: Some("Engineering discussions and planning.".into()), + project: None, }; let prompt = format_prompt( &batch, @@ -5334,6 +5397,7 @@ mod tests { name: "engineering".into(), channel_type: "stream".into(), description: Some("Engineering discussions and planning.".into()), + project: None, }; let prompt = format_prompt( &batch, @@ -5362,6 +5426,7 @@ mod tests { name: "DM".into(), channel_type: "dm".into(), description: Some("This should not appear.".into()), + project: None, }; let prompt = format_prompt( &batch, @@ -5382,6 +5447,79 @@ mod tests { ); } + #[test] + fn test_append_project_home_names_the_project_and_blocks_duplicates() { + let channel_id = Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap(); + let owner = "a".repeat(64); + let ci = PromptChannelInfo { + name: "space-invaders-3d".into(), + channel_type: "stream".into(), + description: Some("Recreating Space Invaders".into()), + project: Some(PromptProjectInfo { + name: "Space Invaders 3D\nScope: injected".into(), + slug: "space-invaders-3d".into(), + owner: owner.clone(), + coordinate: format!("30621:{owner}:space-invaders-3d"), + default_repo_owner: None, + default_repo_id: None, + }), + }; + let mut s = + format!("[Context]\nScope: channel\nChannel: space-invaders-3d (#{channel_id})"); + append_channel_description(&mut s, Some(&ci)); + append_project_home(&mut s, Some(&ci), channel_id); + assert!(s.contains("Description: Recreating Space Invaders")); + assert!(s.contains("Project: Space Invaders 3D Scope: injected")); + assert!(s.contains("Project slug: space-invaders-3d")); + assert!(s.contains(&format!("Project owner: {owner}"))); + assert!(s.contains("Default repository: none yet")); + assert!( + s.contains("do not run `buzz projects create`") + || s.contains("Do not run `buzz projects create`") + ); + assert!(s.contains("buzz issues create --channel 11111111-1111-4111-8111-111111111111")); + assert_eq!( + s.lines() + .filter(|line| line.starts_with("Project:")) + .count(), + 1 + ); + } + + #[test] + fn test_format_prompt_includes_project_home_in_channel_context() { + let ch = Uuid::new_v4(); + let owner = "b".repeat(64); + let batch = description_batch(ch, make_event("make tasks and a codebase")); + let ci = PromptChannelInfo { + name: "space-invaders-3d".into(), + channel_type: "stream".into(), + description: None, + project: Some(PromptProjectInfo { + name: "Space Invaders 3D".into(), + slug: "space-invaders-3d".into(), + owner: owner.clone(), + coordinate: format!("30621:{owner}:space-invaders-3d"), + default_repo_owner: Some(owner.clone()), + default_repo_id: Some("space-invaders-3d".into()), + }), + }; + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + has_system_prompt_support: true, + ..Default::default() + }, + ) + .join("\n\n"); + assert!(prompt.contains("Project: Space Invaders 3D")); + assert!(prompt.contains(&format!( + "Default repository: space-invaders-3d (owner {owner})" + ))); + assert!(prompt.contains("belong to this project")); + } + #[test] fn test_format_prompt_no_description_when_channel_metadata_unresolved() { let ch = Uuid::new_v4(); diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 17a818867dd..d96bd794674 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -423,6 +423,19 @@ impl RestClient { .map_err(|e| RelayError::Http(e.to_string())) } + /// Query events via `POST /query` with a raw NIP-01 filter document. + /// + /// `nostr::Filter` only encodes single-letter generic tags. Project home + /// lookup needs `#buzz-channel`, which this path serializes verbatim. + pub async fn query_raw(&self, filters: &[Value]) -> Result { + let body_bytes = serde_json::to_vec(filters) + .map_err(|e| RelayError::Http(format!("filter serialize error: {e}")))?; + let resp = self.bridge_post("/query", &body_bytes).await?; + resp.json() + .await + .map_err(|e| RelayError::Http(e.to_string())) + } + /// Count events via the HTTP bridge: `POST /count` with NIP-98 auth. /// /// Accepts a slice of `nostr::Filter` (serialized as JSON array). diff --git a/crates/buzz-cli/src/agent_management.rs b/crates/buzz-cli/src/agent_management.rs index ce4059f8217..e5f25130694 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/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 15284a0d7bd..7c90d47b423 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet}; use crate::client::BuzzClient; use crate::commands::with_git_provenance; +use crate::commands::GIT_ORIGIN_CHANNEL_ENV; use crate::error::CliError; use crate::validate::{read_or_stdin, sdk_err, validate_hex64, validate_repo_id}; use buzz_sdk::{GitIssueMeta, GitRepoCoord, GitStatusMeta}; @@ -264,6 +265,39 @@ pub async fn cmd_create_issue( Ok(()) } +async fn resolve_issue_repo_target( + client: &BuzzClient, + repo_owner: Option<&str>, + repo_id: Option<&str>, + channel: Option<&str>, +) -> Result<(String, String), CliError> { + let owner = repo_owner.map(str::trim).filter(|value| !value.is_empty()); + let id = repo_id.map(str::trim).filter(|value| !value.is_empty()); + match (owner, id) { + (Some(owner), Some(id)) => Ok((owner.to_string(), id.to_string())), + (None, None) => { + let channel = channel + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| std::env::var(GIT_ORIGIN_CHANNEL_ENV).ok()); + let Some(channel) = channel else { + return Err(CliError::Usage( + "provide --repo-owner and --repo-id, or --channel (or set BUZZ_GIT_ORIGIN_CHANNEL_ID)".into(), + )); + }; + let resolved = crate::commands::project_channel::resolve_or_ensure_repo_for_channel( + client, &channel, + ) + .await?; + Ok((resolved.repo_owner, resolved.repo_id)) + } + _ => Err(CliError::Usage( + "provide both --repo-owner and --repo-id, or --channel".into(), + )), + } +} + /// Publish an issue assignment: a kind:1 comment on the issue whose `p` /// tags are the assignees, labeled `t: assignment` (same event shape the /// Desktop app writes). Clients trust it when signed by the issue author @@ -561,11 +595,21 @@ pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(), IssuesCmd::Create { repo_owner, repo_id, + channel, title, content, label, to, - } => cmd_create_issue(client, &repo_owner, &repo_id, &title, &content, &label, &to).await, + } => { + let (repo_owner, repo_id) = resolve_issue_repo_target( + client, + repo_owner.as_deref(), + repo_id.as_deref(), + channel.as_deref(), + ) + .await?; + cmd_create_issue(client, &repo_owner, &repo_id, &title, &content, &label, &to).await + } IssuesCmd::Get { event } => cmd_get_issue(client, &event).await, IssuesCmd::List { repo_owner, diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index ad2c36e200c..8bb24218eb5 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -12,6 +12,7 @@ pub mod notes; pub mod pack; pub mod patches; pub mod pr; +pub mod project_channel; pub mod projects; pub mod reactions; pub mod repos; @@ -23,7 +24,7 @@ pub mod workflows; use crate::{client::normalize_write_response, error::CliError}; use nostr::{EventBuilder, Tag}; -const GIT_ORIGIN_CHANNEL_ENV: &str = "BUZZ_GIT_ORIGIN_CHANNEL_ID"; +pub(crate) const GIT_ORIGIN_CHANNEL_ENV: &str = "BUZZ_GIT_ORIGIN_CHANNEL_ID"; const GIT_ORIGIN_AGENT_ENV: &str = "BUZZ_GIT_ORIGIN_AGENT_NAME"; /// Add trusted, session-scoped provenance supplied by the ACP harness. diff --git a/crates/buzz-cli/src/commands/project_channel.rs b/crates/buzz-cli/src/commands/project_channel.rs new file mode 100644 index 00000000000..8b3a7c81c52 --- /dev/null +++ b/crates/buzz-cli/src/commands/project_channel.rs @@ -0,0 +1,432 @@ +//! Resolve the repository that belongs to a project home channel. +//! +//! Channel-first projects bind a default `kind:30617` at create time. Creating +//! a task in that channel still has to land on *this* project, so the CLI finds +//! (or creates) a `kind:30617` bound to the same `buzz-channel` rather than +//! asking the caller to invent a second project. + +use buzz_core::kind::KIND_GIT_REPO_ANNOUNCEMENT; +use nostr::Event; + +use crate::client::BuzzClient; +use crate::commands::projects::{fetch_projects_for_channel, try_add_own_repo_to_channel_project}; +use crate::error::CliError; +use crate::validate::{validate_repo_id, validate_uuid}; + +pub struct ChannelProjectRepo { + pub repo_owner: String, + pub repo_id: String, +} + +/// Find this channel's project repository, creating one when the project has none. +pub async fn resolve_or_ensure_repo_for_channel( + client: &BuzzClient, + channel: &str, +) -> Result { + validate_uuid(channel)?; + let projects = fetch_projects_for_channel(client, channel).await?; + let repos = fetch_channel_repos(client, channel).await?; + let project = pick_authoritative_project(&projects, &repos, channel)?; + if let Some((_, repo)) = project { + return Ok(repo); + } + + let caller = client.keys().public_key().to_hex(); + if let Some(repo) = repos.iter().find_map(|event| { + event + .pubkey + .to_hex() + .eq_ignore_ascii_case(&caller) + .then(|| repo_from_announcement(event, channel)) + .flatten() + }) { + let _ = try_add_own_repo_to_channel_project(client, channel, &repo.repo_id).await; + return Ok(repo); + } + + let Some(event) = projects.iter().find(|event| { + event.pubkey.to_hex().eq_ignore_ascii_case(&caller) && !project_is_unlisted(event) + }) else { + return Err(CliError::Usage( + "this channel is not a project home; pass --repo-owner and --repo-id".into(), + )); + }; + ensure_default_repo(client, channel, event).await +} + +fn project_is_unlisted(event: &Event) -> bool { + event.tags.iter().any(|tag| { + matches!(tag.as_slice(), [name, value, ..] if name == "buzz-visibility" && value == "unlisted") + }) +} + +fn project_dtag(event: &Event) -> Option { + first_tag_value(event, "d").map(String::from) +} + +fn project_name(event: &Event) -> Option { + first_tag_value(event, "name").map(String::from) +} + +fn first_tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> { + event.tags.iter().find_map(|tag| match tag.as_slice() { + [tag_name, value, ..] if tag_name == name && !value.is_empty() => Some(value.as_str()), + _ => None, + }) +} + +fn project_member_repos(event: &Event) -> impl Iterator + '_ { + event.tags.iter().filter_map(|tag| match tag.as_slice() { + [name, value, ..] if name == "a" => parse_repo_a_tag(value), + _ => None, + }) +} + +fn repo_authorizes_project(repo: &Event, project: &Event) -> bool { + let signer = project.pubkey.to_hex(); + repo.pubkey.to_hex().eq_ignore_ascii_case(&signer) + || repo.tags.iter().any(|tag| { + tag.as_slice().first().map(String::as_str) == Some("maintainers") + && tag.as_slice()[1..] + .iter() + .any(|value| value.eq_ignore_ascii_case(&signer)) + }) +} + +fn repo_from_announcement(event: &Event, channel: &str) -> Option { + if event.kind.as_u16() != KIND_GIT_REPO_ANNOUNCEMENT as u16 + || repo_is_unlisted(event) + || first_tag_value(event, "buzz-channel") != Some(channel) + { + return None; + } + Some(ChannelProjectRepo { + repo_owner: event.pubkey.to_hex(), + repo_id: first_tag_value(event, "d")?.to_string(), + }) +} + +fn pick_authoritative_project<'a>( + projects: &'a [Event], + repos: &'a [Event], + channel: &str, +) -> Result, CliError> { + let mut matches = projects.iter().filter_map(|project| { + if project_is_unlisted(project) { + return None; + } + project_member_repos(project).find_map(|member| { + repos.iter().find_map(|repo| { + let bound = repo_from_announcement(repo, channel)?; + (bound.repo_owner.eq_ignore_ascii_case(&member.repo_owner) + && bound.repo_id == member.repo_id + && repo_authorizes_project(repo, project)) + .then_some((project, bound)) + }) + }) + }); + let selected = matches.next(); + if matches.next().is_some() { + return Err(CliError::Conflict(format!( + "channel {channel} has multiple authoritative projects; pass --repo-owner and --repo-id" + ))); + } + Ok(selected) +} + +pub(crate) fn parse_repo_a_tag(value: &str) -> Option { + let mut parts = value.splitn(3, ':'); + let kind = parts.next()?; + let owner = parts.next()?.trim(); + let id = parts.next()?.trim(); + if kind != "30617" || owner.len() != 64 || id.is_empty() { + return None; + } + Some(ChannelProjectRepo { + repo_owner: owner.to_ascii_lowercase(), + repo_id: id.to_string(), + }) +} + +fn repo_is_unlisted(event: &Event) -> bool { + event.tags.iter().any(|tag| { + matches!( + tag.as_slice(), + [name, value, ..] if name == "buzz-visibility" && value == "unlisted" + ) + }) +} + +async fn fetch_channel_repos(client: &BuzzClient, channel: &str) -> Result, CliError> { + let filter = serde_json::json!({ + "kinds": [KIND_GIT_REPO_ANNOUNCEMENT], + "#buzz-channel": [channel], + "limit": 1000, + }); + let raw = client.query(&filter).await?; + serde_json::from_str(&raw) + .map_err(|error| CliError::Other(format!("failed to parse relay response: {error}"))) +} + +fn require_repo_channel_binding(event: &Event, channel: &str) -> Result<(), CliError> { + match first_tag_value(event, "buzz-channel") { + Some(bound) if bound == channel => Ok(()), + Some(bound) => Err(CliError::Conflict(format!( + "repository {:?} is already bound to channel {bound}; pass --repo-owner and --repo-id", + first_tag_value(event, "d").unwrap_or("") + ))), + None => Err(CliError::Conflict(format!( + "repository {:?} has no channel binding; bind it to {channel} or pass --repo-owner and --repo-id", + first_tag_value(event, "d").unwrap_or("") + ))), + } +} + +async fn ensure_default_repo( + client: &BuzzClient, + channel: &str, + project: &Event, +) -> Result { + let slug = project_dtag(project) + .ok_or_else(|| CliError::Other("project announcement is missing its d tag".into()))?; + let repo_id = repo_id_from_project_slug(&slug)?; + let name = project_name(project).unwrap_or_else(|| slug.clone()); + let name = truncate_repo_name(&name); + let caller = client.keys().public_key().to_hex(); + + if let Some(existing) = + crate::commands::repos::fetch_own_repo_announcement(client, &repo_id).await? + { + require_repo_channel_binding(&existing, channel)?; + let _ = try_add_own_repo_to_channel_project(client, channel, &repo_id).await; + return Ok(ChannelProjectRepo { + repo_owner: existing.pubkey.to_hex(), + repo_id, + }); + } + + let builder = crate::commands::repos::build_create_announcement( + &repo_id, + Some(&name), + None, + &[], + None, + &[], + Some(channel), + )?; + let event = client.sign_event(builder)?; + client.submit_event(event).await?; + let _ = try_add_own_repo_to_channel_project(client, channel, &repo_id).await; + Ok(ChannelProjectRepo { + repo_owner: caller, + repo_id, + }) +} + +pub(crate) fn repo_id_from_project_slug(slug: &str) -> Result { + if validate_repo_id(slug).is_ok() { + return Ok(slug.to_string()); + } + let mut out = String::new(); + for ch in slug.chars() { + if out.len() >= 64 { + break; + } + if ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' || ch == '-' { + out.push(ch); + } else if !out.is_empty() && !out.ends_with('-') { + out.push('-'); + } + } + while out.starts_with('.') { + out.remove(0); + } + if out.ends_with('-') { + out.pop(); + } + validate_repo_id(&out)?; + Ok(out) +} + +fn truncate_repo_name(name: &str) -> String { + if name.len() <= 128 { + return name.to_string(); + } + name.chars().take(128).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repo_id_from_project_slug_keeps_valid_ids() { + assert_eq!( + repo_id_from_project_slug("space-invaders-3d").unwrap(), + "space-invaders-3d" + ); + } + + #[test] + fn repo_id_from_project_slug_sanitizes_invalid_characters() { + assert_eq!( + repo_id_from_project_slug("Space Invaders 3D!").unwrap(), + "Space-Invaders-3D" + ); + } + + fn signed_event(keys: &nostr::Keys, kind: u16, tags: Vec) -> Event { + nostr::EventBuilder::new(nostr::Kind::Custom(kind), "") + .tags(tags) + .sign_with_keys(keys) + .unwrap() + } + + fn tag(parts: &[&str]) -> nostr::Tag { + nostr::Tag::parse(parts.iter().copied()).unwrap() + } + + #[test] + fn authoritative_project_requires_repo_owner_consent() { + let owner = nostr::Keys::generate(); + let attacker = nostr::Keys::generate(); + let channel = "11111111-1111-4111-8111-111111111111"; + let owner_hex = owner.public_key().to_hex(); + let repo_coord = format!("30617:{owner_hex}:game"); + let repo = signed_event( + &owner, + 30617, + vec![tag(&["d", "game"]), tag(&["buzz-channel", channel])], + ); + let hostile = signed_event( + &attacker, + 30621, + vec![ + tag(&["d", "spoof"]), + tag(&["buzz-channel", channel]), + tag(&["a", &repo_coord]), + ], + ); + assert!(pick_authoritative_project(&[hostile], &[repo], channel) + .unwrap() + .is_none()); + } + + #[test] + fn authorized_project_selects_channel_bound_member() { + let owner = nostr::Keys::generate(); + let channel = "11111111-1111-4111-8111-111111111111"; + let owner_hex = owner.public_key().to_hex(); + let repo_coord = format!("30617:{owner_hex}:game"); + let repo = signed_event( + &owner, + 30617, + vec![tag(&["d", "game"]), tag(&["buzz-channel", channel])], + ); + let project = signed_event( + &owner, + 30621, + vec![ + tag(&["d", "game"]), + tag(&["buzz-channel", channel]), + tag(&["a", &repo_coord]), + ], + ); + let (_, selected) = pick_authoritative_project(&[project], &[repo], channel) + .unwrap() + .unwrap(); + assert_eq!(selected.repo_owner, owner_hex); + assert_eq!(selected.repo_id, "game"); + } + + #[test] + fn ambiguous_authoritative_projects_fail_closed() { + let owner = nostr::Keys::generate(); + let channel = "11111111-1111-4111-8111-111111111111"; + let owner_hex = owner.public_key().to_hex(); + let repo_coord = format!("30617:{owner_hex}:game"); + let repo = signed_event( + &owner, + 30617, + vec![tag(&["d", "game"]), tag(&["buzz-channel", channel])], + ); + let projects = ["one", "two"].map(|slug| { + signed_event( + &owner, + 30621, + vec![ + tag(&["d", slug]), + tag(&["buzz-channel", channel]), + tag(&["a", &repo_coord]), + ], + ) + }); + assert!(matches!( + pick_authoritative_project(&projects, &[repo], channel), + Err(CliError::Conflict(_)) + )); + } + + #[test] + fn existing_repo_must_bind_requested_channel() { + let owner = nostr::Keys::generate(); + let requested = "11111111-1111-4111-8111-111111111111"; + let other = "22222222-2222-4222-8222-222222222222"; + let matching = signed_event( + &owner, + 30617, + vec![tag(&["d", "game"]), tag(&["buzz-channel", requested])], + ); + assert!(require_repo_channel_binding(&matching, requested).is_ok()); + + let foreign = signed_event( + &owner, + 30617, + vec![tag(&["d", "game"]), tag(&["buzz-channel", other])], + ); + assert!(matches!( + require_repo_channel_binding(&foreign, requested), + Err(CliError::Conflict(_)) + )); + } + + #[test] + fn later_maintainer_value_authorizes_project() { + let owner = nostr::Keys::generate(); + let maintainer = nostr::Keys::generate(); + let unrelated = nostr::Keys::generate().public_key().to_hex(); + let channel = "11111111-1111-4111-8111-111111111111"; + let owner_hex = owner.public_key().to_hex(); + let maintainer_hex = maintainer.public_key().to_hex(); + let repo_coord = format!("30617:{owner_hex}:game"); + let repo = signed_event( + &owner, + 30617, + vec![ + tag(&["d", "game"]), + tag(&["buzz-channel", channel]), + tag(&["maintainers", &unrelated, &maintainer_hex]), + ], + ); + let project = signed_event( + &maintainer, + 30621, + vec![ + tag(&["d", "suite"]), + tag(&["buzz-channel", channel]), + tag(&["a", &repo_coord]), + ], + ); + assert!(pick_authoritative_project(&[project], &[repo], channel) + .unwrap() + .is_some()); + } + + #[test] + fn parse_repo_a_tag_reads_nip34_coordinate() { + let owner = "a".repeat(64); + let parsed = parse_repo_a_tag(&format!("30617:{owner}:game")).unwrap(); + assert_eq!(parsed.repo_owner, owner); + assert_eq!(parsed.repo_id, "game"); + } +} diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs index 00e6f3efb96..06d8a865dba 100644 --- a/crates/buzz-cli/src/commands/projects.rs +++ b/crates/buzz-cli/src/commands/projects.rs @@ -21,12 +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). @@ -63,9 +109,123 @@ fn parse_events(json: &str) -> Result, CliError> { .map_err(|e| CliError::Other(format!("failed to parse relay response: {e}"))) } -/// Fetch the caller's own live kind:30621 head for `slug`. -async fn fetch_own_project(client: &BuzzClient, slug: &str) -> Result, CliError> { - fetch_project(client, slug, None).await +/// Fetch listed kind:30621 heads whose `buzz-channel` is `channel`. +fn project_tags_match_channel<'a>(tags: impl IntoIterator, channel: &str) -> bool { + tags.into_iter() + .any(|tag| tag_name(tag) == Some("buzz-channel") && tag_value(tag) == Some(channel)) +} + +pub(crate) async fn fetch_projects_for_channel( + client: &BuzzClient, + channel: &str, +) -> Result, CliError> { + let filter = serde_json::json!({ + "kinds": [KIND_PROJECT], + "limit": 1000, + }); + let raw = client.query(&filter).await?; + Ok(parse_events(&raw)? + .into_iter() + .filter(|event| project_tags_match_channel(event.tags.iter(), channel)) + .collect()) +} + +fn project_is_unlisted(event: &Event) -> bool { + event.tags.iter().any(|tag| { + matches!( + tag.as_slice(), + [name, value, ..] if name == "buzz-visibility" && value == "unlisted" + ) + }) +} + +fn project_slug(event: &Event) -> Option { + event.tags.iter().find_map(|tag| match tag.as_slice() { + [name, value, ..] if name == "d" && !value.is_empty() => Some(value.clone()), + _ => None, + }) +} + +/// Add repos to a project the caller owns. Returns the relay write JSON. +pub async fn add_repos_to_own_project( + client: &BuzzClient, + slug: &str, + repos: &[String], +) -> Result { + validate_project_slug(slug)?; + let caller_pubkey = client.keys().public_key().to_hex(); + + let new_members: Vec = repos + .iter() + .map(|r| expand_repo_coord(r, &caller_pubkey)) + .collect::, _>>()?; + + let mut seen = std::collections::HashSet::new(); + for m in &new_members { + if !seen.insert(m.coord.clone()) { + return Err(CliError::Usage(format!( + "duplicate --repo coordinate in this invocation: {:?}", + m.coord + ))); + } + } + + let head = fetch_own_project(client, slug) + .await? + .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let next_ts = next_timestamp(&head, Timestamp::now())?; + + let mut tags: Vec = head.tags.iter().cloned().collect(); + let existing_coords: std::collections::HashSet = head + .tags + .iter() + .filter(|t| tag_name(t) == Some("a")) + .filter_map(|t| tag_value(t).map(String::from)) + .collect(); + let mut added = 0usize; + for m in &new_members { + if !existing_coords.contains(m.coord.as_str()) { + let parts = m.to_tag_parts(); + let parts_ref: Vec<&str> = parts.iter().map(String::as_str).collect(); + tags.push( + Tag::parse(parts_ref.iter().copied()) + .map_err(|e| CliError::Other(format!("member tag construction failed: {e}")))?, + ); + added += 1; + } + } + + if added == 0 { + return Err(CliError::Conflict(format!( + "all requested repositories are already members of project {slug:?}" + ))); + } + + let builder = rebuild_project(&head.content, tags, next_ts)?; + let event = client.sign_event(builder)?; + client.submit_event(event).await +} + +/// If this channel is already a project the caller owns, attach `repo_id`. +pub async fn try_add_own_repo_to_channel_project( + client: &BuzzClient, + channel: &str, + repo_id: &str, +) -> Result<(), CliError> { + let projects = fetch_projects_for_channel(client, channel).await?; + let caller = client.keys().public_key().to_hex(); + let Some(event) = projects.iter().find(|candidate| { + candidate.pubkey.to_hex().eq_ignore_ascii_case(&caller) && !project_is_unlisted(candidate) + }) else { + return Ok(()); + }; + let Some(slug) = project_slug(event) else { + return Ok(()); + }; + match add_repos_to_own_project(client, &slug, &[repo_id.to_string()]).await { + Ok(_) | Err(CliError::Conflict(_)) => Ok(()), + Err(error) => Err(error), + } } /// Fetch a project head by slug and optional owner pubkey. @@ -93,6 +253,11 @@ async fn fetch_project( Ok(events.into_iter().next()) } +/// Fetch the caller's own live kind:30621 head for `slug`. +async fn fetch_own_project(client: &BuzzClient, slug: &str) -> Result, CliError> { + fetch_project(client, slug, None).await +} + // ── Tag helpers ─────────────────────────────────────────────────────────────── fn tag_name(tag: &Tag) -> Option<&str> { @@ -187,11 +352,18 @@ pub async fn cmd_create( let caller_pubkey = client.keys().public_key().to_hex(); // Expand and validate repo coordinates. - let members: Vec = repos + let mut members: Vec = repos .iter() .map(|r| expand_repo_coord(r, &caller_pubkey)) .collect::, _>>()?; + if members.is_empty() && channel.is_none() { + return Err(CliError::Usage( + "pass --channel to create a default repository, or --repo to attach an existing one" + .into(), + )); + } + // Dedupe: preserve first occurrence, reject duplicates with Usage. let mut seen = std::collections::HashSet::new(); for m in &members { @@ -225,6 +397,32 @@ pub async fn cmd_create( "project {slug:?} already exists; use 'buzz projects update' to modify it" ))); } + if let Some(channel) = channel { + if let Some(existing) = fetch_projects_for_channel(client, channel) + .await? + .into_iter() + .find(|event| { + event.pubkey.to_hex().eq_ignore_ascii_case(&caller_pubkey) + && !project_is_unlisted(event) + }) + { + let existing_slug = project_slug(&existing).unwrap_or_else(|| slug.to_string()); + return Err(CliError::Conflict(format!( + "you already own project {existing_slug:?} for channel {channel}; update that project instead" + ))); + } + } + + if members.is_empty() { + let home = channel.ok_or_else(|| { + CliError::Usage( + "pass --channel to create a default repository, or --repo to attach an existing one" + .into(), + ) + })?; + let repo_id = ensure_default_create_repo(client, slug, name, description, home).await?; + members.push(expand_repo_coord(&repo_id, &caller_pubkey)?); + } // ── Build via Layer B (enforces all writer policy) ──────────────────── let builder = build_project(slug, name, description, &members, channel, visibility) @@ -294,63 +492,10 @@ pub async fn cmd_add_repo( slug: &str, repos: &[String], ) -> Result<(), CliError> { - validate_project_slug(slug)?; - let caller_pubkey = client.keys().public_key().to_hex(); - - // ── Local validation before any .await ──────────────────────────────── - let new_members: Vec = repos - .iter() - .map(|r| expand_repo_coord(r, &caller_pubkey)) - .collect::, _>>()?; - - // Dedupe within this invocation: first occurrence wins, duplicate → Usage. - let mut seen = std::collections::HashSet::new(); - for m in &new_members { - if !seen.insert(m.coord.clone()) { - return Err(CliError::Usage(format!( - "duplicate --repo coordinate in this invocation: {:?}", - m.coord - ))); - } - } - - // ── Network: fetch head ─────────────────────────────────────────────── - let head = fetch_own_project(client, slug) - .await? - .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; - let next_ts = next_timestamp(&head, Timestamp::now())?; - - // Build the new tag set: keep existing tags (including hinted members), - // append new members only if not already present (by coordinate). - let mut tags: Vec = head.tags.iter().cloned().collect(); - let existing_coords: std::collections::HashSet = head - .tags - .iter() - .filter(|t| tag_name(t) == Some("a")) - .filter_map(|t| tag_value(t).map(String::from)) - .collect(); - let mut added = 0usize; - for m in &new_members { - if !existing_coords.contains(m.coord.as_str()) { - let parts = m.to_tag_parts(); - let parts_ref: Vec<&str> = parts.iter().map(String::as_str).collect(); - tags.push( - Tag::parse(parts_ref.iter().copied()) - .map_err(|e| CliError::Other(format!("member tag construction failed: {e}")))?, - ); - added += 1; - } - } - - // All requested coordinates were already present — no change to publish. - if added == 0 { - return Err(CliError::Conflict(format!( - "all requested repositories are already members of project {slug:?}" - ))); - } - - let builder = rebuild_project(&head.content, tags, next_ts)?; - submit_project(client, builder, None).await + let raw = add_repos_to_own_project(client, slug, repos).await?; + let response = parse_write_response(&raw, "project changed concurrently; retry")?; + println!("{response}"); + Ok(()) } /// `buzz projects remove-repo` @@ -554,6 +699,36 @@ pub async fn cmd_delete(client: &BuzzClient, slug: &str) -> Result<(), CliError> Ok(()) } +async fn ensure_default_create_repo( + client: &BuzzClient, + slug: &str, + name: Option<&str>, + description: Option<&str>, + channel: &str, +) -> Result { + let repo_id = repo_id_from_project_slug(slug)?; + if fetch_own_repo_announcement(client, &repo_id) + .await? + .is_some() + { + return Ok(repo_id); + } + let raw_name = name.unwrap_or(slug); + let display_name: String = raw_name.chars().take(128).collect(); + let builder = build_create_announcement( + &repo_id, + Some(&display_name), + description, + &[], + None, + &[], + Some(channel), + )?; + let event = client.sign_event(builder)?; + client.submit_event(event).await?; + Ok(repo_id) +} + // ── Validation helpers ──────────────────────────────────────────────────────── /// Validate a project slug: non-empty, ≤1024 bytes, verbatim. @@ -608,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, @@ -652,6 +846,19 @@ mod tests { const OWNER_HEX: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const OWNER_B_HEX: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + #[test] + fn project_channel_matching_ignores_unrelated_claims() { + let expected = "11111111-1111-4111-8111-111111111111"; + let tags = make_head_tags(&[ + make_test_tag(&["buzz-channel", "22222222-2222-4222-8222-222222222222"]), + make_test_tag(&["name", "Unrelated"]), + ]); + assert!(!project_tags_match_channel(tags.iter(), expected)); + + let tags = make_head_tags(&[make_test_tag(&["buzz-channel", expected])]); + assert!(project_tags_match_channel(tags.iter(), expected)); + } + #[test] fn expand_repo_coord_bare_expands_with_caller_pubkey() { let coord = expand_repo_coord("my-repo", OWNER_HEX).unwrap(); @@ -1099,6 +1306,24 @@ mod tests { .expect("client construction") } + /// Creating without --repo or --channel must fail locally; the default + /// repository needs a home channel to bind as git ACL. + #[tokio::test] + async fn create_without_repo_or_channel_returns_usage_before_any_network_call() { + let client = discard_client(); + let err = cmd_create(&client, "my-slug", &[], None, None, None, None) + .await + .expect_err("missing repo and channel must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage, got {err:?}" + ); + assert!( + format!("{err}").contains("--channel"), + "Usage message must mention --channel, got {err:?}" + ); + } + /// Invalid visibility token must return Usage before touching the relay. #[tokio::test] async fn create_invalid_visibility_returns_usage_before_any_network_call() { diff --git a/crates/buzz-cli/src/commands/repos.rs b/crates/buzz-cli/src/commands/repos.rs index e54b95ef20e..886d6e04192 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -14,7 +14,7 @@ fn parse_events(json: &str) -> Result, CliError> { .map_err(|error| CliError::Other(format!("failed to parse relay response: {error}"))) } -async fn fetch_own_repo_announcement( +pub(crate) async fn fetch_own_repo_announcement( client: &BuzzClient, repo_id: &str, ) -> Result, CliError> { @@ -209,7 +209,7 @@ async fn submit_repo_update(client: &BuzzClient, builder: EventBuilder) -> Resul /// UUID is shape-validated here and its existence/membership is the relay's /// authority at git-access time, same posture as `repos bind`. #[allow(clippy::too_many_arguments)] -fn build_create_announcement( +pub(crate) fn build_create_announcement( repo_id: &str, name: Option<&str>, description: Option<&str>, @@ -267,6 +267,14 @@ pub async fn cmd_create_repo( // a chat message — agents announce repos with it (see base_prompt.md). let link = crate::links::repo_link(&owner, repo_id); crate::client::print_create_response(&resp, "link", &link); + if let Some(channel) = channel { + // Best-effort: a repo announced into a project home channel should + // join that project instead of rendering as a second project card. + let _ = crate::commands::projects::try_add_own_repo_to_channel_project( + client, channel, repo_id, + ) + .await; + } Ok(()) } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 5cac8c941e1..d0155970fa2 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1285,14 +1285,15 @@ impl ProjectVisibility { pub enum ProjectsCmd { /// Create a new multi-repo project (NIP-MP kind:30621) /// - /// Requires at least one --repo. Fails with Conflict if the project already exists. + /// With no `--repo`, creates a default repository bound to `--channel`. + /// Fails with Conflict if the project already exists. Create { /// Project identifier (slug), up to 1024 bytes slug: String, /// Member repository coordinate: bare Buzz repo id (e.g. `buzz`) or full /// `30617::` for cross-owner or colon-bearing repo ids. - /// At least one --repo is required. - #[arg(long = "repo", required = true)] + /// Omit to create a default repository named after the slug (requires `--channel`). + #[arg(long = "repo")] repo: Vec, /// Display name (≤256 bytes) #[arg(long)] @@ -1333,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 { @@ -1633,12 +1656,18 @@ pub enum PrCmd { pub enum IssuesCmd { /// Create a git issue (NIP-34 kind:1621) Create { - /// Repo owner pubkey (64-char hex) + /// Repo owner pubkey (64-char hex). Optional when `--channel` (or + /// `BUZZ_GIT_ORIGIN_CHANNEL_ID`) names a project home. #[arg(long)] - repo_owner: String, - /// Repo identifier (d-tag) + repo_owner: Option, + /// Repo identifier (d-tag). Optional when `--channel` (or + /// `BUZZ_GIT_ORIGIN_CHANNEL_ID`) names a project home. #[arg(long)] - repo_id: String, + repo_id: Option, + /// Project home channel. Infers the repository, creating one bound to + /// this project when none exists. Defaults to `BUZZ_GIT_ORIGIN_CHANNEL_ID`. + #[arg(long)] + channel: Option, /// Issue title #[arg(long, alias = "subject")] title: String, @@ -2368,6 +2397,7 @@ mod tests { assert_eq!( names(&cmd, "projects"), vec![ + "add-channel", "add-repo", "create", "delete", @@ -2414,7 +2444,7 @@ mod tests { ("pack", 2), ("patches", 4), ("pr", 5), - ("projects", 7), + ("projects", 8), ("reactions", 3), ("repos", 5), ("social", 7), @@ -2485,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 c4776564e34..e9794cb9889 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 d4626d2c6fa..93f41cbee30 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 ( { @@ -12,24 +12,13 @@ const ProjectDetailScreen = React.lazy(async () => { export const Route = createFileRoute("/projects/$projectId")({ component: ProjectDetailRouteComponent, - validateSearch: (search: Record) => ({ - commitHash: - typeof search.commitHash === "string" ? search.commitHash : undefined, - pullRequestId: - typeof search.pullRequestId === "string" - ? search.pullRequestId - : undefined, - issueId: typeof search.issueId === "string" ? search.issueId : undefined, - repositoryId: - typeof search.repositoryId === "string" ? search.repositoryId : undefined, - tab: isEntityLinkTab(search.tab) ? search.tab : undefined, - }), + validateSearch: parseProjectDetailSearch, }); function ProjectDetailRouteComponent() { usePreviewFeatureWarning("projects"); const { projectId } = Route.useParams(); - const { commitHash, pullRequestId, issueId, repositoryId, tab } = + const { commitHash, filePath, pullRequestId, issueId, repositoryId, tab } = Route.useSearch(); const entityNavigationId = useLocation({ select: (location) => { @@ -45,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 27541889f26..6d6b68d8032 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/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..10a948e7053 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getChannelIntroKind, + shouldPrioritizeIdleAuxiliary, + shouldUseFocusIdleDrawer, +} from "./ChannelPane.helpers.ts"; + +function channel(overrides = {}) { + return { + ttlDeadline: null, + ttlSeconds: null, + visibility: "open", + ...overrides, + }; +} + +test("focus idle drawers yield to every higher-priority auxiliary surface", () => { + const idleDrawer = { + channelManagementOpen: false, + hasAgentSession: false, + hasIdleAuxiliaryPanel: true, + hasIdlePanelCloseHandler: true, + hasProfilePanel: false, + hasThreadSurface: false, + useSplitAuxiliaryPane: true, + }; + + assert.equal(shouldUseFocusIdleDrawer(idleDrawer), true); + for (const surface of [ + "channelManagementOpen", + "hasAgentSession", + "hasProfilePanel", + "hasThreadSurface", + ]) { + assert.equal( + shouldUseFocusIdleDrawer({ ...idleDrawer, [surface]: true }), + false, + `idle drawer must yield when ${surface} is open`, + ); + } +}); + +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", + ); +}); + +test("idle auxiliary priority does not depend on thread layout mode", () => { + assert.equal(shouldPrioritizeIdleAuxiliary(true, true), true); + assert.equal(shouldPrioritizeIdleAuxiliary(true, false), false); + assert.equal(shouldPrioritizeIdleAuxiliary(false, true), false); +}); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index cb0600a28ae..695fef166fe 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -3,7 +3,42 @@ 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 shouldUseFocusIdleDrawer({ + channelManagementOpen, + hasAgentSession, + hasIdleAuxiliaryPanel, + hasIdlePanelCloseHandler, + hasProfilePanel, + hasThreadSurface, + useSplitAuxiliaryPane, +}: { + channelManagementOpen: boolean; + hasAgentSession: boolean; + hasIdleAuxiliaryPanel: boolean; + hasIdlePanelCloseHandler: boolean; + hasProfilePanel: boolean; + hasThreadSurface: boolean; + useSplitAuxiliaryPane: boolean; +}): boolean { + return ( + useSplitAuxiliaryPane && + !channelManagementOpen && + !hasAgentSession && + !hasProfilePanel && + !hasThreadSurface && + hasIdleAuxiliaryPanel && + hasIdlePanelCloseHandler + ); +} + +export function getChannelIntroKind( + channel: Channel, + projectHome = false, +): string { + if (projectHome) { + return "project channel"; + } + const isPrivate = channel.visibility === "private"; const isEphemeral = isEphemeralChannel(channel); @@ -28,6 +63,14 @@ export function getChannelIntroDescription(channel: Channel): string | null { ); } +/** Whether a caller-owned auxiliary sheet should render ahead of a thread. */ +export function shouldPrioritizeIdleAuxiliary( + overrideThread: boolean, + hasIdleAuxiliary: boolean, +) { + return overrideThread && hasIdleAuxiliary; +} + export function isWelcomeSetupSystemMessage(message: TimelineMessage) { if (message.kind !== KIND_SYSTEM_MESSAGE) { return false; diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index bccc163ed40..679b4c38041 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"; @@ -44,8 +45,13 @@ import { WelcomeComposerGuidanceLayer, } from "@/features/channels/ui/WelcomeComposerBanner"; import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; -import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers"; +import { + mentionsKnownAgent, + shouldPrioritizeIdleAuxiliary, + shouldUseFocusIdleDrawer, +} 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 +83,10 @@ export const ChannelPane = React.memo(function ChannelPane({ editTarget = null, fetchOlder, header, + idleAuxiliaryPanel = null, + idleAuxiliaryHeaderActions, + idleAuxiliaryOverridesThread = false, + idleAuxiliaryTitle = "", hasOlderMessages, historyExhausted, isFetchingOlder, @@ -104,8 +114,10 @@ export const ChannelPane = React.memo(function ChannelPane({ onCloseAgentSession, onCloseChannelManagement, onChannelManagementDeleted, + onCloseIdleAuxiliaryPanel, onCloseProfilePanel, onAddAgent, + onAddFiles, onBrowseChannels, onCreateChannel, onCloseThread, @@ -253,7 +265,6 @@ export const ChannelPane = React.memo(function ChannelPane({ onEdit(target); return true; }, [findLastOwnEditable, messages, onEdit]); - const handleEditLastOwnThreadMessage = React.useCallback((): boolean => { if (!onEdit) return false; const scope: TimelineMessage[] = []; @@ -274,7 +285,6 @@ export const ChannelPane = React.memo(function ChannelPane({ currentPubkey, relaySelfQuery.data, ); - const isComposerDisabled = !activeChannel?.isMember || activeChannel.archivedAt !== null || @@ -284,7 +294,6 @@ export const ChannelPane = React.memo(function ChannelPane({ isSending; const knownAgentPubkeys = React.useMemo(() => { const pubkeys = new Set(); - for (const pubkey of agentPubkeys ?? []) { pubkeys.add(pubkey.toLowerCase()); } @@ -294,7 +303,6 @@ export const ChannelPane = React.memo(function ChannelPane({ for (const agent of activityAgents) { pubkeys.add(agent.pubkey.toLowerCase()); } - return pubkeys; }, [activityAgents, agentPubkeys, agentSessionAgents]); const handleSendMessage = React.useCallback( @@ -313,7 +321,6 @@ export const ChannelPane = React.memo(function ChannelPane({ isActiveWelcomeChannel && (containsWelcomePersonaMention(content) || mentionsKnownAgent(mentionPubkeys, knownAgentPubkeys)); - messageTimelineRef.current?.scrollToBottomOnNextUpdate(); await onSendMessage( content, @@ -323,7 +330,6 @@ export const ChannelPane = React.memo(function ChannelPane({ threadContext, forceRest, ); - if ( channelId && channelId !== activeChannelId && @@ -332,7 +338,6 @@ export const ChannelPane = React.memo(function ChannelPane({ ) { await goChannel(channelId, { replace: true }); } - if (shouldCompleteWelcomeBanner) { completeWelcomeComposerBanner(); } @@ -386,7 +391,6 @@ export const ChannelPane = React.memo(function ChannelPane({ }), [activeChannel, currentPubkey, profiles], ); - const handleWelcomeAddAgent = React.useCallback(() => { onAddAgent?.({ beforeSend: () => @@ -396,6 +400,7 @@ export const ChannelPane = React.memo(function ChannelPane({ const standardChannelIntro = useChannelIntro({ activeChannel, onAddAgent, + onAddFiles, onBrowseChannels, onCreateChannel, onOpenMembers, @@ -428,7 +433,6 @@ export const ChannelPane = React.memo(function ChannelPane({ for (const message of threadAllMessages) { messagesById.set(message.id, message); } - return buildVideoReviewPresentationByMessageId({ channelId: activeChannel?.id ?? null, channelName: activeChannel?.name, @@ -449,7 +453,6 @@ export const ChannelPane = React.memo(function ChannelPane({ threadAllMessages, threadHeadMessage, ]); - const isOverlay = useIsThreadPanelOverlay(); const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay; const threadViewMode = useThreadViewMode(); @@ -457,17 +460,6 @@ export const ChannelPane = React.memo(function ChannelPane({ threadViewMode === "focus" && useSplitAuxiliaryPane && (Boolean(threadHeadMessage) || shouldShowThreadSkeleton); - const { channelIsCovered, markExitComplete } = useFocusDrawerPresence( - useFocusThreadDrawer, - onCloseThread, - ); - const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } = - useThreadViewModeSwitch({ - activeThreadHeadId: threadHeadMessage?.id ?? null, - externalScrollTargetId: threadScrollTargetId, - onExternalTargetResolved: onThreadScrollTargetResolved, - onModeChange: markExitComplete, - }); const selectedAgent = React.useMemo( () => agentSessionSelection.resolveSelectedAgentSession({ @@ -478,6 +470,36 @@ export const ChannelPane = React.memo(function ChannelPane({ }), [agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles], ); + const hasIdleAuxiliary = + Boolean(idleAuxiliaryPanel) && Boolean(onCloseIdleAuxiliaryPanel); + const useFocusIdleDrawer = shouldUseFocusIdleDrawer({ + channelManagementOpen, + hasAgentSession: Boolean(activeChannel && selectedAgent), + hasIdleAuxiliaryPanel: Boolean(idleAuxiliaryPanel), + hasIdlePanelCloseHandler: Boolean(onCloseIdleAuxiliaryPanel), + hasProfilePanel: Boolean(profilePanelPubkey), + hasThreadSurface: Boolean(threadHeadMessage) || shouldShowThreadSkeleton, + useSplitAuxiliaryPane, + }); + const priorityIdleAuxiliary = shouldPrioritizeIdleAuxiliary( + idleAuxiliaryOverridesThread, + hasIdleAuxiliary, + ); + const { channelIsCovered, markExitComplete } = useFocusDrawerPresence( + useFocusThreadDrawer || useFocusIdleDrawer, + priorityIdleAuxiliary + ? (onCloseIdleAuxiliaryPanel ?? onCloseThread) + : useFocusThreadDrawer + ? onCloseThread + : (onCloseIdleAuxiliaryPanel ?? onCloseThread), + ); + const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } = + useThreadViewModeSwitch({ + activeThreadHeadId: threadHeadMessage?.id ?? null, + externalScrollTargetId: threadScrollTargetId, + onExternalTargetResolved: onThreadScrollTargetResolved, + onModeChange: markExitComplete, + }); const hasSplitAuxiliaryPane = useSplitAuxiliaryPane && (channelManagementOpen || @@ -516,6 +538,38 @@ 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 idleAuxiliarySurface = + idleAuxiliaryPanel && onCloseIdleAuxiliaryPanel + ? wrapIdlePanel( + + {idleAuxiliaryPanel} + , + ) + : null; const threadHeaderLeading = useSplitAuxiliaryPane ? ( ) : undefined; @@ -542,7 +596,6 @@ export const ChannelPane = React.memo(function ChannelPane({ data-testid="channel-shared-header-backdrop" /> ) : null} - {!isSinglePanelView ? (
- + {activeChannel ? ( + + ) : null} Viewing{" "} @@ -758,16 +816,8 @@ export const ChannelPane = React.memo(function ChannelPane({
) : null} - - {/* - * `AnimatePresence` keeps the focus thread drawer mounted through its exit - * animation — without it the drawer's own existence condition - * (`useFocusThreadDrawer`, which is derived from `threadHeadMessage`) goes - * false on the same frame as the close, and there is nothing left to - * animate. It can hold the real thread through the exit rather than a - * frozen snapshot because the panel is fully prop-driven. - */} - + {/* Serialize replacements so focus drawers keep one travel direction. */} + {channelManagementOpen && activeChannel ? ( + ) : priorityIdleAuxiliary && idleAuxiliarySurface ? ( + idleAuxiliarySurface ) : threadHeadMessage ? ( (() => { const panel = ( @@ -935,7 +987,9 @@ export const ChannelPane = React.memo(function ChannelPane({ ); return wrapAux(panel, "user-profile-panel"); })() - ) : null} + ) : ( + idleAuxiliarySurface + )} ); diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 760ef58073b..83a9794e5aa 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -14,6 +14,7 @@ import type { } from "@/features/profile/ui/UserProfilePanel"; import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; import type { Channel } from "@/shared/api/types"; +import type { IdleAuxiliaryHeaderControls } from "./IdleAuxiliaryPanel"; export type ChannelPaneProps = { activeChannel: Channel | null; activityAgents?: BotActivityAgent[]; @@ -46,6 +47,16 @@ export type ChannelPaneProps = { } | null; fetchOlder?: () => Promise; header?: React.ReactNode; + /** + * Idle-state body for the right auxiliary pane (project extras, etc.). + * Uses the same slot as thread, profile, agent-session, and management panels. + * By default it yields to those surfaces; callers may opt into thread override. + */ + idleAuxiliaryPanel?: React.ReactNode; + idleAuxiliaryHeaderActions?: IdleAuxiliaryHeaderControls; + /** Show the idle auxiliary surface ahead of an already-open thread. */ + idleAuxiliaryOverridesThread?: boolean; + idleAuxiliaryTitle?: string; hasOlderMessages?: boolean; /** True when the loaded window provably starts at the channel's beginning. */ historyExhausted?: boolean; @@ -79,8 +90,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..1a92135e9c3 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -88,6 +88,13 @@ export function ChannelScreen({ autoSendDraftKey, currentIdentity, currentProfile, + headerEndActions, + idleAuxiliaryPanel, + idleAuxiliaryHeaderActions, + idleAuxiliaryOverridesThread, + idleAuxiliaryTitle, + onAddFiles, + onCloseIdleAuxiliaryPanel, onCloseForumPost, onSelectForumPost, selectedForumPostId, @@ -742,6 +749,7 @@ export function ChannelScreen({ activeDmPresenceStatus={activeDmPresenceStatus} chromeWrapperRef={channelHeaderChromeRef} currentPubkey={currentPubkey} + headerEndActions={headerEndActions} isAddBotOpen={isAddBotOpen} isJoining={joinChannelMutation.isPending} onAddBotOpenChange={setIsAddBotOpen} @@ -762,6 +770,7 @@ export function ChannelScreen({ activeDmPresenceStatus, channelHeaderChromeRef, currentPubkey, + headerEndActions, isAddBotOpen, joinChannelMutation.isPending, joinChannelMutation.mutateAsync, @@ -845,9 +854,14 @@ export function ChannelScreen({ canResetThreadPanelWidth={canResetThreadPanelWidth} fetchOlder={fetchOlder} header={channelHeader} + idleAuxiliaryPanel={idleAuxiliaryPanel} + idleAuxiliaryHeaderActions={idleAuxiliaryHeaderActions} + idleAuxiliaryOverridesThread={idleAuxiliaryOverridesThread} + idleAuxiliaryTitle={idleAuxiliaryTitle} hasOlderMessages={hasOlderMessages} historyExhausted={historyExhausted} onAddAgent={handleOpenAddBot} + onAddFiles={onAddFiles} onBrowseChannels={openBrowseChannels} onCreateChannel={openCreateChannel} onOpenMembers={handleOpenMembersSidebar} @@ -900,6 +914,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..e5550667044 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.types.ts +++ b/desktop/src/features/channels/ui/ChannelScreen.types.ts @@ -1,9 +1,12 @@ +import type { ReactNode } from "react"; + import type { Channel, Identity, Profile, RelayEvent, } from "@/shared/api/types"; +import type { IdleAuxiliaryHeaderControls } from "./IdleAuxiliaryPanel"; export type ChannelScreenProps = { activeChannel: Channel | null; @@ -16,6 +19,13 @@ export type ChannelScreenProps = { autoSendDraftKey: string | null; currentIdentity?: Identity; currentProfile?: Profile; + idleAuxiliaryPanel?: ReactNode; + idleAuxiliaryHeaderActions?: IdleAuxiliaryHeaderControls; + idleAuxiliaryOverridesThread?: boolean; + 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(); @@ -218,9 +221,9 @@ export function FocusThreadDrawer({ // share a radius — a smaller one here would put two radii on one // element. `shadow-panel-left` draws the left edge and its corners; // 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", + "absolute inset-y-0 right-0 flex flex-col overflow-hidden rounded-l-2xl bg-background shadow-panel-left outline-hidden", )} - 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..7a9c454554e --- /dev/null +++ b/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx @@ -0,0 +1,82 @@ +import type * as React from "react"; + +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, + onResetWidth, + onResizeStart, + title, + useSplitAuxiliaryPane, + widthPx, +}: { + canResetWidth: boolean; + children: React.ReactNode; + headerControls?: IdleAuxiliaryHeaderControls; + isFocusDrawer?: boolean; + isSinglePanelView: boolean; + onClose: () => void; + onResetWidth: () => void; + onResizeStart: React.PointerEventHandler; + title: string; + useSplitAuxiliaryPane: boolean; + widthPx: number; +}) { + const split = useSplitAuxiliaryPane && !isFocusDrawer; + return ( + + + {title} + + {headerControls?.actions ? ( + + {headerControls.actions} + + ) : null} + + } + > + + {children} + + + ); +} diff --git a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx index 43e4d80e6ba..68f68890bbf 100644 --- a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx +++ b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx @@ -6,10 +6,12 @@ import { cn } from "@/shared/lib/cn"; type RightAuxiliaryPaneProps = { canResetWidth: boolean; children: React.ReactNode; + className?: string; constrainToAvailableSpace?: boolean; detached?: boolean; onResetWidth: () => void; onResizeStart: (event: React.PointerEvent) => void; + showResizeIndicator?: boolean; testId?: string; widthPx: number; }; @@ -17,10 +19,12 @@ type RightAuxiliaryPaneProps = { export function RightAuxiliaryPane({ canResetWidth, children, + className, constrainToAvailableSpace = true, detached = false, onResetWidth, onResizeStart, + showResizeIndicator = true, testId, widthPx, }: RightAuxiliaryPaneProps) { @@ -31,6 +35,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={{ @@ -53,7 +58,12 @@ export function RightAuxiliaryPane({ } type="button" > - + {showResizeIndicator ? ( + + ) : null}
{children} diff --git a/desktop/src/features/channels/ui/useChannelIntro.tsx b/desktop/src/features/channels/ui/useChannelIntro.tsx index 19f2da0edbc..3374fb7a654 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, @@ -9,6 +9,8 @@ import { isWelcomeChannel, isWelcomeExperienceChannel, } from "@/features/onboarding/welcome"; +import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel"; +import { ProjectChannelIcon } from "@/features/projects/ui/ProjectChannelIcon"; import type { Channel } from "@/shared/api/types"; import { HashSearch } from "@/shared/ui/icons"; @@ -29,6 +31,7 @@ type ChannelIntroAction = { export function useChannelIntro({ activeChannel, onAddAgent, + onAddFiles, onBrowseChannels, onCreateChannel, onOpenMembers, @@ -36,11 +39,14 @@ export function useChannelIntro({ }: { activeChannel: Channel | null; onAddAgent?: (options?: { beforeSend?: () => void }) => void; + onAddFiles?: () => void; onBrowseChannels?: () => void; onCreateChannel?: () => void; onOpenMembers?: () => void; onWelcomeAddAgent?: () => void; }) { + const projectHome = useIsProjectHomeChannel(activeChannel?.id); + return React.useMemo(() => { if (!activeChannel || activeChannel.channelType === "dm") { return null; @@ -79,7 +85,7 @@ export function useChannelIntro({ actions, channelKindLabel: isWelcomeChannel(activeChannel) ? "private welcome channel" - : getChannelIntroKind(activeChannel), + : getChannelIntroKind(activeChannel, projectHome), channelName: activeChannel.name, description: isWelcomeChannel(activeChannel) ? null @@ -89,11 +95,21 @@ 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.", - icon: , - label: "Add agents", + description: "Add an agent here.", + icon: , + label: "Add agent", onClick: onAddAgent, testId: "channel-intro-action-create-agent", }); @@ -102,7 +118,7 @@ export function useChannelIntro({ if (onOpenMembers) { actions.push({ description: "Invite members.", - icon: , + icon: , label: "Add people", onClick: onOpenMembers, testId: "channel-intro-action-add-people", @@ -112,16 +128,22 @@ export function useChannelIntro({ return { actions, - channelKindLabel: getChannelIntroKind(activeChannel), + channelKindLabel: getChannelIntroKind(activeChannel, projectHome), channelName: activeChannel.name, description: getChannelIntroDescription(activeChannel), + hideBeginning: projectHome, + icon: projectHome ? ( + + ) : undefined, }; }, [ activeChannel, onAddAgent, + onAddFiles, onBrowseChannels, onCreateChannel, onOpenMembers, onWelcomeAddAgent, + projectHome, ]); } 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..fc4379f8b7f 100644 --- a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx +++ b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx @@ -16,6 +16,7 @@ export type ChannelIntro = { channelKindLabel: string; channelName: string; description?: string | null; + hideBeginning?: boolean; icon?: React.ReactNode; }; @@ -50,20 +51,22 @@ export function ChannelIntroBlock({

#{intro.channelName}

-

- This is the beginning of the{" "} - - {intro.channelKindLabel} - - . -

+ {intro.hideBeginning ? null : ( +

+ This is the beginning of the{" "} + + {intro.channelKindLabel} + + . +

+ )} {intro.description ? (

{intro.description}

) : null} {intro.actions?.length ? ( -
+
{intro.actions.map((action) => { const hasDescription = Boolean(action.description); @@ -72,8 +75,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 +87,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 +98,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(), + viewerPubkey: projectEvent.pubkey, + }); + 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..1e397c7141e 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -78,11 +78,9 @@ export type { ProjectPullRequestCommentAnchor, Repository, }; - export type ProjectPullRequestCommentDecision = "request-changes"; const HIDDEN_PROJECT_CARDS_KEY = "buzz.projects.hidden-cards.v1"; - export type RepoState = { branches: Array<{ name: string; commit: string }>; tags: Array<{ name: string; commit: string }>; @@ -172,9 +170,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 +182,7 @@ export async function fetchProjects( return buildProjectsFromFetcher(fetcher, { relayOrigin: getCachedRelayOrigin(), hiddenAddresses: new Set(readHiddenProjectCards()), + viewerPubkey, }); } @@ -210,8 +211,10 @@ function eventToRepoState(event: RelayEvent): RepoState { updatedAt: event.created_at, }; } - -async function fetchRepoState(project: Repository): Promise { +/** Load the trusted relay state used to resolve a repository's live refs. */ +export async function fetchRepoState( + project: Repository, +): Promise { const relaySelf = await getRelaySelf(); const trustedAuthors = [ ...new Set( diff --git a/desktop/src/features/projects/lib/projectAgentConversation.test.mjs b/desktop/src/features/projects/lib/projectAgentConversation.test.mjs index 5c620c73947..a24cf3f8152 100644 --- a/desktop/src/features/projects/lib/projectAgentConversation.test.mjs +++ b/desktop/src/features/projects/lib/projectAgentConversation.test.mjs @@ -148,6 +148,53 @@ test("pointers to unknown channels or agents are not restorable", () => { ); }); +test("a stored project-channel pointer restores when it matches the home channel", () => { + const home = { + id: "project-channel-1", + channelType: "stream", + isMember: true, + memberPubkeys: [SELF_PUBKEY, AGENT_PUBKEY], + participantPubkeys: [], + }; + const restored = restoreProjectsAgentConversation({ + stored: { + agentPubkey: AGENT_PUBKEY, + channelId: home.id, + opener: OPENER, + }, + channels: [home], + candidates: [AGENT], + currentPubkey: SELF_PUBKEY, + homeChannelId: home.id, + }); + assert.equal(restored?.channel, home); + assert.equal(restored?.agent, AGENT); +}); + +test("a stored project-channel pointer does not restore a different home", () => { + const home = { + id: "project-channel-1", + channelType: "stream", + isMember: true, + memberPubkeys: [SELF_PUBKEY], + participantPubkeys: [], + }; + assert.equal( + restoreProjectsAgentConversation({ + stored: { + agentPubkey: AGENT_PUBKEY, + channelId: home.id, + opener: OPENER, + }, + channels: [home], + candidates: [AGENT], + currentPubkey: SELF_PUBKEY, + homeChannelId: "other-project-channel", + }), + null, + ); +}); + test("a pointer naming a non-DM or foreign-participant channel is not restorable", () => { const stored = { agentPubkey: AGENT_PUBKEY, @@ -532,6 +579,29 @@ test("the captured scope rides every relay side effect of a first send", async ( assert.equal(result.channel.id, "dm-on-wss://tenant-a.example"); }); +test("a home channel first send does not open a DM", async () => { + const backend = makeScopedBackend("wss://tenant-a.example"); + const home = { id: "project-channel-1" }; + const result = await submitProjectAgentMessage({ + agent: { pubkey: AGENT_PUBKEY, isManaged: false, isActive: true }, + conversation: null, + content: "build this project", + mentionPubkeys: [AGENT_PUBKEY], + relayScope: "wss://tenant-a.example", + signerScope: SELF_PUBKEY, + homeChannel: home, + startAgent: backend.startAgent, + openDm: () => { + throw new Error("project home chat must use the project channel"); + }, + send: backend.send, + }); + + assert.deepEqual(backend.state.dmOpens, []); + assert.equal(result.channel.id, home.id); + assert.equal(backend.state.sends[0].request.channelId, home.id); +}); + test("follow-ups reply to the opener so same-second id ordering cannot hide them", async () => { const backend = makeScopedBackend("wss://tenant-a.example"); await submitProjectAgentMessage({ diff --git a/desktop/src/features/projects/lib/projectAgentConversation.ts b/desktop/src/features/projects/lib/projectAgentConversation.ts index 823791edc6c..c9c35abce60 100644 --- a/desktop/src/features/projects/lib/projectAgentConversation.ts +++ b/desktop/src/features/projects/lib/projectAgentConversation.ts @@ -54,11 +54,14 @@ export function restoreProjectsAgentConversation< channels, candidates, currentPubkey, + homeChannelId, }: { stored: StoredProjectsAgentConversation | null; channels: readonly Channel[]; candidates: readonly Agent[]; currentPubkey: string | null; + /** When set, a stored pointer to this project channel (not a DM) can restore. */ + homeChannelId?: string | null; }): { channel: Channel; agent: Agent; @@ -74,9 +77,16 @@ export function restoreProjectsAgentConversation< const agent = candidates.find( (candidate) => candidate.pubkey === agentPubkey, ); - if (!channel || !agent || channel.channelType !== "dm") return null; - const participants = channel.participantPubkeys.map(normalizePubkey); + if (!channel || !agent) return null; const self = normalizePubkey(currentPubkey); + if (homeChannelId && channel.id === homeChannelId) { + // Project-home chat lives on the project channel. Membership is the + // restore proof — the channel is not a 1:1 DM. + if (!channel.isMember) return null; + return { agent, channel, opener: stored.opener }; + } + if (channel.channelType !== "dm") return null; + const participants = channel.participantPubkeys.map(normalizePubkey); const hasAgent = participants.includes(agentPubkey); // The contract is participants === {agent, self}: requiring the current // user's own membership matters as much as rejecting strangers — a stored @@ -159,6 +169,7 @@ export async function submitProjectAgentMessage({ mediaTags, relayScope, signerScope, + homeChannel, startAgent, openDm, send, @@ -174,6 +185,8 @@ export async function submitProjectAgentMessage({ /** Signing identity (owner pubkey, hex) captured together with * `relayScope`; null when unknown. */ signerScope: string | null; + /** When set, the first message lands here instead of opening a 1:1 DM. */ + homeChannel?: Ch | null; startAgent: (input: { pubkey: string; expectedRelayUrl?: string; @@ -205,6 +218,7 @@ export async function submitProjectAgentMessage({ } const channel = conversation?.channel ?? + homeChannel ?? (await openDm({ pubkeys: [agent.pubkey], expectedRelayUrl, diff --git a/desktop/src/features/projects/lib/projectAgentSelection.test.mjs b/desktop/src/features/projects/lib/projectAgentSelection.test.mjs new file mode 100644 index 00000000000..ba764c88184 --- /dev/null +++ b/desktop/src/features/projects/lib/projectAgentSelection.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { pickDefaultProjectsAgent } from "./projectAgentSelection.ts"; + +test("prefers Fizz over the first running agent", () => { + const implementationPartner = { + name: "Implementation Partner", + personaId: "custom:implementation", + }; + const fizz = { name: "Fizz", personaId: "builtin:fizz" }; + assert.equal(pickDefaultProjectsAgent([implementationPartner, fizz]), fizz); +}); + +test("ignores an unmanaged agent using the Fizz display name", () => { + const managed = { name: "Builder", personaId: "custom:builder" }; + const spoofedFizz = { name: "Fizz" }; + assert.equal(pickDefaultProjectsAgent([managed, spoofedFizz]), managed); + assert.equal(pickDefaultProjectsAgent([managed]), managed); + assert.equal(pickDefaultProjectsAgent([]), null); +}); diff --git a/desktop/src/features/projects/lib/projectAgentSelection.ts b/desktop/src/features/projects/lib/projectAgentSelection.ts new file mode 100644 index 00000000000..0c41a13d922 --- /dev/null +++ b/desktop/src/features/projects/lib/projectAgentSelection.ts @@ -0,0 +1,12 @@ +const WELCOME_GUIDE_PERSONA_ID = "builtin:fizz"; + +/** Prefers the built-in welcome lead for a new Projects conversation. */ +export function pickDefaultProjectsAgent< + Agent extends { name: string; personaId?: string | null }, +>(agents: readonly Agent[]): Agent | null { + return ( + agents.find((agent) => agent.personaId === WELCOME_GUIDE_PERSONA_ID) ?? + agents[0] ?? + null + ); +} 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..801f249befa --- /dev/null +++ b/desktop/src/features/projects/lib/projectCollection.test.mjs @@ -0,0 +1,156 @@ +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 an authorized home-channel repo into the project", () => { + const project = explicitProject(); + const repoCard = standaloneRepo({ repository: { maintainers: [OWNER] } }); + 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 rejects a hostile home-channel claim", () => { + const project = explicitProject(); + const repoCard = standaloneRepo(); + const folded = absorbStandaloneProjectRepositories([project, repoCard]); + + assert.equal(folded.length, 2); + assert.deepEqual(folded[0].repositories, []); + assert.equal(folded[1].projectAddress, 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 authorized absorbed channel repos missing from the signed project", () => { + const repo = standaloneRepo({ repository: { maintainers: [OWNER] } }); + const project = explicitProject({ + repositories: [repo.repositories[0]], + repositoryAddresses: [repo.projectAddress], + }); + const pending = homeRepositoriesToBind(project, []); + assert.equal(pending.length, 1); + assert.equal(pending[0].repoAddress, repo.projectAddress); +}); + +test("homeRepositoriesToBind rejects a hostile absorbed channel repo", () => { + const repo = standaloneRepo(); + const project = explicitProject({ + repositories: [repo.repositories[0]], + repositoryAddresses: [repo.projectAddress], + }); + + assert.deepEqual(homeRepositoriesToBind(project, []), []); +}); + +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..ea329855f09 --- /dev/null +++ b/desktop/src/features/projects/lib/projectCollection.ts @@ -0,0 +1,120 @@ +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 repositoryAuthorizesProjectOwner( + project: Project, + repository: Repository, +): boolean { + const projectOwner = project.owner.toLowerCase(); + if (repository.owner.toLowerCase() === projectOwner) return true; + return Boolean( + repository.maintainers?.some( + (maintainer) => maintainer.toLowerCase() === projectOwner, + ), + ); +} + +function hostForStandaloneRepository( + explicitProjects: Project[], + repository: Repository, +): Project | undefined { + const channelHost = repository.channelId + ? explicitProjects.find( + (project) => + project.projectChannelId === repository.channelId && + repositoryAuthorizesProjectOwner(project, repository), + ) + : 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 && + repositoryAuthorizesProjectOwner(project, repository)) || + (repository.owner.toLowerCase() === project.owner.toLowerCase() && + 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/projectDetailSearch.test.mjs b/desktop/src/features/projects/lib/projectDetailSearch.test.mjs new file mode 100644 index 00000000000..d64cab98296 --- /dev/null +++ b/desktop/src/features/projects/lib/projectDetailSearch.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseProjectDetailSearch, + wantsProjectRepositorySurface, +} from "./projectDetailSearch.ts"; + +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", + extra: "dropped", + }); + + 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"); + assert.equal("extra" in search, false); +}); + +test("parseProjectDetailSearch drops empty channel panel params", () => { + const search = parseProjectDetailSearch({ + thread: "", + messageId: "", + tab: "not-a-tab", + }); + + assert.equal(search.thread, undefined); + assert.equal(search.messageId, undefined); + assert.equal(search.tab, undefined); +}); + +test("wantsProjectRepositorySurface is false for channel-first project home", () => { + assert.equal( + wantsProjectRepositorySurface({ + projectId: "30621:owner:platform", + }), + false, + ); +}); + +test("wantsProjectRepositorySurface is true for repo, tab, or work-item params", () => { + assert.equal( + wantsProjectRepositorySurface({ + projectId: "30621:owner:platform", + repositoryId: "30617:owner:buzz", + }), + true, + ); + assert.equal( + wantsProjectRepositorySurface({ + projectId: "30621:owner:platform", + tab: "files", + }), + true, + ); + assert.equal( + wantsProjectRepositorySurface({ + filePath: "src/main.ts", + projectId: "30621:owner:platform", + }), + true, + ); +}); + +test("wantsProjectRepositorySurface is true for a legacy kind:30617 project id", () => { + assert.equal( + wantsProjectRepositorySurface({ + projectId: "30617:owner:buzz", + }), + true, + ); +}); diff --git a/desktop/src/features/projects/lib/projectDetailSearch.ts b/desktop/src/features/projects/lib/projectDetailSearch.ts new file mode 100644 index 00000000000..460ade0400c --- /dev/null +++ b/desktop/src/features/projects/lib/projectDetailSearch.ts @@ -0,0 +1,68 @@ +import { + parseProfilePanelTab, + parseProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; +import { KIND_REPO_ANNOUNCEMENT } from "@/shared/constants/kinds"; +import { isEntityLinkTab } from "@/shared/lib/entityLink"; + +function optionalSearchString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** + * Project detail URLs carry forge params (repository, tab, issue) and, on + * channel-first home, the same auxiliary-panel params a stream channel uses + * (thread, agent session, profile). Both must survive `validateSearch` or + * ChannelScreen cannot keep threads and agent activity on this route. + */ +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), + tab: isEntityLinkTab(search.tab) ? search.tab : undefined, + agentSession: nonEmptyString(search.agentSession), + agentSessionChannel: nonEmptyString(search.agentSessionChannel), + autoSend: nonEmptyString(search.autoSend), + channelManagement: nonEmptyString(search.channelManagement), + messageId: nonEmptyString(search.messageId), + profile: nonEmptyString(search.profile), + profileTab: parseProfilePanelTab(search.profileTab) ?? undefined, + profileView: parseProfilePanelView(search.profileView) ?? undefined, + thread: nonEmptyString(search.thread), + threadRootId: nonEmptyString(search.threadRootId), + }; +} + +/** + * Channel-first project home is the default. A repository forge surface is + * requested by an explicit repo/work-item search param, or by a legacy + * kind:30617 project id (the project *is* that repository). + */ +export function wantsProjectRepositorySurface(input: { + commitHash?: string; + filePath?: string; + issueId?: string; + projectId: string; + pullRequestId?: string; + repositoryId?: string; + tab?: string; +}): boolean { + if ( + input.repositoryId || + input.tab || + input.issueId || + input.pullRequestId || + input.commitHash || + input.filePath + ) { + return true; + } + return input.projectId.startsWith(`${KIND_REPO_ANNOUNCEMENT}:`); +} 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..a7c02ccb6ae --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeChannel.test.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + findProjectHomeByChannelId, + hasAuthoritativeHomeBinding, + 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("hasAuthoritativeHomeBinding rejects a bare project route assertion", () => { + assert.equal( + hasAuthoritativeHomeBinding(project({ repositories: [] })), + false, + ); +}); + +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); +}); + +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 new file mode 100644 index 00000000000..091a4409bfa --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeChannel.ts @@ -0,0 +1,63 @@ +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; + projectChannelId: string | null; + repositories: ReadonlyArray<{ + channelId?: string | null; + maintainers?: ReadonlyArray; + owner: string; + }>; +}; + +export 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/lib/projectHomeSummary.test.mjs b/desktop/src/features/projects/lib/projectHomeSummary.test.mjs new file mode 100644 index 00000000000..7e24b2f6385 --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeSummary.test.mjs @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { presentContextCount } from "./projectHomeSummary.ts"; + +test("presentContextCount hides empty values", () => { + assert.equal(presentContextCount(undefined), undefined); + assert.equal(presentContextCount(0), undefined); + assert.equal(presentContextCount(3), 3); +}); diff --git a/desktop/src/features/projects/lib/projectHomeSummary.ts b/desktop/src/features/projects/lib/projectHomeSummary.ts new file mode 100644 index 00000000000..8928276002f --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeSummary.ts @@ -0,0 +1,6 @@ +/** Right-edge context counts omit empty values, matching the Projects overview. */ +export function presentContextCount( + value: number | undefined, +): number | undefined { + return value != null && value > 0 ? value : undefined; +} diff --git a/desktop/src/features/projects/lib/projectHomeTemplate.test.mjs b/desktop/src/features/projects/lib/projectHomeTemplate.test.mjs new file mode 100644 index 00000000000..6f6ebfd8f5e --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeTemplate.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + applyProjectHomeCanvas, + PROJECT_HOME_CHANNEL_TEMPLATE, + PROJECT_HOME_TEMPLATE_ID, + renderProjectHomeCanvas, +} from "./projectHomeTemplate.ts"; + +test("project home is the built-in default project template", () => { + assert.equal(PROJECT_HOME_CHANNEL_TEMPLATE.id, PROJECT_HOME_TEMPLATE_ID); + assert.equal(PROJECT_HOME_CHANNEL_TEMPLATE.isBuiltin, true); + assert.equal(PROJECT_HOME_CHANNEL_TEMPLATE.name, "Project home"); +}); + +test("project home dispatches its rendered canvas to the created channel", async () => { + const calls = []; + const originalWindow = globalThis.window; + const tauriInternals = { + invoke: async (command, args) => { + calls.push({ command, args }); + return { ok: true, event_id: "event-1" }; + }, + }; + globalThis.window = { __TAURI_INTERNALS__: tauriInternals }; + globalThis.__TAURI_INTERNALS__ = tauriInternals; + try { + const applied = await applyProjectHomeCanvas({ + channelId: "11111111-1111-4111-8111-111111111111", + project: { + id: "30621:owner:space-invaders", + dtag: "space-invaders", + name: "Space Invaders", + owner: "a".repeat(64), + repositories: [], + }, + }); + assert.equal(applied, true); + assert.equal(calls.length, 1); + assert.equal(calls[0].command, "set_canvas"); + assert.equal( + calls[0].args.channelId, + "11111111-1111-4111-8111-111111111111", + ); + assert.match(calls[0].args.content, /# Project Channel: Space Invaders/); + } finally { + globalThis.window = originalWindow; + delete globalThis.__TAURI_INTERNALS__; + } +}); + +test("project home canvas fills project, repository, and channel values", () => { + const content = renderProjectHomeCanvas({ + channelId: "11111111-1111-4111-8111-111111111111", + project: { + id: "30621:owner:space-invaders", + dtag: "space-invaders", + name: "Space Invaders", + owner: "a".repeat(64), + repositories: [ + { + cloneUrls: ["https://relay.example/git/owner/space-invaders"], + dtag: "space-invaders", + owner: "b".repeat(64), + }, + ], + }, + }); + + assert.match(content, /# Project Channel: Space Invaders/); + assert.match(content, /`space-invaders`/); + assert.match(content, /b{64}/); + assert.match(content, /https:\/\/relay\.example\/git\/owner\/space-invaders/); + assert.match(content, /11111111-1111-4111-8111-111111111111/); + assert.equal(content.includes("{{"), false); + assert.match(content, /buzz issues status --issue /); + assert.match(content, /buzz pr open --repo-owner/); + assert.match(content, /buzz canvas set .* --content -/); +}); diff --git a/desktop/src/features/projects/lib/projectHomeTemplate.ts b/desktop/src/features/projects/lib/projectHomeTemplate.ts new file mode 100644 index 00000000000..bb6545466a3 --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeTemplate.ts @@ -0,0 +1,101 @@ +import { setCanvas } from "@/shared/api/tauri"; +import type { ChannelTemplate } from "@/shared/api/types"; +import type { Project } from "@/features/projects/hooks"; + +export const PROJECT_HOME_TEMPLATE_ID = "builtin:project-home"; + +export const PROJECT_HOME_CANVAS_TEMPLATE = `# Project Channel: {{PROJECT_NAME}} + +This channel is the working home of **{{PROJECT_NAME}}**. + +- Initial repository: \`{{REPO_SLUG}}\` +- Repository owner: \`{{REPO_OWNER_HEX}}\` +- Clone URL: \`{{REPO_CLONE_URL}}\` +- Project channel: \`{{CHANNEL_UUID}}\` + +Everything about this project—decisions, tasks, code review, and releases—happens here, in the open. + +## How to think about this channel + +- **The channel is the project's memory.** If you did it and did not post it, it did not happen. Milestones (picked up, blocked, PR up, merged, done) are top-level posts; details go in threads. +- **Issues are the task queue.** Work starts from an issue. No issue? Create one before you build. +- **The repository is the source of truth for code; the channel is the source of truth for intent.** Read both before acting. +- **One owner per task.** Claim before you build. If it is assigned to someone else, review or unblock—do not duplicate. + +## What you can do here + +| Action | Command | +| --- | --- | +| Inspect the repository | \`buzz repos get --owner {{REPO_OWNER_HEX}} --id {{REPO_SLUG}}\` | +| Create a task | \`buzz issues create --channel {{CHANNEL_UUID}} --title "..." --content -\` | +| Claim or assign a task | \`buzz issues assign --issue --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --assignee \` | +| Track task state | \`buzz issues status --issue --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --status open|resolved|closed|draft\` | +| Open a review | \`buzz pr open --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --subject "..." --body-file - --commit --clone {{REPO_CLONE_URL}} --branch-name --channel {{CHANNEL_UUID}}\` | +| Update a review | \`buzz pr update --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --pr --pr-author --commit --clone {{REPO_CLONE_URL}}\` | +| Mark a review merged or closed | \`buzz pr status --pr --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --status merged|closed\` | +| Share files or artifacts | \`buzz upload file --file \` | +| Update this living document | \`buzz canvas set --channel {{CHANNEL_UUID}} --content -\` | + +## Workflow + +1. **Pick up:** Find or create an issue, self-assign it, and post a one-line “picked up” message in the channel. +2. **Build:** Clone or reuse a checkout under \`REPOS/\`. Work on a branch, never the default branch. Follow the repository's configured commit and sign-off policy. +3. **Verify:** Run the fullest relevant test suite before calling anything done. +4. **Ship:** Open a review and post the returned Buzz link verbatim so it renders as a card. Mark the issue resolved when merged. +5. **Report:** @mention whoever delegated the work in the message that delivers the result or blocker—not in acknowledgements. + +## Norms + +- Reply in-thread to continue a topic; use a top-level post for a new topic. Avoid bare acknowledgements. +- @mention only when someone must act; naming someone in narrative does not require an @mention. +- Blocked for more than 30 minutes after honest effort? Post the blocker and what you tried. +- Praise in public; correct the work, not the person. +- Give decisions of record—scope cuts, API choices, and deferrals—their own top-level post so they remain findable. + +Keep this canvas current as the project evolves.`; + +export const PROJECT_HOME_CHANNEL_TEMPLATE: ChannelTemplate = { + id: PROJECT_HOME_TEMPLATE_ID, + name: "Project home", + description: null, + channelType: "stream", + visibility: "open", + canvasTemplate: PROJECT_HOME_CANVAS_TEMPLATE, + agents: { personas: [], teams: [] }, + isBuiltin: true, + createdAt: "", + updatedAt: "", +}; + +export function renderProjectHomeCanvas(input: { + channelId: string; + project: Project; +}) { + const repository = input.project.repositories[0]; + const values: Record = { + CHANNEL_UUID: input.channelId, + PROJECT_NAME: input.project.name, + REPO_CLONE_URL: repository?.cloneUrls[0] ?? "Unavailable", + REPO_OWNER_HEX: repository?.owner ?? input.project.owner, + REPO_SLUG: repository?.dtag ?? input.project.dtag, + }; + return Object.entries(values).reduce( + (content, [key, value]) => content.replaceAll(`{{${key}}}`, value), + PROJECT_HOME_CANVAS_TEMPLATE, + ); +} + +export async function applyProjectHomeCanvas(input: { + channelId: string; + project: Project; +}) { + try { + await setCanvas({ + channelId: input.channelId, + content: renderProjectHomeCanvas(input), + }); + return true; + } catch { + return false; + } +} diff --git a/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.test.mjs b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.test.mjs new file mode 100644 index 00000000000..68ee2d82393 --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isProjectHomeWorkspaceSheetTab, + projectHomeWorkspaceSheetExpandTab, + projectHomeWorkspaceSheetTitle, +} from "./projectHomeWorkspaceSheet.ts"; + +test("isProjectHomeWorkspaceSheetTab accepts overview workspace rows", () => { + assert.equal(isProjectHomeWorkspaceSheetTab("issues"), true); + assert.equal(isProjectHomeWorkspaceSheetTab("files"), true); + assert.equal(isProjectHomeWorkspaceSheetTab("channels"), false); + assert.equal(isProjectHomeWorkspaceSheetTab(undefined), false); +}); + +test("projectHomeWorkspaceSheetTitle matches overview row labels", () => { + assert.equal(projectHomeWorkspaceSheetTitle("issues"), "Tasks"); + assert.equal(projectHomeWorkspaceSheetTitle("prs"), "Reviews"); + assert.equal(projectHomeWorkspaceSheetTitle("commits"), "Commits"); + 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 new file mode 100644 index 00000000000..bf3ce4f65dc --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts @@ -0,0 +1,40 @@ +export const PROJECT_HOME_WORKSPACE_SHEET_TABS = [ + "issues", + "prs", + "commits", + "files", + "contributors", +] as const; + +export type ProjectHomeWorkspaceSheetTab = + (typeof PROJECT_HOME_WORKSPACE_SHEET_TABS)[number]; + +export function isProjectHomeWorkspaceSheetTab( + value: string | undefined, +): value is ProjectHomeWorkspaceSheetTab { + return ( + value != null && + (PROJECT_HOME_WORKSPACE_SHEET_TABS as readonly string[]).includes(value) + ); +} + +const WORKSPACE_SHEET_TITLES: Record = { + commits: "Commits", + contributors: "People", + files: "Files", + issues: "Tasks", + prs: "Reviews", +}; + +export function projectHomeWorkspaceSheetTitle( + tab: ProjectHomeWorkspaceSheetTab, +): 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/lib/projectRelatedChannels.test.mjs b/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs index ef94588ccc3..e6fc3196f20 100644 --- a/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs +++ b/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs @@ -2,7 +2,10 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { + collapseProjectRelatedChannelRows, collectProjectRelatedChannelRows, + listProjectBoundChannels, + listProjectChildChannels, projectRelatedChannelRowKey, uniqueProjectRelatedChannelCount, } from "./projectRelatedChannels.ts"; @@ -104,6 +107,37 @@ test("collects one row per repository channel binding", () => { ); }); +test("collapses repositories sharing one project channel", () => { + const rows = collectProjectRelatedChannelRows([ + makeProject({ + repositories: [ + makeRepository({ name: "web" }), + makeRepository({ id: "repo-mobile", name: "mobile" }), + makeRepository({ + channelId: CHANNEL_B, + id: "repo-relay", + name: "relay", + }), + ], + }), + ]); + + assert.deepEqual(collapseProjectRelatedChannelRows(rows), [ + { + channelId: CHANNEL_A, + projectId: "project-buzz", + projectName: "buzz", + repositoryNames: ["web", "mobile"], + }, + { + channelId: CHANNEL_B, + projectId: "project-buzz", + projectName: "buzz", + repositoryNames: ["relay"], + }, + ]); +}); + test("keeps a project channel only when no repository in that project shares it", () => { assert.deepEqual( collectProjectRelatedChannelRows([ @@ -183,3 +217,135 @@ test("row keys distinguish project-level bindings from repository bindings", () `${CHANNEL_A}:project-buzz:repo-buzz`, ); }); + +test("listProjectBoundChannels puts the home channel first", () => { + assert.deepEqual( + listProjectBoundChannels( + makeProject({ + projectChannelId: CHANNEL_B, + repositories: [ + makeRepository({ channelId: CHANNEL_A }), + makeRepository({ + id: "repo-relay", + name: "relay-tools", + channelId: CHANNEL_A, + }), + ], + }), + ), + [ + { + channelId: CHANNEL_B, + repositoryId: null, + role: "home", + }, + { + channelId: CHANNEL_A, + repositoryId: "repo-buzz", + role: "related", + }, + ], + ); +}); + +test("listProjectBoundChannels omits a repository channel that is the home channel", () => { + assert.deepEqual( + listProjectBoundChannels( + makeProject({ + projectChannelId: CHANNEL_A, + repositories: [makeRepository({ channelId: CHANNEL_A })], + }), + ), + [ + { + channelId: CHANNEL_A, + repositoryId: null, + role: "home", + }, + ], + ); +}); + +test("listProjectBoundChannels is empty when nothing is bound", () => { + assert.deepEqual(listProjectBoundChannels(makeProject()), []); +}); + +test("listProjectBoundChannels includes extra related channels after home", () => { + const related = "33333333-3333-4333-8333-333333333333"; + assert.deepEqual( + listProjectBoundChannels( + makeProject({ + projectChannelId: CHANNEL_B, + relatedChannelIds: [related], + repositories: [makeRepository({ channelId: CHANNEL_A })], + }), + ), + [ + { + channelId: CHANNEL_B, + repositoryId: null, + role: "home", + }, + { + channelId: related, + repositoryId: null, + role: "related", + }, + { + channelId: CHANNEL_A, + repositoryId: "repo-buzz", + role: "related", + }, + ], + ); +}); + +test("listProjectChildChannels omits the home channel", () => { + const related = "33333333-3333-4333-8333-333333333333"; + assert.deepEqual( + listProjectChildChannels( + makeProject({ + projectChannelId: CHANNEL_B, + relatedChannelIds: [related], + repositories: [makeRepository({ channelId: CHANNEL_A })], + }), + ), + [ + { + channelId: related, + repositoryId: null, + role: "related", + }, + { + channelId: CHANNEL_A, + repositoryId: "repo-buzz", + role: "related", + }, + ], + ); +}); + +test("collectProjectRelatedChannelRows includes extra related channels", () => { + const related = "33333333-3333-4333-8333-333333333333"; + const rows = collectProjectRelatedChannelRows([ + makeProject({ + projectChannelId: CHANNEL_B, + relatedChannelIds: [related, CHANNEL_A], + repositories: [makeRepository()], + }), + ]); + assert.equal( + rows.some((row) => row.channelId === related && row.repositoryId == null), + true, + ); + assert.equal( + uniqueProjectRelatedChannelCount([ + makeProject({ + projectChannelId: CHANNEL_B, + relatedChannelIds: [related], + repositories: [makeRepository()], + }), + ]), + 3, + ); +}); diff --git a/desktop/src/features/projects/lib/projectRelatedChannels.ts b/desktop/src/features/projects/lib/projectRelatedChannels.ts index 102e9d9a03c..d5166a48536 100644 --- a/desktop/src/features/projects/lib/projectRelatedChannels.ts +++ b/desktop/src/features/projects/lib/projectRelatedChannels.ts @@ -4,6 +4,7 @@ export type ProjectRelatedChannelSource = { id: string; name: string; projectChannelId: string | null; + relatedChannelIds?: readonly string[]; repositories: Array<{ id: string; name: string; @@ -19,6 +20,14 @@ export type ProjectRelatedChannelRow = { repositoryName: string | null; }; +/** One display row per distinct channel within a project. */ +export type ProjectRelatedChannelDisplayRow = { + channelId: string; + projectId: string; + projectName: string; + repositoryNames: string[]; +}; + function trimmedChannelId(value: string | null | undefined) { const channelId = value?.trim() ?? ""; return channelId.length > 0 ? channelId : null; @@ -57,6 +66,19 @@ export function collectProjectRelatedChannelRows( repositoryId: null, repositoryName: null, }); + repositoryChannelIds.add(projectChannelId); + } + for (const relatedChannelId of project.relatedChannelIds ?? []) { + const channelId = trimmedChannelId(relatedChannelId); + if (!channelId || repositoryChannelIds.has(channelId)) continue; + repositoryChannelIds.add(channelId); + rows.push({ + channelId, + projectId: project.id, + projectName: project.name, + repositoryId: null, + repositoryName: null, + }); } } return rows; @@ -73,3 +95,102 @@ export function uniqueProjectRelatedChannelCount( export function projectRelatedChannelRowKey(row: ProjectRelatedChannelRow) { return `${row.channelId}:${row.projectId}:${row.repositoryId ?? "project"}`; } + +/** Collapses repository bindings that point at the same project channel. */ +export function collapseProjectRelatedChannelRows( + rows: readonly ProjectRelatedChannelRow[], +): ProjectRelatedChannelDisplayRow[] { + const collapsed = new Map(); + for (const row of rows) { + const key = `${row.projectId}:${row.channelId}`; + const current = collapsed.get(key); + if (current) { + if ( + row.repositoryName && + !current.repositoryNames.includes(row.repositoryName) + ) { + current.repositoryNames.push(row.repositoryName); + } + continue; + } + collapsed.set(key, { + channelId: row.channelId, + projectId: row.projectId, + projectName: row.projectName, + repositoryNames: row.repositoryName ? [row.repositoryName] : [], + }); + } + return [...collapsed.values()]; +} + +/** Stable key for one collapsed project-channel row. */ +export function projectRelatedChannelDisplayRowKey( + row: ProjectRelatedChannelDisplayRow, +) { + return `${row.channelId}:${row.projectId}`; +} + +export type ProjectBoundChannel = { + channelId: string; + repositoryId: string | null; + role: "home" | "related"; +}; + +/** + * Unique channels bound to one project: the home stream first, then each + * repository channel that is not already the home channel. + */ +export function listProjectBoundChannels( + project: Pick< + ProjectRelatedChannelSource, + "projectChannelId" | "relatedChannelIds" | "repositories" + >, +): ProjectBoundChannel[] { + const channels: ProjectBoundChannel[] = []; + const seen = new Set(); + const homeChannelId = trimmedChannelId(project.projectChannelId); + if (homeChannelId) { + channels.push({ + channelId: homeChannelId, + repositoryId: null, + role: "home", + }); + seen.add(homeChannelId); + } + for (const relatedChannelId of project.relatedChannelIds ?? []) { + const channelId = trimmedChannelId(relatedChannelId); + if (!channelId || seen.has(channelId)) continue; + channels.push({ + channelId, + repositoryId: null, + role: "related", + }); + seen.add(channelId); + } + for (const repository of project.repositories) { + const channelId = trimmedChannelId(repository.channelId); + if (!channelId || seen.has(channelId)) continue; + channels.push({ + channelId, + repositoryId: repository.id, + role: "related", + }); + seen.add(channelId); + } + return channels; +} + +/** + * Nested sidebar rows under a project: bound streams except the home + * channel, which is the project row itself. + */ +export function listProjectChildChannels( + project: Pick< + ProjectRelatedChannelSource, + "projectChannelId" | "relatedChannelIds" | "repositories" + >, +): ProjectBoundChannel[] { + return listProjectBoundChannels(project).filter( + (channel) => channel.role !== "home", + ); +} diff --git a/desktop/src/features/projects/lib/projectsActivityDigest.test.mjs b/desktop/src/features/projects/lib/projectsActivityDigest.test.mjs new file mode 100644 index 00000000000..38832d05060 --- /dev/null +++ b/desktop/src/features/projects/lib/projectsActivityDigest.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildProjectsActivityDigest } from "./projectsActivityDigest.ts"; + +const NOW = 2_000_000_000; + +test("summarizes recent activity in a short highlighted sentence", () => { + const project = { id: "project-a" }; + const digest = buildProjectsActivityDigest({ + issues: [ + { project, issue: { createdAt: NOW - 60 } }, + { project, issue: { createdAt: NOW - 120 } }, + ], + nowSeconds: NOW, + projects: [project], + pullRequests: [{ project, pullRequest: { createdAt: NOW - 180 } }], + snapshots: { + "project-a": { + commits: [ + { timestamp: NOW - 30 }, + { timestamp: NOW - 90 }, + { timestamp: NOW - 8 * 24 * 60 * 60 }, + ], + }, + }, + }); + + assert.equal(digest.prefix, "This week:"); + assert.deepEqual(digest.highlights, [ + "2 new commits", + "2 tasks opened", + "1 review opened", + "1 active project", + ]); + assert.ok( + `${digest.prefix} ${digest.highlights.join(", ")}${digest.suffix}`.split( + /\s+/, + ).length <= 30, + ); +}); + +test("falls back to current totals when no recent activity is loaded", () => { + const digest = buildProjectsActivityDigest({ + issues: [], + nowSeconds: NOW, + projects: [{ id: "a" }, { id: "b" }], + pullRequests: [], + summaries: { + a: { issueCount: 4, prCount: 2 }, + b: { issueCount: 1, prCount: 3 }, + }, + }); + + assert.equal(digest.prefix, "Currently tracking"); + assert.deepEqual(digest.highlights, ["2 projects", "5 tasks", "5 reviews"]); +}); diff --git a/desktop/src/features/projects/lib/projectsActivityDigest.ts b/desktop/src/features/projects/lib/projectsActivityDigest.ts new file mode 100644 index 00000000000..08edc4fdda5 --- /dev/null +++ b/desktop/src/features/projects/lib/projectsActivityDigest.ts @@ -0,0 +1,88 @@ +import type { + Project, + ProjectActivitySummary, + ProjectIssueListItem, + ProjectPullRequestListItem, + ProjectRepoSnapshot, +} from "@/features/projects/hooks"; + +const WEEK_SECONDS = 7 * 24 * 60 * 60; + +export type ProjectsActivityDigest = { + highlights: string[]; + prefix: string; + suffix: string; +}; + +function plural(count: number, singular: string, pluralForm = `${singular}s`) { + return `${count} ${count === 1 ? singular : pluralForm}`; +} + +/** Builds a short, deterministic sentence from the currently loaded activity. */ +export function buildProjectsActivityDigest({ + issues, + nowSeconds, + projects, + pullRequests, + snapshots, + summaries, +}: { + issues: ProjectIssueListItem[]; + nowSeconds: number; + projects: Project[]; + pullRequests: ProjectPullRequestListItem[]; + snapshots?: Record; + summaries?: Record; +}): ProjectsActivityDigest { + const since = nowSeconds - WEEK_SECONDS; + const activeProjectIds = new Set(); + let commitCount = 0; + for (const [projectId, snapshot] of Object.entries(snapshots ?? {})) { + const recent = snapshot.commits.filter( + (commit) => commit.timestamp >= since, + ).length; + commitCount += recent; + if (recent > 0) activeProjectIds.add(projectId); + } + const taskCount = issues.filter(({ issue, project }) => { + const recent = issue.createdAt >= since; + if (recent) activeProjectIds.add(project.id); + return recent; + }).length; + const reviewCount = pullRequests.filter(({ project, pullRequest }) => { + const recent = pullRequest.createdAt >= since; + if (recent) activeProjectIds.add(project.id); + return recent; + }).length; + const highlights = [ + commitCount > 0 ? `${plural(commitCount, "new commit")}` : null, + taskCount > 0 ? `${plural(taskCount, "task")} opened` : null, + reviewCount > 0 ? `${plural(reviewCount, "review")} opened` : null, + ].filter((value): value is string => value !== null); + + if (highlights.length > 0) { + highlights.push(`${plural(activeProjectIds.size, "active project")}`); + return { + highlights, + prefix: "This week:", + suffix: ".", + }; + } + + const totals = Object.values(summaries ?? {}).reduce( + (result, summary) => ({ + reviews: result.reviews + summary.prCount, + tasks: result.tasks + summary.issueCount, + }), + { reviews: 0, tasks: 0 }, + ); + return { + highlights: [ + plural(projects.length, "project"), + plural(totals.tasks, "task"), + plural(totals.reviews, "review"), + ], + prefix: "Currently tracking", + suffix: ".", + }; +} diff --git a/desktop/src/features/projects/lib/projectsSearch.test.mjs b/desktop/src/features/projects/lib/projectsSearch.test.mjs new file mode 100644 index 00000000000..47fa06e89dc --- /dev/null +++ b/desktop/src/features/projects/lib/projectsSearch.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { matchesProjectsSearch } from "./projectsSearch.ts"; + +test("matches every case-insensitive token across fields", () => { + assert.equal( + matchesProjectsSearch("buzz mobile", [ + "Buzz Platform", + "Desktop, relay, and mobile clients", + ]), + true, + ); + assert.equal( + matchesProjectsSearch("buzz missing", [ + "Buzz Platform", + "Desktop, relay, and mobile clients", + ]), + false, + ); +}); + +test("empty search matches everything", () => { + assert.equal(matchesProjectsSearch(" ", []), true); +}); diff --git a/desktop/src/features/projects/lib/projectsSearch.ts b/desktop/src/features/projects/lib/projectsSearch.ts new file mode 100644 index 00000000000..290e985e468 --- /dev/null +++ b/desktop/src/features/projects/lib/projectsSearch.ts @@ -0,0 +1,10 @@ +/** Case-insensitive token matching for Projects-local search. */ +export function matchesProjectsSearch( + query: string, + values: ReadonlyArray, +) { + const tokens = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean); + if (tokens.length === 0) return true; + const haystack = values.filter(Boolean).join(" ").toLocaleLowerCase(); + return tokens.every((token) => haystack.includes(token)); +} diff --git a/desktop/src/features/projects/lib/useProjectSelection.tsx b/desktop/src/features/projects/lib/useProjectSelection.tsx index 551e59afd64..eec6883d4a7 100644 --- a/desktop/src/features/projects/lib/useProjectSelection.tsx +++ b/desktop/src/features/projects/lib/useProjectSelection.tsx @@ -25,10 +25,12 @@ const ProjectSelectionContext = export function ProjectSelectionProvider({ children, + onClear, onSelect, resetKey, }: { children: React.ReactNode; + onClear?: () => void; onSelect?: () => void; resetKey: string; }) { @@ -42,6 +44,9 @@ export function ProjectSelectionProvider({ } const onSelectRef = React.useRef(onSelect); onSelectRef.current = onSelect; + const onClearRef = React.useRef(onClear); + onClearRef.current = onClear; + const wasActiveRef = React.useRef(false); const clear = React.useCallback(() => { setState(EMPTY_PROJECT_SELECTION); @@ -61,7 +66,10 @@ export function ProjectSelectionProvider({ }, []); React.useEffect(() => { - if (state.items.length > 0) onSelectRef.current?.(); + const active = state.items.length > 0; + if (active && !wasActiveRef.current) onSelectRef.current?.(); + if (!active && wasActiveRef.current) onClearRef.current?.(); + wasActiveRef.current = active; }, [state.items.length]); React.useEffect(() => { 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/projectChannelRequest.test.mjs b/desktop/src/features/projects/projectChannelRequest.test.mjs new file mode 100644 index 00000000000..4d109ffb218 --- /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 00000000000..944dc293352 --- /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/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..21afa771d7c 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; @@ -43,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[]; @@ -50,6 +61,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 +126,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 +362,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 +379,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 +407,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 +451,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 +508,7 @@ export function buildProjectReadModels({ visibleRepositoriesByAddress, ); return project && - project.visibility === "listed" && + projectIsListingEligible(project, viewerPubkey) && !hiddenAddresses.has(project.projectAddress) ? [project] : []; @@ -548,3 +593,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/projectWorkItems.test.mjs b/desktop/src/features/projects/projectWorkItems.test.mjs index 21ee577b54e..d4060e130a9 100644 --- a/desktop/src/features/projects/projectWorkItems.test.mjs +++ b/desktop/src/features/projects/projectWorkItems.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { fetchProjectsWorkItems } from "./projectWorkItems.ts"; +import { + fetchProjectsWorkItems, + projectsWithWorkItemRepositories, +} from "./projectWorkItems.ts"; // ── Work-item deduplication ───────────────────────────────────────────────── // @@ -27,8 +30,31 @@ const projectB = { repositories: [{ repoAddress: REPO_ADDRESS }], }; +test("work-item scope keeps explicit and repository-only read models", () => { + const explicitProject = { + id: "explicit", + legacy: false, + repositories: [{ repoAddress: REPO_ADDRESS }], + }; + const repositoryOnlyProject = { + id: "repository-only", + legacy: true, + repositories: [{ repoAddress: `30617:${REPO_OWNER}:standalone` }], + }; + const emptyProject = { id: "empty", legacy: false, repositories: [] }; + + assert.deepEqual( + projectsWithWorkItemRepositories([ + explicitProject, + repositoryOnlyProject, + emptyProject, + ]).map((project) => project.id), + ["explicit", "repository-only"], + ); +}); + // Minimal valid NIP-34 issue event for the shared repo. -function makeIssue(id, updatedAt = 100) { +function makeIssue(id, updatedAt = 100, repoAddress = REPO_ADDRESS) { return { id, kind: 1621, @@ -36,12 +62,34 @@ function makeIssue(id, updatedAt = 100) { created_at: updatedAt, content: "An issue", tags: [ - ["a", REPO_ADDRESS], + ["a", repoAddress], ["subject", "Fix the thing"], ], }; } +test("fetchProjectsWorkItems accumulates issues from every project repository", async () => { + const secondAddress = `30617:${REPO_OWNER}:desktop`; + const project = { + repositories: [ + { repoAddress: REPO_ADDRESS }, + { repoAddress: secondAddress }, + ], + }; + const result = await fetchProjectsWorkItems( + [project], + makeFetchEvents([ + makeIssue(ISSUE_ID, 100, REPO_ADDRESS), + makeIssue("j".repeat(64), 90, secondAddress), + ]), + ); + + assert.deepEqual( + result.issues.items.map(({ repository }) => repository.repoAddress).sort(), + [REPO_ADDRESS, secondAddress].sort(), + ); +}); + // Minimal valid NIP-34 pull request event for the shared repo. function makePR(id, updatedAt = 100) { return { diff --git a/desktop/src/features/projects/projectWorkItems.ts b/desktop/src/features/projects/projectWorkItems.ts index 11acbc8b34b..d6dfd49a865 100644 --- a/desktop/src/features/projects/projectWorkItems.ts +++ b/desktop/src/features/projects/projectWorkItems.ts @@ -62,6 +62,13 @@ export type ProjectsWorkItemsResult = { }; }; +/** Includes every repository-bearing read model, including repository-only ones. */ +export function projectsWithWorkItemRepositories< + TProject extends ProjectReference, +>(projects: readonly TProject[]): TProject[] { + return projects.filter((project) => project.repositories.length > 0); +} + function groupByRepoAddress(events: RelayEvent[]): Map { const grouped = new Map(); for (const event of events) { diff --git a/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx b/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx index 609c38a2cc6..e47aceb2a13 100644 --- a/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx +++ b/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx @@ -22,6 +22,7 @@ export function AddProjectRepositoryDialog({ onOpenChange, open, project, + projects, }: { accessChannelId?: string; channels: Channel[]; @@ -29,37 +30,51 @@ export function AddProjectRepositoryDialog({ onAdd: (input: AddProjectRepositoryInput) => Promise; onOpenChange: (open: boolean) => void; open: boolean; - project: Project; + project?: Project; + projects?: Project[]; }) { + const projectOptions = React.useMemo( + () => projects ?? (project ? [project] : []), + [project, projects], + ); + const [selectedProjectId, setSelectedProjectId] = React.useState( + project?.id ?? projectOptions[0]?.id ?? "", + ); + const selectedProject = + projectOptions.find((candidate) => candidate.id === selectedProjectId) ?? + projectOptions[0]; const [name, setName] = React.useState(""); const [cloneUrl, setCloneUrl] = React.useState(""); const [selectedChannelId, setSelectedChannelId] = React.useState(""); const [errorMessage, setErrorMessage] = React.useState(null); const nameInputRef = React.useRef(null); + const projectSelectRef = React.useRef(null); React.useEffect(() => { if (!open) return; setName(""); setCloneUrl(""); + setSelectedProjectId(project?.id ?? projectOptions[0]?.id ?? ""); setSelectedChannelId(accessChannelId ?? ""); setErrorMessage(null); const timerId = globalThis.setTimeout( - () => nameInputRef.current?.focus(), + () => + (projects ? projectSelectRef.current : nameInputRef.current)?.focus(), 50, ); return () => globalThis.clearTimeout(timerId); - }, [accessChannelId, open]); + }, [accessChannelId, open, project?.id, projectOptions, projects]); async function handleSubmit(event: React.FormEvent) { event.preventDefault(); - if (!name.trim() || !selectedChannelId) return; + if (!name.trim() || !selectedChannelId || !selectedProject) return; setErrorMessage(null); try { await onAdd({ accessChannelId: selectedChannelId, cloneUrl: cloneUrl.trim() || undefined, name: name.trim(), - project, + project: selectedProject, }); onOpenChange(false); } catch (error) { @@ -81,11 +96,20 @@ export function AddProjectRepositoryDialog({ className="max-w-lg" contentClassName="pt-3" data-testid="add-project-repository-dialog" - description={`Add another repository to ${project.name}.`} + description={ + selectedProject + ? `Add another repository to ${selectedProject.name}.` + : "Choose a project for this repository." + } 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..8d1ae7d8f70 --- /dev/null +++ b/desktop/src/features/projects/ui/CreateProjectFormSettings.tsx @@ -0,0 +1,265 @@ +import { ChevronDown, Plus } from "lucide-react"; +import * as React from "react"; + +import { ChannelPermissionsSettings } from "@/features/channels/ui/ChannelPermissionsSettings"; +import type { CreateProjectFormSettingsState } from "@/features/projects/ui/useCreateProjectFormSettings"; +import { TemplateFormDialog } from "@/features/settings/ui/ChannelTemplatesSettingsCard"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { cn } from "@/shared/lib/cn"; + +const NONE_AGENT_VALUE = "__none__"; +const NONE_TEAM_VALUE = "__no-team__"; +const NO_TEMPLATE_VALUE = "__no-template__"; + +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, + handleTemplateChange, + handleTemplateCreated, + personas, + projectVisibility, + runtimesAvailable, + setAgentPersonaId, + setChannelVisibility, + setProjectVisibility, + setTeamId, + teamId, + teams, + templateId, + templates, + channelVisibility, +}: CreateProjectFormSettingsState & { disabled: boolean }) { + const [isCreateTemplateOpen, setIsCreateTemplateOpen] = React.useState(false); + const selectedPersona = personas.find( + (persona) => persona.id === agentPersonaId, + ); + const selectedTeam = teams.find((team) => team.id === teamId); + const selectedTemplate = templates.find( + (template) => template.id === templateId, + ); + const listingLabel = projectVisibility === "unlisted" ? "Unlisted" : "Listed"; + const agentLabel = selectedPersona?.displayName ?? "None"; + const agentDisabled = disabled || (!runtimesAvailable && personas.length > 0); + const teamDisabled = disabled || (!runtimesAvailable && teams.length > 0); + + return ( + <> + + +
+ + Template + + Project home by default + + + + + + + + + handleTemplateChange(value === NO_TEMPLATE_VALUE ? "" : value) + } + value={templateId || NO_TEMPLATE_VALUE} + > + + None + + {templates.map((template) => ( + + {template.name} + + ))} + + + setIsCreateTemplateOpen(true)}> + + Create new channel template… + + + + +
+ +
+ + Team + + Optional + + + + + + + + + setTeamId(value === NONE_TEAM_VALUE ? "" : value) + } + value={teamId || NONE_TEAM_VALUE} + > + + None + + {teams.map((team) => ( + + {team.name} + + ))} + + + +
+ +
+ + 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/DiscussionChannels.tsx b/desktop/src/features/projects/ui/DiscussionChannels.tsx index 665f77b6f2a..bd5d370524e 100644 --- a/desktop/src/features/projects/ui/DiscussionChannels.tsx +++ b/desktop/src/features/projects/ui/DiscussionChannels.tsx @@ -27,6 +27,7 @@ import { ProjectEntityFacepile, ProjectEntityListRow, } from "./ProjectEntityListRow"; +import { ProjectPanelState } from "./ProjectPanelState"; import { useProjectConversationPanel } from "./ProjectConversationPanelContext"; // Relay search caps a page at 500. Use the full page and surface a lower-bound @@ -400,13 +401,12 @@ export function DiscussionChannelsPanel({ } if (channels.length === 0) { return ( -

- No channels reference this repository yet. Paste its link (or a review - or task link) in a channel and it will show up here. -

+ ); } diff --git a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx index 14013a6157a..50449df4f9f 100644 --- a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx @@ -9,6 +9,7 @@ import { normalizeRelayUrl } from "@/features/communities/communityStorage"; import { useCommunities } from "@/features/communities/useCommunities"; import type { ProjectDetailAgentContext } from "@/features/projects/lib/projectDetailAgentContext"; import { projectDetailAgentContextBlock } from "@/features/projects/lib/projectDetailAgentContext"; +import { pickDefaultProjectsAgent } from "@/features/projects/lib/projectAgentSelection"; import { restoreProjectsAgentConversation, submitProjectAgentMessage, @@ -24,6 +25,7 @@ import { import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; +import { addChannelMembers } from "@/shared/api/tauri"; import { sendChannelMessage } from "@/shared/api/tauriMessages"; import type { Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -45,25 +47,29 @@ type ProjectAgentConversation = { }; export function ProjectAgentChatPanel({ - canResetWidth, + canResetWidth = false, constrainToAvailableSpace = true, context, detached = false, + homeChannel = null, + layout = "pane", onClose, onResetWidth, onResizeStart, sharedHeaderBackdrop, - widthPx, + widthPx = 0, }: { - canResetWidth: boolean; + canResetWidth?: boolean; constrainToAvailableSpace?: boolean; context: ProjectDetailAgentContext; detached?: boolean; + homeChannel?: Channel | null; + layout?: "pane" | "canvas"; onClose?: () => void; - onResetWidth: () => void; - onResizeStart: (event: React.PointerEvent) => void; + onResetWidth?: () => void; + onResizeStart?: (event: React.PointerEvent) => void; sharedHeaderBackdrop?: boolean; - widthPx: number; + widthPx?: number; }) { const { activeCommunity } = useCommunities(); const identityQuery = useIdentityQuery(); @@ -97,7 +103,8 @@ export function ProjectAgentChatPanel({ const profileQuery = useProfileQuery(); const openDmMutation = useOpenDmMutation(); const startAgentMutation = useStartManagedAgentMutation(); - const selectedAgent = conversation?.agent ?? candidates[0] ?? null; + const selectedAgent = + conversation?.agent ?? pickDefaultProjectsAgent(candidates); const candidateProfilesQuery = useUsersBatchQuery( selectedAgent ? [selectedAgent.pubkey] : [], ); @@ -119,11 +126,13 @@ export function ProjectAgentChatPanel({ candidates, channels: channelsQuery.data ?? [], currentPubkey: identityQuery.data?.pubkey ?? null, + homeChannelId: homeChannel?.id ?? null, stored: storedConversation, }), [ candidates, channelsQuery.data, + homeChannel?.id, identityQuery.data?.pubkey, storedConversation, ], @@ -148,10 +157,24 @@ export function ProjectAgentChatPanel({ // `submitProjectAgentMessage` binds every relay side effect to the // scope captured here (fail closed), and threads follow-ups onto the // opener so a same-second follow-up cannot be hidden by id ordering. + if (homeChannel) { + const alreadyMember = homeChannel.memberPubkeys.some( + (pubkey) => + normalizePubkey(pubkey) === normalizePubkey(selectedAgent.pubkey), + ); + if (!alreadyMember) { + await addChannelMembers({ + channelId: homeChannel.id, + pubkeys: [selectedAgent.pubkey], + role: "bot", + }); + } + } const { channel, sent } = await submitProjectAgentMessage({ agent: selectedAgent, conversation, content: `${trimmed}${contextPayload}`, + homeChannel, mentionPubkeys: [ ...new Set([...mentionPubkeys, selectedAgent.pubkey]), ], @@ -209,6 +232,7 @@ export function ProjectAgentChatPanel({ [ contextPayload, conversation, + homeChannel, identityQuery.data?.pubkey, isSending, openDmMutation, @@ -225,98 +249,122 @@ export function ProjectAgentChatPanel({ setConversation(null); }, [storageScope]); - return ( - -
+ {layout === "pane" ? ( -
-
- {conversation ? ( - - ) : ( -
-

- Ask about this page -

-

- Start a conversation with the project agent. -

-
- )} -
- {context.selection?.length ? ( - - ) : null} - - - {conversation ? ( - - ) : null} - - } - /> + ) : null} +
+
+ {conversation ? ( + + ) : ( +
+

+ {homeChannel + ? "Explain what this project should be" + : "Ask about this page"} +

+

+ {homeChannel + ? "The project agent will build it out from this channel." + : "Start a conversation with the project agent."} +

+
+ )}
+ {context.selection?.length ? ( + + ) : null} + + + {conversation ? ( + + ) : null} + + } + /> +
+
+ ); + + if (layout === "canvas") { + return ( +
+ {conversationBody}
+ ); + } + + return ( + {})} + onResizeStart={onResizeStart ?? (() => {})} + testId="project-agent-chat-panel" + widthPx={widthPx} + > + {conversationBody} ); } diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index e9672d643bd..8532183d6e9 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -1,6 +1,7 @@ import { CircleAlert, CircleDot, + EyeOff, FolderGit2, Folders, GitCommit, @@ -235,6 +236,24 @@ function StatusPill({ status }: { status: string }) { return null; } + if (status === "unlisted") { + return ( + + + + + + + Hidden from the shared project list + + ); + } + return ( {status} diff --git a/desktop/src/features/projects/ui/ProjectChannelHome.tsx b/desktop/src/features/projects/ui/ProjectChannelHome.tsx new file mode 100644 index 00000000000..d7b0d766fad --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectChannelHome.tsx @@ -0,0 +1,436 @@ +import { useSearch } from "@tanstack/react-router"; +import { Maximize2, Plus } from "lucide-react"; +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { ChannelScreenLoadingFallback } from "@/features/channels/ui/ChannelScreenLoadingFallback"; +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"; +import type { EntityLinkTab } from "@/shared/lib/entityLink"; +import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; +import { SIDEBAR_WIDTH_MIN } from "@/shared/layout/sidebarLayout"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon"; +import { useOptionalSidebar } from "@/shared/ui/sidebar"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; +import { ProjectContextRail } from "./ProjectContextRail"; +import { ProjectDetailChrome } from "./ProjectDetailChrome"; +import { ProjectHomeColumn } from "./ProjectHomeColumn"; +import { ProjectHomeContextPanel } from "./ProjectHomeContextPanel"; +import { + ProjectHomeWorkspaceSheet, + type ProjectHomeWorkspaceCreateAction, + type ProjectHomeWorkspaceDetail, +} from "./ProjectHomeWorkspaceSheet"; +import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; + +const EMPTY_TARGET_MESSAGE_EVENTS: RelayEvent[] = []; +const PROJECT_HOME_SUMMARY_WIDTH_KEY = + "buzz.desktop.project-home-summary-width"; + +const ChannelScreenView = React.lazy(async () => { + const module = await import("@/features/channels/ui/ChannelScreen"); + return { default: module.ChannelScreen }; +}); + +function ignoreForumPost() {} +function ignoreForumPostSelect() {} + +function ProjectHomeHeaderToggle({ + children, + label, + onClick, + open, + testId, +}: { + children: React.ReactNode; + label: string; + onClick: () => void; + open: boolean; + testId: string; +}) { + return ( + + + + + {label} + + ); +} + +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(); + const identityQuery = useIdentityQuery(); + const profileQuery = useProfileQuery(); + const channelsQuery = useChannelsQuery(); + const search = useSearch({ strict: false }) as { + autoSend?: string; + messageId?: string; + }; + const [summaryOpen, setSummaryOpen] = React.useState(true); + const [addRepositoryOpen, setAddRepositoryOpen] = React.useState(false); + const [workspaceSheetTab, setWorkspaceSheetTab] = + React.useState(null); + const [workspaceRepositoryId, setWorkspaceRepositoryId] = React.useState< + string | null + >(null); + const [workspaceCreateAction, setWorkspaceCreateAction] = + React.useState(null); + const [workspaceDetail, setWorkspaceDetail] = + React.useState(null); + const summaryWidth = useThreadPanelWidth(undefined, { + minWidthPx: SIDEBAR_WIDTH_MIN, + sessionKey: PROJECT_HOME_SUMMARY_WIDTH_KEY, + }); + const homeChannel = + channelsQuery.data?.find( + (channel) => channel.id === project.projectChannelId, + ) ?? null; + const waitingForChannel = channelsQuery.isPending && !homeChannel; + const workspaceRepository = + project.repositories.find( + (repository) => repository.id === workspaceRepositoryId, + ) ?? + project.repositories[0] ?? + null; + const workspaceSheetOpen = + workspaceSheetTab != null && workspaceRepository != null; + const previousWorkspaceSheetOpenRef = React.useRef(workspaceSheetOpen); + const workspaceSheetVisibilityChanged = + previousWorkspaceSheetOpenRef.current !== workspaceSheetOpen; + React.useEffect(() => { + previousWorkspaceSheetOpenRef.current = workspaceSheetOpen; + }, [workspaceSheetOpen]); + const summaryVisible = summaryOpen && !workspaceSheetOpen; + + const openWorkspaceSheet = React.useCallback( + (tab: ProjectHomeWorkspaceSheetTab, repositoryId?: string) => { + 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( + (repositoryId: string, tab?: EntityLinkTab) => { + if (!isProjectHomeWorkspaceSheetTab(tab)) { + void goProject(project.id, { repositoryId, tab }); + return; + } + openWorkspaceSheet(tab, repositoryId); + }, + [goProject, openWorkspaceSheet, project.id], + ); + const handleOpenRepository = React.useCallback( + (repositoryId: string) => { + void goProject(project.id, { repositoryId }); + }, + [goProject, project.id], + ); + const handleRepositoryChange = React.useCallback(() => { + void goProject(project.id); + }, [goProject, project.id]); + const handleAddFiles = React.useCallback(() => { + setAddRepositoryOpen(true); + }, []); + const handleFilesAdded = React.useCallback((repositoryId: string) => { + setWorkspaceCreateAction(null); + setWorkspaceDetail(null); + setWorkspaceRepositoryId(repositoryId); + setWorkspaceSheetTab("files"); + }, []); + const handleWorkspaceRepositoryChange = React.useCallback( + (repositoryId: string) => { + setWorkspaceCreateAction(null); + setWorkspaceDetail(null); + setWorkspaceRepositoryId(repositoryId); + }, + [], + ); + useHealProjectHomeRepositories(project, identityQuery.data?.pubkey); + const handleOpenCommit = React.useCallback( + (commitHash: string) => { + if (!workspaceRepository) return; + void goProject(project.id, { + commitHash, + repositoryId: workspaceRepository.id, + tab: "commits", + }); + }, + [goProject, project.id, workspaceRepository], + ); + const handleExpandWorkspace = React.useCallback(() => { + if (!workspaceRepository || !workspaceSheetTab) return; + void goProject(project.id, { + repositoryId: workspaceRepository.id, + ...workspaceDetail?.navigation, + 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 ? ( + + ) : null; + + return ( + +
+
+ { + if (workspaceSheetOpen) { + closeWorkspaceSheet(); + return; + } + setSummaryOpen((open) => !open); + }} + open={summaryVisible} + testId="project-home-drawer-toggle" + > + + + } + activeTabCrumb={null} + activeWorkItemCrumb={null} + onGoProjectHome={() => undefined} + onGoProjects={() => { + void goProjects(); + }} + project={project} + /> + {waitingForChannel ? ( + + ) : homeChannel ? ( + + } + > + + {workspaceCreateAction ? ( + + + + + + {workspaceCreateAction.label} + + + ) : null} + + + + + {expandLabel} + + + ), + backLabel: workspaceDetail?.backLabel, + onBack: workspaceDetail?.onBack, + }} + idleAuxiliaryOverridesThread={workspaceSheetOpen} + 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} + +
+
+ ); +} 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/ProjectChannelManagement.tsx b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx new file mode 100644 index 00000000000..9c774c9a3b2 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx @@ -0,0 +1,82 @@ +import { Plus } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { useIsManagedAgent } from "@/features/agent-memory/hooks"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { ownsAuthorAgent } from "@/features/profile/lib/identity"; +import type { Project } from "@/features/projects/hooks"; +import { useAddProjectChannelMutation } from "@/features/projects/useAddProjectChannel"; +import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog"; +import { Button } from "@/shared/ui/button"; + +export function ProjectChannelManagement({ + identityPubkey, + project, +}: { + identityPubkey?: string; + project: Project; +}) { + const { goChannel } = useAppNavigation(); + const [createOpen, setCreateOpen] = React.useState(false); + const createMutation = useAddProjectChannelMutation(); + const ownerProfileQuery = useUsersBatchQuery([project.owner], { + enabled: Boolean(identityPubkey), + }); + const projectOwnerProfile = + ownerProfileQuery.data?.profiles[project.owner.toLowerCase()]; + const projectOwnerIsManaged = useIsManagedAgent(project.owner) === true; + const viewerIsProjectOwner = + identityPubkey?.toLowerCase() === project.owner.toLowerCase(); + const viewerOwnsProjectAgent = ownsAuthorAgent( + projectOwnerProfile, + identityPubkey, + ); + const canEdit = + !project.legacy && + (viewerIsProjectOwner || projectOwnerIsManaged || viewerOwnsProjectAgent); + const ownerControlAgentPubkey = + viewerOwnsProjectAgent && !projectOwnerIsManaged && !viewerIsProjectOwner + ? project.owner + : undefined; + + return ( + <> + {canEdit ? ( + { + const result = await createMutation.mutateAsync({ + ...input, + ownerControlAgentPubkey, + project, + }); + toast.success(`Channel "#${result.channel.name}" created.`); + await goChannel(result.channel.id); + }} + onOpenChange={setCreateOpen} + testId="create-project-channel-dialog" + title="Create a project channel" + /> + ) : null} + + + ); +} diff --git a/desktop/src/features/projects/ui/ProjectChannelRequestDialog.tsx b/desktop/src/features/projects/ui/ProjectChannelRequestDialog.tsx new file mode 100644 index 00000000000..632d65c4ae1 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectChannelRequestDialog.tsx @@ -0,0 +1,91 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { useProjectChannelRequests } from "@/features/projects/useProjectChannelRequests"; + +/** Global owner-review surface for project-channel requests from managed agents. */ +export function ProjectChannelRequestDialog() { + const management = useProjectChannelRequests(); + const request = management.request?.request; + + return ( + { + if (!open) management.dismiss(); + }} + open={request != null} + > + + + Create project channel? + + Your agent requested a new channel in{" "} + {management.project?.name ?? "this project"}. Review the details + before creating it. + + + {request ? ( +
+
+
Name
+
+ #{request.name} +
+
+ {request.description ? ( +
+
+ Description +
+
+ {request.description} +
+
+ ) : null} +
+
+ Visibility +
+
{request.visibility}
+
+ {request.templateName ? ( +
+
+ Template +
+
+ {request.templateName} +
+
+ ) : null} +
+ ) : null} + {management.error ? ( +

{management.error}

+ ) : null} + + + Cancel + + { + event.preventDefault(); + void management.approve(); + }} + > + {management.isPending ? "Creating…" : "Create channel"} + + +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectContextRail.tsx b/desktop/src/features/projects/ui/ProjectContextRail.tsx index 86204111030..7f0c0a8b70c 100644 --- a/desktop/src/features/projects/ui/ProjectContextRail.tsx +++ b/desktop/src/features/projects/ui/ProjectContextRail.tsx @@ -5,6 +5,7 @@ import { cn } from "@/shared/lib/cn"; const CONTEXT_RAIL_GUTTER_PX = 8; export function ProjectContextRail({ + animateWidth = true, children, open, panelWidthPx, @@ -12,6 +13,7 @@ export function ProjectContextRail({ rounded = true, testId = "project-context-rail", }: { + animateWidth?: boolean; children: React.ReactNode; open: boolean; panelWidthPx: number; @@ -24,7 +26,7 @@ export function ProjectContextRail({ aria-hidden={!open} className={cn( "relative z-30 h-full shrink-0 overflow-hidden motion-reduce:transition-none", - resizing + resizing || !animateWidth ? "transition-none" : "transition-[width] duration-200 ease-linear", )} diff --git a/desktop/src/features/projects/ui/ProjectDetailChrome.tsx b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx index f9740b8aa32..0293d1b2b93 100644 --- a/desktop/src/features/projects/ui/ProjectDetailChrome.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx @@ -26,8 +26,63 @@ export function ProjectDetailChrome({ onGoProjectHome: () => void; onGoProjects: () => void; project: Project; - repository: Repository; + repository?: Repository | null; }) { + const repositoryCrumb = repository ? ( + activeWorkItemCrumb ? ( + <> + + + + + + {activeWorkItemCrumb.title} + + + ) : activeTabCrumb ? ( + <> + + + + {activeTabCrumb} + + + ) : ( + + {repository.name} + + ) + ) : null; return (
- - - {activeWorkItemCrumb ? ( + {repositoryCrumb ? ( <> - - - - {activeWorkItemCrumb.title} - - - ) : activeTabCrumb ? ( - <> - - - - {activeTabCrumb} - + {repositoryCrumb} ) : ( - {repository.name} + {project.name} )} diff --git a/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx b/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx index 36238689857..d960d3f09fc 100644 --- a/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx @@ -24,18 +24,17 @@ import { } from "@/features/profile/lib/identity"; import { CircleDot, + FolderGit2, GitBranch, GitCommitHorizontal, GitPullRequest, } from "lucide-react"; import { CopyCommitHashButton } from "./ProjectCommitCopyButton"; -import { - PROJECT_DETAIL_PANEL_CLASS, - PROJECT_DETAIL_PANEL_MESSAGE_CLASS, -} from "./projectPanelStyles"; +import { PROJECT_DETAIL_PANEL_CLASS } from "./projectPanelStyles"; import { ProfileIdentityButton } from "./ProjectProfileIdentity"; import { ProjectWorkItemRow } from "./ProjectWorkItemRow"; +import { ProjectPanelState } from "./ProjectPanelState"; function pluralize(count: number, singular: string, plural = `${singular}s`) { return `${count} ${count === 1 ? singular : plural}`; @@ -152,12 +151,10 @@ export function ContributorsPanel({ if (rows.length === 0) { return ( -

- No git contributors are available yet. -

+ ); } @@ -240,6 +237,7 @@ export function ContributorsPanel({ export function ActivityPanel({ branch, + commitItems, snapshot, isLoading, error, @@ -252,10 +250,18 @@ export function ActivityPanel({ viewerGitIdentity, }: { branch?: string; + commitItems?: Array<{ + branch?: string; + commit: ProjectRepoCommit; + project: Repository; + projectId: string; + pullRequests?: ProjectPullRequest[]; + repoContributors?: ProjectRepoContributor[]; + }>; snapshot: ProjectRepoSnapshot | null | undefined; isLoading: boolean; error: unknown; - onSelectCommit?: (commit: ProjectRepoCommit) => void; + onSelectCommit?: (commit: ProjectRepoCommit, project: Repository) => void; profiles?: UserProfileLookup; project: Repository; projectId: string; @@ -263,21 +269,33 @@ export function ActivityPanel({ repoContributors: ProjectRepoContributor[]; viewerGitIdentity?: ViewerGitIdentity | null; }) { - const commits = snapshot?.commits ?? []; - const commitAuthorPubkeys = commitAuthorPubkeysFromPullRequests( - pullRequests ?? [], - ); - const rangeItems = commits.map((commit) => { - const matchedProfile = profileForCommit( + const items = + commitItems ?? + (snapshot?.commits ?? []).map((commit) => ({ + branch, commit, + project, + projectId, + pullRequests, + repoContributors, + })); + const showRepositoryName = + commitItems !== undefined && + new Set(items.map((item) => item.project.repoAddress)).size > 1; + const rangeItems = items.map((item) => { + const commitAuthorPubkeys = commitAuthorPubkeysFromPullRequests( + item.pullRequests ?? [], + ); + const matchedProfile = profileForCommit( + item.commit, profiles, commitAuthorPubkeys, viewerGitIdentity, ); return commitSelectionItem( - commit, - project, - projectId, + item.commit, + item.project, + item.projectId, matchedProfile?.pubkey, ); }); @@ -286,25 +304,31 @@ export function ActivityPanel({ return ; } - if (commits.length === 0) { + if (items.length === 0) { return ( -

- {error - ? "Could not load repository activity from git." - : "No commits are available yet."} -

+ ); } return (
- {commits.map((commit) => { + {items.map((item) => { + const commitAuthorPubkeys = commitAuthorPubkeysFromPullRequests( + item.pullRequests ?? [], + ); const matchedProfile = profileForCommit( - commit, + item.commit, profiles, commitAuthorPubkeys, viewerGitIdentity, @@ -314,36 +338,51 @@ export function ActivityPanel({ pubkey: matchedProfile.pubkey, profiles, }) - : commit.authorName || commit.authorEmail || "Unknown author"; - const matchingContributor = repoContributors.find( + : item.commit.authorName || + item.commit.authorEmail || + "Unknown author"; + const matchingContributor = (item.repoContributors ?? []).find( (contributor) => contributor.name.trim().toLowerCase() === - commit.authorName.trim().toLowerCase() || + item.commit.authorName.trim().toLowerCase() || contributor.email.trim().toLowerCase() === - commit.authorEmail.trim().toLowerCase(), + item.commit.authorEmail.trim().toLowerCase(), ); return ( - - {branch} + {showRepositoryName ? ( + <> + + {item.project.name} + + ) : ( + <> + + {item.branch} + + )} ) : undefined } - onOpen={onSelectCommit ? () => onSelectCommit(commit) : undefined} + onOpen={ + onSelectCommit + ? () => onSelectCommit(item.commit, item.project) + : undefined + } selection={{ item: commitSelectionItem( - commit, - project, - projectId, + item.commit, + item.project, + item.projectId, matchedProfile?.pubkey, ), rangeItems, @@ -352,7 +391,7 @@ export function ActivityPanel({ } testId="project-activity-feed-item" - title={commit.subject} + title={item.commit.subject} trailing={ <> - {relativeTime(commit.timestamp)} + {relativeTime(item.commit.timestamp)} } diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index b7f39a8cf33..827bbce1002 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -45,6 +45,8 @@ import { projectRepoUnavailableReason, refineRepoUnavailableReason, } from "@/features/projects/lib/projectRepoAvailability"; +import { wantsProjectRepositorySurface } from "@/features/projects/lib/projectDetailSearch"; +import { hasAuthoritativeHomeBinding } from "@/features/projects/lib/projectHomeChannel"; import { selectProjectRepository } from "@/features/projects/projectModels"; import { ProjectSelectionProvider } from "@/features/projects/lib/useProjectSelection"; import { useMemberChannelIds } from "@/features/projects/useRepositoryAccess"; @@ -61,6 +63,7 @@ import { ProjectDetailChrome } from "./ProjectDetailChrome"; import { ProjectConversationPanelController } from "./ProjectConversationPanelContext"; import { ProjectDetailRightPanel } from "./ProjectDetailRightPanel"; import { ProjectDetailUnavailableState } from "./ProjectDetailUnavailableState"; +import { ProjectChannelHome } from "./ProjectChannelHome"; import { ProjectRightPanelControls } from "./ProjectRightPanelControls"; import { buildProjectDetailCrumbs } from "./useProjectDetailCrumbs"; import { useProjectDetailPeople } from "./useProjectDetailPeople"; @@ -84,6 +87,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { const { commitHash, entityNavigationId, + filePath, projectId, pullRequestId, issueId, @@ -282,15 +286,17 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { const handleBranchChange = React.useCallback( (branch: string | null) => { selectBranch(branch); + if (!branch) return; + const localBranches = repoSyncStatusQuery.data?.localBranches; if ( - branch && repoSource === "local" && - branch !== repoSyncStatusQuery.data?.localBranch + localBranches && + !localBranches.includes(branch) ) { setRepoSource("remote"); } }, - [repoSource, repoSyncStatusQuery.data?.localBranch, selectBranch], + [repoSource, repoSyncStatusQuery.data?.localBranches, selectBranch], ); const handleTagChange = React.useCallback( (tag: string) => { @@ -673,6 +679,25 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { /> ); } + const showChannelHome = + hasAuthoritativeHomeBinding(project) && + !wantsProjectRepositorySurface({ + commitHash, + filePath, + issueId, + projectId, + pullRequestId, + repositoryId, + tab, + }); + if (showChannelHome) { + return ( + + ); + } if (!repository) { return ( { + if (project.projectChannelId) { + void goProject(project.id); + return; + } + handleGoToProjectHome(); + }; const agentPageContext = buildProjectDetailAgentContext({ activeTab, branch: activeBranch, @@ -834,7 +866,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { actions={repositoryPanelAction} activeTabCrumb={activeTabCrumb} activeWorkItemCrumb={activeWorkItemCrumb} - onGoProjectHome={handleGoToProjectHome} + onGoProjectHome={goChannelHome} onGoProjects={() => { void goProjects(); }} @@ -856,6 +888,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ? workspaceTabForShareTab(requestedTab) : undefined } + initialFilePath={filePath} initialTabRequestKey={entityNavigationId} fileContentSource={fileContentSource} commitDiff={commitDiffQuery.data} @@ -900,6 +933,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { handleSelectedPullRequestIdChange } onSelectedTabChange={setActiveTab} + onBack={goChannelHome} profiles={profiles} project={repository} projectId={project.id} diff --git a/desktop/src/features/projects/ui/ProjectEntityListRow.tsx b/desktop/src/features/projects/ui/ProjectEntityListRow.tsx index d0769e31e50..8acfd90b129 100644 --- a/desktop/src/features/projects/ui/ProjectEntityListRow.tsx +++ b/desktop/src/features/projects/ui/ProjectEntityListRow.tsx @@ -295,7 +295,7 @@ export function ProjectEntityListRow({ {affiliation ? ( {peopleContent} - {count != null ? ( + {count != null || countTestId ? ( - - - {count} - {countSuffix} - + {count != null ? ( + <> + + + {count} + {countSuffix} + + + ) : null} ) : null} {beforeDate ? ( diff --git a/desktop/src/features/projects/ui/ProjectHomeCodebasePanel.tsx b/desktop/src/features/projects/ui/ProjectHomeCodebasePanel.tsx new file mode 100644 index 00000000000..038902f97fc --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeCodebasePanel.tsx @@ -0,0 +1,133 @@ +import { ChevronDown, FolderGit2 } from "lucide-react"; + +import { + useProjectRepoSnapshotQuery, + useRepoStateQuery, + type Project, + type Repository, +} from "@/features/projects/hooks"; +import { resolveProjectDefaultBranch } from "@/features/projects/lib/projectBranches"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; +import { RepositoryFilesPanel } from "./ProjectRepositoryPanel"; +import { useRepositoryFileContentSource } from "./useRepositoryFileContentSource"; + +export function ProjectHomeCodebasePanel({ + identityPubkey, + onFilesContextChange, + onOpenCommit, + onRepositoryAdded, + onSelectRepository, + project, + projects, + repository, +}: { + identityPubkey?: string; + onFilesContextChange?: (context: { + kind: "file" | "folder"; + onBack?: () => void; + path: string; + }) => void; + onOpenCommit?: (commitHash: string) => void; + onRepositoryAdded: (repositoryId: string) => void; + onSelectRepository: (repositoryId: string) => void; + project: Project; + projects: Project[]; + repository: Repository | null; +}) { + const repoStateQuery = useRepoStateQuery(repository); + const defaultBranch = repository + ? resolveProjectDefaultBranch(repository.defaultBranch, repoStateQuery.data) + : null; + const snapshotQuery = useProjectRepoSnapshotQuery( + repository, + defaultBranch, + null, + null, + Boolean(repository), + ); + const fileContentSource = useRepositoryFileContentSource({ + activeBranch: defaultBranch, + activeTag: null, + pullRequest: null, + repository, + selectedTag: null, + source: "remote", + }); + const snapshot = snapshotQuery.data ?? null; + const files = snapshot?.files ?? []; + + if (!repository) { + return ( +
+

+ Attach a repository to browse the file tree beside this channel. +

+ +
+ ); + } + + return ( +
+ {project.repositories.length > 1 ? ( +
+ + + + + + {project.repositories.map((candidate) => ( + onSelectRepository(candidate.id)} + > + {candidate.name} + + ))} + + +
+ ) : null} +
+ +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectHomeColumn.tsx b/desktop/src/features/projects/ui/ProjectHomeColumn.tsx new file mode 100644 index 00000000000..628f0988e55 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeColumn.tsx @@ -0,0 +1,46 @@ +import type * as React from "react"; + +import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; +import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel"; +import { cn } from "@/shared/lib/cn"; + +export function ProjectHomeColumn({ + bodyClassName, + canResetWidth, + children, + onResetWidth, + onResizeStart, + testId, + widthPx, +}: { + bodyClassName?: string; + canResetWidth: boolean; + children: React.ReactNode; + onResetWidth: () => void; + onResizeStart: (event: React.PointerEvent) => void; + testId: string; + widthPx: number; +}) { + return ( + +
+ + {children} + +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.test.mjs b/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.test.mjs new file mode 100644 index 00000000000..a55a935cf46 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.test.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +after(() => dom.window.close()); + +function repository(id, name) { + return { + id, + name, + repoAddress: `30617:owner:${id}`, + defaultBranch: "main", + }; +} + +test("multi-repository commits remain visibly degraded when one repository fails", async () => { + const { cleanup, render, screen } = await import("@testing-library/react"); + const { ProjectHomeCommitsPanel } = await import( + "./ProjectHomeCommitsPanel.tsx" + ); + const loadedRepository = repository("loaded", "Loaded"); + const failedRepository = repository("failed", "Failed"); + + const React = await import("react"); + try { + render( + React.createElement(ProjectHomeCommitsPanel, { + onSelectCommit: () => {}, + projectId: "project-1", + pullRequests: [], + results: [ + { + error: null, + isLoading: false, + repository: loadedRepository, + snapshot: { + contributors: [], + commits: [ + { + hash: "a".repeat(40), + shortHash: "aaaaaaa", + authorName: "Alice", + authorEmail: "alice@example.com", + timestamp: 2, + subject: "Loaded commit", + }, + ], + }, + }, + { + error: new Error("unavailable"), + isLoading: false, + repository: failedRepository, + snapshot: null, + }, + ], + }), + ); + + assert.match( + screen.getByTestId("project-home-commits-degraded").textContent, + /Showing commits from 1 of 2 repositories/, + ); + assert.match(document.body.textContent, /Loaded commit/); + } finally { + cleanup(); + } +}); + +test("multi-repository commits are merged in descending timestamp order", async () => { + const { cleanup, render } = await import("@testing-library/react"); + const { ProjectHomeCommitsPanel } = await import( + "./ProjectHomeCommitsPanel.tsx" + ); + const React = await import("react"); + const result = (id, subject, timestamp) => ({ + error: null, + isLoading: false, + repository: repository(id, id), + snapshot: { + contributors: [], + commits: [ + { + hash: id.repeat(40), + shortHash: id.repeat(7), + authorName: id, + authorEmail: `${id}@example.com`, + timestamp, + subject, + }, + ], + }, + }); + + try { + render( + React.createElement(ProjectHomeCommitsPanel, { + onSelectCommit: () => {}, + projectId: "project-1", + pullRequests: [], + results: [ + result("a", "Older commit", 1), + result("b", "Newer commit", 2), + ], + }), + ); + + assert.ok( + document.body.textContent.indexOf("Newer commit") < + document.body.textContent.indexOf("Older commit"), + ); + } finally { + cleanup(); + } +}); diff --git a/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.tsx b/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.tsx new file mode 100644 index 00000000000..9876c0fcbb2 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.tsx @@ -0,0 +1,107 @@ +import type { ProjectPullRequest, Repository } from "@/features/projects/hooks"; +import { AlertTriangle } from "lucide-react"; +import { + projectRepoUnavailablePresentation, + projectRepoUnavailableReason, +} from "@/features/projects/lib/projectRepoAvailability"; +import type { ViewerGitIdentity } from "@/features/projects/lib/projectContributorMatching"; +import type { ProjectRepositorySnapshotResult } from "@/features/projects/useProjectRepositorySnapshots"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { ProjectRepoCommit } from "@/shared/api/types"; +import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState"; +import { ProjectPanelState } from "./ProjectPanelState"; +import { ActivityPanel } from "./ProjectDetailFeedPanels"; + +export function ProjectHomeCommitsPanel({ + onSelectCommit, + profiles, + projectId, + pullRequests, + results, + viewerGitIdentity, +}: { + onSelectCommit: (commit: ProjectRepoCommit, repository: Repository) => void; + profiles?: UserProfileLookup; + projectId: string; + pullRequests: ProjectPullRequest[]; + results: ProjectRepositorySnapshotResult[]; + viewerGitIdentity?: ViewerGitIdentity | null; +}) { + const loaded = results.filter( + (result) => (result.snapshot?.commits.length ?? 0) > 0, + ); + const commitItems = loaded + .flatMap(({ repository, snapshot }) => + (snapshot?.commits ?? []).map((commit) => ({ + branch: repository.defaultBranch, + commit, + project: repository, + projectId, + pullRequests, + repoContributors: snapshot?.contributors ?? [], + })), + ) + .sort((left, right) => right.commit.timestamp - left.commit.timestamp); + const failed = results.filter((result) => result.error); + const firstFailure = failed[0]; + const failure = firstFailure + ? projectRepoUnavailablePresentation( + projectRepoUnavailableReason(firstFailure.error), + ) + : null; + if (results.some((result) => result.isLoading) && loaded.length === 0) { + return ; + } + if (loaded.length === 0) { + return ( + 1 + ? ` ${failed.length - 1} other repositories also failed.` + : "" + }` + : "Commits pushed to this project's repositories will appear here." + } + error={failed.length > 0} + title={failure?.title ?? "No commits yet"} + /> + ); + } + + const firstItem = commitItems[0]; + if (!firstItem) return null; + return ( +
+ {failed.length > 0 ? ( +
+ +

+ Showing commits from {loaded.length} of {results.length}{" "} + repositories. {failed.length}{" "} + {failed.length === 1 ? "repository could" : "repositories could"}{" "} + not be loaded. +

+
+ ) : null} + +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx b/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx new file mode 100644 index 00000000000..63245ab632c --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx @@ -0,0 +1,397 @@ +import { + ChevronDown, + CircleDot, + FileCode2, + FolderGit2, + GitCommitHorizontal, + GitPullRequest, + Hash, + Users, +} from "lucide-react"; +import * as React from "react"; + +import { presentContextCount } from "@/features/projects/lib/projectHomeSummary"; +import type { ProjectHomeWorkspaceSheetTab } from "@/features/projects/lib/projectHomeWorkspaceSheet"; +import { resolveProjectDefaultBranch } from "@/features/projects/lib/projectBranches"; +import { listProjectBoundChannels } from "@/features/projects/lib/projectRelatedChannels"; +import { + useProjectActivitySummariesQuery, + useProjectRepoSnapshotQuery, + useRepoStateQuery, + type Project, +} from "@/features/projects/hooks"; +import { ProjectChannelIcon } from "@/features/projects/ui/ProjectChannelIcon"; +import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import type { EntityLinkTab } from "@/shared/lib/entityLink"; +import { Button } from "@/shared/ui/button"; +import { ProjectChannelManagement } from "./ProjectChannelManagement"; +import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; +import { SECTION_ACTION_VISIBILITY_CLASS } from "@/features/sidebar/ui/sidebarSectionStyles"; + +const PROJECT_HOME_SIDEBAR_ROW_CLASS = + "h-8 w-full justify-start gap-2 rounded-md px-2 py-1.5 text-left text-sm font-normal text-sidebar-foreground/80 transition-[background-color,color] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50"; + +function ContextSection({ + children, + collapsible = false, + headerAction, + testId, + title, +}: { + children: React.ReactNode; + collapsible?: boolean; + headerAction?: React.ReactNode; + testId?: string; + title?: string; +}) { + const [expanded, setExpanded] = React.useState(true); + return ( +
+ {title || headerAction ? ( +
+ {title && collapsible ? ( + + ) : title ? ( +

+ {title} +

+ ) : ( + + )} + {headerAction ? ( + + {headerAction} + + ) : null} +
+ ) : null} + {!collapsible || expanded ? children : null} +
+ ); +} + +function ContextRowContent({ + children, + count, + icon, +}: { + children: React.ReactNode; + count?: number; + icon: React.ReactNode; +}) { + return ( + <> + + {icon} + + {children} + + {count ?? ""} + + + ); +} + +function ContextNavButton({ + children, + count, + disabled, + icon, + onClick, + pressed, + testId, + title, +}: { + children: React.ReactNode; + count?: number; + disabled?: boolean; + icon: React.ReactNode; + onClick?: () => void; + pressed?: boolean; + testId?: string; + title?: string; +}) { + return ( + + ); +} + +function ChannelContextRow({ + channel, + onClick, + projectHome, + testId, +}: { + channel: Channel; + onClick?: () => void; + projectHome?: boolean; + testId: string; +}) { + const Icon = projectHome ? ProjectChannelIcon : Hash; + if (onClick) { + return ( + } onClick={onClick} testId={testId}> + {channel.name} + + ); + } + return ( +
+ }>{channel.name} +
+ ); +} + +export function ProjectHomeContextPanel({ + activeWorkspaceTab, + channel, + channels = [], + identityPubkey, + onAddRepository, + onOpenChannel, + onOpenRepository, + onOpenWorkspace, + onRepositoryChange, + project, + projects, +}: { + activeWorkspaceTab?: ProjectHomeWorkspaceSheetTab | null; + channel: Channel | null; + channels?: Channel[]; + identityPubkey?: string; + onAddRepository?: () => void; + onOpenChannel?: (channelId: string) => void; + onOpenRepository: (repositoryId: string) => void; + onOpenWorkspace: (repositoryId: string, tab?: EntityLinkTab) => void; + onRepositoryChange: (repositoryId: string) => void; + project: Project; + projects: Project[]; +}) { + const firstRepository = project.repositories[0] ?? null; + const addRepositoryTitle = firstRepository + ? undefined + : "Add a repository to this project"; + const openWorkspace = (tab: EntityLinkTab) => { + if (firstRepository) { + onOpenWorkspace(firstRepository.id, tab); + return; + } + onAddRepository?.(); + }; + const peopleCount = new Set([ + project.owner, + ...project.repositories.flatMap((repository) => repository.contributors), + ]).size; + const activityQuery = useProjectActivitySummariesQuery([project]); + const activity = activityQuery.data?.[project.id]; + const repoStateQuery = useRepoStateQuery(firstRepository); + const defaultBranch = firstRepository + ? resolveProjectDefaultBranch( + firstRepository.defaultBranch, + repoStateQuery.data, + ) + : null; + const snapshotQuery = useProjectRepoSnapshotQuery( + firstRepository, + defaultBranch, + null, + null, + Boolean(firstRepository), + ); + const channelsById = new Map( + channels.map((candidate) => [candidate.id, candidate]), + ); + const boundChannels = listProjectBoundChannels(project).flatMap((binding) => { + const boundChannel = channelsById.get(binding.channelId); + if (!boundChannel) return []; + return [{ ...binding, channel: boundChannel }]; + }); + const listedChannels = + boundChannels.length > 0 + ? boundChannels + : channel + ? [ + { + channel, + channelId: channel.id, + repositoryId: null, + role: "home" as const, + }, + ] + : []; + + return ( +
+ + } + onClick={() => openWorkspace("issues")} + pressed={activeWorkspaceTab === "issues"} + testId="project-home-context-tasks" + title={addRepositoryTitle} + > + Tasks + + } + onClick={() => openWorkspace("prs")} + pressed={activeWorkspaceTab === "prs"} + testId="project-home-context-reviews" + title={addRepositoryTitle} + > + Reviews + + } + onClick={() => openWorkspace("commits")} + pressed={activeWorkspaceTab === "commits"} + testId="project-home-context-commits" + title={addRepositoryTitle} + > + Commits + + } + onClick={() => openWorkspace("files")} + pressed={activeWorkspaceTab === "files"} + testId="project-home-context-files" + title={addRepositoryTitle} + > + Files + + } + onClick={() => + firstRepository && + onOpenWorkspace(firstRepository.id, "contributors") + } + pressed={activeWorkspaceTab === "contributors"} + testId="project-home-context-people" + title={addRepositoryTitle} + > + People + + + + } + testId="project-home-context-channel" + title="Channels" + > + {listedChannels.length > 0 ? ( + listedChannels.map((binding) => { + const isHome = binding.role === "home"; + return ( + onOpenChannel(binding.channel.id) + } + projectHome={isHome} + testId={ + isHome + ? "project-home-context-home-channel" + : `project-home-context-channel-${binding.channel.name}` + } + /> + ); + }) + ) : ( +

+ }>Unavailable +

+ )} +
+ + } + testId="project-home-context-codebase" + title="Codebase" + > + {project.repositories.length > 0 ? ( + project.repositories.map((repository) => ( + } + key={repository.id} + onClick={() => onOpenRepository(repository.id)} + testId={`project-home-context-repo-${repository.dtag}`} + > + {repository.name} + + )) + ) : ( +

+ None yet +

+ )} +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.test.mjs b/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.test.mjs new file mode 100644 index 00000000000..07e03a73354 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.test.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const source = readFileSync( + new URL("./ProjectHomeWorkspaceSheet.tsx", import.meta.url), + "utf8", +); + +test("aggregated commit detail uses the repository that owns the selected commit", () => { + const detailPanel = source.match(//)?.[0]; + + assert.ok(detailPanel, "expected the commit detail panel to be rendered"); + assert.match(detailPanel, /project=\{selectedCommitRepository\}/); +}); diff --git a/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx b/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx new file mode 100644 index 00000000000..eca3cb6a614 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx @@ -0,0 +1,423 @@ +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { + useProjectPullRequestsQuery, + useProjectRepoSnapshotQuery, + useProjectsWorkItemsQuery, + useRepoStateQuery, + type Project, +} from "@/features/projects/hooks"; +import { gitContributorPubkeysFromCommits } from "@/features/projects/lib/projectContributorMatching"; +import { resolveProjectDefaultBranch } from "@/features/projects/lib/projectBranches"; +import type { ProjectHomeWorkspaceSheetTab } from "@/features/projects/lib/projectHomeWorkspaceSheet"; +import { useProjectCommitDiffQuery } from "@/features/projects/useProjectCommitDiff"; +import { useProjectRepositorySnapshots } from "@/features/projects/useProjectRepositorySnapshots"; +import { CreateProjectIssueDialog } from "./CreateProjectIssueDialog"; +import { CreatePullRequestDialog } from "./CreatePullRequestDialog"; +import { ProjectCommitDetailPanel } from "./ProjectCommitDetailPanel"; +import { ContributorsPanel } from "./ProjectDetailFeedPanels"; +import { ProjectHomeCodebasePanel } from "./ProjectHomeCodebasePanel"; +import { ProjectHomeCommitsPanel } from "./ProjectHomeCommitsPanel"; +import { ProjectIssuesPanel } from "./ProjectIssuesPanel"; +import { PullRequestsPanel } from "./ProjectPullRequestsPanel"; +import { PROJECT_DETAIL_PANEL_CLASS } from "./projectPanelStyles"; +import { useProjectDetailPeople } from "./useProjectDetailPeople"; + +export type ProjectHomeWorkspaceCreateAction = { + disabled?: boolean; + label: string; + onClick: () => void; + title?: string; +}; + +export type ProjectHomeWorkspaceDetail = { + backLabel: string; + navigation: { + commitHash?: string; + filePath?: string; + issueId?: string; + pullRequestId?: string; + repositoryId?: string; + }; + onBack: () => void; +}; + +export function ProjectHomeWorkspaceSheet({ + identityPubkey, + onCreateActionChange, + onDetailChange, + onOpenCommit, + onRepositoryAdded, + onSelectRepository, + project, + projects, + repository, + tab, +}: { + identityPubkey?: string; + onCreateActionChange?: ( + action: ProjectHomeWorkspaceCreateAction | null, + ) => void; + onDetailChange?: (detail: ProjectHomeWorkspaceDetail | null) => void; + onOpenCommit: (commitHash: string) => void; + onRepositoryAdded: (repositoryId: string) => void; + onSelectRepository: (repositoryId: string) => void; + project: Project; + projects: Project[]; + repository: Project["repositories"][number]; + tab: ProjectHomeWorkspaceSheetTab; +}) { + const { goProject } = useAppNavigation(); + const { activeCommunity } = useCommunities(); + const [selectedIssueId, setSelectedIssueId] = React.useState( + null, + ); + const [selectedPullRequestId, setSelectedPullRequestId] = React.useState< + string | null + >(null); + const [selectedCommitHash, setSelectedCommitHash] = React.useState< + string | null + >(null); + const [selectedCommitRepositoryId, setSelectedCommitRepositoryId] = + React.useState(null); + const [filesContext, setFilesContext] = React.useState<{ + kind: "file" | "folder"; + onBack?: () => void; + path: string; + } | null>(null); + const [createIssueOpen, setCreateIssueOpen] = React.useState(false); + const [createPullRequestOpen, setCreatePullRequestOpen] = + React.useState(false); + + const projectScope = React.useMemo(() => [project], [project]); + const workItemsQuery = useProjectsWorkItemsQuery(projectScope); + const pullRequestsQuery = useProjectPullRequestsQuery(repository); + const issueItems = React.useMemo( + () => + (workItemsQuery.data?.issues.items ?? []).map( + ({ issue, repository: issueRepository }) => ({ + issue, + project: issueRepository, + }), + ), + [workItemsQuery.data?.issues.items], + ); + const issues = React.useMemo( + () => issueItems.map(({ issue }) => issue), + [issueItems], + ); + const pullRequests = pullRequestsQuery.data ?? []; + const people = useProjectDetailPeople({ + issues, + pullRequests, + repository, + }); + const repoStateQuery = useRepoStateQuery(repository); + const defaultBranch = resolveProjectDefaultBranch( + repository.defaultBranch, + repoStateQuery.data, + ); + const snapshotQuery = useProjectRepoSnapshotQuery( + repository, + defaultBranch, + null, + null, + true, + ); + const snapshot = snapshotQuery.data ?? null; + const repositorySnapshots = useProjectRepositorySnapshots( + project.repositories, + tab === "commits", + ); + const selectedCommitResult = + repositorySnapshots.find( + ({ repository: candidate }) => + candidate.id === selectedCommitRepositoryId, + ) ?? null; + const selectedCommitRepository = + selectedCommitResult?.repository ?? repository; + const commitDiffQuery = useProjectCommitDiffQuery( + selectedCommitRepository, + selectedCommitHash, + "remote", + activeCommunity?.reposDir, + ); + const contributorPubkeysByGitIdentity = React.useMemo( + () => + gitContributorPubkeysFromCommits(snapshot?.commits ?? [], pullRequests), + [pullRequests, snapshot?.commits], + ); + const selectedPullRequest = + pullRequests.find( + (pullRequest) => pullRequest.id === selectedPullRequestId, + ) ?? null; + const selectedIssueItem = + issueItems.find(({ issue }) => issue.id === selectedIssueId) ?? null; + const selectedCommit = + selectedCommitResult?.snapshot?.commits.find( + (commit) => commit.hash === selectedCommitHash, + ) ?? + snapshot?.commits.find((commit) => commit.hash === selectedCommitHash) ?? + null; + const selectedCommitPullRequest = selectedCommitHash + ? selectedCommitRepository.id === repository.id + ? pullRequests.find( + (pullRequest) => + pullRequest.commit === selectedCommitHash || + pullRequest.initialCommit === selectedCommitHash, + ) + : null + : null; + const handleIssueCreated = React.useCallback( + async ( + createdProject: Project, + _createdRepository: Project["repositories"][number], + issueId: string, + ) => { + if (createdProject.id !== project.id) { + await goProject(createdProject.id, { issueId }); + return; + } + await workItemsQuery.refetch(); + setSelectedIssueId(issueId); + }, + [goProject, project.id, workItemsQuery], + ); + const handlePullRequestCreated = React.useCallback( + async ( + createdProject: Project, + createdRepository: Project["repositories"][number], + pullRequestId: string, + ) => { + if (createdProject.id !== project.id) { + await goProject(createdProject.id, { + pullRequestId, + repositoryId: createdRepository.id, + }); + return; + } + if (createdRepository.id !== repository.id) { + onSelectRepository(createdRepository.id); + } + await pullRequestsQuery.refetch(); + setSelectedPullRequestId(pullRequestId); + }, + [ + goProject, + onSelectRepository, + project.id, + pullRequestsQuery, + repository.id, + ], + ); + const detail = React.useMemo(() => { + if (tab === "issues" && selectedIssueId) { + return { + backLabel: "Back to Tasks", + navigation: { + issueId: selectedIssueId, + repositoryId: selectedIssueItem?.project.id, + }, + onBack: () => setSelectedIssueId(null), + }; + } + if (tab === "prs" && selectedPullRequestId) { + return { + backLabel: "Back to Reviews", + navigation: { pullRequestId: selectedPullRequestId }, + onBack: () => setSelectedPullRequestId(null), + }; + } + if (tab === "commits" && selectedCommitHash) { + return { + backLabel: "Back to Commits", + navigation: { + commitHash: selectedCommitHash, + repositoryId: selectedCommitRepository.id, + }, + onBack: () => { + setSelectedCommitHash(null); + setSelectedCommitRepositoryId(null); + }, + }; + } + if (tab === "files" && filesContext?.onBack) { + return { + backLabel: "Back to Files", + navigation: { filePath: filesContext.path }, + onBack: filesContext.onBack, + }; + } + return null; + }, [ + filesContext, + selectedCommitHash, + selectedCommitRepository.id, + selectedIssueId, + selectedIssueItem?.project.id, + selectedPullRequestId, + tab, + ]); + React.useEffect(() => { + onDetailChange?.(detail); + }, [detail, onDetailChange]); + React.useEffect( + () => () => { + onDetailChange?.(null); + }, + [onDetailChange], + ); + React.useEffect(() => { + if (tab === "issues" && !selectedIssueId) { + onCreateActionChange?.({ + disabled: project.repositories.length === 0, + label: "Create task", + onClick: () => setCreateIssueOpen(true), + }); + return; + } + if (tab === "prs" && !selectedPullRequestId) { + onCreateActionChange?.({ + disabled: projects.length === 0, + label: "Create review", + onClick: () => setCreatePullRequestOpen(true), + title: "Create review — choose a repository and branches to compare", + }); + return; + } + onCreateActionChange?.(null); + }, [ + onCreateActionChange, + project.repositories.length, + projects.length, + selectedIssueId, + selectedPullRequestId, + tab, + ]); + React.useEffect( + () => () => { + onCreateActionChange?.(null); + }, + [onCreateActionChange], + ); + + let body: React.ReactNode; + switch (tab) { + case "issues": + body = ( + + ); + break; + case "prs": + body = ( + + ); + break; + case "commits": + body = selectedCommitHash ? ( + + ) : ( + { + setSelectedCommitRepositoryId(commitRepository.id); + setSelectedCommitHash(commit.hash); + }} + profiles={people.profiles} + projectId={project.id} + pullRequests={pullRequests} + results={repositorySnapshots} + viewerGitIdentity={people.viewerGitIdentity} + /> + ); + break; + case "files": + body = ( + + ); + break; + case "contributors": + body = ( + + ); + break; + } + + const listPanel = + (tab === "issues" && !selectedIssueId) || + (tab === "prs" && !selectedPullRequestId); + + return ( +
+ {listPanel ? ( +
+ {body} +
+ ) : ( + body + )} + {createPullRequestOpen ? ( + + ) : null} + +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx index 1f1046a422f..21fc916db04 100644 --- a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx @@ -56,6 +56,7 @@ import { } from "./ProjectStatusProgressIcon"; import { ProjectWorkItemGroup } from "./ProjectWorkItemGroup"; import { ProjectWorkItemRow } from "./ProjectWorkItemRow"; +import { ProjectPanelState } from "./ProjectPanelState"; export function issueStatusClassName(status: ProjectIssue["status"]) { if (status === "Triage" || status === "In Progress") return "text-amber-500"; @@ -115,6 +116,11 @@ const ISSUE_STATUS_ORDER: readonly ProjectIssue["status"][] = [ "Closed", ]; +export type ProjectIssuePanelItem = { + issue: ProjectIssue; + project: Project; +}; + function issueMembers( project: Project, issue: ProjectIssue, @@ -414,50 +420,69 @@ export function ProjectIssueDetail({ } export function ProjectIssuesPanel({ + error, + isLoading, + issueItems, onSelectedIssueIdChange, profiles, project, selectedIssueId, }: { + error?: unknown; + isLoading?: boolean; + issueItems?: ProjectIssuePanelItem[]; onSelectedIssueIdChange: (id: string | null) => void; profiles?: UserProfileLookup; project: Project; selectedIssueId: string | null; }) { - const issuesQuery = useProjectIssuesQuery(project); - const issues = issuesQuery.data ?? []; - const selectedIssue = - issues.find((issue) => issue.id === selectedIssueId) ?? null; + const issuesQuery = useProjectIssuesQuery( + issueItems === undefined ? project : null, + ); + const resolvedItems = + issueItems ?? (issuesQuery.data ?? []).map((issue) => ({ issue, project })); + const selectedItem = + resolvedItems.find(({ issue }) => issue.id === selectedIssueId) ?? null; + const loading = isLoading ?? issuesQuery.isLoading; + const loadError = error ?? issuesQuery.error; - if (issuesQuery.isLoading) { + if (loading) { return ; } - if (issues.length === 0) { + if (resolvedItems.length === 0) { return ( -

- {issuesQuery.error - ? "Could not load tasks for this repository." - : "No tasks yet."} -

+ ); } - if (selectedIssue) { + if (selectedItem) { return ( ); } const groups = ISSUE_STATUS_ORDER.map((status) => ({ - items: issues.filter((issue) => issue.status === status), + items: resolvedItems.filter(({ issue }) => issue.status === status), status, })).filter((group) => group.items.length > 0); - const rangeItems = issues.map((issue) => issueSelectionItem(project, issue)); + const rangeItems = resolvedItems.map(({ issue, project: itemProject }) => + issueSelectionItem(itemProject, issue), + ); return (
@@ -472,17 +497,19 @@ export function ProjectIssuesPanel({ state={visual.progress} /> } - items={items.map((issue) => issueSelectionItem(project, issue))} + items={items.map(({ issue, project: itemProject }) => + issueSelectionItem(itemProject, issue), + )} key={status} label={status} > - {items.map((issue) => ( + {items.map(({ issue, project: itemProject }) => ( onSelectedIssueIdChange(issue.id)} profiles={profiles} - project={project} + project={itemProject} rangeItems={rangeItems} /> ))} diff --git a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx index 36a937ad5a4..22927798504 100644 --- a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx @@ -62,7 +62,7 @@ export function ProjectOverviewPanel({ unavailableReason, }: ProjectOverviewPanelProps) { return ( -
+
{/* ReadmePanel renders its own "no README" fallback while keeping repository recovery actions reachable. */} + +
+

{title}

+ {description ? ( +

+ {description} +

+ ) : null} +
+ {action} +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectReadmePanel.tsx b/desktop/src/features/projects/ui/ProjectReadmePanel.tsx index 9bc89e52131..9f6bb57c51d 100644 --- a/desktop/src/features/projects/ui/ProjectReadmePanel.tsx +++ b/desktop/src/features/projects/ui/ProjectReadmePanel.tsx @@ -4,6 +4,7 @@ import { ExternalLink, Globe, Loader2, + MessageCircle, } from "lucide-react"; import type { ProjectRepoFile } from "@/features/projects/hooks"; @@ -26,6 +27,7 @@ import { } from "./ProjectRepositorySource"; import { GitHubMark } from "./GitHubMark"; import { ProjectRepositoryUnavailableState } from "./ProjectRepositoryUnavailableState"; +import { ProjectPanelState } from "./ProjectPanelState"; export function findReadmeFile(files: ProjectRepoFile[]) { const readmes = files.filter((file) => @@ -260,16 +262,37 @@ export function ReadmePanel({ } if (!file || !fileContent.content) { + const loadError = Boolean(fileContent.error); + const emptyRepository = gitDataState === "empty"; return ( -
+
{header} -
- {fileContent.error - ? "Could not load this README. Try again after refreshing the repository." - : gitDataState === "empty" - ? "No files have been pushed to this repository yet." - : "Add a README to this repository to describe setup, usage, and project context."} -
+ + + Chat with an agent + + ) : undefined + } + description={ + loadError + ? "Refresh the repository or ask an agent to investigate." + : emptyRepository + ? "Ask an agent to create the initial codebase or connect an existing repository." + : "Add a README to describe setup, usage, and project context." + } + error={loadError} + panel={false} + title={ + loadError + ? "Could not load the README" + : emptyRepository + ? "No files have been pushed yet" + : "No README yet" + } + />
); } diff --git a/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx index fd08d490511..c31db004e55 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx @@ -23,20 +23,29 @@ import { AttachProjectRepositoryDialog } from "./AttachProjectRepositoryDialog"; export function ProjectRepositoryManagement({ compact = false, + createOpen: createOpenProp, + hideTriggers = false, identityPubkey, onChange, + onCreateOpenChange, project, projects, repository, }: { compact?: boolean; + createOpen?: boolean; + hideTriggers?: boolean; identityPubkey?: string; onChange: (repositoryId: string) => void; + onCreateOpenChange?: (open: boolean) => void; project: Project; projects: Project[]; - repository: Repository; + repository?: Repository | null; }) { - const [createOpen, setCreateOpen] = React.useState(false); + const [uncontrolledCreateOpen, setUncontrolledCreateOpen] = + React.useState(false); + const createOpen = createOpenProp ?? uncontrolledCreateOpen; + const setCreateOpen = onCreateOpenChange ?? setUncontrolledCreateOpen; const [attachOpen, setAttachOpen] = React.useState(false); const channelsQuery = useChannelsQuery(); const createMutation = useAddProjectRepositoryMutation(); @@ -71,18 +80,19 @@ export function ProjectRepositoryManagement({ [channelsQuery.data], ); const inheritedChannelId = [ - repository.channelId, + repository?.channelId, project.projectChannelId, project.repositories.find( - (candidate) => candidate.id !== repository.id && candidate.channelId, + (candidate) => candidate.id !== repository?.id && candidate.channelId, )?.channelId, ].find( (candidate) => candidate && accessChannels.some((channel) => channel.id === candidate), ); const canManageAccess = + Boolean(repository) && accessChannels.length > 0 && - identityPubkey?.toLowerCase() === repository.owner.toLowerCase(); + identityPubkey?.toLowerCase() === repository?.owner.toLowerCase(); const attachCandidates = React.useMemo(() => { const currentAddresses = new Set(project.repositoryAddresses); const candidates = new Map(); @@ -132,18 +142,24 @@ export function ProjectRepositoryManagement({ project={project} repositories={attachCandidates} /> - {canEdit ? ( + {!hideTriggers ? (
-
{stateMessage}
+ {state}
); } @@ -790,10 +781,7 @@ export function RepositoryFilesPanel({ { - setSelectedFile(null); - openPath(path); - }} + onOpenPath={openPath} /> ); } diff --git a/desktop/src/features/projects/ui/ProjectRightPanelControls.tsx b/desktop/src/features/projects/ui/ProjectRightPanelControls.tsx index 1e382326e2b..0127bc43a3b 100644 --- a/desktop/src/features/projects/ui/ProjectRightPanelControls.tsx +++ b/desktop/src/features/projects/ui/ProjectRightPanelControls.tsx @@ -1,4 +1,4 @@ -import { Info, MessageCircle } from "lucide-react"; +import { MessageCircle } from "lucide-react"; import { toggleTerminalPanel, @@ -6,6 +6,7 @@ import { } from "@/features/terminal/terminalPanelStore"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon"; import { TerminalPanelIcon } from "@/shared/ui/TerminalPanelIcon"; export type ProjectRightPanelMode = "chat" | "repository"; @@ -122,12 +123,10 @@ export function ProjectRightPanelControls({ type="button" variant="ghost" > -
diff --git a/desktop/src/features/projects/ui/ProjectSelectableGroup.tsx b/desktop/src/features/projects/ui/ProjectSelectableGroup.tsx index e49f80af620..15690d24cb1 100644 --- a/desktop/src/features/projects/ui/ProjectSelectableGroup.tsx +++ b/desktop/src/features/projects/ui/ProjectSelectableGroup.tsx @@ -15,6 +15,7 @@ export function ProjectSelectableGroup({ icon, items, label, + labelClassName, labelTestId, testId, }: { @@ -27,6 +28,7 @@ export function ProjectSelectableGroup({ icon: React.ReactNode; items: ProjectSelectionItem[]; label: string; + labelClassName?: string; labelTestId?: string; testId: string; }) { @@ -44,6 +46,7 @@ export function ProjectSelectableGroup({
{label} diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx index b8bb317c1bc..58b6dd93364 100644 --- a/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx @@ -1,4 +1,4 @@ -import { Glasses } from "lucide-react"; +import { ArrowLeft } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { TabsList, TabsTrigger } from "@/shared/ui/tabs"; @@ -7,49 +7,62 @@ export const PROJECT_TAB_TRIGGER_CLASS = "h-7 shrink-0 rounded-full bg-muted/30 px-3 text-xs font-medium leading-5 tracking-tight text-muted-foreground shadow-none transition-colors hover:bg-muted/55 hover:text-foreground data-[state=active]:bg-muted data-[state=active]:text-foreground data-[state=active]:shadow-none"; export const PROJECT_TAB_SELECTED_CLASS = "bg-muted text-foreground"; -const PROJECT_OVERVIEW_TAB_CLASS = - "h-7 w-7 shrink-0 rounded-full bg-muted/30 p-1.5 text-muted-foreground shadow-none transition-colors hover:bg-muted/55 hover:text-foreground data-[state=active]:bg-muted data-[state=active]:text-foreground data-[state=active]:shadow-none"; +const PROJECT_TAB_ICON_BUTTON_CLASS = + "h-7 w-7 shrink-0 rounded-full bg-muted/30 p-1.5 text-muted-foreground shadow-none transition-colors hover:bg-muted/55 hover:text-foreground"; function ProjectTabLabel({ children }: { children: string }) { return {children}; } -export function ProjectTabsList({ prsActive }: { prsActive?: boolean }) { +export function ProjectTabsList({ + onBack, + prsActive, +}: { + onBack: () => void; + prsActive?: boolean; +}) { return ( - - + + + + Overview + + + Files + + + Commits + + + Tasks + + + Review + + + Channels + + + Contributors + + +
); } diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx index 2c526900d38..cfac55fa69f 100644 --- a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx @@ -53,17 +53,16 @@ import { ProjectRepositoryUnavailableState } from "./ProjectRepositoryUnavailabl import { PROJECT_COLUMN_HEADER_BACKDROP_CLASS, PROJECT_DETAIL_PANEL_CLASS, - PROJECT_DETAIL_PANEL_MESSAGE_CLASS, + PROJECT_SECTION_HEADER_CLASS, } from "./projectPanelStyles"; import { ProjectSectionHeader } from "./ProjectSectionHeader"; +import { ProjectPanelState } from "./ProjectPanelState"; import { CreatePullRequestDialog } from "./CreatePullRequestDialog"; import { CreateIssueDialog, type CreateIssueDialogInput, } from "./CreateIssueDialog"; -const SECTION_HEADER_CLASS = "mx-4 mb-2 rounded-xl bg-muted/40"; - type CreatePullRequestAction = { projects: Project[]; reposDir?: string | null; @@ -96,6 +95,7 @@ export function WorkspaceTabs({ createPullRequestRequestKey, updatePullRequestAction, initialTab, + initialFilePath, initialTabRequestKey, fileContentSource, localSnapshot, @@ -119,6 +119,7 @@ export function WorkspaceTabs({ onSelectedIssueIdChange, onSelectedPullRequestIdChange, onSelectedTabChange, + onBack, onOpenMergeRecoveryTerminal, snapshot, snapshotError, @@ -142,6 +143,8 @@ export function WorkspaceTabs({ updatePullRequestAction?: UpdatePullRequestAction; /** Tab to open on mount (workspace vocabulary), e.g. from a share link. */ initialTab?: string; + /** File or folder to open when entering the repository Files tab. */ + initialFilePath?: string; /** Changes for every entity-link activation, including repeated links. */ initialTabRequestKey?: string; fileContentSource?: RepositoryFileContentSource; @@ -170,6 +173,7 @@ export function WorkspaceTabs({ onSelectedPullRequestIdChange: (id: string | null) => void; /** Reports the active tab so the screen breadcrumb can mirror it. */ onSelectedTabChange?: (tab: string) => void; + onBack: () => void; onOpenMergeRecoveryTerminal?: OpenMergeRecoveryTerminal; snapshot: ProjectRepoSnapshot | null | undefined; snapshotError: unknown; @@ -350,13 +354,13 @@ export function WorkspaceTabs({ const sectionHeader = selectedTab === "files" && files.length > 0 ? ( ) : selectedTab === "activity" && !selectedCommitHash ? ( @@ -367,7 +371,7 @@ export function WorkspaceTabs({ label: "Create task", onClick: () => setCreateIssueOpen(true), }} - className={SECTION_HEADER_CLASS} + className={PROJECT_SECTION_HEADER_CLASS} icon={CircleDot} title="Tasks" /> @@ -381,19 +385,19 @@ export function WorkspaceTabs({ onClick: () => setCreatePullRequestOpen(true), title: "Create review — choose a repository and branches to compare", }} - className={SECTION_HEADER_CLASS} + className={PROJECT_SECTION_HEADER_CLASS} icon={GitPullRequest} title="Reviews" /> ) : selectedTab === "channels" ? ( ) : selectedTab === "contributors" ? ( @@ -412,7 +416,7 @@ export function WorkspaceTabs({ }`} data-testid="project-workspace-tab-menu" > - +
{updatePullRequestAction ? (
+ ) : ( ; }; @@ -117,54 +104,6 @@ function contentPreview(content: string) { return markdownToPlainText(content).replace(/\s+/g, " ").trim().slice(0, 280); } -function activitySelectionItem( - item: ProjectActivityItem, -): ProjectSelectionItem | null { - const project = item.target.project; - const repository = - item.target.type === "issue" || item.target.type === "pull-request" - ? item.target.repository - : project.repositories[0]; - const channelId = repository?.channelId ?? project.projectChannelId; - if (item.target.type === "commit") { - return selectionItemFromCommit({ - author: item.actorPubkey, - channelId, - commitHash: item.target.commitHash, - projectId: project.id, - shareLink: repository - ? commitShareLink(repository, item.target.commitHash) - : null, - title: item.title, - }); - } - if (item.target.type === "issue") { - return selectionItemFromTask({ - author: item.target.issue.author, - channelId, - id: item.target.issue.id, - shareLink: issueShareLink(item.target.issue), - title: item.target.issue.title, - }); - } - if (item.target.type === "pull-request") { - return selectionItemFromReview({ - author: item.target.pullRequest.author, - channelId, - id: item.target.pullRequest.id, - shareLink: pullRequestShareLink(item.target.pullRequest), - title: item.target.pullRequest.title, - }); - } - return selectionItemFromProject({ - channelId: project.projectChannelId, - id: project.id, - owner: project.owner, - shareLink: projectShareLink(project), - title: project.name, - }); -} - function buildActivityItems({ issues, projects, @@ -402,7 +341,6 @@ function ActivityCard({ onOpen, onOpenProject, profiles, - rangeItems, }: { compact: boolean; isFirst: boolean; @@ -411,7 +349,6 @@ function ActivityCard({ onOpen: () => void; onOpenProject: () => void; profiles?: UserProfileLookup; - rangeItems: ProjectSelectionItem[]; }) { const visual = PROJECT_EVENT_VISUALS[item.kind]; const TypeIcon = visual.icon; @@ -421,21 +358,12 @@ function ActivityCard({ const actorLabel = item.actorPubkey ? resolveUserLabel({ profiles, pubkey: item.actorPubkey }) : item.actorName || "Someone"; - const selection = useProjectSelection(); - const selectionItem = activitySelectionItem(item); - const selected = Boolean( - selectionItem && selection?.isSelected(selectionItem.id), - ); - const showSelectControl = Boolean(selectionItem && selection && selected); - return (
- {open ? ( -
-
- - - -
-
- ) : null} - - ); -} diff --git a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx index c7c55367be5..aa464bf3f1f 100644 --- a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx +++ b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx @@ -21,6 +21,7 @@ import { } from "@/shared/hooks/useIncrementalMount"; import { cn } from "@/shared/lib/cn"; import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState"; +import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem"; @@ -30,6 +31,7 @@ import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon"; import { PROJECT_GRID_CARD_BODY_CLASS } from "./projectGridCardStyles"; import { ProjectListRowMenu } from "./ProjectListRowMenu"; import { ProjectSelectableGroup } from "./ProjectSelectableGroup"; +import { ProjectPanelState } from "./ProjectPanelState"; import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice"; import { groupProjectWorkItemsByProject } from "./projectWorkItemGroups"; @@ -253,21 +255,37 @@ export function ProjectsIssuesList({ ); if (error && issues.length === 0) { - return loadNotice; + return ( + + {isRetrying ? "Retrying..." : "Retry"} + + } + description={ + error instanceof Error ? error.message : "The relay request failed." + } + error + panel={false} + title="Could not load tasks" + /> + ); } if (issues.length === 0) { return (
{loadNotice} -
- {emptyMessage} -
+
); } diff --git a/desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx b/desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx index 99587133974..4f1f1696ea0 100644 --- a/desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx +++ b/desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx @@ -1,128 +1,48 @@ import type { - ProjectsFilter, - ProjectsRepositoryScope, ProjectsSort, ProjectsViewMode, - ProjectsWorkItemScope, } from "@/features/projects/lib/projectsViewHelpers"; -import { ProjectsListScopeDropdown } from "@/features/projects/ui/ProjectsListScopeDropdown"; import { ProjectsViewModeToggle } from "@/features/projects/ui/ProjectsToolbar"; -const PROJECT_SCOPE_OPTIONS: Array<{ - label: string; - value: ProjectsRepositoryScope; -}> = [ - { label: "All", value: "all" }, - { label: "Accessible", value: "accessible" }, - { label: "My Projects", value: "mine" }, - { label: "Local", value: "local" }, -]; -const REPOSITORY_SCOPE_OPTIONS: Array<{ - label: string; - value: ProjectsRepositoryScope; -}> = [ - { label: "All", value: "all" }, - { label: "Accessible", value: "accessible" }, - { label: "My Repositories", value: "mine" }, - { label: "Local", value: "local" }, - { label: "Buzz-hosted", value: "buzz" }, - { label: "Linked", value: "linked" }, -]; -const PULL_REQUEST_SCOPE_OPTIONS: Array<{ - label: string; - value: ProjectsWorkItemScope; -}> = [ - { label: "All", value: "all" }, - { label: "My Reviews", value: "mine" }, -]; -const ISSUE_SCOPE_OPTIONS: Array<{ - label: string; - value: ProjectsWorkItemScope; -}> = [ - { label: "All", value: "all" }, - { label: "My Tasks", value: "mine" }, - { label: "Assigned to me", value: "assigned" }, -]; - type ProjectsListHeaderBarProps = { - filter: ProjectsFilter; - issueScope: ProjectsWorkItemScope; - onIssueScopeChange: (scope: ProjectsWorkItemScope) => void; - onPullRequestScopeChange: (scope: ProjectsWorkItemScope) => void; - onRepositoryScopeChange: (scope: ProjectsRepositoryScope) => void; - onSortChange: (sort: ProjectsSort) => void; onViewModeChange: (viewMode: ProjectsViewMode) => void; - pullRequestScope: ProjectsWorkItemScope; - repositoryScope: ProjectsRepositoryScope; - sort: ProjectsSort; viewMode: ProjectsViewMode; }; -/** - * Compact controls rendered in the Projects section header. - */ +/** Shared Projects sort control used by the top navigation/search row. */ +export function ProjectsSortSelect({ + onChange, + sort, +}: { + onChange: (sort: ProjectsSort) => void; + sort: ProjectsSort; +}) { + return ( + + ); +} + +/** Compact layout controls rendered in the Projects section header. */ export function ProjectsListHeaderBar({ - filter, - issueScope, - onIssueScopeChange, - onPullRequestScopeChange, - onRepositoryScopeChange, - onSortChange, onViewModeChange, - pullRequestScope, - repositoryScope, - sort, viewMode, }: ProjectsListHeaderBarProps) { - const scopeDropdown = - filter === "prs" ? ( - - ) : filter === "issues" ? ( - - ) : filter === "projects" ? ( - - ) : ( - - ); - return (
- {scopeDropdown} - - diff --git a/desktop/src/features/projects/ui/ProjectsOverviewContextSheet.tsx b/desktop/src/features/projects/ui/ProjectsOverviewContextSheet.tsx index c964bad15cc..116292e5585 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewContextSheet.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewContextSheet.tsx @@ -1,8 +1,7 @@ -import { Info } from "lucide-react"; import * as React from "react"; -import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon"; import { Sheet, SheetContent, SheetTitle } from "@/shared/ui/sheet"; export const ProjectsOverviewNarrowContextToggle = React.forwardRef< @@ -21,12 +20,10 @@ export const ProjectsOverviewNarrowContextToggle = React.forwardRef< type="button" variant="ghost" > - )); diff --git a/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx b/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx index 3c23c7452b0..c0e410ff22a 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { FolderGit2, Folders } from "lucide-react"; import type { Project, ProjectActivitySummary, @@ -17,14 +18,16 @@ import { } from "@/features/projects/lib/projectShareLinks"; import { isProjectOwnedByCurrentUser, + isProjectMine, projectPeople, - type ProjectsFilter, type ProjectsViewMode, } from "@/features/projects/lib/projectsViewHelpers"; import { + type ProjectSelectionItem, selectionItemFromProject, selectionItemFromRepository, } from "@/features/projects/lib/projectSelection"; +import { ProjectSelectableGroup } from "@/features/projects/ui/ProjectSelectableGroup"; import { EmptyFilteredState, ProjectGridCard, @@ -35,7 +38,78 @@ import { RepositoryListRow, } from "@/features/projects/ui/RepositoryCards"; import { useIncrementalMount } from "@/shared/hooks/useIncrementalMount"; -import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +const RESPONSIVE_CARD_GRID_CLASS = + "grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(min(100%,16rem),1fr))]"; + +function CollectionGroup({ + children, + icon, + items, + title, +}: { + children: React.ReactNode; + icon: React.ReactNode; + items: ProjectSelectionItem[]; + title: string; +}) { + return ( + + {children} + + ); +} + +function repositoryIsMine( + repository: Repository, + currentPubkey: string | undefined, +) { + if (!currentPubkey) return false; + const viewer = normalizePubkey(currentPubkey); + return ( + normalizePubkey(repository.owner) === viewer || + repository.contributors.some((pubkey) => normalizePubkey(pubkey) === viewer) + ); +} + +function projectSelectionItems(projects: readonly Project[]) { + return projects.map((project) => + selectionItemFromProject({ + channelId: project.projectChannelId, + id: project.id, + owner: project.owner, + shareLink: projectShareLink(project), + title: project.name, + }), + ); +} + +function repositorySelectionItems( + rows: ReadonlyArray<{ project: Project; repository: Repository }>, +) { + return rows.map((row) => + selectionItemFromRepository({ + channelId: row.repository.channelId ?? row.project.projectChannelId, + id: row.repository.id, + owner: row.repository.owner, + shareLink: repositoryShareLink(row.repository), + title: row.repository.name, + }), + ); +} // Stable fallback so a cache miss cannot hand a memoized card a fresh array. const EMPTY_PEOPLE: string[] = []; @@ -43,7 +117,6 @@ const EMPTY_PEOPLE: string[] = []; export function ProjectsOverviewProjectItems({ currentPubkey, deleteDisabled, - filter, localRepoNames, onDelete, onOpen, @@ -56,7 +129,6 @@ export function ProjectsOverviewProjectItems({ }: { currentPubkey: string | undefined; deleteDisabled: boolean; - filter: ProjectsFilter; localRepoNames: Set; onDelete: (project: Project) => void; onOpen: (project: Project) => void; @@ -114,82 +186,122 @@ export function ProjectsOverviewProjectItems({ () => visibleProjects.slice(0, mountedCount), [mountedCount, visibleProjects], ); + const mountedProjectIds = React.useMemo( + () => new Set(mountedProjects.map((project) => project.id)), + [mountedProjects], + ); if (visibleProjects.length === 0) { return ; } + const groups = [ + { + items: visibleProjects.filter((project) => + isProjectMine(project, currentPubkey), + ), + title: "Mine", + }, + { + items: visibleProjects.filter( + (project) => !isProjectMine(project, currentPubkey), + ), + title: "Other projects", + }, + ].filter((group) => group.items.length > 0); if (viewMode === "grid") { return ( -
- {mountedProjects.map((project) => { - const summary = summaries?.[project.id]; - return ( -
- +
+ {groups.map((group) => ( + } + items={projectSelectionItems(group.items)} + key={group.title} + title={group.title} + > +
+ {group.items + .filter((project) => mountedProjectIds.has(project.id)) + .map((project) => { + const summary = summaries?.[project.id]; + return ( +
+ +
+ ); + })}
- ); - })} +
+ ))}
); } return ( -
- {visibleProjects.map((project) => { - const summary = summaries?.[project.id]; - return ( -
- +
+ {groups.map((group) => ( + } + items={projectSelectionItems(group.items)} + key={group.title} + title={group.title} + > +
+ {group.items.map((project) => { + const summary = summaries?.[project.id]; + return ( +
+ +
+ ); + })}
- ); - })} +
+ ))}
); } export function ProjectsOverviewRepositoryItems({ + currentPubkey, localRepoNames, onOpen, onOpenTerminal, @@ -198,6 +310,7 @@ export function ProjectsOverviewRepositoryItems({ viewMode, visibleRepositories, }: { + currentPubkey: string | undefined; localRepoNames: Set; onOpen: (project: Project, repository: Repository) => void; onOpenTerminal: (repository: Repository) => void; @@ -233,53 +346,102 @@ export function ProjectsOverviewRepositoryItems({ () => visibleRepositories.slice(0, mountedCount), [mountedCount, visibleRepositories], ); + const mountedRepositoryAddresses = React.useMemo( + () => + new Set( + mountedRepositories.map(({ repository }) => repository.repoAddress), + ), + [mountedRepositories], + ); if (visibleRepositories.length === 0) { return ; } + const groups = [ + { + items: visibleRepositories.filter(({ repository }) => + repositoryIsMine(repository, currentPubkey), + ), + title: "Mine", + }, + { + items: visibleRepositories.filter( + ({ repository }) => !repositoryIsMine(repository, currentPubkey), + ), + title: "Other repositories", + }, + ].filter((group) => group.items.length > 0); if (viewMode === "grid") { return ( -
- {mountedRepositories.map(({ project, repository }) => ( -
+ {groups.map((group) => ( + } + items={repositorySelectionItems(group.items)} + key={group.title} + title={group.title} > - -
+
+ {group.items + .filter(({ repository }) => + mountedRepositoryAddresses.has(repository.repoAddress), + ) + .map(({ project, repository }) => ( +
+ +
+ ))} +
+ ))}
); } return ( -
- {visibleRepositories.map(({ project, repository }) => ( -
+ {groups.map((group) => ( + } + items={repositorySelectionItems(group.items)} + key={group.title} + title={group.title} > - -
+
+ {group.items.map(({ project, repository }) => ( +
+ +
+ ))} +
+ ))}
); diff --git a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx index f774c192d51..5cdaee0b171 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx @@ -10,6 +10,7 @@ import { } from "lucide-react"; import * as React from "react"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Project, ProjectActivitySummary, @@ -21,19 +22,18 @@ import { projectSelectionPresentation, } from "@/features/projects/lib/projectSelection"; import type { ProjectsFilter } from "@/features/projects/lib/projectsViewHelpers"; +import type { ProjectsActivityDigest } from "@/features/projects/lib/projectsActivityDigest"; import { useProjectSelection } from "@/features/projects/lib/useProjectSelection"; -import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; -import { ProjectsCreateMenu } from "./ProjectsCreateMenu"; +import { ProjectsOverviewPeople } from "./ProjectsOverviewRail"; import { ProjectsSelectionCountMenu } from "./ProjectsSelectionCountMenu"; -import { useCommunities } from "@/features/communities/useCommunities"; -import { useActiveCommunityIcon } from "@/features/communities/useCommunityIcons"; import { + type OverviewContextAction, type OverviewContextStatIcon, type ProjectsOverviewSection, projectsOverviewContext, } from "./projectsOverviewContext"; -import { ProjectsOverviewPeople } from "./ProjectsOverviewRail"; export type { ProjectsOverviewSection }; @@ -56,37 +56,60 @@ type ProjectsOverviewPanelProps = { type ProjectsOverviewContextPanelProps = { filter: ProjectsFilter; + canCreateTarget: boolean; issues: ProjectIssue[]; + onAddChannel: () => void; + onAddRepository: () => void; onChatWithAgent: (items: ProjectSelectionItem[]) => void; onCreateIssue: () => void; - onCreateProject: () => void; onCreatePullRequest: () => void; onSelectSection: (section: ProjectsOverviewSection) => void; profiles?: UserProfileLookup; + projectReadModels: Project[]; projects: Project[]; pullRequests: ProjectPullRequest[]; + repositorySummaries?: Record; summaries?: Record; }; -function OverviewActionButton({ - children, - onClick, - testId, +function OverviewCreateButton({ + action, + canCreateTarget, + onAddChannel, + onAddRepository, + onCreateIssue, + onCreatePullRequest, }: { - children: React.ReactNode; - onClick: () => void; - testId?: string; + action: Exclude; + canCreateTarget: boolean; + onAddChannel: () => void; + onAddRepository: () => void; + onCreateIssue: () => void; + onCreatePullRequest: () => void; }) { + const actionHandler = + action.kind === "issue" + ? onCreateIssue + : action.kind === "pullRequest" + ? onCreatePullRequest + : action.kind === "channel" + ? onAddChannel + : onAddRepository; + const requiresProject = + action.kind === "channel" || action.kind === "repository"; return ( ); } @@ -113,7 +136,9 @@ function OverviewStatRow({ {label} - {count} + + {count} + ); } @@ -128,59 +153,60 @@ export function ProjectsOverviewPanel({ ); } -export function ProjectsActivityIntro() { - const { activeCommunity } = useCommunities(); - const communityIconQuery = useActiveCommunityIcon(activeCommunity?.relayUrl); - const communityIcon = communityIconQuery.data ?? null; - +export function ProjectsActivityIntro({ + digest, +}: { + digest: ProjectsActivityDigest; +}) { return (
-
- {communityIcon ? ( - - ) : ( - - )} -

Projects Activity

-

- Keeping up with the community has never been easier—or mattered more. +

+ {digest.prefix}{" "} + {digest.highlights.map((highlight, index) => ( + + {index > 0 + ? index === digest.highlights.length - 1 + ? ", and " + : ", " + : null} + + {highlight} + + + ))} + {digest.suffix}

); } export function ProjectsOverviewContextPanel({ + canCreateTarget, filter, issues, + onAddChannel, + onAddRepository, onChatWithAgent, onCreateIssue, - onCreateProject, onCreatePullRequest, onSelectSection, profiles, + projectReadModels, projects, pullRequests, + repositorySummaries, summaries, }: ProjectsOverviewContextPanelProps) { const selection = useProjectSelection(); @@ -196,22 +222,28 @@ export function ProjectsOverviewContextPanel({ projectsOverviewContext({ filter, issues, + projectReadModels, projects, pullRequests, + repositorySummaries, summaries, }), - [filter, issues, projects, pullRequests, summaries], + [ + filter, + issues, + projectReadModels, + projects, + pullRequests, + repositorySummaries, + summaries, + ], ); - const actionHandler = - context.action?.kind === "issue" - ? onCreateIssue - : context.action?.kind === "pullRequest" - ? onCreatePullRequest - : onCreateProject; - return (
@@ -230,41 +262,34 @@ export function ProjectsOverviewContextPanel({ > {context.title} - + {context.action ? ( + + ) : null}
)} {selectionPresentation ? null : ( - <> -
- {context.action ? ( - - - {context.action.label} - - ) : null} -
- {context.stats.map((stat) => ( - onSelectSection(stat.section)} - /> - ))} -
-
+
+
+ {context.stats.map((stat) => ( + onSelectSection(stat.section)} + /> + ))} +
{context.people.length > 0 ? (
) : null} - +
)}
diff --git a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx index f4315576f57..1a209df6de2 100644 --- a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx +++ b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx @@ -21,6 +21,7 @@ import { type UserProfileLookup, } from "@/features/profile/lib/identity"; import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState"; +import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem"; @@ -30,12 +31,14 @@ import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon"; import { PROJECT_GRID_CARD_BODY_CLASS } from "./projectGridCardStyles"; import { ProjectListRowMenu } from "./ProjectListRowMenu"; import { ProjectSelectableGroup } from "./ProjectSelectableGroup"; +import { ProjectPanelState } from "./ProjectPanelState"; import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice"; import { groupProjectWorkItemsByProject } from "./projectWorkItemGroups"; type ProjectsPullRequestsListProps = { /** Render without container chrome — a parent table container provides border and rounding. */ embedded?: boolean; + emptyMessage?: string; error: unknown; failedSections: ProjectWorkItemSection[]; isLoading: boolean; @@ -199,6 +202,7 @@ const PullRequestListRow = React.memo(function PullRequestListRow({ export function ProjectsPullRequestsList({ embedded, + emptyMessage = "No reviews yet", error, failedSections, isLoading, @@ -258,21 +262,37 @@ export function ProjectsPullRequestsList({ ); if (error && pullRequests.length === 0) { - return loadNotice; + return ( + + {isRetrying ? "Retrying..." : "Retry"} + + } + description={ + error instanceof Error ? error.message : "The relay request failed." + } + error + panel={false} + title="Could not load reviews" + /> + ); } if (pullRequests.length === 0) { return (
{loadNotice} -
- No reviews yet. -
+
); } diff --git a/desktop/src/features/projects/ui/ProjectsSectionSearch.tsx b/desktop/src/features/projects/ui/ProjectsSectionSearch.tsx new file mode 100644 index 00000000000..c6b05cc9b1c --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectsSectionSearch.tsx @@ -0,0 +1,157 @@ +import { Search, X } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import * as React from "react"; + +import type { + ProjectsFilter, + ProjectsSort, +} from "@/features/projects/lib/projectsViewHelpers"; +import { ProjectsSortSelect } from "@/features/projects/ui/ProjectsListHeaderBar"; +import { projectsSectionTitle } from "@/features/projects/ui/projectsSectionMeta"; +import { ProjectsToolbar } from "@/features/projects/ui/ProjectsToolbar"; +import { Button } from "@/shared/ui/button"; + +export function ProjectsSectionSearch({ + filter, + onFilterChange, + onQueryChange, + onSortChange, + sort, +}: { + filter: ProjectsFilter; + onFilterChange: (filter: ProjectsFilter) => void; + onQueryChange: (query: string) => void; + onSortChange: (sort: ProjectsSort) => void; + sort: ProjectsSort; +}) { + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const deferredQuery = React.useDeferredValue(query); + const focusFrameRef = React.useRef(null); + const reduceMotion = useReducedMotion(); + const transition = { + duration: reduceMotion ? 0 : 0.06, + ease: [0.2, 0.8, 0.2, 1] as const, + }; + const close = React.useCallback(() => { + setOpen(false); + setQuery(""); + onQueryChange(""); + }, [onQueryChange]); + + React.useEffect(() => { + onQueryChange(deferredQuery); + }, [deferredQuery, onQueryChange]); + const focusSearchInput = React.useCallback( + (input: HTMLInputElement | null) => { + if (!input) return; + focusFrameRef.current = window.requestAnimationFrame(() => input.focus()); + }, + [], + ); + React.useEffect( + () => () => { + if (focusFrameRef.current !== null) { + window.cancelAnimationFrame(focusFrameRef.current); + } + }, + [], + ); + + return ( +
+ +
+ + {open ? ( + + setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + close(); + }} + placeholder={`Search ${projectsSectionTitle(filter).toLocaleLowerCase()}`} + ref={focusSearchInput} + type="search" + value={query} + /> + {filter !== "all" && filter !== "channels" ? ( +
+ +
+ ) : null} +
+ ) : ( + + + + )} +
+
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectsSelectionCountMenu.tsx b/desktop/src/features/projects/ui/ProjectsSelectionCountMenu.tsx index b03d5e243a3..72e93bab1e0 100644 --- a/desktop/src/features/projects/ui/ProjectsSelectionCountMenu.tsx +++ b/desktop/src/features/projects/ui/ProjectsSelectionCountMenu.tsx @@ -1,4 +1,15 @@ -import { Bot, GitPullRequest, Link2, X } from "lucide-react"; +import { + Bot, + CircleDot, + FolderGit2, + Folders, + GitCommitHorizontal, + GitPullRequest, + Hash, + Link2, + ListChecks, + X, +} from "lucide-react"; import * as React from "react"; import { @@ -19,6 +30,16 @@ function selectionActionIcon(id: ProjectSelectionAction["id"]) { return Link2; } +function selectionKindIcon(kind: ProjectSelectionItem["kind"] | undefined) { + if (kind === "channel") return Hash; + if (kind === "commit") return GitCommitHorizontal; + if (kind === "project") return Folders; + if (kind === "repository") return FolderGit2; + if (kind === "review") return GitPullRequest; + if (kind === "task") return CircleDot; + return ListChecks; +} + /** Inline actions for the current Projects selection. */ export function ProjectsSelectionCountMenu({ onChatWithAgent, @@ -33,6 +54,8 @@ export function ProjectsSelectionCountMenu({ }) { const selection = useProjectSelection(); const openChannelWithDraft = useProjectDiscussInChannel(selectionItems); + const selectionKind = selectionItems[0]?.kind; + const SelectionIcon = selectionKindIcon(selectionKind); const discussInChannel = React.useCallback( (channelId: string) => { @@ -67,13 +90,26 @@ export function ProjectsSelectionCountMenu({ return (
-

- {presentation.title} -

-
+ +

+ {presentation.title} +

+
+
{presentation.actions .filter( (action) => diff --git a/desktop/src/features/projects/ui/ProjectsToolbar.tsx b/desktop/src/features/projects/ui/ProjectsToolbar.tsx index 24a933bcd66..84cf3f678e3 100644 --- a/desktop/src/features/projects/ui/ProjectsToolbar.tsx +++ b/desktop/src/features/projects/ui/ProjectsToolbar.tsx @@ -1,4 +1,5 @@ import { LayoutGrid, List } from "lucide-react"; +import { motion } from "motion/react"; import * as React from "react"; import type { @@ -26,6 +27,7 @@ const MASK_RIGHT = type ProjectsToolbarProps = { filter: ProjectsFilter; onFilterChange: (filter: ProjectsFilter) => void; + reduceMotion?: boolean; }; export function ProjectsViewModeToggle({ @@ -104,6 +106,7 @@ function useHorizontalOverflow(ref: React.RefObject) { export function ProjectsToolbar({ filter, onFilterChange, + reduceMotion = false, }: ProjectsToolbarProps) { const scrollRef = React.useRef(null); const overflow = useHorizontalOverflow(scrollRef); @@ -149,29 +152,45 @@ export function ProjectsToolbar({ > Project owner filter {filterOptions.map((option) => ( - + + ))}
diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index be0d3da1f0c..7e85fff648b 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -1,9 +1,11 @@ -import { Search } from "lucide-react"; +import { Plus } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useManagedAgentsQuery } from "@/features/agents/hooks"; import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { ownsAuthorAgent } from "@/features/profile/lib/identity"; import { type Project, type ProjectIssue, @@ -16,18 +18,24 @@ import { useProjectsWorkItemsQuery, } from "@/features/projects/hooks"; import { useRepositoryActivitySummariesQuery } from "@/features/projects/repositoryActivityHooks"; -import { useCreateProjectMutation } from "@/features/projects/useCreateProject"; +import { + type CreateProjectInput, + useCreateProjectMutation, +} from "@/features/projects/useCreateProject"; +import { isExplicitProject } from "@/features/projects/projectModels"; +import { projectsWithWorkItemRepositories } from "@/features/projects/projectWorkItems"; import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots"; import { buildProjectSelectionAgentContext } from "@/features/projects/lib/projectDetailAgentContext"; +import { buildProjectsActivityDigest } from "@/features/projects/lib/projectsActivityDigest"; +import { matchesProjectsSearch } from "@/features/projects/lib/projectsSearch"; import type { ProjectSelectionItem } from "@/features/projects/lib/projectSelection"; +import { addProjectToSidebar } from "@/features/projects/lib/projectSidebarMembership"; +import { useProjectSidebarMembership } from "@/features/projects/lib/useProjectSidebarMembership"; import { useMemberChannelIds, useRepositoryUnavailableReasonFor, } from "@/features/projects/useRepositoryAccess"; -import { - projectRepoHostForProject, - projectRepoHostForRepository, -} from "@/features/projects/lib/projectRepoHost"; +import { projectRepoHostForProject } from "@/features/projects/lib/projectRepoHost"; import { ProjectsActivityFeed } from "@/features/projects/ui/ProjectsActivityFeed"; import { ProjectsChannelsList } from "@/features/projects/ui/ProjectsChannelsList"; import { @@ -42,55 +50,41 @@ import { import { ProjectsOverviewChromeActions } from "@/features/projects/ui/ProjectsOverviewChromeActions"; import { ProjectContextRail } from "@/features/projects/ui/ProjectContextRail"; import { - openAppSearch, projectsSectionIcon, projectsSectionTitle, } from "@/features/projects/ui/projectsSectionMeta"; import { EmptyState } from "@/features/projects/ui/ProjectCards"; +import { ProjectBrowserDialog } from "@/features/projects/ui/ProjectBrowserDialog"; import { ProjectsOverviewProjectItems, ProjectsOverviewRepositoryItems, } from "@/features/projects/ui/ProjectsOverviewItems"; -import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog"; import { CreateProjectIssueDialog } from "@/features/projects/ui/CreateProjectIssueDialog"; import { CreatePullRequestDialog } from "@/features/projects/ui/CreatePullRequestDialog"; import { ProjectAgentChatPanel } from "@/features/projects/ui/ProjectAgentChatPanel"; +import { ProjectsCategoryCreateDialogs } from "@/features/projects/ui/ProjectsCategoryCreateDialogs"; import { ProjectsIssuesList } from "@/features/projects/ui/ProjectsIssuesList"; import { ProjectsWorkspaceChrome } from "@/features/projects/ui/ProjectDetailChrome"; import { ProjectsPullRequestsList } from "@/features/projects/ui/ProjectsPullRequestsList"; import { ProjectsWorkItemsLoadNotice } from "@/features/projects/ui/ProjectsWorkItemsLoadNotice"; import { ProjectsListHeaderBar } from "@/features/projects/ui/ProjectsListHeaderBar"; +import { ProjectsSectionSearch } from "@/features/projects/ui/ProjectsSectionSearch"; import { ProjectSectionHeader } from "@/features/projects/ui/ProjectSectionHeader"; import { PROJECT_COLUMN_HEADER_BACKDROP_CLASS } from "@/features/projects/ui/projectPanelStyles"; -import { ProjectsToolbar } from "@/features/projects/ui/ProjectsToolbar"; import { ProjectSelectionProvider } from "@/features/projects/lib/useProjectSelection"; -import { - hasLocalCheckout, - hasLocalRepositoryCheckout, -} from "@/features/projects/lib/projectLocalRepos"; +import { hasLocalRepositoryCheckout } from "@/features/projects/lib/projectLocalRepos"; import { getProjectUpdatedAt, - isProjectAccessibleToViewer, - isProjectMine, - isRepositoryAccessibleToViewer, projectHasAgent, projectOwnerIsUser, projectPeople, type ProjectsFilter, - type ProjectsRepositoryScope, type ProjectsSort, type ProjectsViewMode, - type ProjectsWorkItemScope, readStoredFilter, - readStoredIssueScope, - readStoredPullRequestScope, - readStoredRepositoryScope, readStoredSort, readStoredViewMode, writeStoredFilter, - writeStoredIssueScope, - writeStoredPullRequestScope, - writeStoredRepositoryScope, writeStoredSort, writeStoredViewMode, } from "@/features/projects/lib/projectsViewHelpers"; @@ -101,6 +95,7 @@ import { useProjectPanelWidths, } from "@/features/projects/ui/useProjectPanelWidths"; import { useMediaBreakpoint } from "@/shared/hooks/use-mobile"; +import { useNow } from "@/shared/lib/useNow"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; import { useCommunities } from "@/features/communities/useCommunities"; import { useIdentityQuery } from "@/shared/api/hooks"; @@ -130,7 +125,12 @@ export function ProjectsView() { useProjectsScrollIndicator(); const projectsQuery = useProjectsQuery(); const identityQuery = useIdentityQuery(); - const projects = projectsQuery.data ?? []; + const managedAgentsQuery = useManagedAgentsQuery(); + const projectReadModels = projectsQuery.data ?? []; + const projects = React.useMemo( + () => projectReadModels.filter(isExplicitProject), + [projectReadModels], + ); const localRepositoriesQuery = useProjectLocalRepositoriesQuery( activeCommunity?.reposDir, ); @@ -140,9 +140,14 @@ export function ProjectsView() { ? "repositories" : storedFilter; }); + const [searchQuery, setSearchQuery] = React.useState(""); const [overviewPanelOpen, setOverviewPanelOpen] = React.useState(true); const [narrowContextOpen, setNarrowContextOpen] = React.useState(false); const contextToggleRef = React.useRef(null); + const selectionDrawerStateRef = React.useRef<{ + narrow: boolean; + open: boolean; + } | null>(null); const isNarrowProjectsLayout = useMediaBreakpoint( PROJECTS_CONTEXT_POD_MIN_VIEWPORT_PX, ); @@ -150,22 +155,13 @@ export function ProjectsView() { useProjectPanelWidths("chat"); const activitySummariesQuery = useProjectActivitySummariesQuery(projects); const repositoryActivitySummariesQuery = useRepositoryActivitySummariesQuery( - filter === "repositories" ? projects : [], - ); - const [repositoryScope, setRepositoryScope] = - React.useState(() => { - const storedScope = readStoredRepositoryScope(); - return filter === "projects" && - (storedScope === "buzz" || storedScope === "linked") - ? "all" - : storedScope; - }); - const [pullRequestScope, setPullRequestScope] = - React.useState(() => readStoredPullRequestScope()); - const [issueScope, setIssueScope] = React.useState( - () => readStoredIssueScope(), + filter === "repositories" ? projectReadModels : [], + ); + const workItemProjects = React.useMemo( + () => projectsWithWorkItemRepositories(projectReadModels), + [projectReadModels], ); - const projectsWorkItemsQuery = useProjectsWorkItemsQuery(projects); + const projectsWorkItemsQuery = useProjectsWorkItemsQuery(workItemProjects); // One blobless clone per primary Buzz repository, only while the overview // header is visible. const snapshotProjects = React.useMemo( @@ -187,7 +183,9 @@ export function ProjectsView() { repoSnapshotsQuery.data?.unavailable, memberChannelIds, ); - const [createProjectOpen, setCreateProjectOpen] = React.useState(false); + const [projectBrowserOpen, setProjectBrowserOpen] = React.useState(false); + const [createChannelOpen, setCreateChannelOpen] = React.useState(false); + const [createRepositoryOpen, setCreateRepositoryOpen] = React.useState(false); const [createIssueOpen, setCreateIssueOpen] = React.useState(false); const [createPullRequestOpen, setCreatePullRequestOpen] = React.useState(false); @@ -231,37 +229,90 @@ export function ProjectsView() { enabled: projectPubkeys.length > 0, }); const profiles = profilesQuery.data?.profiles; + const activityDigestNow = useNow(600_000); + const activityDigest = React.useMemo( + () => + buildProjectsActivityDigest({ + issues: projectsWorkItemsQuery.data?.issues.items ?? [], + nowSeconds: Math.floor(activityDigestNow / 1_000), + projects, + pullRequests: projectsWorkItemsQuery.data?.pullRequests.items ?? [], + snapshots: repoSnapshotsQuery.data?.snapshots, + summaries: activitySummariesQuery.data, + }), + [ + activityDigestNow, + activitySummariesQuery.data, + projects, + projectsWorkItemsQuery.data, + repoSnapshotsQuery.data?.snapshots, + ], + ); const deleteProjectMutation = useDeleteProjectMutation(); const currentPubkey = identityQuery.data?.pubkey; - - const handleViewModeChange = React.useCallback( - (nextViewMode: ProjectsViewMode) => { - setStoredViewMode(nextViewMode); - writeStoredViewMode(nextViewMode); - }, - [], + const addedProjectAddresses = useProjectSidebarMembership( + relayOrigin, + currentPubkey, ); - - const handleRepositoryScopeChange = React.useCallback( - (scope: ProjectsRepositoryScope) => { - setRepositoryScope(scope); - writeStoredRepositoryScope(scope); + const addedProjectAddressSet = React.useMemo( + () => new Set(addedProjectAddresses), + [addedProjectAddresses], + ); + const handleCreateProject = React.useCallback( + async (input: CreateProjectInput) => { + const result = await createProjectMutation.mutateAsync(input); + if (result.compatibilityWarning) { + toast.warning("Created as a standalone project", { + description: result.compatibilityWarning, + }); + } else { + toast.success(`Project "${result.project.name}" created.`); + } + await goProject(result.project.id); }, - [], + [createProjectMutation, goProject], ); - - const handlePullRequestScopeChange = React.useCallback( - (scope: ProjectsWorkItemScope) => { - setPullRequestScope(scope); - writeStoredPullRequestScope(scope); + const managedAgentPubkeys = React.useMemo( + () => + new Set( + (managedAgentsQuery.data ?? []).map((agent) => + normalizePubkey(agent.pubkey), + ), + ), + [managedAgentsQuery.data], + ); + const editableProjects = React.useMemo(() => { + if (!currentPubkey) return []; + const viewer = normalizePubkey(currentPubkey); + return projects.filter((project) => { + const owner = normalizePubkey(project.owner); + return ( + owner === viewer || + managedAgentPubkeys.has(owner) || + ownsAuthorAgent(profiles?.[owner], currentPubkey) + ); + }); + }, [currentPubkey, managedAgentPubkeys, profiles, projects]); + const ownerControlAgentPubkeyFor = React.useCallback( + (project: Project) => { + const owner = normalizePubkey(project.owner); + if ( + owner === normalizePubkey(currentPubkey ?? "") || + managedAgentPubkeys.has(owner) + ) { + return undefined; + } + return ownsAuthorAgent(profiles?.[owner], currentPubkey) + ? project.owner + : undefined; }, - [], + [currentPubkey, managedAgentPubkeys, profiles], ); - const handleIssueScopeChange = React.useCallback( - (scope: ProjectsWorkItemScope) => { - setIssueScope(scope); - writeStoredIssueScope(scope); + const handleViewModeChange = React.useCallback( + (nextViewMode: ProjectsViewMode) => { + setStoredViewMode(nextViewMode); + writeStoredViewMode(nextViewMode); }, [], ); @@ -281,16 +332,6 @@ export function ProjectsView() { [localRepositoriesQuery.data], ); - const repositoryAccessInput = React.useMemo( - () => ({ - currentPubkey, - localRepoNames, - memberChannelIds, - relayOrigin, - }), - [currentPubkey, localRepoNames, memberChannelIds, relayOrigin], - ); - const visibleProjects = React.useMemo(() => { if (filter !== "projects" && filter !== "agents" && filter !== "users") { return []; @@ -298,22 +339,20 @@ export function ProjectsView() { const sortedProjects = projects .filter((project) => { + if ( + !matchesProjectsSearch(searchQuery, [ + project.name, + project.description, + ...project.repositories.flatMap((repository) => [ + repository.name, + repository.description, + ]), + ]) + ) { + return false; + } const summary = activitySummariesQuery.data?.[project.id]; const people = projectPeople(project, summary); - if (repositoryScope === "accessible") - return isProjectAccessibleToViewer(project, repositoryAccessInput); - if (repositoryScope === "mine") - return isProjectMine(project, currentPubkey); - if (repositoryScope === "local") - return hasLocalCheckout(project, localRepoNames); - if (repositoryScope === "buzz") - return ( - projectRepoHostForProject(project, relayOrigin).kind === "buzz" - ); - if (repositoryScope === "linked") - return ( - projectRepoHostForProject(project, relayOrigin).kind === "external" - ); if (filter === "agents") { return projectHasAgent(project, people, profiles); } @@ -338,14 +377,10 @@ export function ProjectsView() { return sortedProjects; }, [ activitySummariesQuery.data, - currentPubkey, filter, - localRepoNames, profiles, projects, - relayOrigin, - repositoryAccessInput, - repositoryScope, + searchQuery, sort, ]); @@ -353,7 +388,7 @@ export function ProjectsView() { if (filter !== "repositories") return []; const repositories = [ ...new Map( - projects + projectReadModels .flatMap((project) => project.repositories.map((repository) => ({ project, @@ -364,40 +399,13 @@ export function ProjectsView() { ).values(), ]; return repositories - .filter(({ repository }) => { - if (repositoryScope === "accessible") { - return isRepositoryAccessibleToViewer( - repository, - repositoryAccessInput, - ); - } - if (repositoryScope === "mine") { - if (!currentPubkey) return false; - const normalizedCurrentPubkey = normalizePubkey(currentPubkey); - return ( - normalizePubkey(repository.owner) === normalizedCurrentPubkey || - repository.contributors.some( - (pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey, - ) - ); - } - if (repositoryScope === "local") { - return hasLocalRepositoryCheckout(repository, localRepoNames); - } - if (repositoryScope === "buzz") { - return ( - projectRepoHostForRepository(repository, relayOrigin).kind === - "buzz" - ); - } - if (repositoryScope === "linked") { - return ( - projectRepoHostForRepository(repository, relayOrigin).kind === - "external" - ); - } - return true; - }) + .filter(({ project, repository }) => + matchesProjectsSearch(searchQuery, [ + repository.name, + repository.description, + project.name, + ]), + ) .sort((left, right) => { if (sort === "name") { return left.repository.name.localeCompare(right.repository.name); @@ -414,61 +422,58 @@ export function ProjectsView() { return rightUpdatedAt - leftUpdatedAt; }); }, [ - currentPubkey, filter, - localRepoNames, - projects, - relayOrigin, - repositoryAccessInput, + projectReadModels, repositoryActivitySummariesQuery.data, - repositoryScope, + searchQuery, sort, ]); const visiblePullRequests = React.useMemo(() => { const pullRequests = projectsWorkItemsQuery.data?.pullRequests.items ?? []; - const scopedPullRequests = - pullRequestScope === "mine" && currentPubkey - ? pullRequests.filter( - ({ pullRequest }) => - normalizePubkey(pullRequest.author) === - normalizePubkey(currentPubkey), - ) - : pullRequests; - return [...scopedPullRequests].sort((left, right) => { - if (sort === "name") { - return left.pullRequest.title.localeCompare(right.pullRequest.title); - } - if (sort === "created") { - return right.pullRequest.createdAt - left.pullRequest.createdAt; - } - return right.pullRequest.updatedAt - left.pullRequest.updatedAt; - }); - }, [currentPubkey, projectsWorkItemsQuery.data, pullRequestScope, sort]); + return pullRequests + .filter(({ project, pullRequest, repository }) => + matchesProjectsSearch(searchQuery, [ + pullRequest.title, + pullRequest.content, + pullRequest.status, + project.name, + repository.name, + ]), + ) + .sort((left, right) => { + if (sort === "name") { + return left.pullRequest.title.localeCompare(right.pullRequest.title); + } + if (sort === "created") { + return right.pullRequest.createdAt - left.pullRequest.createdAt; + } + return right.pullRequest.updatedAt - left.pullRequest.updatedAt; + }); + }, [projectsWorkItemsQuery.data, searchQuery, sort]); const visibleIssues = React.useMemo(() => { const issues = projectsWorkItemsQuery.data?.issues.items ?? []; - const viewer = currentPubkey ? normalizePubkey(currentPubkey) : null; - const scopedIssues = - issueScope === "mine" && viewer - ? issues.filter(({ issue }) => normalizePubkey(issue.author) === viewer) - : issueScope === "assigned" && viewer - ? issues.filter(({ issue }) => - issue.assignees.some( - (assignee) => normalizePubkey(assignee) === viewer, - ), - ) - : issues; - return [...scopedIssues].sort((left, right) => { - if (sort === "name") { - return left.issue.title.localeCompare(right.issue.title); - } - if (sort === "created") { - return right.issue.createdAt - left.issue.createdAt; - } - return right.issue.updatedAt - left.issue.updatedAt; - }); - }, [currentPubkey, issueScope, projectsWorkItemsQuery.data, sort]); + return issues + .filter(({ issue, project, repository }) => + matchesProjectsSearch(searchQuery, [ + issue.title, + issue.content, + issue.status, + project.name, + repository.name, + ]), + ) + .sort((left, right) => { + if (sort === "name") { + return left.issue.title.localeCompare(right.issue.title); + } + if (sort === "created") { + return right.issue.createdAt - left.issue.createdAt; + } + return right.issue.updatedAt - left.issue.updatedAt; + }); + }, [projectsWorkItemsQuery.data, searchQuery, sort]); const { agentContext: selectionAgentContext, overviewContext: overviewAgentContext, @@ -491,18 +496,11 @@ export function ProjectsView() { // lets React keep the click responsive and paint the previous tab until // the new tree is ready instead of blocking the main thread. React.startTransition(() => { - if ( - nextFilter === "projects" && - (repositoryScope === "buzz" || repositoryScope === "linked") - ) { - setRepositoryScope("all"); - writeStoredRepositoryScope("all"); - } setSelectionAgentContext(null); setFilter(nextFilter); }); }, - [repositoryScope, setSelectionAgentContext], + [setSelectionAgentContext], ); // Route by the canonical `owner:dtag` project ID — a bare dtag is @@ -595,15 +593,10 @@ export function ProjectsView() { ); } - if (projects.length === 0) { - return ; - } - const projectItems = ( ); @@ -675,22 +660,27 @@ export function ProjectsView() { pullRequests={ projectsWorkItemsQuery.data?.pullRequests.items ?? EMPTY_ITEMS } + searchQuery={searchQuery} snapshots={repoSnapshotsQuery.data?.snapshots} /> ); const contextPanelProps = { + canCreateTarget: editableProjects.length > 0, filter, issues: contextIssues, + onAddChannel: () => setCreateChannelOpen(true), + onAddRepository: () => setCreateRepositoryOpen(true), onChatWithAgent: (items: ProjectSelectionItem[]) => setSelectionAgentContext(buildProjectSelectionAgentContext(items)), onCreateIssue: () => setCreateIssueOpen(true), - onCreateProject: () => setCreateProjectOpen(true), onCreatePullRequest: () => setCreatePullRequestOpen(true), profiles, + projectReadModels, projects, pullRequests: contextPullRequests, + repositorySummaries: repositoryActivitySummariesQuery.data, summaries: activitySummariesQuery.data, }; const contextOpen = isNarrowProjectsLayout @@ -722,8 +712,27 @@ export function ProjectsView() { return ( { + const previous = selectionDrawerStateRef.current; + selectionDrawerStateRef.current = null; + if (!previous) return; + if (previous.narrow) { + setNarrowContextOpen(previous.open); + } else { + setOverviewPanelOpen(previous.open); + } + }} onSelect={() => { - if (!isNarrowProjectsLayout) setOverviewPanelOpen(true); + if (selectionDrawerStateRef.current) return; + selectionDrawerStateRef.current = { + narrow: isNarrowProjectsLayout, + open: isNarrowProjectsLayout ? narrowContextOpen : overviewPanelOpen, + }; + if (isNarrowProjectsLayout) { + setNarrowContextOpen(true); + } else { + setOverviewPanelOpen(true); + } }} resetKey={filter} > @@ -754,22 +763,21 @@ export function ProjectsView() { overviewDetached ? "projects-overview-content-pod" : undefined } > - { - const result = await createProjectMutation.mutateAsync(input); - if (result.compatibilityWarning) { - toast.warning("Created as a standalone project", { - description: result.compatibilityWarning, - }); - } else { - toast.success(`Project "${result.project.name}" created.`); - } - handleRepositoryScopeChange("all"); - handleFilterChange("projects"); + onCreate={handleCreateProject} + onOpenChange={setProjectBrowserOpen} + onSelectProject={(project) => { + addProjectToSidebar( + project.projectAddress, + relayOrigin, + currentPubkey, + ); + void goProject(project.id); }} - onOpenChange={setCreateProjectOpen} - open={createProjectOpen} + open={projectBrowserOpen} + projects={projectReadModels} + selectedProjectAddresses={addedProjectAddressSet} /> {createPullRequestOpen ? ( +
+ -
- -
- {filter === "all" ? ( + {projectReadModels.length === 0 ? ( + + ) : filter === "all" ? ( - +
{activityFeed}
@@ -855,7 +874,7 @@ export function ProjectsView() { ) : ( <> ) : filter === "channels" ? ( - + ) : filter === "projects" ? ( projectItems ) : ( @@ -947,11 +979,12 @@ export function ProjectsView() {