From a6c5f1db928a22350bd124b72796921fd945359d Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Sat, 22 Aug 2026 20:57:13 -0400 Subject: [PATCH 01/22] feat(projects): add agent and CLI project-home support Give agents bounded project-home context and project-aware CLI operations while keeping channel matching client-filtered through the existing relay query surface. Signed-off-by: Thomas Petersen --- crates/buzz-acp/src/base_prompt.md | 9 + crates/buzz-acp/src/lib.rs | 13 +- crates/buzz-acp/src/pool.rs | 89 ++++- crates/buzz-acp/src/prompt_project.rs | 235 ++++++++++++++ crates/buzz-acp/src/queue.rs | 174 +++++++++- crates/buzz-acp/src/relay.rs | 13 + crates/buzz-cli/src/commands/issues.rs | 46 ++- crates/buzz-cli/src/commands/mod.rs | 3 +- .../buzz-cli/src/commands/project_channel.rs | 255 +++++++++++++++ crates/buzz-cli/src/commands/projects.rs | 307 ++++++++++++++---- crates/buzz-cli/src/commands/repos.rs | 12 +- crates/buzz-cli/src/lib.rs | 21 +- 12 files changed, 1074 insertions(+), 103 deletions(-) create mode 100644 crates/buzz-acp/src/prompt_project.rs create mode 100644 crates/buzz-cli/src/commands/project_channel.rs diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 7a979b62e0c..485eb272332 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` | | `buzz issues` | `create`, `get`, `list`, `status`, `assign` | | `buzz pr` | `open`, `update`, `get`, `list`, `status` | | `buzz upload` | `file` | @@ -31,6 +32,14 @@ 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. + `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..10abfca6a79 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_listed_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,47 @@ 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 filter = serde_json::json!({ + "kinds": [buzz_core::kind::KIND_PROJECT], + "limit": 1000, + }); + + let json = fetch_with_retry(|| async { + match timeout( + CONTEXT_FETCH_TIMEOUT, + rest.query_raw(std::slice::from_ref(&filter)), + ) + .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()?; + pick_listed_project_home(events, &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 +8439,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 +8454,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..1a56ac9cb8f --- /dev/null +++ b/crates/buzz-acp/src/prompt_project.rs @@ -0,0 +1,235 @@ +//! Parse a channel's NIP-MP project home for ACP `[Context]`. + +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, +} + +/// Pick the oldest listed `kind:30621` bound to the channel. +/// +/// Unlisted heads are ignored. When two listed projects share the channel +/// (a duplicate card), the earlier head is the original home. +pub fn pick_listed_project_home(events: &[Value], channel_id: &str) -> Option { + let mut listed: Vec<&Value> = events + .iter() + .filter(|event| { + !event_is_unlisted(event) && event_has_tag_value(event, "buzz-channel", channel_id) + }) + .collect(); + listed.sort_by_key(|event| { + event + .get("created_at") + .and_then(Value::as_u64) + .unwrap_or(u64::MAX) + }); + listed.into_iter().find_map(parse_prompt_project) +} + +fn event_has_tag_value(event: &Value, name: &str, value: &str) -> bool { + event + .get("tags") + .and_then(Value::as_array) + .into_iter() + .flatten() + .any(|tag| { + let values = tag.as_array(); + values + .and_then(|parts| parts.first()) + .and_then(Value::as_str) + == Some(name) + && values + .and_then(|parts| parts.get(1)) + .and_then(Value::as_str) + == Some(value) + }) +} + +fn event_is_unlisted(event: &Value) -> bool { + event + .get("tags") + .and_then(Value::as_array) + .into_iter() + .flatten() + .any(|tag| { + let arr = tag.as_array(); + matches!( + arr.map(|values| { + values.first().and_then(Value::as_str) == Some("buzz-visibility") + && values.get(1).and_then(Value::as_str) == Some("unlisted") + }), + Some(true) + ) + }) +} + +fn parse_prompt_project(event: &Value) -> Option { + let owner = event + .get("pubkey") + .and_then(Value::as_str)? + .trim() + .to_ascii_lowercase(); + if owner.len() != 64 { + return None; + } + let tags = event.get("tags").and_then(Value::as_array)?; + let mut slug = None; + let mut name = None; + let mut default_repo = None; + for tag in tags { + let Some(arr) = tag.as_array() else { continue }; + match arr.first().and_then(Value::as_str) { + Some("d") if slug.is_none() => { + slug = arr + .get(1) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + } + Some("name") if name.is_none() => { + name = arr + .get(1) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + } + Some("a") if default_repo.is_none() => { + default_repo = arr + .get(1) + .and_then(Value::as_str) + .and_then(parse_repo_coord); + } + _ => {} + } + } + let slug = slug?; + let owner_for_coord = owner.clone(); + Some(PromptProjectInfo { + name: name.unwrap_or_else(|| slug.clone()), + coordinate: format!("30621:{owner_for_coord}:{slug}"), + slug, + owner, + default_repo_owner: default_repo + .as_ref() + .map(|(repo_owner, _)| repo_owner.clone()), + default_repo_id: default_repo.map(|(_, repo_id)| repo_id), + }) +} + +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_event( + created_at: u64, + owner: &str, + slug: &str, + name: &str, + extra_tags: Vec, + ) -> Value { + let mut tags = vec![ + json!(["d", slug]), + json!(["name", name]), + json!(["buzz-channel", CHANNEL_ID]), + ]; + tags.extend(extra_tags); + json!({ + "pubkey": owner, + "created_at": created_at, + "kind": 30621, + "tags": tags, + }) + } + + #[test] + fn pick_listed_project_home_prefers_oldest_listed() { + let owner = "a".repeat(64); + let later = "b".repeat(64); + let events = vec![ + project_event( + 200, + &later, + "space-invaders-3d", + "Agent copy", + vec![json!(["a", format!("30617:{later}:space-invaders-3d")])], + ), + project_event( + 100, + &owner, + "space-invaders-3d", + "Space Invaders 3D", + vec![], + ), + ]; + let home = pick_listed_project_home(&events, CHANNEL_ID).expect("listed project"); + assert_eq!(home.owner, owner); + assert_eq!(home.name, "Space Invaders 3D"); + assert_eq!(home.slug, "space-invaders-3d"); + assert_eq!(home.coordinate, format!("30621:{owner}:space-invaders-3d")); + assert_eq!(home.default_repo_id, None); + } + + #[test] + fn pick_listed_project_home_skips_unlisted_and_reads_member_repo() { + let owner = "a".repeat(64); + let repo_owner = "c".repeat(64); + let events = vec![ + project_event( + 50, + &owner, + "hidden", + "Hidden", + vec![json!(["buzz-visibility", "unlisted"])], + ), + project_event( + 80, + &owner, + "space-invaders-3d", + "Space Invaders 3D", + vec![json!(["a", format!("30617:{repo_owner}:game")])], + ), + ]; + let home = pick_listed_project_home(&events, CHANNEL_ID).expect("listed project"); + assert_eq!(home.slug, "space-invaders-3d"); + assert_eq!( + home.default_repo_owner.as_deref(), + Some(repo_owner.as_str()) + ); + assert_eq!(home.default_repo_id.as_deref(), Some("game")); + } + + #[test] + fn pick_listed_project_home_ignores_other_channels() { + let owner = "a".repeat(64); + let mut unrelated = project_event(1, &owner, "wrong", "Wrong", vec![]); + unrelated["tags"][2][1] = json!("22222222-2222-4222-8222-222222222222"); + let expected = project_event(2, &owner, "right", "Right", vec![]); + + let home = + pick_listed_project_home(&[unrelated, expected], CHANNEL_ID).expect("project home"); + assert_eq!(home.slug, "right"); + } +} 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/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..abf0d332094 --- /dev/null +++ b/crates/buzz-cli/src/commands/project_channel.rs @@ -0,0 +1,255 @@ +//! 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, Tag}; + +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 project = pick_oldest_listed(&projects); + if let Some(event) = project { + if let Some(member) = first_member_repo(event) { + return Ok(member); + } + } + + if let Some(repo) = pick_channel_repo(client, channel).await? { + let caller = client.keys().public_key().to_hex(); + if repo.repo_owner.eq_ignore_ascii_case(&caller) { + let _ = try_add_own_repo_to_channel_project(client, channel, &repo.repo_id).await; + } + return Ok(repo); + } + + let Some(event) = project 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 pick_oldest_listed(events: &[Event]) -> Option<&Event> { + events + .iter() + .filter(|event| !project_is_unlisted(event)) + .min_by_key(|event| event.created_at) +} + +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 { + event.tags.iter().find_map(|tag| match tag.as_slice() { + [name, value, ..] if name == "d" && !value.is_empty() => Some(value.clone()), + _ => None, + }) +} + +fn project_name(event: &Event) -> Option { + event.tags.iter().find_map(|tag| match tag.as_slice() { + [name, value, ..] if name == "name" && !value.is_empty() => Some(value.clone()), + _ => None, + }) +} + +fn first_member_repo(event: &Event) -> Option { + event.tags.iter().find_map(|tag| match tag.as_slice() { + [name, value, ..] if name == "a" => parse_repo_a_tag(value), + _ => None, + }) +} + +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" + ) + }) +} + +fn repo_dtag(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, + }) +} + +async fn pick_channel_repo( + client: &BuzzClient, + channel: &str, +) -> Result, CliError> { + let filter = serde_json::json!({ + "kinds": [KIND_GIT_REPO_ANNOUNCEMENT], + "#buzz-channel": [channel], + "limit": 20, + }); + let raw = client.query(&filter).await?; + let events: Vec = serde_json::from_str(&raw) + .map_err(|error| CliError::Other(format!("failed to parse relay response: {error}")))?; + let Some(event) = events + .iter() + .filter(|event| !repo_is_unlisted(event)) + .min_by_key(|event| event.created_at) + else { + return Ok(None); + }; + let Some(repo_id) = repo_dtag(event) else { + return Ok(None); + }; + Ok(Some(ChannelProjectRepo { + repo_owner: event.pubkey.to_hex(), + repo_id, + })) +} + +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(); + let project_owner = project.pubkey.to_hex(); + + if let Some(existing) = + crate::commands::repos::fetch_own_repo_announcement(client, &repo_id).await? + { + 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 mut builder = crate::commands::repos::build_create_announcement( + &repo_id, + Some(&name), + None, + &[], + None, + &[], + Some(channel), + )?; + if !project_owner.eq_ignore_ascii_case(&caller) { + builder = builder.tag(Tag::parse(["maintainers", project_owner.as_str()]).map_err( + |error| { + CliError::Other(format!( + "failed to tag project owner as maintainer: {error}" + )) + }, + )?); + } + 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" + ); + } + + #[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..07761e24fae 100644 --- a/crates/buzz-cli/src/commands/projects.rs +++ b/crates/buzz-cli/src/commands/projects.rs @@ -25,6 +25,8 @@ use nostr::{Event, EventBuilder, Tag, Timestamp}; 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; // ── Buzz repo-ID grammar (bare --repo shorthand) ───────────────────────────── @@ -63,9 +65,140 @@ 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 every listed kind:30621 head for `slug`, any owner. +async fn fetch_projects_by_dtag(client: &BuzzClient, slug: &str) -> Result, CliError> { + let filter = serde_json::json!({ + "kinds": [KIND_PROJECT], + "#d": [slug], + "limit": 20, + }); + let raw = client.query(&filter).await?; + parse_events(&raw) +} + +/// 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, + }) +} + +fn other_listed_project<'a>(events: &'a [Event], caller_pubkey: &str) -> Option<&'a Event> { + events.iter().find(|event| { + !event.pubkey.to_hex().eq_ignore_ascii_case(caller_pubkey) && !project_is_unlisted(event) + }) +} + +/// 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 +226,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 +325,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 +370,38 @@ pub async fn cmd_create( "project {slug:?} already exists; use 'buzz projects update' to modify it" ))); } + if let Some(existing) = + other_listed_project(&fetch_projects_by_dtag(client, slug).await?, &caller_pubkey) + { + let owner = existing.pubkey.to_hex(); + return Err(CliError::Conflict(format!( + "project {slug:?} already exists (owner {owner}); do not create another. Add a repository to that project instead." + ))); + } + if let Some(channel) = channel { + if let Some(existing) = fetch_projects_for_channel(client, channel) + .await? + .into_iter() + .find(|event| !project_is_unlisted(event)) + { + let existing_slug = project_slug(&existing).unwrap_or_else(|| slug.to_string()); + let owner = existing.pubkey.to_hex(); + return Err(CliError::Conflict(format!( + "channel {channel} is already the home of project {existing_slug:?} (owner {owner}); do not create another project. Use `buzz repos create --channel {channel}` and `buzz projects add-repo {existing_slug}`." + ))); + } + } + + 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 +471,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 +678,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. @@ -652,6 +806,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 +1266,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..af198f4582c 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)] @@ -1633,12 +1634,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, From 7a9af2ac1583c44a3f5afca51eadd917498688a9 Mon Sep 17 00:00:00 2001 From: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Date: Sun, 23 Aug 2026 01:12:15 -0400 Subject: [PATCH 02/22] fix(projects): authenticate channel home routing Co-authored-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Signed-off-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/pool.rs | 30 +- crates/buzz-acp/src/prompt_project.rs | 340 +++++++++--------- .../buzz-cli/src/commands/project_channel.rs | 243 +++++++++---- crates/buzz-cli/src/commands/projects.rs | 33 +- 4 files changed, 376 insertions(+), 270 deletions(-) diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 10abfca6a79..7b5bdccb452 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -36,7 +36,7 @@ use crate::acp::{ }; use crate::config::{compose_session_title, DedupMode, PermissionMode}; use crate::observer; -use crate::prompt_project::{pick_listed_project_home, PromptProjectInfo}; +use crate::prompt_project::{pick_authoritative_project_home, PromptProjectInfo}; use crate::queue::{ CancelReason, ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo, PromptProfile, PromptProfileLookup, ThreadTags, @@ -2990,18 +2990,20 @@ pub(crate) async fn fetch_project_home_for_channel( channel_id: Uuid, rest: &RestClient, ) -> Option { - let filter = serde_json::json!({ - "kinds": [buzz_core::kind::KIND_PROJECT], - "limit": 1000, - }); + 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(std::slice::from_ref(&filter)), - ) - .await - { + match timeout(CONTEXT_FETCH_TIMEOUT, rest.query_raw(&filters)).await { Ok(Ok(json)) => Some(json), Ok(Err(e)) => { tracing::debug!( @@ -3021,7 +3023,11 @@ pub(crate) async fn fetch_project_home_for_channel( }) .await?; let events = json.as_array()?; - pick_listed_project_home(events, &channel_id.to_string()) + 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. diff --git a/crates/buzz-acp/src/prompt_project.rs b/crates/buzz-acp/src/prompt_project.rs index 1a56ac9cb8f..9f43822b2a5 100644 --- a/crates/buzz-acp/src/prompt_project.rs +++ b/crates/buzz-acp/src/prompt_project.rs @@ -1,4 +1,6 @@ -//! Parse a channel's NIP-MP project home for ACP `[Context]`. +//! Parse a channel's authoritative NIP-MP project home for ACP `[Context]`. + +use std::collections::HashMap; use serde_json::Value; @@ -13,115 +15,124 @@ pub struct PromptProjectInfo { pub default_repo_id: Option, } -/// Pick the oldest listed `kind:30621` bound to the channel. +/// Resolve one listed project whose member repository authoritatively binds the channel. /// -/// Unlisted heads are ignored. When two listed projects share the channel -/// (a duplicate card), the earlier head is the original home. -pub fn pick_listed_project_home(events: &[Value], channel_id: &str) -> Option { - let mut listed: Vec<&Value> = events +/// 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(|event| { - !event_is_unlisted(event) && event_has_tag_value(event, "buzz-channel", channel_id) + .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 = tag_values(event, "maintainers") + .map(str::to_ascii_lowercase) + .collect(); + Some(((owner, id.to_string()), maintainers)) }) - .collect(); - listed.sort_by_key(|event| { - event - .get("created_at") - .and_then(Value::as_u64) - .unwrap_or(u64::MAX) - }); - listed.into_iter().find_map(parse_prompt_project) + .collect() +} + +fn first_tag_value<'a>(event: &'a Value, name: &'static str) -> Option<&'a str> { + tag_values(event, name).next() } -fn event_has_tag_value(event: &Value, name: &str, value: &str) -> bool { +fn tag_values<'a>(event: &'a Value, name: &'static str) -> impl Iterator { event .get("tags") .and_then(Value::as_array) .into_iter() .flatten() - .any(|tag| { - let values = tag.as_array(); - values - .and_then(|parts| parts.first()) - .and_then(Value::as_str) - == Some(name) - && values - .and_then(|parts| parts.get(1)) - .and_then(Value::as_str) - == Some(value) - }) + .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 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 - .get("tags") - .and_then(Value::as_array) - .into_iter() - .flatten() - .any(|tag| { - let arr = tag.as_array(); - matches!( - arr.map(|values| { - values.first().and_then(Value::as_str) == Some("buzz-visibility") - && values.get(1).and_then(Value::as_str) == Some("unlisted") - }), - Some(true) - ) - }) + event_has_tag_value(event, "buzz-visibility", "unlisted") } fn parse_prompt_project(event: &Value) -> Option { - let owner = event - .get("pubkey") - .and_then(Value::as_str)? - .trim() - .to_ascii_lowercase(); + 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 tags = event.get("tags").and_then(Value::as_array)?; - let mut slug = None; - let mut name = None; - let mut default_repo = None; - for tag in tags { - let Some(arr) = tag.as_array() else { continue }; - match arr.first().and_then(Value::as_str) { - Some("d") if slug.is_none() => { - slug = arr - .get(1) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - } - Some("name") if name.is_none() => { - name = arr - .get(1) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - } - Some("a") if default_repo.is_none() => { - default_repo = arr - .get(1) - .and_then(Value::as_str) - .and_then(parse_repo_coord); - } - _ => {} - } + let slug = first_tag_value(event, "d")?.trim().to_string(); + if slug.is_empty() { + return None; } - let slug = slug?; - let owner_for_coord = owner.clone(); + let name = first_tag_value(event, "name") + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&slug) + .to_string(); Some(PromptProjectInfo { - name: name.unwrap_or_else(|| slug.clone()), - coordinate: format!("30621:{owner_for_coord}:{slug}"), + name, + coordinate: format!("30621:{owner}:{slug}"), slug, owner, - default_repo_owner: default_repo - .as_ref() - .map(|(repo_owner, _)| repo_owner.clone()), - default_repo_id: default_repo.map(|(_, repo_id)| repo_id), + default_repo_owner: None, + default_repo_id: None, }) } @@ -143,93 +154,100 @@ mod tests { const CHANNEL_ID: &str = "11111111-1111-4111-8111-111111111111"; - fn project_event( - created_at: u64, - owner: &str, - slug: &str, - name: &str, - extra_tags: Vec, - ) -> Value { - let mut tags = vec![ - json!(["d", slug]), - json!(["name", name]), - json!(["buzz-channel", CHANNEL_ID]), - ]; - tags.extend(extra_tags); - json!({ - "pubkey": owner, - "created_at": created_at, - "kind": 30621, - "tags": tags, - }) + 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 pick_listed_project_home_prefers_oldest_listed() { + fn requires_repo_owned_channel_binding() { let owner = "a".repeat(64); - let later = "b".repeat(64); - let events = vec![ - project_event( - 200, - &later, - "space-invaders-3d", - "Agent copy", - vec![json!(["a", format!("30617:{later}:space-invaders-3d")])], - ), - project_event( - 100, - &owner, - "space-invaders-3d", - "Space Invaders 3D", - vec![], - ), - ]; - let home = pick_listed_project_home(&events, CHANNEL_ID).expect("listed project"); - assert_eq!(home.owner, owner); - assert_eq!(home.name, "Space Invaders 3D"); - assert_eq!(home.slug, "space-invaders-3d"); - assert_eq!(home.coordinate, format!("30621:{owner}:space-invaders-3d")); - assert_eq!(home.default_repo_id, None); + 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 pick_listed_project_home_skips_unlisted_and_reads_member_repo() { + fn hostile_project_cannot_claim_foreign_repo() { let owner = "a".repeat(64); - let repo_owner = "c".repeat(64); - let events = vec![ - project_event( - 50, - &owner, - "hidden", - "Hidden", - vec![json!(["buzz-visibility", "unlisted"])], - ), - project_event( - 80, + 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, - "space-invaders-3d", - "Space Invaders 3D", - vec![json!(["a", format!("30617:{repo_owner}:game")])], - ), - ]; - let home = pick_listed_project_home(&events, CHANNEL_ID).expect("listed project"); - assert_eq!(home.slug, "space-invaders-3d"); - assert_eq!( - home.default_repo_owner.as_deref(), - Some(repo_owner.as_str()) - ); - assert_eq!(home.default_repo_id.as_deref(), Some("game")); + "game", + CHANNEL_ID, + vec![json!(["maintainers", maintainer])], + )], + CHANNEL_ID, + ) + .unwrap(); + assert_eq!(home.owner, maintainer); } #[test] - fn pick_listed_project_home_ignores_other_channels() { + fn ambiguous_authoritative_projects_fail_closed() { let owner = "a".repeat(64); - let mut unrelated = project_event(1, &owner, "wrong", "Wrong", vec![]); - unrelated["tags"][2][1] = json!("22222222-2222-4222-8222-222222222222"); - let expected = project_event(2, &owner, "right", "Right", vec![]); + 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()); + } - let home = - pick_listed_project_home(&[unrelated, expected], CHANNEL_ID).expect("project home"); - assert_eq!(home.slug, "right"); + #[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-cli/src/commands/project_channel.rs b/crates/buzz-cli/src/commands/project_channel.rs index abf0d332094..ed0af5bf14f 100644 --- a/crates/buzz-cli/src/commands/project_channel.rs +++ b/crates/buzz-cli/src/commands/project_channel.rs @@ -6,7 +6,7 @@ //! asking the caller to invent a second project. use buzz_core::kind::KIND_GIT_REPO_ANNOUNCEMENT; -use nostr::{Event, Tag}; +use nostr::Event; use crate::client::BuzzClient; use crate::commands::projects::{fetch_projects_for_channel, try_add_own_repo_to_channel_project}; @@ -25,22 +25,28 @@ pub async fn resolve_or_ensure_repo_for_channel( ) -> Result { validate_uuid(channel)?; let projects = fetch_projects_for_channel(client, channel).await?; - let project = pick_oldest_listed(&projects); - if let Some(event) = project { - if let Some(member) = first_member_repo(event) { - return Ok(member); - } + let repos = fetch_channel_repos(client, channel).await?; + let project = pick_authoritative_project(&projects, &repos, channel)?; + if let Some((_, repo)) = project { + return Ok(repo); } - if let Some(repo) = pick_channel_repo(client, channel).await? { - let caller = client.keys().public_key().to_hex(); - if repo.repo_owner.eq_ignore_ascii_case(&caller) { - let _ = try_add_own_repo_to_channel_project(client, channel, &repo.repo_id).await; - } + 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) = project else { + 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(), )); @@ -48,43 +54,84 @@ pub async fn resolve_or_ensure_repo_for_channel( ensure_default_repo(client, channel, event).await } -fn pick_oldest_listed(events: &[Event]) -> Option<&Event> { - events - .iter() - .filter(|event| !project_is_unlisted(event)) - .min_by_key(|event| event.created_at) -} - fn project_is_unlisted(event: &Event) -> bool { event.tags.iter().any(|tag| { - matches!( - tag.as_slice(), - [name, value, ..] if name == "buzz-visibility" && value == "unlisted" - ) + matches!(tag.as_slice(), [name, value, ..] if name == "buzz-visibility" && value == "unlisted") }) } fn project_dtag(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, - }) + 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() { - [name, value, ..] if name == "name" && !value.is_empty() => Some(value.clone()), + [tag_name, value, ..] if tag_name == name && !value.is_empty() => Some(value.as_str()), _ => None, }) } -fn first_member_repo(event: &Event) -> Option { - event.tags.iter().find_map(|tag| match tag.as_slice() { +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| { + matches!(tag.as_slice(), [name, value, ..] + if name == "maintainers" && 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()?; @@ -108,39 +155,15 @@ fn repo_is_unlisted(event: &Event) -> bool { }) } -fn repo_dtag(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, - }) -} - -async fn pick_channel_repo( - client: &BuzzClient, - channel: &str, -) -> Result, CliError> { +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": 20, + "limit": 1000, }); let raw = client.query(&filter).await?; - let events: Vec = serde_json::from_str(&raw) - .map_err(|error| CliError::Other(format!("failed to parse relay response: {error}")))?; - let Some(event) = events - .iter() - .filter(|event| !repo_is_unlisted(event)) - .min_by_key(|event| event.created_at) - else { - return Ok(None); - }; - let Some(repo_id) = repo_dtag(event) else { - return Ok(None); - }; - Ok(Some(ChannelProjectRepo { - repo_owner: event.pubkey.to_hex(), - repo_id, - })) + serde_json::from_str(&raw) + .map_err(|error| CliError::Other(format!("failed to parse relay response: {error}"))) } async fn ensure_default_repo( @@ -154,7 +177,6 @@ async fn ensure_default_repo( let name = project_name(project).unwrap_or_else(|| slug.clone()); let name = truncate_repo_name(&name); let caller = client.keys().public_key().to_hex(); - let project_owner = project.pubkey.to_hex(); if let Some(existing) = crate::commands::repos::fetch_own_repo_announcement(client, &repo_id).await? @@ -166,7 +188,7 @@ async fn ensure_default_repo( }); } - let mut builder = crate::commands::repos::build_create_announcement( + let builder = crate::commands::repos::build_create_announcement( &repo_id, Some(&name), None, @@ -175,15 +197,6 @@ async fn ensure_default_repo( &[], Some(channel), )?; - if !project_owner.eq_ignore_ascii_case(&caller) { - builder = builder.tag(Tag::parse(["maintainers", project_owner.as_str()]).map_err( - |error| { - CliError::Other(format!( - "failed to tag project owner as maintainer: {error}" - )) - }, - )?); - } let event = client.sign_event(builder)?; client.submit_event(event).await?; let _ = try_add_own_repo_to_channel_project(client, channel, &repo_id).await; @@ -245,6 +258,98 @@ mod tests { ); } + 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 parse_repo_a_tag_reads_nip34_coordinate() { let owner = "a".repeat(64); diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs index 07761e24fae..d01f3e68b8e 100644 --- a/crates/buzz-cli/src/commands/projects.rs +++ b/crates/buzz-cli/src/commands/projects.rs @@ -65,17 +65,6 @@ fn parse_events(json: &str) -> Result, CliError> { .map_err(|e| CliError::Other(format!("failed to parse relay response: {e}"))) } -/// Fetch every listed kind:30621 head for `slug`, any owner. -async fn fetch_projects_by_dtag(client: &BuzzClient, slug: &str) -> Result, CliError> { - let filter = serde_json::json!({ - "kinds": [KIND_PROJECT], - "#d": [slug], - "limit": 20, - }); - let raw = client.query(&filter).await?; - parse_events(&raw) -} - /// 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() @@ -113,12 +102,6 @@ fn project_slug(event: &Event) -> Option { }) } -fn other_listed_project<'a>(events: &'a [Event], caller_pubkey: &str) -> Option<&'a Event> { - events.iter().find(|event| { - !event.pubkey.to_hex().eq_ignore_ascii_case(caller_pubkey) && !project_is_unlisted(event) - }) -} - /// Add repos to a project the caller owns. Returns the relay write JSON. pub async fn add_repos_to_own_project( client: &BuzzClient, @@ -370,24 +353,18 @@ pub async fn cmd_create( "project {slug:?} already exists; use 'buzz projects update' to modify it" ))); } - if let Some(existing) = - other_listed_project(&fetch_projects_by_dtag(client, slug).await?, &caller_pubkey) - { - let owner = existing.pubkey.to_hex(); - return Err(CliError::Conflict(format!( - "project {slug:?} already exists (owner {owner}); do not create another. Add a repository to that project instead." - ))); - } if let Some(channel) = channel { if let Some(existing) = fetch_projects_for_channel(client, channel) .await? .into_iter() - .find(|event| !project_is_unlisted(event)) + .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()); - let owner = existing.pubkey.to_hex(); return Err(CliError::Conflict(format!( - "channel {channel} is already the home of project {existing_slug:?} (owner {owner}); do not create another project. Use `buzz repos create --channel {channel}` and `buzz projects add-repo {existing_slug}`." + "you already own project {existing_slug:?} for channel {channel}; update that project instead" ))); } } From 7bbed3f127f25559fc301044842ee6582b2fdc9a Mon Sep 17 00:00:00 2001 From: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Date: Sun, 23 Aug 2026 01:22:06 -0400 Subject: [PATCH 03/22] fix(projects): validate reused repo binding Co-authored-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Signed-off-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/prompt_project.rs | 15 +++- .../buzz-cli/src/commands/project_channel.rs | 76 ++++++++++++++++++- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/crates/buzz-acp/src/prompt_project.rs b/crates/buzz-acp/src/prompt_project.rs index 9f43822b2a5..0f7e3e40951 100644 --- a/crates/buzz-acp/src/prompt_project.rs +++ b/crates/buzz-acp/src/prompt_project.rs @@ -78,7 +78,7 @@ fn authoritative_channel_repos( if id.is_empty() { return None; } - let maintainers = tag_values(event, "maintainers") + let maintainers = multi_tag_values(event, "maintainers") .map(str::to_ascii_lowercase) .collect(); Some(((owner, id.to_string()), maintainers)) @@ -101,6 +101,17 @@ fn tag_values<'a>(event: &'a Value, name: &'static str) -> impl Iterator(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) } @@ -210,7 +221,7 @@ mod tests { &owner, "game", CHANNEL_ID, - vec![json!(["maintainers", maintainer])], + vec![json!(["maintainers", "c".repeat(64), maintainer])], )], CHANNEL_ID, ) diff --git a/crates/buzz-cli/src/commands/project_channel.rs b/crates/buzz-cli/src/commands/project_channel.rs index ed0af5bf14f..8b3a7c81c52 100644 --- a/crates/buzz-cli/src/commands/project_channel.rs +++ b/crates/buzz-cli/src/commands/project_channel.rs @@ -86,8 +86,10 @@ 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| { - matches!(tag.as_slice(), [name, value, ..] - if name == "maintainers" && value.eq_ignore_ascii_case(&signer)) + tag.as_slice().first().map(String::as_str) == Some("maintainers") + && tag.as_slice()[1..] + .iter() + .any(|value| value.eq_ignore_ascii_case(&signer)) }) } @@ -166,6 +168,20 @@ async fn fetch_channel_repos(client: &BuzzClient, channel: &str) -> Result 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, @@ -181,6 +197,7 @@ async fn ensure_default_repo( 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(), @@ -350,6 +367,61 @@ mod tests { )); } + #[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); From ac2c45e842459c655eadd9a0b10b889e66297bc1 Mon Sep 17 00:00:00 2001 From: thomaspblock Date: Sun, 23 Aug 2026 17:48:23 -0400 Subject: [PATCH 04/22] feat(projects): make project creation channel-first (#6591) ## Summary - create explicit NIP-MP projects with a home channel and default repository - preserve standalone repository folding, project deletion, and deterministic repository selection - restore Template, Team, visibility, and agent settings in the project creation flow This is Part 2 of the channel-first Projects stack, following #6590. It is independently based on `main`; Part 3 adds the project-home channel surface. ## Testing - focused project collection, creation, channel, and model tests: 38/38 passed - Desktop unit suite: 5,415/5,415 passed - TypeScript, Biome, and differential file-size checks passed - full pre-push gate passed ## Post-Deploy Monitoring & Validation - create listed and unlisted projects with and without templates in the first staging Desktop session - healthy signals: one home channel, one default repository, stable project coordinates, and no duplicate legacy card - failure signals: partial project creation, duplicate projects, missing default repository, or stale sidebar entries; mitigate by reverting this PR --------- Signed-off-by: Thomas Petersen --- .../src/features/projects/createProject.ts | 299 ++++++++++++++++++ desktop/src/features/projects/hooks.ts | 9 +- .../projects/lib/projectCollection.test.mjs | 135 ++++++++ .../projects/lib/projectCollection.ts | 103 ++++++ .../projects/projectChannelCreation.test.mjs | 105 ++++++ .../projects/projectChannelCreation.ts | 71 +++++ .../projects/projectCreation.test.mjs | 163 ++++++++-- .../src/features/projects/projectCreation.ts | 218 +++++++++++-- .../features/projects/projectEnumeration.ts | 19 +- .../features/projects/projectModels.test.mjs | 63 ++++ .../src/features/projects/projectModels.ts | 64 +++- .../projects/ui/CreateProjectDialog.tsx | 2 +- .../projects/ui/CreateProjectFormContent.tsx | 149 +-------- .../projects/ui/CreateProjectFormSettings.tsx | 149 +++++++++ .../ui/useCreateProjectFormSettings.ts | 93 ++++++ .../src/features/projects/useCreateProject.ts | 148 ++------- .../tests/e2e/project-commit-detail.spec.ts | 31 +- 17 files changed, 1474 insertions(+), 347 deletions(-) create mode 100644 desktop/src/features/projects/createProject.ts create mode 100644 desktop/src/features/projects/lib/projectCollection.test.mjs create mode 100644 desktop/src/features/projects/lib/projectCollection.ts create mode 100644 desktop/src/features/projects/projectChannelCreation.test.mjs create mode 100644 desktop/src/features/projects/projectChannelCreation.ts create mode 100644 desktop/src/features/projects/ui/CreateProjectFormSettings.tsx create mode 100644 desktop/src/features/projects/ui/useCreateProjectFormSettings.ts diff --git a/desktop/src/features/projects/createProject.ts b/desktop/src/features/projects/createProject.ts new file mode 100644 index 00000000000..f11a7905303 --- /dev/null +++ b/desktop/src/features/projects/createProject.ts @@ -0,0 +1,299 @@ +import { + createChannelManagedAgents, + type CreateChannelManagedAgentInput, +} from "@/features/agents/channelAgents"; +import { fetchProjects, type Project } from "@/features/projects/hooks"; +import { + buildDefaultProjectRepositoryTemplate, + buildProjectBootstrapTemplates, + conflictingListedProject, + isUnsupportedProjectKindError, + projectDtagFromName, + type ProjectListingVisibility, +} from "@/features/projects/projectCreation"; +import { buildProjectPatchTemplate } from "@/features/projects/projectRepositoryCreation"; +import { buildProjectReadModels } from "@/features/projects/projectModels"; +import { relayClient } from "@/shared/api/relayClient"; +import { createChannel, signRelayEvent } from "@/shared/api/tauri"; +import { getIdentity } from "@/shared/api/tauriIdentity"; +import type { + Channel, + ChannelVisibility, + RelayEvent, +} from "@/shared/api/types"; +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl"; + +export type CreateProjectInput = { + name: string; + description?: string; + channelVisibility?: ChannelVisibility; + projectVisibility?: ProjectListingVisibility; + agents?: readonly CreateChannelManagedAgentInput[]; +}; + +export type CreateProjectResult = { + channel: Channel | null; + /** Retained for callers shared with legacy repository-only creation. */ + compatibilityWarning?: string; + project: Project; +}; + +export type CreateProjectResumeState = { + channels: Map; + projectIds: Set; +}; + +function formatAgentFailures( + failures: ReadonlyArray<{ name: string; error: string }>, +) { + if (failures.length === 1) { + const [failure] = failures; + return `The project was created, but adding ${failure.name} failed: ${failure.error}`; + } + return `The project was created, but adding agents failed: ${failures + .map((failure) => `${failure.name}: ${failure.error}`) + .join("; ")}`; +} + +async function publishProjectEvent(event: RelayEvent) { + try { + await relayClient.publishEvent( + event, + "Timed out creating project.", + "Failed to create project.", + ); + } catch (error) { + if (isUnsupportedProjectKindError(error)) { + throw new Error( + "This relay does not support projects yet, so a project channel cannot be published here.", + ); + } + throw error; + } +} + +async function publishRepositoryEvent(event: RelayEvent) { + await relayClient.publishEvent( + event, + "Timed out creating the project repository.", + "Failed to create the project repository.", + ); +} + +function readCreatedProject( + projectEvent: RelayEvent, + repositoryEvent: RelayEvent | null, +): Project { + const [project] = buildProjectReadModels({ + projectEvents: [projectEvent], + repositoryEvents: repositoryEvent ? [repositoryEvent] : [], + relayOrigin: getCachedRelayOrigin(), + }); + if (!project) { + throw new Error("The project was created but could not be read."); + } + return project; +} + +async function addRequestedAgents( + channelId: string, + agents: readonly CreateChannelManagedAgentInput[] | undefined, +) { + if (!agents || agents.length === 0) return; + const result = await createChannelManagedAgents(channelId, agents); + if (result.failures.length > 0) { + throw new Error(formatAgentFailures(result.failures)); + } +} + +async function fetchOwnHead( + kind: number, + ownerPubkey: string, + dtag: string, +): Promise { + const events = await relayClient.fetchEvents({ + kinds: [kind], + authors: [ownerPubkey], + "#d": [dtag], + limit: 1, + }); + return events[0] ?? null; +} + +async function ensureDefaultRepository({ + channelId, + input, + ownerPubkey, + project, +}: { + channelId: string; + input: CreateProjectInput; + ownerPubkey: string; + project: Project; +}): Promise { + const repositoryTemplate = buildDefaultProjectRepositoryTemplate({ + description: input.description, + name: input.name, + ownerPubkey, + projectChannelId: channelId, + }); + const existingRepository = + project.repositories.find( + (repository) => + repository.repoAddress === repositoryTemplate.repositoryAddress, + ) ?? null; + if (existingRepository) return project; + + let repositoryEvent = await fetchOwnHead( + KIND_REPO_ANNOUNCEMENT, + ownerPubkey, + repositoryTemplate.dtag, + ); + if (!repositoryEvent) { + repositoryEvent = await signRelayEvent(repositoryTemplate.repository); + await publishRepositoryEvent(repositoryEvent); + } + + if ( + project.repositoryAddresses.includes(repositoryTemplate.repositoryAddress) + ) { + const liveProject = + (await fetchOwnHead( + KIND_PROJECT_ANNOUNCEMENT, + ownerPubkey, + project.dtag, + )) ?? null; + if (!liveProject) { + throw new Error("The project was created but could not be read."); + } + return readCreatedProject(liveProject, repositoryEvent); + } + + const liveHead = await fetchOwnHead( + KIND_PROJECT_ANNOUNCEMENT, + ownerPubkey, + project.dtag, + ); + if (!liveHead) { + throw new Error( + "Could not find this project on the relay. Refresh and try again.", + ); + } + const patched = buildProjectPatchTemplate({ + liveHead, + ownerPubkey, + repositoryAddresses: [ + ...new Set([ + ...project.repositoryAddresses, + repositoryTemplate.repositoryAddress, + ]), + ].sort(), + }); + const projectEvent = await signRelayEvent(patched); + await publishProjectEvent(projectEvent); + return readCreatedProject(projectEvent, repositoryEvent); +} + +async function finishCreate( + channel: Channel | null, + project: Project, + input: CreateProjectInput, + resume: CreateProjectResumeState, + projectId: string, +): Promise { + const agentChannelId = channel?.id ?? project.projectChannelId; + if (agentChannelId && input.agents && input.agents.length > 0) { + await addRequestedAgents(agentChannelId, input.agents); + } + resume.projectIds.delete(projectId); + resume.channels.delete(projectId); + return { channel, project }; +} + +/** Creates the home channel, a bound default repository, and the NIP-MP project. */ +export async function createProject( + input: CreateProjectInput, + resume: CreateProjectResumeState, +): Promise { + const identity = await getIdentity(); + const dtagPreview = projectDtagFromName(input.name); + if (!dtagPreview) { + throw new Error("Project name must include letters or numbers."); + } + const existing = await fetchProjects(); + const ownerPubkey = identity.pubkey.toLowerCase(); + const existingProject = existing.find( + (project) => + project.owner.toLowerCase() === ownerPubkey && + project.dtag === dtagPreview, + ); + const projectId = `${ownerPubkey}:${dtagPreview}`; + const canResume = resume.projectIds.has(projectId); + if (existingProject && !canResume) { + throw new Error(`You already have a project named "${dtagPreview}".`); + } + if (existingProject && !existingProject.legacy) { + const cachedChannel = resume.channels.get(projectId) ?? null; + const channelId = + cachedChannel?.id ?? existingProject.projectChannelId ?? ""; + const project = channelId + ? await ensureDefaultRepository({ + channelId, + input, + ownerPubkey, + project: existingProject, + }) + : existingProject; + return finishCreate(cachedChannel, project, input, resume, projectId); + } + const conflict = conflictingListedProject(existing, { + dtag: dtagPreview, + name: input.name, + ownerPubkey, + }); + if (conflict) { + throw new Error( + `A project named "${conflict.name}" already exists. Open that one instead of creating another.`, + ); + } + + resume.projectIds.add(projectId); + let channel = resume.channels.get(projectId); + if (!channel) { + channel = await createChannel({ + channelType: "stream", + description: input.description, + name: input.name.trim(), + visibility: input.channelVisibility ?? "open", + }); + resume.channels.set(projectId, channel); + } + + const templates = buildProjectBootstrapTemplates({ + description: input.description, + name: input.name, + ownerPubkey: identity.pubkey, + projectChannelId: channel.id, + projectVisibility: input.projectVisibility ?? "listed", + }); + const existingRepositoryEvent = await fetchOwnHead( + KIND_REPO_ANNOUNCEMENT, + ownerPubkey, + templates.dtag, + ); + const projectEvent = await signRelayEvent(templates.project); + await publishProjectEvent(projectEvent); + + let repositoryEvent = existingRepositoryEvent; + if (!repositoryEvent) { + repositoryEvent = await signRelayEvent(templates.repository); + await publishRepositoryEvent(repositoryEvent); + } + + const project = readCreatedProject(projectEvent, repositoryEvent); + return finishCreate(channel, project, input, resume, projectId); +} diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index ebfc15a083e..4e26287f65e 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -172,9 +172,11 @@ export async function fetchProjects( signal?: AbortSignal, ): Promise { // Delegates to `buildProjectsFromFetcher` in `projectEnumeration.ts`, which - // is the pure, Tauri-free core of this operation. That helper's javadoc - // explains the fail-closed tombstone contract and the NIP-OA owner-deletion - // relay-side-suppression decision. + // is the pure, Tauri-free core of this operation. Its javadoc explains + // fail-closed tombstones and NIP-OA owner-deletion suppression. + const viewerPubkey = await getIdentity() + .then((identity) => identity.pubkey) + .catch(() => undefined); const fetcher: FetchProjectEventsExhaustively = fetchExhaustively ?? ((kinds, extraFilter) => @@ -182,6 +184,7 @@ export async function fetchProjects( return buildProjectsFromFetcher(fetcher, { relayOrigin: getCachedRelayOrigin(), hiddenAddresses: new Set(readHiddenProjectCards()), + viewerPubkey, }); } diff --git a/desktop/src/features/projects/lib/projectCollection.test.mjs b/desktop/src/features/projects/lib/projectCollection.test.mjs new file mode 100644 index 00000000000..d504234e131 --- /dev/null +++ b/desktop/src/features/projects/lib/projectCollection.test.mjs @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + absorbStandaloneProjectRepositories, + homeRepositoriesToBind, +} from "./projectCollection.ts"; + +const OWNER = "a".repeat(64); +const AGENT = "b".repeat(64); +const CHANNEL = "11111111-1111-4111-8111-111111111111"; + +function explicitProject(overrides = {}) { + return { + id: `30621:${OWNER}:space-invaders-3d`, + dtag: "space-invaders-3d", + name: "Space Invaders 3D", + description: "Recreating Space Invaders the Game but in 3D", + owner: OWNER, + createdAt: 100, + projectChannelId: CHANNEL, + relatedChannelIds: [], + status: "active", + projectAddress: `30621:${OWNER}:space-invaders-3d`, + primaryRepositoryAddress: null, + repositoryAddresses: [], + repositoryRelayHints: {}, + repositories: [], + unavailableRepositoryAddresses: [], + visibility: "listed", + legacy: false, + ...overrides, + }; +} + +function standaloneRepo(overrides = {}) { + const owner = overrides.owner ?? AGENT; + const dtag = overrides.dtag ?? "space-invaders-3d"; + const repoAddress = `30617:${owner}:${dtag}`; + const repository = { + id: `${owner}:${dtag}`, + dtag, + name: "Space Invaders 3D", + description: "A 3D remake of Space Invaders built with three.js", + cloneUrls: [], + webUrl: null, + owner, + contributors: [owner], + createdAt: 200, + status: "active", + defaultBranch: "main", + repoAddress, + channelId: CHANNEL, + ...overrides.repository, + }; + return { + id: repoAddress, + dtag, + name: repository.name, + description: repository.description, + owner, + createdAt: 200, + projectChannelId: null, + relatedChannelIds: [], + status: "active", + projectAddress: repoAddress, + primaryRepositoryAddress: repoAddress, + repositoryAddresses: [repoAddress], + repositoryRelayHints: {}, + repositories: [repository], + unavailableRepositoryAddresses: [], + visibility: "listed", + legacy: true, + ...overrides.card, + }; +} + +test("absorbStandaloneProjectRepositories folds a home-channel repo into the project", () => { + const project = explicitProject(); + const repoCard = standaloneRepo(); + const folded = absorbStandaloneProjectRepositories([project, repoCard]); + + assert.equal(folded.length, 1); + assert.equal(folded[0].legacy, false); + assert.equal(folded[0].repositories.length, 1); + assert.equal(folded[0].repositories[0].repoAddress, repoCard.projectAddress); + assert.equal(folded[0].repositoryAddresses[0], repoCard.projectAddress); +}); + +test("absorbStandaloneProjectRepositories folds the owner's same-slug repo", () => { + const project = explicitProject(); + const repoCard = standaloneRepo({ + owner: OWNER, + repository: { channelId: null }, + }); + const folded = absorbStandaloneProjectRepositories([project, repoCard]); + + assert.equal(folded.length, 1); + assert.equal(folded[0].repositories[0].owner, OWNER); +}); + +test("absorbStandaloneProjectRepositories keeps an unrelated standalone repo", () => { + const project = explicitProject(); + const repoCard = standaloneRepo({ + dtag: "other-game", + owner: AGENT, + repository: { channelId: null, dtag: "other-game" }, + }); + const folded = absorbStandaloneProjectRepositories([project, repoCard]); + + assert.equal(folded.length, 2); + assert.equal( + folded.some((item) => item.legacy), + true, + ); +}); + +test("homeRepositoriesToBind lists absorbed channel repos missing from the signed project", () => { + const project = explicitProject({ + repositories: [standaloneRepo().repositories[0]], + repositoryAddresses: [standaloneRepo().projectAddress], + }); + const pending = homeRepositoriesToBind(project, []); + assert.equal(pending.length, 1); + assert.equal(pending[0].repoAddress, standaloneRepo().projectAddress); +}); + +test("homeRepositoriesToBind ignores repos already on the signed project", () => { + const repo = standaloneRepo().repositories[0]; + const project = explicitProject({ + repositories: [repo], + repositoryAddresses: [repo.repoAddress], + }); + assert.equal(homeRepositoriesToBind(project, [repo.repoAddress]).length, 0); +}); diff --git a/desktop/src/features/projects/lib/projectCollection.ts b/desktop/src/features/projects/lib/projectCollection.ts new file mode 100644 index 00000000000..73924c06f4a --- /dev/null +++ b/desktop/src/features/projects/lib/projectCollection.ts @@ -0,0 +1,103 @@ +import type { Project, Repository } from "@/features/projects/projectModels"; + +function withAbsorbedRepository( + project: Project, + repository: Repository, +): Project { + if (project.repositoryAddresses.includes(repository.repoAddress)) { + return project; + } + return { + ...project, + primaryRepositoryAddress: + project.primaryRepositoryAddress ?? repository.repoAddress, + repositories: [...project.repositories, repository], + repositoryAddresses: [ + ...project.repositoryAddresses, + repository.repoAddress, + ], + }; +} + +function hostForStandaloneRepository( + explicitProjects: Project[], + repository: Repository, +): Project | undefined { + const channelHost = repository.channelId + ? explicitProjects.find( + (project) => project.projectChannelId === repository.channelId, + ) + : undefined; + if (channelHost) return channelHost; + return explicitProjects.find( + (project) => + project.owner === repository.owner && project.dtag === repository.dtag, + ); +} + +function repositoryBelongsOnProjectHome( + project: Project, + repository: Repository, +): boolean { + return Boolean( + (repository.channelId && + repository.channelId === project.projectChannelId) || + (repository.owner === project.owner && repository.dtag === project.dtag), + ); +} + +/** + * Repositories already shown on the project (after absorb) that are not yet + * on the signed `kind:30621` `a` tag set. The owner should bind them so + * other clients see the same grouping. + */ +export function homeRepositoriesToBind( + project: Project, + signedAddresses: ReadonlyArray | ReadonlySet, +): Repository[] { + const signed = new Set(signedAddresses); + return project.repositories.filter( + (repository) => + !signed.has(repository.repoAddress) && + repositoryBelongsOnProjectHome(project, repository), + ); +} + +/** + * After the NIP-MP fold, keep a repository off the standalone-project list + * when it already belongs to a listing-eligible project's home channel, or + * when the same owner already has an explicit project with that slug. + * + * Agents often announce a repo (`repos create --channel`) without + * `projects add-repo`. Without this, the same work shows up as a second card. + */ +export function absorbStandaloneProjectRepositories( + projects: Project[], +): Project[] { + const explicitProjects = projects.filter((project) => !project.legacy); + if (explicitProjects.length === 0) return projects; + + const absorbed = new Set(); + let nextExplicit = explicitProjects; + for (const card of projects) { + if (!card.legacy) continue; + const repository = card.repositories[0]; + if (!repository) continue; + const host = hostForStandaloneRepository(nextExplicit, repository); + if (!host) continue; + absorbed.add(card.projectAddress); + nextExplicit = nextExplicit.map((project) => + project.projectAddress === host.projectAddress + ? withAbsorbedRepository(project, repository) + : project, + ); + } + + if (absorbed.size === 0) return projects; + return [ + ...nextExplicit, + ...projects.filter( + (project) => project.legacy && !absorbed.has(project.projectAddress), + ), + ]; +} diff --git a/desktop/src/features/projects/projectChannelCreation.test.mjs b/desktop/src/features/projects/projectChannelCreation.test.mjs new file mode 100644 index 00000000000..3782a919051 --- /dev/null +++ b/desktop/src/features/projects/projectChannelCreation.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { buildProjectRelatedChannelPatchTemplate } from "./projectChannelCreation.ts"; +import { + MAX_PROJECT_RELATED_CHANNELS, + PROJECT_RELATED_CHANNEL_TAG, +} from "./projectModels.ts"; + +const OWNER = "a".repeat(64); +const OTHER = "b".repeat(64); +const CHANNEL_A = "11111111-1111-4111-8111-111111111111"; +const CHANNEL_B = "22222222-2222-4222-8222-222222222222"; + +function liveHead(tags = []) { + return { + content: "", + created_at: 100, + id: "project-head", + kind: 30621, + pubkey: OWNER, + sig: "sig", + tags: [ + ["d", "sprout"], + ["name", "Sprout"], + ["buzz-channel", CHANNEL_A], + ...tags, + ], + }; +} + +test("appends a related channel tag and preserves the live head", () => { + const patched = buildProjectRelatedChannelPatchTemplate({ + channelId: CHANNEL_B, + liveHead: liveHead([["description", "A project"]]), + ownerPubkey: OWNER, + }); + + assert.equal(patched.alreadyBound, false); + assert.deepEqual(patched.project.tags, [ + ["d", "sprout"], + ["name", "Sprout"], + ["buzz-channel", CHANNEL_A], + ["description", "A project"], + [PROJECT_RELATED_CHANNEL_TAG, CHANNEL_B], + ]); +}); + +test("is idempotent when the related channel is already tagged", () => { + const patched = buildProjectRelatedChannelPatchTemplate({ + channelId: CHANNEL_B, + liveHead: liveHead([[PROJECT_RELATED_CHANNEL_TAG, CHANNEL_B]]), + ownerPubkey: OWNER, + }); + + assert.equal(patched.alreadyBound, true); + assert.equal( + patched.project.tags.filter((tag) => tag[0] === PROJECT_RELATED_CHANNEL_TAG) + .length, + 1, + ); +}); + +test("refuses to bind the home channel as a related channel", () => { + assert.throws( + () => + buildProjectRelatedChannelPatchTemplate({ + channelId: CHANNEL_A, + liveHead: liveHead(), + ownerPubkey: OWNER, + }), + /already this project's home/, + ); +}); + +test("only the project owner can add related channels", () => { + assert.throws( + () => + buildProjectRelatedChannelPatchTemplate({ + channelId: CHANNEL_B, + liveHead: liveHead(), + ownerPubkey: OTHER, + }), + /Only the project owner/, + ); +}); + +test("caps extra related channels", () => { + const tags = Array.from( + { length: MAX_PROJECT_RELATED_CHANNELS }, + (_, index) => { + const suffix = String(index + 1).padStart(12, "0"); + return [PROJECT_RELATED_CHANNEL_TAG, `33333333-3333-4333-8333-${suffix}`]; + }, + ); + assert.throws( + () => + buildProjectRelatedChannelPatchTemplate({ + channelId: CHANNEL_B, + liveHead: liveHead(tags), + ownerPubkey: OWNER, + }), + /extra channels/, + ); +}); diff --git a/desktop/src/features/projects/projectChannelCreation.ts b/desktop/src/features/projects/projectChannelCreation.ts new file mode 100644 index 00000000000..a75425c23be --- /dev/null +++ b/desktop/src/features/projects/projectChannelCreation.ts @@ -0,0 +1,71 @@ +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_PROJECT_ANNOUNCEMENT } from "@/shared/constants/kinds"; +import { + isValidProjectChannelId, + MAX_PROJECT_RELATED_CHANNELS, + PROJECT_RELATED_CHANNEL_TAG, + validateProjectEventEnvelope, +} from "@/features/projects/projectModels"; +import type { ProjectEventTemplate } from "./projectCreation"; + +/** + * Appends a `buzz-related-channel` tag to a live project head. Every other + * tag is preserved so adding a stream cannot erase unknown metadata. + */ +export function buildProjectRelatedChannelPatchTemplate({ + channelId, + liveHead, + ownerPubkey, +}: { + channelId: string; + liveHead: RelayEvent; + ownerPubkey: string; +}): { alreadyBound: boolean; project: ProjectEventTemplate } { + const normalizedOwner = ownerPubkey.trim().toLowerCase(); + if (normalizedOwner !== liveHead.pubkey.toLowerCase()) { + throw new Error("Only the project owner can add channels."); + } + const normalizedChannelId = channelId.trim(); + if (!isValidProjectChannelId(normalizedChannelId)) { + throw new Error("Project channel is invalid."); + } + const homeChannelId = liveHead.tags.find( + (tag) => tag[0] === "buzz-channel", + )?.[1]; + if (homeChannelId === normalizedChannelId) { + throw new Error("That channel is already this project's home."); + } + const existingRelated = liveHead.tags + .filter((tag) => tag[0] === PROJECT_RELATED_CHANNEL_TAG) + .map((tag) => tag[1]) + .filter((value): value is string => Boolean(value)); + if (existingRelated.includes(normalizedChannelId)) { + validateProjectEventEnvelope(liveHead.tags, liveHead.content); + return { + alreadyBound: true, + project: { + kind: KIND_PROJECT_ANNOUNCEMENT, + content: liveHead.content, + tags: liveHead.tags.map((tag) => [...tag]), + }, + }; + } + if (existingRelated.length >= MAX_PROJECT_RELATED_CHANNELS) { + throw new Error( + `A project cannot contain more than ${MAX_PROJECT_RELATED_CHANNELS} extra channels.`, + ); + } + const tags = [ + ...liveHead.tags.map((tag) => [...tag]), + [PROJECT_RELATED_CHANNEL_TAG, normalizedChannelId], + ]; + validateProjectEventEnvelope(tags, liveHead.content); + return { + alreadyBound: false, + project: { + kind: KIND_PROJECT_ANNOUNCEMENT, + content: liveHead.content, + tags, + }, + }; +} diff --git a/desktop/src/features/projects/projectCreation.test.mjs b/desktop/src/features/projects/projectCreation.test.mjs index ed6e9328cd2..9a3cf0212ba 100644 --- a/desktop/src/features/projects/projectCreation.test.mjs +++ b/desktop/src/features/projects/projectCreation.test.mjs @@ -2,77 +2,198 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - buildInitialProjectEventTemplates, + buildDefaultProjectRepositoryTemplate, + buildProjectAnnouncementTemplate, + buildProjectBootstrapTemplates, + conflictingListedProject, isUnsupportedProjectKindError, } from "./projectCreation.ts"; const OWNER = "a".repeat(64); const CHANNEL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; -test("buildInitialProjectEventTemplates emits a NIP-MP project", () => { - const templates = buildInitialProjectEventTemplates({ - accessChannelId: CHANNEL, - cloneUrl: "https://relay.example/git/owner/sprout.git", - description: "A multi-repository workspace", +test("buildProjectAnnouncementTemplate emits a channel-first NIP-MP project", () => { + const templates = buildProjectAnnouncementTemplate({ + description: "A workspace that starts as a conversation", name: "Sprout", ownerPubkey: OWNER, - webUrl: "https://example.com/sprout", + projectChannelId: CHANNEL, }); assert.equal(templates.dtag, "sprout"); assert.equal(templates.project.kind, 30621); - assert.equal(templates.repository.kind, 30617); assert.deepEqual(templates.project.tags, [ ["d", "sprout"], ["name", "Sprout"], ["buzz-channel", CHANNEL], - ["description", "A multi-repository workspace"], - ["a", `30617:${OWNER}:sprout`], + ["description", "A workspace that starts as a conversation"], ]); assert.equal(templates.project.content, ""); + assert.equal( + templates.project.tags.some((tag) => tag[0] === "a"), + false, + ); +}); + +test("buildProjectAnnouncementTemplate records unlisted visibility and members", () => { + const address = `30617:${OWNER}:sprout`; + const templates = buildProjectAnnouncementTemplate({ + name: "Sprout", + ownerPubkey: OWNER, + projectChannelId: CHANNEL, + projectVisibility: "unlisted", + repositoryAddresses: [address], + }); + + assert.deepEqual(templates.project.tags, [ + ["d", "sprout"], + ["name", "Sprout"], + ["buzz-channel", CHANNEL], + ["buzz-visibility", "unlisted"], + ["a", address], + ]); +}); + +test("buildProjectBootstrapTemplates binds a default repository to the home channel", () => { + const templates = buildProjectBootstrapTemplates({ + description: "A workspace that starts as a conversation", + name: "Sprout", + ownerPubkey: OWNER, + projectChannelId: CHANNEL, + }); + const repositoryAddress = `30617:${OWNER}:sprout`; + + assert.equal(templates.dtag, "sprout"); + assert.equal(templates.repositoryAddress, repositoryAddress); + assert.equal(templates.project.kind, 30621); + assert.equal(templates.repository.kind, 30617); + assert.deepEqual(templates.project.tags, [ + ["d", "sprout"], + ["name", "Sprout"], + ["buzz-channel", CHANNEL], + ["description", "A workspace that starts as a conversation"], + ["a", repositoryAddress], + ]); assert.deepEqual(templates.repository.tags, [ ["d", "sprout"], ["name", "Sprout"], ["buzz-channel", CHANNEL], - ["description", "A multi-repository workspace"], - ["clone", "https://relay.example/git/owner/sprout.git"], - ["web", "https://example.com/sprout"], + ["description", "A workspace that starts as a conversation"], ]); }); -test("buildInitialProjectEventTemplates rejects names without an identifier", () => { +test("buildDefaultProjectRepositoryTemplate uses the project slug as the repo id", () => { + const template = buildDefaultProjectRepositoryTemplate({ + name: "Space Invaders 3D", + ownerPubkey: OWNER, + projectChannelId: CHANNEL, + }); + + assert.equal(template.dtag, "space-invaders-3d"); + assert.equal(template.repositoryAddress, `30617:${OWNER}:space-invaders-3d`); +}); + +test("buildProjectAnnouncementTemplate rejects names without an identifier", () => { assert.throws( () => - buildInitialProjectEventTemplates({ - accessChannelId: CHANNEL, + buildProjectAnnouncementTemplate({ name: "!!!", ownerPubkey: OWNER, + projectChannelId: CHANNEL, }), /letters or numbers/, ); }); -test("buildInitialProjectEventTemplates enforces the description tag byte limit", () => { +test("buildProjectAnnouncementTemplate enforces the description tag byte limit", () => { assert.doesNotThrow(() => - buildInitialProjectEventTemplates({ - accessChannelId: CHANNEL, + buildProjectAnnouncementTemplate({ description: "🙂".repeat(512), name: "Sprout", ownerPubkey: OWNER, + projectChannelId: CHANNEL, }), ); assert.throws( () => - buildInitialProjectEventTemplates({ - accessChannelId: CHANNEL, + buildProjectAnnouncementTemplate({ description: "🙂".repeat(513), name: "Sprout", ownerPubkey: OWNER, + projectChannelId: CHANNEL, }), /2,048 bytes/, ); }); +test("buildProjectAnnouncementTemplate rejects an invalid project channel", () => { + assert.throws( + () => + buildProjectAnnouncementTemplate({ + name: "Sprout", + ownerPubkey: OWNER, + projectChannelId: "not-a-channel", + }), + /Project channel is invalid/, + ); +}); + +test("conflictingListedProject ignores the caller's own slug and legacy cards", () => { + assert.equal( + conflictingListedProject( + [ + { + dtag: "sprout", + legacy: false, + name: "Sprout", + owner: OWNER, + }, + ], + { dtag: "sprout", name: "Sprout", ownerPubkey: OWNER }, + ), + null, + ); + assert.equal( + conflictingListedProject( + [ + { + dtag: "sprout", + legacy: true, + name: "Sprout", + owner: "b".repeat(64), + }, + ], + { dtag: "sprout", name: "Sprout", ownerPubkey: OWNER }, + ), + null, + ); +}); + +test("conflictingListedProject blocks another listed project with the same name or slug", () => { + const other = { + dtag: "space-invaders-3d", + legacy: false, + name: "Space Invaders 3D", + owner: "b".repeat(64), + }; + assert.deepEqual( + conflictingListedProject([other], { + dtag: "space-invaders-3d", + name: "Space Invaders 3D", + ownerPubkey: OWNER, + }), + other, + ); + assert.deepEqual( + conflictingListedProject([other], { + dtag: "space-invaders-3d-remake", + name: "Space Invaders 3D", + ownerPubkey: OWNER, + }), + other, + ); +}); + test("isUnsupportedProjectKindError recognizes relay kind compatibility failures", () => { assert.equal( isUnsupportedProjectKindError( diff --git a/desktop/src/features/projects/projectCreation.ts b/desktop/src/features/projects/projectCreation.ts index a42cf7bfd79..38bd4521e0e 100644 --- a/desktop/src/features/projects/projectCreation.ts +++ b/desktop/src/features/projects/projectCreation.ts @@ -10,13 +10,18 @@ export type ProjectEventTemplate = { tags: string[][]; }; -export type InitialProjectEventTemplates = { +export type ProjectAnnouncementTemplate = { dtag: string; project: ProjectEventTemplate; +}; + +export type ProjectBootstrapTemplates = ProjectAnnouncementTemplate & { repository: ProjectEventTemplate; repositoryAddress: string; }; +export type ProjectListingVisibility = "listed" | "unlisted"; + export function isUnsupportedProjectKindError(error: unknown): boolean { return ( error instanceof Error && @@ -24,28 +29,64 @@ export function isUnsupportedProjectKindError(error: unknown): boolean { ); } -function projectDtagFromName(name: string): string { +export function projectDtagFromName(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } -export function buildInitialProjectEventTemplates({ - accessChannelId, - cloneUrl, +export type ListedProjectIdentity = { + dtag: string; + legacy: boolean; + name: string; + owner: string; +}; + +/** + * A second listed project with the same slug or display name is the duplicate + * card users see when an agent runs `projects create` inside an existing + * project. Same-owner + same-slug is handled as resume/idempotent create by + * the caller; this finds a *different* listed project that should block create. + */ +export function conflictingListedProject( + projects: readonly ListedProjectIdentity[], + input: { dtag: string; name: string; ownerPubkey: string }, +): ListedProjectIdentity | null { + const ownerPubkey = input.ownerPubkey.toLowerCase(); + const normalizedName = input.name.trim().toLowerCase(); + return ( + projects.find((project) => { + if (project.legacy) return false; + const sameOwnerSlug = + project.owner.toLowerCase() === ownerPubkey && + project.dtag === input.dtag; + if (sameOwnerSlug) return false; + return ( + project.dtag === input.dtag || + project.name.trim().toLowerCase() === normalizedName + ); + }) ?? null + ); +} + +function normalizeProjectAnnouncementInput({ description, name, ownerPubkey, - webUrl, + projectChannelId, }: { - accessChannelId: string; - cloneUrl?: string; description?: string; name: string; ownerPubkey: string; - webUrl?: string; -}): InitialProjectEventTemplates { + projectChannelId: string; +}): { + dtag: string; + normalizedDescription: string; + normalizedName: string; + normalizedOwner: string; + normalizedProjectChannelId: string; +} { const normalizedName = name.trim(); if (!normalizedName) { throw new Error("Project name is required."); @@ -66,36 +107,74 @@ export function buildInitialProjectEventTemplates({ if (new TextEncoder().encode(normalizedDescription).byteLength > 2_048) { throw new Error("Project description must not exceed 2,048 bytes."); } - const repositoryTags: string[][] = [ - ["d", dtag], - ["name", normalizedName], - ]; + const normalizedProjectChannelId = projectChannelId.trim(); + if (!isValidProjectChannelId(normalizedProjectChannelId)) { + throw new Error("Project channel is invalid."); + } + + return { + dtag, + normalizedDescription, + normalizedName, + normalizedOwner, + normalizedProjectChannelId, + }; +} + +/** Channel-first NIP-MP project: metadata, home channel, optional members. */ +export function buildProjectAnnouncementTemplate({ + description, + name, + ownerPubkey, + projectChannelId, + projectVisibility = "listed", + repositoryAddresses = [], +}: { + description?: string; + name: string; + ownerPubkey: string; + projectChannelId: string; + projectVisibility?: ProjectListingVisibility; + repositoryAddresses?: readonly string[]; +}): ProjectAnnouncementTemplate { + const { + dtag, + normalizedDescription, + normalizedName, + normalizedProjectChannelId, + } = normalizeProjectAnnouncementInput({ + description, + name, + ownerPubkey, + projectChannelId, + }); + + if (new Set(repositoryAddresses).size !== repositoryAddresses.length) { + throw new Error("A project cannot contain duplicate repositories."); + } + if ( + repositoryAddresses.some( + (address) => !/^30617:[0-9a-f]{64}:.+$/.test(address), + ) + ) { + throw new Error("Repository address is invalid."); + } + const projectTags: string[][] = [ ["d", dtag], ["name", normalizedName], + ["buzz-channel", normalizedProjectChannelId], ]; - const normalizedAccessChannelId = accessChannelId.trim(); - if (!isValidProjectChannelId(normalizedAccessChannelId)) { - throw new Error("Repository access channel is invalid."); - } - repositoryTags.push(["buzz-channel", normalizedAccessChannelId]); - projectTags.push(["buzz-channel", normalizedAccessChannelId]); if (normalizedDescription) { - repositoryTags.push(["description", normalizedDescription]); projectTags.push(["description", normalizedDescription]); } - const normalizedCloneUrl = cloneUrl?.trim(); - if (normalizedCloneUrl) { - repositoryTags.push(["clone", normalizedCloneUrl]); + if (projectVisibility === "unlisted") { + projectTags.push(["buzz-visibility", "unlisted"]); } - const normalizedWebUrl = webUrl?.trim(); - if (normalizedWebUrl) { - repositoryTags.push(["web", normalizedWebUrl]); + for (const address of [...repositoryAddresses].sort()) { + projectTags.push(["a", address]); } - const repositoryAddress = `${KIND_REPO_ANNOUNCEMENT}:${normalizedOwner}:${dtag}`; - projectTags.push(["a", repositoryAddress]); - return { dtag, project: { @@ -103,11 +182,88 @@ export function buildInitialProjectEventTemplates({ content: "", tags: projectTags, }, + }; +} + +/** Default 30617 bound to the project home channel, using the project slug. */ +export function buildDefaultProjectRepositoryTemplate({ + description, + name, + ownerPubkey, + projectChannelId, +}: { + description?: string; + name: string; + ownerPubkey: string; + projectChannelId: string; +}): { + dtag: string; + repository: ProjectEventTemplate; + repositoryAddress: string; +} { + const { + dtag, + normalizedDescription, + normalizedName, + normalizedOwner, + normalizedProjectChannelId, + } = normalizeProjectAnnouncementInput({ + description, + name, + ownerPubkey, + projectChannelId, + }); + const repositoryAddress = `${KIND_REPO_ANNOUNCEMENT}:${normalizedOwner}:${dtag}`; + const repositoryTags: string[][] = [ + ["d", dtag], + ["name", normalizedName], + ["buzz-channel", normalizedProjectChannelId], + ]; + if (normalizedDescription) { + repositoryTags.push(["description", normalizedDescription]); + } + return { + dtag, + repositoryAddress, repository: { kind: KIND_REPO_ANNOUNCEMENT, content: normalizedDescription, tags: repositoryTags, }, - repositoryAddress, + }; +} + +/** Home channel + default repository already listed on the project. */ +export function buildProjectBootstrapTemplates({ + description, + name, + ownerPubkey, + projectChannelId, + projectVisibility = "listed", +}: { + description?: string; + name: string; + ownerPubkey: string; + projectChannelId: string; + projectVisibility?: ProjectListingVisibility; +}): ProjectBootstrapTemplates { + const repository = buildDefaultProjectRepositoryTemplate({ + description, + name, + ownerPubkey, + projectChannelId, + }); + const announcement = buildProjectAnnouncementTemplate({ + description, + name, + ownerPubkey, + projectChannelId, + projectVisibility, + repositoryAddresses: [repository.repositoryAddress], + }); + return { + ...announcement, + repository: repository.repository, + repositoryAddress: repository.repositoryAddress, }; } diff --git a/desktop/src/features/projects/projectEnumeration.ts b/desktop/src/features/projects/projectEnumeration.ts index ac494321042..6a1929fc2c6 100644 --- a/desktop/src/features/projects/projectEnumeration.ts +++ b/desktop/src/features/projects/projectEnumeration.ts @@ -5,6 +5,7 @@ import { KIND_PROJECT_ANNOUNCEMENT, KIND_REPO_ANNOUNCEMENT, } from "@/shared/constants/kinds"; +import { absorbStandaloneProjectRepositories } from "./lib/projectCollection"; import { buildProjectReadModels, type Project } from "./projectModels"; const PROJECT_ENUMERATION_PAGE_SIZE = 500; @@ -173,6 +174,7 @@ export async function buildProjectsFromFetcher( options: { relayOrigin?: string | null; hiddenAddresses?: ReadonlySet; + viewerPubkey?: string | null; } = {}, ): Promise { const [projectEvents, repositoryEvents] = await Promise.all([ @@ -200,11 +202,14 @@ export async function buildProjectsFromFetcher( ); } - return buildProjectReadModels({ - projectEvents, - repositoryEvents, - deletionEvents: tombstoneResult.events, - relayOrigin: options.relayOrigin ?? null, - hiddenAddresses: options.hiddenAddresses ?? new Set(), - }).sort((a, b) => b.createdAt - a.createdAt); + return absorbStandaloneProjectRepositories( + buildProjectReadModels({ + projectEvents, + repositoryEvents, + deletionEvents: tombstoneResult.events, + relayOrigin: options.relayOrigin ?? null, + hiddenAddresses: options.hiddenAddresses ?? new Set(), + viewerPubkey: options.viewerPubkey, + }), + ).sort((a, b) => b.createdAt - a.createdAt); } diff --git a/desktop/src/features/projects/projectModels.test.mjs b/desktop/src/features/projects/projectModels.test.mjs index 7be105584a9..837a0deb531 100644 --- a/desktop/src/features/projects/projectModels.test.mjs +++ b/desktop/src/features/projects/projectModels.test.mjs @@ -90,6 +90,31 @@ test("buildProjectReadModels resolves repositories with a deterministic selectio projects[0].repositoryRelayHints[backendAddress], "wss://relay.example", ); + assert.equal( + projects[0].projectChannelId, + "11111111-1111-4111-8111-111111111111", + ); + assert.deepEqual(projects[0].relatedChannelIds, []); +}); + +test("buildProjectReadModels keeps extra related channel ids", () => { + const relatedA = "22222222-2222-4222-8222-222222222222"; + const relatedB = "33333333-3333-4333-8333-333333333333"; + const home = "11111111-1111-4111-8111-111111111111"; + const projects = buildProjectReadModels({ + projectEvents: [ + projectEvent([ + ["buzz-related-channel", relatedA], + ["buzz-related-channel", home], + ["buzz-related-channel", relatedB], + ["buzz-related-channel", relatedA], + ]), + ], + repositoryEvents: [], + relayOrigin: RELAY_ORIGIN, + }); + + assert.deepEqual(projects[0].relatedChannelIds, [relatedA, relatedB]); }); test("buildProjectReadModels keeps unclaimed repositories as implicit projects", () => { @@ -194,6 +219,44 @@ test("selectProjectRepository honors a request and falls back to primary", () => assert.equal(selectProjectRepository(projects[0], null)?.dtag, "backend"); }); +test("buildProjectReadModels keeps the viewer's own unlisted project", () => { + const repoAddress = `30617:${PROJECT_OWNER}:secret`; + const unlisted = { + ...projectEvent([["a", repoAddress]]), + tags: [ + ["d", "secret"], + ["name", "Secret"], + ["buzz-channel", "11111111-1111-4111-8111-111111111111"], + ["buzz-visibility", "unlisted"], + ["a", repoAddress], + ], + }; + const asStranger = buildProjectReadModels({ + projectEvents: [unlisted], + repositoryEvents: [repositoryEvent(PROJECT_OWNER, "secret")], + relayOrigin: RELAY_ORIGIN, + }); + const asOwner = buildProjectReadModels({ + projectEvents: [unlisted], + repositoryEvents: [repositoryEvent(PROJECT_OWNER, "secret")], + relayOrigin: RELAY_ORIGIN, + viewerPubkey: PROJECT_OWNER, + }); + + assert.equal( + asStranger.some((project) => project.dtag === "secret" && !project.legacy), + false, + ); + assert.equal( + asStranger.some((project) => project.legacy && project.dtag === "secret"), + true, + ); + assert.equal(asOwner.length, 1); + assert.equal(asOwner[0]?.legacy, false); + assert.equal(asOwner[0]?.dtag, "secret"); + assert.equal(asOwner[0]?.visibility, "unlisted"); +}); + function coordinateParts(coordinate) { const first = coordinate.indexOf(":"); const second = coordinate.indexOf(":", first + 1); diff --git a/desktop/src/features/projects/projectModels.ts b/desktop/src/features/projects/projectModels.ts index 6d539b54d9e..a89ddcd689e 100644 --- a/desktop/src/features/projects/projectModels.ts +++ b/desktop/src/features/projects/projectModels.ts @@ -32,6 +32,12 @@ export type Project = { owner: string; createdAt: number; projectChannelId: string | null; + /** + * Extra streams linked to this project via repeatable + * `buzz-related-channel` tags. Client convention: NIP-MP treats the tag as + * unrecognized metadata, so older readers ignore it. + */ + relatedChannelIds: string[]; status: string; projectAddress: string; primaryRepositoryAddress: string | null; @@ -50,6 +56,12 @@ type BuildProjectReadModelsInput = { deletionEvents?: RelayEvent[]; relayOrigin?: string | null; hiddenAddresses?: ReadonlySet; + /** + * When set, the viewer's own unlisted projects stay in the collection so the + * creator can still open them. Other viewers keep the NIP-MP fold: unlisted + * projects are absent and do not claim members. + */ + viewerPubkey?: string | null; }; const MAX_D_TAG_BYTES = 1_024; @@ -109,6 +121,12 @@ export function isValidProjectChannelId(value: string): boolean { ); } +/** Repeatable project tag naming an extra stream besides `buzz-channel`. */ +export const PROJECT_RELATED_CHANNEL_TAG = "buzz-related-channel"; + +/** Cap extra project streams so a tag list cannot grow without bound. */ +export const MAX_PROJECT_RELATED_CHANNELS = 64; + const SINGLETON_METADATA_TAGS = [ "name", "description", @@ -339,6 +357,16 @@ export function eventToExplicitProject( const visibility = rawVisibility === "unlisted" ? ("unlisted" as const) : ("listed" as const); const channel = getTag(event, "buzz-channel"); + const projectChannelId = + channel && isValidProjectChannelId(channel) ? channel : null; + const relatedChannelIds = [ + ...new Set( + getAllTags(event, PROJECT_RELATED_CHANNEL_TAG).filter( + (channelId) => + isValidProjectChannelId(channelId) && channelId !== projectChannelId, + ), + ), + ].slice(0, MAX_PROJECT_RELATED_CHANNELS); return { id: projectAddress, dtag, @@ -346,8 +374,8 @@ export function eventToExplicitProject( description: getTag(event, "description") ?? "", owner, createdAt: event.created_at, - projectChannelId: - channel && isValidProjectChannelId(channel) ? channel : null, + projectChannelId, + relatedChannelIds, status: visibility === "listed" ? "active" : "unlisted", projectAddress, primaryRepositoryAddress, @@ -374,6 +402,7 @@ function repositoryToLegacyProject(repository: Repository): Project { owner: repository.owner, createdAt: repository.createdAt, projectChannelId: null, + relatedChannelIds: [], status: repository.status, projectAddress: repository.repoAddress, primaryRepositoryAddress: repository.repoAddress, @@ -417,12 +446,23 @@ function buildDeletionThresholds( return thresholds; } +function projectIsListingEligible( + project: Project, + viewerPubkey: string | null | undefined, +): boolean { + if (project.visibility !== "unlisted") return true; + return Boolean( + viewerPubkey && project.owner === viewerPubkey.trim().toLowerCase(), + ); +} + export function buildProjectReadModels({ projectEvents, repositoryEvents, deletionEvents = [], relayOrigin, hiddenAddresses = new Set(), + viewerPubkey, }: BuildProjectReadModelsInput): Project[] { const deletionThresholds = buildDeletionThresholds(deletionEvents); @@ -463,7 +503,7 @@ export function buildProjectReadModels({ visibleRepositoriesByAddress, ); return project && - project.visibility === "listed" && + projectIsListingEligible(project, viewerPubkey) && !hiddenAddresses.has(project.projectAddress) ? [project] : []; @@ -548,3 +588,21 @@ export function addRepositoryToProject( ) ?? [], }; } + +/** Returns the optimistic read model after linking an extra project stream. */ +export function addRelatedChannelToProject( + project: Project, + channelId: string, + createdAt: number, +): Project { + const relatedChannelIds = [ + ...new Set([...(project.relatedChannelIds ?? []), channelId]), + ].filter( + (id) => id !== project.projectChannelId && isValidProjectChannelId(id), + ); + return { + ...project, + createdAt, + relatedChannelIds: relatedChannelIds.slice(0, MAX_PROJECT_RELATED_CHANNELS), + }; +} diff --git a/desktop/src/features/projects/ui/CreateProjectDialog.tsx b/desktop/src/features/projects/ui/CreateProjectDialog.tsx index d3ac4dfe84b..7f75a45f7dd 100644 --- a/desktop/src/features/projects/ui/CreateProjectDialog.tsx +++ b/desktop/src/features/projects/ui/CreateProjectDialog.tsx @@ -9,7 +9,7 @@ type CreateProjectDialogProps = { open: boolean; }; -/** Modal for publishing a project with its initial NIP-34 repository. */ +/** Modal for creating a project channel. */ export function CreateProjectDialog({ isCreating, onCreate, diff --git a/desktop/src/features/projects/ui/CreateProjectFormContent.tsx b/desktop/src/features/projects/ui/CreateProjectFormContent.tsx index df825d83f01..c685187ff51 100644 --- a/desktop/src/features/projects/ui/CreateProjectFormContent.tsx +++ b/desktop/src/features/projects/ui/CreateProjectFormContent.tsx @@ -1,8 +1,9 @@ import { ArrowLeft } from "lucide-react"; import * as React from "react"; -import { useChannelsQuery } from "@/features/channels/hooks"; import type { CreateProjectInput } from "@/features/projects/useCreateProject"; +import { CreateProjectFormSettings } from "@/features/projects/ui/CreateProjectFormSettings"; +import { useCreateProjectFormSettings } from "@/features/projects/ui/useCreateProjectFormSettings"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; @@ -33,51 +34,35 @@ export function CreateProjectFormContent({ }) { const [name, setName] = React.useState(""); const [description, setDescription] = React.useState(""); - const [cloneUrl, setCloneUrl] = React.useState(""); - const [webUrl, setWebUrl] = React.useState(""); - const [accessChannelId, setAccessChannelId] = React.useState(""); const [errorMessage, setErrorMessage] = React.useState(null); const nameInputRef = React.useRef(null); - const channelsQuery = useChannelsQuery({ enabled: active }); - const accessChannels = React.useMemo( - () => - (channelsQuery.data ?? []).filter( - (channel) => - channel.isMember && - !channel.archivedAt && - channel.channelType !== "dm", - ), - [channelsQuery.data], - ); + const settings = useCreateProjectFormSettings(active); React.useEffect(() => { if (!active) return; setName(initialName); setDescription(""); - setCloneUrl(""); - setWebUrl(""); - setAccessChannelId(accessChannels[0]?.id ?? ""); setErrorMessage(null); const timerId = globalThis.setTimeout(() => { nameInputRef.current?.focus(); }, 50); return () => globalThis.clearTimeout(timerId); - }, [accessChannels, active, initialName]); + }, [active, initialName]); async function handleSubmit(event: React.FormEvent) { event.preventDefault(); const trimmedName = name.trim(); - if (!trimmedName || !accessChannelId) return; + if (!trimmedName) return; setErrorMessage(null); try { await onCreate({ - accessChannelId, name: trimmedName, description: description.trim() || undefined, - cloneUrl: cloneUrl.trim() || undefined, - webUrl: webUrl.trim() || undefined, + channelVisibility: settings.channelVisibility, + projectVisibility: settings.projectVisibility, + agents: settings.buildAgents(), }); onCreated(); } catch (error) { @@ -92,14 +77,12 @@ export function CreateProjectFormContent({ className="max-w-lg" contentClassName="pt-3" data-testid="create-project-dialog" - description="Projects group one or more repositories published to this workspace's relay." + headerSubtitle="A project starts as a channel with a repository. People in the channel can talk, clone, and open tasks here." footer={
-
- -
- -
-

- Members of this channel can access project repositories. -

-
-
-
- -
- { - setCloneUrl(event.target.value); - setErrorMessage(null); - }} - placeholder="https://relay.example.com/git/bee-garden-game.git" - spellCheck={false} - value={cloneUrl} - /> -
-
- -
- -
- { - setWebUrl(event.target.value); - setErrorMessage(null); - }} - placeholder="https://github.com/owner/repo" - spellCheck={false} - value={webUrl} - /> -
-
+ {errorMessage ? (

{errorMessage}

diff --git a/desktop/src/features/projects/ui/CreateProjectFormSettings.tsx b/desktop/src/features/projects/ui/CreateProjectFormSettings.tsx new file mode 100644 index 00000000000..2f90dc8be1d --- /dev/null +++ b/desktop/src/features/projects/ui/CreateProjectFormSettings.tsx @@ -0,0 +1,149 @@ +import { ChevronDown } from "lucide-react"; + +import { ChannelPermissionsSettings } from "@/features/channels/ui/ChannelPermissionsSettings"; +import type { CreateProjectFormSettingsState } from "@/features/projects/ui/useCreateProjectFormSettings"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { cn } from "@/shared/lib/cn"; + +const NONE_AGENT_VALUE = "__none__"; + +const SETTINGS_ROW_CLASS = + "flex min-h-12 items-center justify-between gap-4 rounded-xl border border-input bg-background px-3 py-3"; + +export function CreateProjectFormSettings({ + agentPersonaId, + disabled, + personas, + projectVisibility, + runtimesAvailable, + setAgentPersonaId, + setChannelVisibility, + setProjectVisibility, + channelVisibility, +}: CreateProjectFormSettingsState & { disabled: boolean }) { + const selectedPersona = personas.find( + (persona) => persona.id === agentPersonaId, + ); + const listingLabel = projectVisibility === "unlisted" ? "Unlisted" : "Listed"; + const agentLabel = selectedPersona?.displayName ?? "None"; + const agentDisabled = disabled || (!runtimesAvailable && personas.length > 0); + + return ( + <> + + +
+ + Project list + + + + + + event.preventDefault()} + style={{ + minWidth: "var(--radix-dropdown-menu-trigger-width)", + }} + > + + setProjectVisibility( + value === "unlisted" ? "unlisted" : "listed", + ) + } + value={projectVisibility} + > + + Listed + + + Unlisted + + + + +
+ +
+ + Coding agent + + + + + + event.preventDefault()} + style={{ + minWidth: "var(--radix-dropdown-menu-trigger-width)", + }} + > + + setAgentPersonaId(value === NONE_AGENT_VALUE ? "" : value) + } + value={agentPersonaId || NONE_AGENT_VALUE} + > + + None + + {personas.map((persona) => ( + + {persona.displayName} + + ))} + + + +
+ + ); +} diff --git a/desktop/src/features/projects/ui/useCreateProjectFormSettings.ts b/desktop/src/features/projects/ui/useCreateProjectFormSettings.ts new file mode 100644 index 00000000000..00a70c587a5 --- /dev/null +++ b/desktop/src/features/projects/ui/useCreateProjectFormSettings.ts @@ -0,0 +1,93 @@ +import * as React from "react"; + +import type { CreateChannelManagedAgentInput } from "@/features/agents/channelAgents"; +import { + useAvailableAcpRuntimes, + usePersonasQuery, +} from "@/features/agents/hooks"; +import { getActivePersonas } from "@/features/agents/lib/catalog"; +import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; +import type { ProjectListingVisibility } from "@/features/projects/projectCreation"; +import type { ChannelVisibility } from "@/shared/api/types"; + +export function useCreateProjectFormSettings(active: boolean) { + const personasQuery = usePersonasQuery({ enabled: active }); + const runtimesQuery = useAvailableAcpRuntimes({ enabled: active }); + const [channelVisibility, setChannelVisibility] = + React.useState("open"); + const [projectVisibility, setProjectVisibility] = + React.useState("listed"); + const [agentPersonaId, setAgentPersonaId] = React.useState(""); + + const personas = React.useMemo( + () => getActivePersonas(personasQuery.data ?? []), + [personasQuery.data], + ); + + React.useEffect(() => { + if (!active) return; + setChannelVisibility("open"); + setProjectVisibility("listed"); + setAgentPersonaId(""); + }, [active]); + + React.useEffect(() => { + if ( + agentPersonaId && + !personas.some((persona) => persona.id === agentPersonaId) + ) { + setAgentPersonaId(""); + } + }, [agentPersonaId, personas]); + + const buildAgents = + React.useCallback((): CreateChannelManagedAgentInput[] => { + if (!agentPersonaId) return []; + const persona = personas.find((entry) => entry.id === agentPersonaId); + if (!persona) { + throw new Error("Choose an agent that still exists."); + } + const defaultRuntime = runtimesQuery.data[0] ?? null; + const resolved = resolvePersonaRuntime( + persona.runtime, + runtimesQuery.data, + defaultRuntime, + false, + ); + if (!resolved.runtime) { + throw new Error( + resolved.warnings[0] ?? + "No agent runtimes are available. Install a runtime to add an agent.", + ); + } + return [ + { + runtime: resolved.runtime, + name: persona.displayName, + personaId: persona.id, + harnessOverride: false, + systemPrompt: persona.systemPrompt, + avatarUrl: persona.avatarUrl ?? undefined, + model: persona.model ?? undefined, + role: "bot", + backend: { type: "local" }, + }, + ]; + }, [agentPersonaId, personas, runtimesQuery.data]); + + return { + agentPersonaId, + buildAgents, + channelVisibility, + personas, + projectVisibility, + runtimesAvailable: runtimesQuery.data.length > 0, + setAgentPersonaId, + setChannelVisibility, + setProjectVisibility, + }; +} + +export type CreateProjectFormSettingsState = ReturnType< + typeof useCreateProjectFormSettings +>; diff --git a/desktop/src/features/projects/useCreateProject.ts b/desktop/src/features/projects/useCreateProject.ts index f4f548683fb..8526e0317d6 100644 --- a/desktop/src/features/projects/useCreateProject.ts +++ b/desktop/src/features/projects/useCreateProject.ts @@ -2,135 +2,34 @@ import * as React from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { - fetchProjects, - type Project, - projectsQueryKey, -} from "@/features/projects/hooks"; + channelsQueryKey, + upsertCachedChannel, +} from "@/features/channels/hooks"; +import { type Project, projectsQueryKey } from "@/features/projects/hooks"; import { - buildInitialProjectEventTemplates, - isUnsupportedProjectKindError, -} from "@/features/projects/projectCreation"; + createProject, + type CreateProjectInput, + type CreateProjectResult, + type CreateProjectResumeState, +} from "@/features/projects/createProject"; import { addProjectToSidebar } from "@/features/projects/lib/projectSidebarMembership"; -import { buildProjectReadModels } from "@/features/projects/projectModels"; -import { relayClient } from "@/shared/api/relayClient"; +import type { Channel } from "@/shared/api/types"; import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl"; -import { signRelayEvent } from "@/shared/api/tauri"; -import { getIdentity } from "@/shared/api/tauriIdentity"; -export type CreateProjectInput = { - accessChannelId: string; - name: string; - description?: string; - cloneUrl?: string; - webUrl?: string; -}; +export type { CreateProjectInput, CreateProjectResult }; -export type CreateProjectResult = { - project: Project; - compatibilityWarning?: string; -}; - -/** Publishes a project announcement and its initial NIP-34 repository. */ -async function createProject( - input: CreateProjectInput, - resumableProjectIds: Set, -): Promise { - const identity = await getIdentity(); - const templates = buildInitialProjectEventTemplates({ - ...input, - ownerPubkey: identity.pubkey, - }); - const existing = await fetchProjects(); - const ownerPubkey = identity.pubkey.toLowerCase(); - const existingProject = existing.find( - (project) => - project.owner.toLowerCase() === ownerPubkey && - project.dtag === templates.dtag, - ); - const projectId = `${ownerPubkey}:${templates.dtag}`; - const canResume = resumableProjectIds.has(projectId); - if (existingProject && !canResume) { - throw new Error(`You already have a project named "${templates.dtag}".`); - } - if (existingProject && !existingProject.legacy) { - if ( - existingProject.repositories.some( - (repository) => repository.repoAddress === templates.repositoryAddress, - ) - ) { - resumableProjectIds.delete(projectId); - return { project: existingProject }; - } - throw new Error(`You already have a project named "${templates.dtag}".`); - } - - resumableProjectIds.add(projectId); - const projectEvent = await signRelayEvent(templates.project); - - let repositoryEvent = null; - if (!existingProject) { - repositoryEvent = await signRelayEvent(templates.repository); - await relayClient.publishEvent( - repositoryEvent, - "Timed out creating the initial repository.", - "Failed to create the initial repository.", - ); - } - - try { - await relayClient.publishEvent( - projectEvent, - "Timed out creating project.", - "Failed to create project.", - ); - } catch (error) { - if (!isUnsupportedProjectKindError(error)) throw error; - - const [legacyProject] = existingProject?.legacy - ? [existingProject] - : buildProjectReadModels({ - projectEvents: [], - repositoryEvents: repositoryEvent ? [repositoryEvent] : [], - relayOrigin: getCachedRelayOrigin(), - }); - if (!legacyProject) throw error; - - resumableProjectIds.delete(projectId); - return { - project: legacyProject, - compatibilityWarning: - "The repository was created, but this relay does not support multi-repository projects yet. It will appear as a standalone project.", - }; - } - - const [project] = repositoryEvent - ? buildProjectReadModels({ - projectEvents: [projectEvent], - repositoryEvents: [repositoryEvent], - relayOrigin: getCachedRelayOrigin(), - }) - : (await fetchProjects()).filter( - (candidate) => - candidate.owner.toLowerCase() === ownerPubkey && - candidate.dtag === templates.dtag && - !candidate.legacy, - ); - if (!project) { - throw new Error("The project was created but could not be read."); - } - resumableProjectIds.delete(projectId); - return { project }; -} - -/** Mutation that creates a project and inserts it into the projects cache. */ +/** Mutation that creates a project home and inserts it into the caches. */ export function useCreateProjectMutation() { const queryClient = useQueryClient(); - const resumableProjectIdsRef = React.useRef(new Set()); + const resumeRef = React.useRef({ + channels: new Map(), + projectIds: new Set(), + }); return useMutation({ mutationFn: (input: CreateProjectInput) => - createProject(input, resumableProjectIdsRef.current), - onSuccess: ({ project }) => { + createProject(input, resumeRef.current), + onSuccess: ({ channel, project }) => { addProjectToSidebar( project.projectAddress, getCachedRelayOrigin(), @@ -148,6 +47,17 @@ export function useCreateProjectMutation() { ), ), ]); + if (channel) { + queryClient.setQueryData( + channelsQueryKey, + (current: Channel[] | undefined) => + upsertCachedChannel(current, channel), + ); + void queryClient.invalidateQueries({ + queryKey: channelsQueryKey, + refetchType: "none", + }); + } void queryClient.invalidateQueries({ queryKey: projectsQueryKey }); }, }); diff --git a/desktop/tests/e2e/project-commit-detail.spec.ts b/desktop/tests/e2e/project-commit-detail.spec.ts index 53804bf85ae..a6f10ccb7fc 100644 --- a/desktop/tests/e2e/project-commit-detail.spec.ts +++ b/desktop/tests/e2e/project-commit-detail.spec.ts @@ -260,9 +260,6 @@ test("creating a project publishes its initial repository grouping", async ({ await page .getByTestId("create-project-description") .fill("A grouped project created through the desktop app."); - await page - .getByTestId("create-project-clone-url") - .fill("https://relay.example.com/git/owner/multi-repo-demo.git"); await page.getByTestId("create-project-submit").click(); await expect(page.getByTestId("create-project-dialog")).toBeHidden(); @@ -314,7 +311,7 @@ test("creating a project publishes its initial repository grouping", async ({ .toBe(2); }); -test("unsupported relays keep the initial repository accessible", async ({ +test("unsupported relays cannot create a channel-first project", async ({ page, }) => { await enableProjectsFeature(page); @@ -329,26 +326,10 @@ test("unsupported relays keep the initial repository accessible", async ({ await page.getByTestId("create-project-name").fill("legacy-fallback"); await page.getByTestId("create-project-submit").click(); - await expect(page.getByTestId("create-project-dialog")).toBeHidden(); - await expect(page.getByText("Created as a standalone project")).toBeVisible(); - await waitForAnimations(page); - const projectEntry = page - .locator( - '[data-testid="project-card-legacy-fallback"], [data-testid="project-row-legacy-fallback"]', - ) - .first(); - await expect(projectEntry).toBeVisible(); - await projectEntry - .getByRole("button", { name: "View legacy-fallback" }) - .click(); - const repositoryRow = page.getByTestId( - "sidebar-project-repository-legacy-fallback", - ); - await expect(repositoryRow).toBeVisible(); - await waitForAnimations(page); - await page.screenshot({ - path: `${SHOTS}/06-single-repository-add.png`, - }); + await expect(page.getByTestId("create-project-dialog")).toBeVisible(); + await expect( + page.getByText("This relay does not support projects yet"), + ).toBeVisible(); const acceptedKinds = await page.evaluate( () => @@ -360,7 +341,7 @@ test("unsupported relays keep the initial repository accessible", async ({ ) .map((event) => event.kind) ?? [], ); - expect(acceptedKinds).toEqual([30617]); + expect(acceptedKinds).toEqual([]); }); test("project creation can retry after its repository publication fails", async ({ From 6c0abfdddbf4f75fadb6ff27d5e917acb3b6085d Mon Sep 17 00:00:00 2001 From: thomaspblock Date: Sun, 23 Aug 2026 17:48:30 -0400 Subject: [PATCH 05/22] feat(channels): support project-home channel surfaces (#6594) ## Summary - classify and render project-home channels through the shared channel glyph and lifecycle helpers - let the normal channel pane host a project idle auxiliary surface and focus drawer - align channel management, headers, member bars, and empty-channel actions with project channel semantics This is Part 3 of the channel-first Projects stack, based on #6591. Part 4 adds the project-home navigation and context experience. ## Testing - focused channel lifecycle, pane helper, and project-home channel tests: 7/7 passed - Desktop unit suite: 5,422/5,422 passed - E2E-mode Desktop build passed - TypeScript, Biome, and differential file-size checks passed - full pre-push gate passed ## Post-Deploy Monitoring & Validation - open normal, temporary, private, and project-home channels in the first staging Desktop session - healthy signals: normal channels retain their existing composer/thread behavior and project homes use the project glyph and auxiliary slot - failure signals: missing composer, incorrect channel kind, stuck focus drawer, or project chrome on a normal channel; mitigate by reverting this PR --------- Signed-off-by: Thomas Petersen Signed-off-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Co-authored-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> --- .../channels/lib/channelLifecycle.test.mjs | 35 +++++++++ .../features/channels/lib/channelLifecycle.ts | 23 ++++++ .../src/features/channels/ui/ChannelGlyph.tsx | 29 ++++++++ .../channels/ui/ChannelManagementSheet.tsx | 25 +++---- .../ui/ChannelManagementSheetRows.tsx | 16 ++-- .../channels/ui/ChannelMembersBar.tsx | 73 ++++++++++--------- .../channels/ui/ChannelPane.helpers.test.mjs | 29 ++++++++ .../channels/ui/ChannelPane.helpers.ts | 9 ++- .../src/features/channels/ui/ChannelPane.tsx | 55 +++++++++++++- .../features/channels/ui/ChannelPane.types.ts | 9 +++ .../features/channels/ui/ChannelScreen.tsx | 11 +++ .../channels/ui/ChannelScreen.types.ts | 7 ++ .../channels/ui/ChannelScreenHeader.tsx | 47 ++++++++---- .../channels/ui/ChannelTypePicker.tsx | 59 +++++++++++---- .../channels/ui/ChannelTypeSettings.tsx | 44 ++++++++++- .../channels/ui/FocusThreadDrawer.tsx | 5 +- .../channels/ui/IdleAuxiliaryPanel.tsx | 64 ++++++++++++++++ .../channels/ui/RightAuxiliaryPane.tsx | 3 + .../features/channels/ui/useChannelIntro.tsx | 15 +++- .../messages/lib/timelineSnapshot.test.mjs | 2 +- .../messages/ui/ChannelIntroBlock.tsx | 12 +-- .../projects/lib/projectHomeChannel.test.mjs | 70 ++++++++++++++++++ .../projects/lib/projectHomeChannel.ts | 42 +++++++++++ .../projects/ui/ProjectChannelIcon.tsx | 20 +++++ desktop/tests/e2e/channels.spec.ts | 6 +- 25 files changed, 603 insertions(+), 107 deletions(-) create mode 100644 desktop/src/features/channels/lib/channelLifecycle.test.mjs create mode 100644 desktop/src/features/channels/lib/channelLifecycle.ts create mode 100644 desktop/src/features/channels/ui/ChannelGlyph.tsx create mode 100644 desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs create mode 100644 desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx create mode 100644 desktop/src/features/projects/lib/projectHomeChannel.test.mjs create mode 100644 desktop/src/features/projects/lib/projectHomeChannel.ts create mode 100644 desktop/src/features/projects/ui/ProjectChannelIcon.tsx diff --git a/desktop/src/features/channels/lib/channelLifecycle.test.mjs b/desktop/src/features/channels/lib/channelLifecycle.test.mjs new file mode 100644 index 00000000000..0477aa7b77f --- /dev/null +++ b/desktop/src/features/channels/lib/channelLifecycle.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { channelLifecycle, channelLifecycleLabel } from "./channelLifecycle.ts"; + +test("channelLifecycle prefers project home over TTL", () => { + assert.equal( + channelLifecycle({ projectHome: true, temporary: false }), + "project", + ); + assert.equal( + channelLifecycle({ projectHome: true, temporary: true }), + "project", + ); +}); + +test("channelLifecycle maps ongoing and temporary streams", () => { + assert.equal( + channelLifecycle({ projectHome: false, temporary: false }), + "ongoing", + ); + assert.equal( + channelLifecycle({ projectHome: false, temporary: true }), + "temporary", + ); +}); + +test("channelLifecycleLabel names project, ongoing, and temporary", () => { + assert.equal(channelLifecycleLabel("project", null), "Project"); + assert.equal(channelLifecycleLabel("ongoing", null), "Ongoing"); + assert.equal( + channelLifecycleLabel("temporary", 7 * 24 * 60 * 60), + "Temporary · 7d", + ); +}); diff --git a/desktop/src/features/channels/lib/channelLifecycle.ts b/desktop/src/features/channels/lib/channelLifecycle.ts new file mode 100644 index 00000000000..09517675610 --- /dev/null +++ b/desktop/src/features/channels/lib/channelLifecycle.ts @@ -0,0 +1,23 @@ +import { formatTtlDuration } from "@/features/channels/lib/ephemeralChannel"; + +export type ChannelLifecycle = "ongoing" | "temporary" | "project"; + +export function channelLifecycle(input: { + projectHome: boolean; + temporary: boolean; +}): ChannelLifecycle { + if (input.projectHome) return "project"; + return input.temporary ? "temporary" : "ongoing"; +} + +export function channelLifecycleLabel( + lifecycle: ChannelLifecycle, + ttlSeconds: number | null, +): string { + if (lifecycle === "project") return "Project"; + if (lifecycle === "temporary" && ttlSeconds != null) { + return `Temporary · ${formatTtlDuration(ttlSeconds)}`; + } + if (lifecycle === "temporary") return "Temporary"; + return "Ongoing"; +} diff --git a/desktop/src/features/channels/ui/ChannelGlyph.tsx b/desktop/src/features/channels/ui/ChannelGlyph.tsx new file mode 100644 index 00000000000..fae767426fd --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelGlyph.tsx @@ -0,0 +1,29 @@ +import { FileText, Hash, Lock } from "lucide-react"; + +import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel"; +import { ProjectChannelIcon } from "@/features/projects/ui/ProjectChannelIcon"; +import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; + +/** Stream/forum glyph for a channel, using the project mark on project homes. */ +export function ChannelGlyph({ + channel, + className, +}: { + channel: Pick; + className?: string; +}) { + const projectHome = useIsProjectHomeChannel(channel.id); + const iconClass = cn("size-4 shrink-0", className); + + if (projectHome) { + return ; + } + if (channel.visibility === "private") { + return ; + } + if (channel.channelType === "forum") { + return ; + } + return ; +} diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx index aea0f9323ec..8a1aaf6a02e 100644 --- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx +++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx @@ -24,10 +24,7 @@ import { import { compareMembersByRole } from "@/features/channels/lib/memberUtils"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelWorkflowsQuery } from "@/features/workflows/hooks"; -import { - DEFAULT_EPHEMERAL_TTL_SECONDS, - formatTtlDuration, -} from "@/features/channels/lib/ephemeralChannel"; +import { DEFAULT_EPHEMERAL_TTL_SECONDS } from "@/features/channels/lib/ephemeralChannel"; import type { Channel, ChannelMember, Workflow } from "@/shared/api/types"; import { useWorkflowEditorOverlay } from "@/shared/context/WorkflowEditorOverlayContext"; import { useFeatureEnabled } from "@/shared/features"; @@ -65,7 +62,10 @@ import { CHANNEL_FORM_FIELD_CONTROL_CLASS, CHANNEL_FORM_FIELD_SHELL_CLASS, } from "./channelFormStyles"; -import { ChannelTypeSettings } from "./ChannelTypeSettings"; +import { + ChannelTypeDetailRow, + ChannelTypeSettings, +} from "./ChannelTypeSettings"; import { ChannelPermissionsSettings } from "./ChannelPermissionsSettings"; import { ActionFieldRow, @@ -555,6 +555,7 @@ export function ChannelManagementSheet({ data-testid="channel-management-lifecycle" > { setIsEphemeralDraft(temporary); @@ -778,16 +779,10 @@ function ChannelManagementPanelContent({ {resolvedChannel.channelType !== "dm" ? ( <> - void; }) { - const Icon = getChannelIcon(channel.channelType); const channelDescription = channel.description.trim(); const description = channelDescription || (onEdit ? "Add a description" : null); @@ -42,7 +34,11 @@ export function ChannelHero({ data-testid="channel-management-hero" >
- + {channel.channelType === "dm" ? ( + + ) : ( + + )}
{channel.channelType !== "dm" && onEdit ? ( - - - - - Members - - {memberCount} - - - {huddleIndicator} - - - Manage channel - - - +
+ + + + + + + + Members + + {memberCount} + + + {huddleIndicator} + + + Manage channel + + + + {endActions} +
) : (
@@ -260,6 +265,8 @@ export function ChannelMembersBar({ Channel settings + + {endActions}
); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs new file mode 100644 index 00000000000..e4f8aa65e18 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getChannelIntroKind } from "./ChannelPane.helpers.ts"; + +function channel(overrides = {}) { + return { + ttlDeadline: null, + ttlSeconds: null, + visibility: "open", + ...overrides, + }; +} + +test("getChannelIntroKind names project homes ahead of regular streams", () => { + assert.equal(getChannelIntroKind(channel(), true), "project channel"); + assert.equal(getChannelIntroKind(channel(), false), "regular channel"); +}); + +test("getChannelIntroKind keeps private and ephemeral labels for other streams", () => { + assert.equal( + getChannelIntroKind(channel({ visibility: "private" })), + "private channel", + ); + assert.equal( + getChannelIntroKind(channel({ ttlSeconds: 3600 })), + "ephemeral channel", + ); +}); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index cb0600a28ae..2a645ba3876 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -3,7 +3,14 @@ import type { TimelineMessage } from "@/features/messages/types"; import type { Channel } from "@/shared/api/types"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; -export function getChannelIntroKind(channel: Channel): string { +export function getChannelIntroKind( + channel: Channel, + projectHome = false, +): string { + if (projectHome) { + return "project channel"; + } + const isPrivate = channel.visibility === "private"; const isEphemeral = isEphemeralChannel(channel); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index bccc163ed40..beb9a992bc1 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Hash, LogIn } from "lucide-react"; +import { LogIn } from "lucide-react"; import { AnimatePresence } from "motion/react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; @@ -27,6 +27,7 @@ import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeig import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel"; +import { IdleAuxiliaryPanel } from "@/features/channels/ui/IdleAuxiliaryPanel"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; import { ThreadViewModeToggle } from "@/features/channels/ui/ThreadViewModeToggle"; import { FocusThreadDrawer } from "@/features/channels/ui/FocusThreadDrawer"; @@ -46,6 +47,7 @@ import { import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers"; import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; +import { ChannelGlyph } from "@/features/channels/ui/ChannelGlyph"; import { useChannelIntro } from "@/features/channels/ui/useChannelIntro"; import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection"; @@ -77,6 +79,8 @@ export const ChannelPane = React.memo(function ChannelPane({ editTarget = null, fetchOlder, header, + idleAuxiliaryPanel = null, + idleAuxiliaryTitle = "", hasOlderMessages, historyExhausted, isFetchingOlder, @@ -104,8 +108,10 @@ export const ChannelPane = React.memo(function ChannelPane({ onCloseAgentSession, onCloseChannelManagement, onChannelManagementDeleted, + onCloseIdleAuxiliaryPanel, onCloseProfilePanel, onAddAgent, + onAddFiles, onBrowseChannels, onCreateChannel, onCloseThread, @@ -396,6 +402,7 @@ export const ChannelPane = React.memo(function ChannelPane({ const standardChannelIntro = useChannelIntro({ activeChannel, onAddAgent, + onAddFiles, onBrowseChannels, onCreateChannel, onOpenMembers, @@ -457,9 +464,15 @@ export const ChannelPane = React.memo(function ChannelPane({ threadViewMode === "focus" && useSplitAuxiliaryPane && (Boolean(threadHeadMessage) || shouldShowThreadSkeleton); + const useFocusIdleDrawer = + useSplitAuxiliaryPane && + Boolean(idleAuxiliaryPanel) && + Boolean(onCloseIdleAuxiliaryPanel); const { channelIsCovered, markExitComplete } = useFocusDrawerPresence( - useFocusThreadDrawer, - onCloseThread, + useFocusThreadDrawer || useFocusIdleDrawer, + useFocusThreadDrawer + ? onCloseThread + : (onCloseIdleAuxiliaryPanel ?? onCloseThread), ); const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } = useThreadViewModeSwitch({ @@ -516,6 +529,19 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : ( wrapAux(panel, "message-thread-panel", { key: THREAD_SURFACE_KEY }) ); + const wrapIdlePanel = (panel: React.ReactNode) => + useFocusIdleDrawer && onCloseIdleAuxiliaryPanel ? ( + + {panel} + + ) : ( + wrapAux(panel, "idle-auxiliary-panel") + ); const threadHeaderLeading = useSplitAuxiliaryPane ? ( ) : undefined; @@ -643,7 +669,12 @@ export const ChannelPane = React.memo(function ChannelPane({ className="flex items-center gap-3 border-t border-border/80 bg-card/50 px-5 py-3" >
- + {activeChannel ? ( + + ) : null} Viewing{" "} @@ -935,6 +966,22 @@ export const ChannelPane = React.memo(function ChannelPane({ ); return wrapAux(panel, "user-profile-panel"); })() + ) : idleAuxiliaryPanel && onCloseIdleAuxiliaryPanel ? ( + wrapIdlePanel( + + {idleAuxiliaryPanel} + , + ) ) : null}
diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 760ef58073b..68fab10d487 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -46,6 +46,13 @@ export type ChannelPaneProps = { } | null; fetchOlder?: () => Promise; header?: React.ReactNode; + /** + * Idle-state body for the right auxiliary pane (project extras, etc.). + * Shown only when no thread, profile, agent session, or channel-management + * panel is open — the same slot as those panels. + */ + idleAuxiliaryPanel?: React.ReactNode; + idleAuxiliaryTitle?: string; hasOlderMessages?: boolean; /** True when the loaded window provably starts at the channel's beginning. */ historyExhausted?: boolean; @@ -79,8 +86,10 @@ export type ChannelPaneProps = { onCloseAgentSession: () => void; onCloseChannelManagement?: () => void; onChannelManagementDeleted?: () => void; + onCloseIdleAuxiliaryPanel?: () => void; onCloseProfilePanel: () => void; onAddAgent?: (options?: { beforeSend?: () => void }) => void; + onAddFiles?: () => void; onBrowseChannels?: () => void; onCreateChannel?: () => void; onCloseThread: () => void; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 6254afd8c71..b01e9f9d265 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -88,6 +88,11 @@ export function ChannelScreen({ autoSendDraftKey, currentIdentity, currentProfile, + headerEndActions, + idleAuxiliaryPanel, + idleAuxiliaryTitle, + onAddFiles, + onCloseIdleAuxiliaryPanel, onCloseForumPost, onSelectForumPost, selectedForumPostId, @@ -742,6 +747,7 @@ export function ChannelScreen({ activeDmPresenceStatus={activeDmPresenceStatus} chromeWrapperRef={channelHeaderChromeRef} currentPubkey={currentPubkey} + headerEndActions={headerEndActions} isAddBotOpen={isAddBotOpen} isJoining={joinChannelMutation.isPending} onAddBotOpenChange={setIsAddBotOpen} @@ -762,6 +768,7 @@ export function ChannelScreen({ activeDmPresenceStatus, channelHeaderChromeRef, currentPubkey, + headerEndActions, isAddBotOpen, joinChannelMutation.isPending, joinChannelMutation.mutateAsync, @@ -845,9 +852,12 @@ export function ChannelScreen({ canResetThreadPanelWidth={canResetThreadPanelWidth} fetchOlder={fetchOlder} header={channelHeader} + idleAuxiliaryPanel={idleAuxiliaryPanel} + idleAuxiliaryTitle={idleAuxiliaryTitle} hasOlderMessages={hasOlderMessages} historyExhausted={historyExhausted} onAddAgent={handleOpenAddBot} + onAddFiles={onAddFiles} onBrowseChannels={openBrowseChannels} onCreateChannel={openCreateChannel} onOpenMembers={handleOpenMembersSidebar} @@ -900,6 +910,7 @@ export function ChannelScreen({ : undefined } onCloseChannelManagement={handleCloseChannelManagement} + onCloseIdleAuxiliaryPanel={onCloseIdleAuxiliaryPanel} onCloseThread={handleCloseThread} onDelete={ activeChannel?.archivedAt ? undefined : handleDelete diff --git a/desktop/src/features/channels/ui/ChannelScreen.types.ts b/desktop/src/features/channels/ui/ChannelScreen.types.ts index 371af6faf5d..223b69b8092 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.types.ts +++ b/desktop/src/features/channels/ui/ChannelScreen.types.ts @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; + import type { Channel, Identity, @@ -16,6 +18,11 @@ export type ChannelScreenProps = { autoSendDraftKey: string | null; currentIdentity?: Identity; currentProfile?: Profile; + idleAuxiliaryPanel?: ReactNode; + idleAuxiliaryTitle?: string; + headerEndActions?: ReactNode; + onAddFiles?: () => void; + onCloseIdleAuxiliaryPanel?: () => void; onCloseForumPost: () => void; onSelectForumPost: (postId: string) => void; selectedForumPostId: string | null; diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 358a0e637bb..44e4d891dc1 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -6,6 +6,7 @@ import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralC import type { ActiveDmHeaderParticipant } from "@/features/channels/useActiveChannelHeader"; import { getChannelDescription } from "@/features/channels/lib/channelDescription"; import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay"; +import { ChannelGlyph } from "@/features/channels/ui/ChannelGlyph"; import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge"; import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar"; import { @@ -38,6 +39,7 @@ type ChannelScreenHeaderProps = { activeDmPresenceStatus: PresenceStatus | null; chromeWrapperRef?: React.Ref; currentPubkey?: string; + headerEndActions?: React.ReactNode; isAddBotOpen?: boolean; isJoining?: boolean; showHeaderContent?: boolean; @@ -58,6 +60,7 @@ export function ChannelScreenHeader({ activeDmPresenceStatus, chromeWrapperRef, currentPubkey, + headerEndActions, isAddBotOpen, isJoining = false, onAddBotOpenChange, @@ -95,19 +98,23 @@ export function ChannelScreenHeader({ ) : null; const channelActions = activeChannel ? ( showJoinButton ? ( - +
+ + {headerEndActions} +
) : ( ) - ) : null; - const actions = activeChannel ? ( -
- {terminalButton} - {channelActions} -
- ) : null; + ) : ( + headerEndActions + ); + const actions = + terminalButton || channelActions ? ( +
+ {terminalButton} + {channelActions} +
+ ) : null; if (!showHeaderContent) { return null; @@ -173,6 +183,11 @@ export function ChannelScreenHeader({ testId="chat-header-dm-avatar" /> ) + ) : activeChannel ? ( + ) : undefined } statusBadge={ diff --git a/desktop/src/features/channels/ui/ChannelTypePicker.tsx b/desktop/src/features/channels/ui/ChannelTypePicker.tsx index 78e2c1080bb..b69e382bbb2 100644 --- a/desktop/src/features/channels/ui/ChannelTypePicker.tsx +++ b/desktop/src/features/channels/ui/ChannelTypePicker.tsx @@ -1,6 +1,8 @@ import { ChevronDown, ClockFading, Hash } from "lucide-react"; import * as React from "react"; +import type { ChannelLifecycle } from "@/features/channels/lib/channelLifecycle"; +import { ProjectChannelIcon } from "@/features/projects/ui/ProjectChannelIcon"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { @@ -11,37 +13,57 @@ import { DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +const LIFECYCLE_LABEL: Record = { + ongoing: "Ongoing", + project: "Project", + temporary: "Temporary", +}; + +const LIFECYCLE_ICON = { + ongoing: Hash, + temporary: ClockFading, +} as const; + export function ChannelTypePicker({ align = "start", + allowProject = false, ariaLabel, className, disabled, + lifecycle, + onLifecycleChange, onOpenChange, - onTemporaryChange, open, - temporary, temporaryOptionAriaLabel = "Temporary channel", testId, }: { align?: React.ComponentProps["align"]; + allowProject?: boolean; ariaLabel?: string; className?: string; disabled?: boolean; + lifecycle: ChannelLifecycle; + onLifecycleChange: (lifecycle: Exclude) => void; onOpenChange?: (open: boolean) => void; - onTemporaryChange: (temporary: boolean) => void; open?: boolean; - temporary: boolean; temporaryOptionAriaLabel?: string; testId?: string; }) { const [internalOpen, setInternalOpen] = React.useState(false); const pickerOpen = open ?? internalOpen; const setPickerOpen = onOpenChange ?? setInternalOpen; - const label = temporary ? "Temporary" : "Ongoing"; - const Icon = temporary ? ClockFading : Hash; + const label = LIFECYCLE_LABEL[lifecycle]; + const Icon = lifecycle === "project" ? null : LIFECYCLE_ICON[lifecycle]; + const projectLocked = lifecycle === "project"; function selectType(nextType: string) { - onTemporaryChange(nextType === "temporary"); + if (nextType === "project" || projectLocked) { + setPickerOpen(false); + return; + } + if (nextType === "temporary" || nextType === "ongoing") { + onLifecycleChange(nextType); + } setPickerOpen(false); } @@ -59,7 +81,11 @@ export function ChannelTypePicker({ type="button" variant="ghost" > - + {Icon ? ( + + ) : ( + + )} {label} @@ -71,15 +97,22 @@ export function ChannelTypePicker({ minWidth: "var(--radix-dropdown-menu-trigger-width)", }} > - - + + {allowProject ? ( + + Project + + ) : null} + Ongoing Temporary diff --git a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx index 6883f4cad1a..82fd1c9f7a1 100644 --- a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx +++ b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx @@ -1,10 +1,16 @@ import { ChevronDown } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { + channelLifecycle, + channelLifecycleLabel, +} from "@/features/channels/lib/channelLifecycle"; import { DEFAULT_EPHEMERAL_TTL_SECONDS, formatTtlDuration, } from "@/features/channels/lib/ephemeralChannel"; +import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel"; +import type { Channel } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -13,6 +19,7 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { EditableInfoFieldRow } from "./ChannelManagementSheetRows"; import { ChannelTypePicker } from "./ChannelTypePicker"; const EPHEMERAL_TIMEOUT_OPTIONS = [ @@ -32,7 +39,34 @@ const CHANNEL_TYPE_RESIZE_TRANSITION = { ease: [0.23, 1, 0.32, 1], } as const; +export function ChannelTypeDetailRow({ + canEdit, + channel, + onEdit, +}: { + canEdit: boolean; + channel: Channel; + onEdit?: () => void; +}) { + const projectHome = useIsProjectHomeChannel(channel.id); + const lifecycle = channelLifecycle({ + projectHome, + temporary: channel.ttlSeconds !== null, + }); + + return ( + + ); +} + export function ChannelTypeSettings({ + channelId, disabled, label = "Channel type", onOpenChange, @@ -43,6 +77,7 @@ export function ChannelTypeSettings({ testIdPrefix, ttlSeconds, }: { + channelId?: string | null; disabled?: boolean; label?: string; onOpenChange?: (open: boolean) => void; @@ -53,6 +88,8 @@ export function ChannelTypeSettings({ testIdPrefix: string; ttlSeconds: number; }) { + const projectHome = useIsProjectHomeChannel(channelId); + const lifecycle = channelLifecycle({ projectHome, temporary }); const shouldReduceMotion = useReducedMotion(); const channelTypeResizeTransition = shouldReduceMotion ? { duration: 0 } @@ -82,17 +119,18 @@ export function ChannelTypeSettings({ {label} onTemporaryChange(next === "temporary")} onOpenChange={onOpenChange} - onTemporaryChange={onTemporaryChange} open={open} - temporary={temporary} testId={`${testIdPrefix}-channel-type`} /> - {temporary ? ( + {temporary && !projectHome ? ( void; }; @@ -139,6 +141,7 @@ const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const; export function FocusThreadDrawer({ channelName, children, + label = "Thread", onClose, }: FocusThreadDrawerProps) { const prefersReducedMotion = useReducedMotion(); @@ -220,7 +223,7 @@ export function FocusThreadDrawer({ // see the token for why a `border-l` cannot. "absolute inset-y-0 right-0 flex flex-col overflow-hidden rounded-l-2xl bg-background shadow-panel-left", )} - aria-label="Thread" + aria-label={label} data-testid="focus-thread-drawer" ref={drawerRef} role="complementary" diff --git a/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx b/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx new file mode 100644 index 00000000000..f254efb0989 --- /dev/null +++ b/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx @@ -0,0 +1,64 @@ +import type * as React from "react"; + +import { + AuxiliaryPanel, + AuxiliaryPanelBody, + AuxiliaryPanelHeader, + AuxiliaryPanelHeaderGroup, + AuxiliaryPanelTitle, +} from "@/shared/layout/AuxiliaryPanel"; + +export function IdleAuxiliaryPanel({ + canResetWidth, + children, + isFocusDrawer = false, + isSinglePanelView, + onClose, + onResetWidth, + onResizeStart, + title, + useSplitAuxiliaryPane, + widthPx, +}: { + canResetWidth: boolean; + children: React.ReactNode; + isFocusDrawer?: boolean; + isSinglePanelView: boolean; + onClose: () => void; + onResetWidth: () => void; + onResizeStart: React.PointerEventHandler; + title: string; + useSplitAuxiliaryPane: boolean; + widthPx: number; +}) { + const split = useSplitAuxiliaryPane && !isFocusDrawer; + return ( + + + {title} + + + } + > + + {children} + + + ); +} diff --git a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx index 43e4d80e6ba..d46de8eb7d7 100644 --- a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx +++ b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx @@ -6,6 +6,7 @@ import { cn } from "@/shared/lib/cn"; type RightAuxiliaryPaneProps = { canResetWidth: boolean; children: React.ReactNode; + className?: string; constrainToAvailableSpace?: boolean; detached?: boolean; onResetWidth: () => void; @@ -17,6 +18,7 @@ type RightAuxiliaryPaneProps = { export function RightAuxiliaryPane({ canResetWidth, children, + className, constrainToAvailableSpace = true, detached = false, onResetWidth, @@ -31,6 +33,7 @@ export function RightAuxiliaryPane({ detached ? "bg-transparent" : "before:pointer-events-none before:absolute before:bottom-0 before:left-0 before:top-0 before:z-50 before:w-px before:bg-border/80 before:content-['']", + className, )} data-testid={testId} style={{ diff --git a/desktop/src/features/channels/ui/useChannelIntro.tsx b/desktop/src/features/channels/ui/useChannelIntro.tsx index 19f2da0edbc..910d836ff08 100644 --- a/desktop/src/features/channels/ui/useChannelIntro.tsx +++ b/desktop/src/features/channels/ui/useChannelIntro.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Bot, Plus, Sparkles, UserPlus } from "lucide-react"; +import { Bot, FolderPlus, Plus, Sparkles, UserPlus } from "lucide-react"; import { getChannelIntroDescription, @@ -29,6 +29,7 @@ type ChannelIntroAction = { export function useChannelIntro({ activeChannel, onAddAgent, + onAddFiles, onBrowseChannels, onCreateChannel, onOpenMembers, @@ -36,6 +37,7 @@ export function useChannelIntro({ }: { activeChannel: Channel | null; onAddAgent?: (options?: { beforeSend?: () => void }) => void; + onAddFiles?: () => void; onBrowseChannels?: () => void; onCreateChannel?: () => void; onOpenMembers?: () => void; @@ -89,6 +91,16 @@ export function useChannelIntro({ } if (!activeChannel.archivedAt && activeChannel.isMember) { + if (onAddFiles) { + actions.push({ + description: "Add a repo.", + icon: , + label: "Add files", + onClick: onAddFiles, + testId: "channel-intro-action-add-files", + }); + } + if (onAddAgent) { actions.push({ description: "Bring them in.", @@ -119,6 +131,7 @@ export function useChannelIntro({ }, [ activeChannel, onAddAgent, + onAddFiles, onBrowseChannels, onCreateChannel, onOpenMembers, diff --git a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs index a0374fbe2b0..ad09e4e6f40 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs +++ b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs @@ -423,7 +423,7 @@ test("timeline-body-surface: loading and deferred-pending both paint the single test("timeline-body-surface: first authoritative rows wait for deferred paint", () => { // A newly selected populated channel has already resolved live rows, but the // deferred snapshot is still empty. It has never committed a settled empty - // surface, so showing its intro here would flash Create agent / Add people. + // surface, so showing its intro here would flash Add agent / Add people. assert.equal( selectTimelineBodySurface({ deferredCount: 0, diff --git a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx index 76d1960b966..c69fd46a32d 100644 --- a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx +++ b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx @@ -63,7 +63,7 @@ export function ChannelIntroBlock({

) : null} {intro.actions?.length ? ( -
+
{intro.actions.map((action) => { const hasDescription = Boolean(action.description); @@ -72,8 +72,8 @@ export function ChannelIntroBlock({ className={cn( "flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring", hasDescription - ? "h-56 w-[13.75rem] flex-col rounded-2xl p-4" - : "h-28 w-64 flex-col rounded-2xl p-4", + ? "h-52 w-48 flex-col rounded-2xl p-3" + : "h-24 w-56 flex-col rounded-2xl p-3", )} data-testid={action.testId} key={action.label} @@ -84,8 +84,8 @@ export function ChannelIntroBlock({ className={cn( "flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground", hasDescription - ? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6" - : "h-10 w-10 [&_svg]:h-4 [&_svg]:w-4", + ? "h-10 w-10 [&_svg]:h-5 [&_svg]:w-5" + : "h-9 w-9 [&_svg]:h-4 [&_svg]:w-4", )} data-testid={ action.testId ? `${action.testId}-icon` : undefined @@ -95,7 +95,7 @@ export function ChannelIntroBlock({ { + assert.equal(isProjectHomeChannel("channel-a", [project()]), true); +}); + +test("isProjectHomeChannel accepts a repository that authorizes the project owner", () => { + assert.equal( + isProjectHomeChannel("channel-a", [ + project({ + owner: MAINTAINER.toUpperCase(), + repositories: [ + { + channelId: "channel-a", + maintainers: [OWNER, MAINTAINER], + owner: OWNER, + }, + ], + }), + ]), + true, + ); +}); + +test("isProjectHomeChannel rejects a bare project channel assertion", () => { + assert.equal( + isProjectHomeChannel("channel-a", [project({ repositories: [] })]), + false, + ); +}); + +test("isProjectHomeChannel rejects unauthorized and mismatched repository bindings", () => { + assert.equal( + isProjectHomeChannel("channel-a", [ + project({ + owner: MAINTAINER, + repositories: [{ channelId: "channel-a", owner: OWNER }], + }), + project({ + repositories: [{ channelId: "channel-b", owner: OWNER }], + }), + ]), + false, + ); +}); + +test("isProjectHomeChannel is false for unbound channels", () => { + assert.equal(isProjectHomeChannel("channel-z", [project()]), false); + assert.equal(isProjectHomeChannel(null, [project()]), false); +}); diff --git a/desktop/src/features/projects/lib/projectHomeChannel.ts b/desktop/src/features/projects/lib/projectHomeChannel.ts new file mode 100644 index 00000000000..fa408467fe4 --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeChannel.ts @@ -0,0 +1,42 @@ +import { useProjectsQuery } from "@/features/projects/hooks"; + +export type ProjectHomeCandidate = { + owner: string; + projectChannelId: string | null; + repositories: ReadonlyArray<{ + channelId?: string | null; + maintainers?: ReadonlyArray; + owner: string; + }>; +}; + +function hasAuthoritativeHomeBinding(project: ProjectHomeCandidate): boolean { + const channelId = project.projectChannelId; + if (!channelId) return false; + + const projectOwner = project.owner.toLowerCase(); + return project.repositories.some((repository) => { + if (repository.channelId !== channelId) return false; + if (repository.owner.toLowerCase() === projectOwner) return true; + return repository.maintainers?.some( + (maintainer) => maintainer.toLowerCase() === projectOwner, + ); + }); +} + +export function isProjectHomeChannel( + channelId: string | null | undefined, + projects: ReadonlyArray, +): boolean { + if (!channelId) return false; + return projects.some( + (project) => + project.projectChannelId === channelId && + hasAuthoritativeHomeBinding(project), + ); +} + +export function useIsProjectHomeChannel(channelId: string | null | undefined) { + const projectsQuery = useProjectsQuery(); + return isProjectHomeChannel(channelId, projectsQuery.data ?? []); +} diff --git a/desktop/src/features/projects/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/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 52d91dea3ed..25513f6ad75 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -450,9 +450,9 @@ async function expectIntroActionCardLayout( } expect(actionBox.height).toBeGreaterThan(actionBox.width); - expect(Math.round(actionBox.width)).toBe(220); - expect(Math.round(iconBox.width)).toBe(48); - expect(Math.round(iconBox.height)).toBe(48); + expect(Math.round(actionBox.width)).toBe(192); + expect(Math.round(iconBox.width)).toBe(40); + expect(Math.round(iconBox.height)).toBe(40); const introIconRadius = await page .getByTestId("message-channel-intro-icon") .evaluate((element) => window.getComputedStyle(element).borderRadius); From 7d6c4abcecfb80bf0582b54751a989b772469884 Mon Sep 17 00:00:00 2001 From: thomaspblock Date: Sun, 23 Aug 2026 18:32:34 -0400 Subject: [PATCH 06/22] feat(projects): make the home channel the project surface (#6595) ## Summary - render an explicit project's home channel through the normal channel timeline and composer - add a resizable project context rail with codebase, channel, people, and workspace navigation - keep project agent conversations bounded to the project home and preserve repository/detail routes This is Part 4 of the channel-first Projects stack, based on #6594. The final part contains overview and workspace completion polish. ## Testing - focused project conversation, route, summary, workspace-sheet, and related-channel tests: 39/39 passed - Desktop unit suite: 5,439/5,439 passed - E2E-mode Desktop build passed - TypeScript, Biome, and differential file-size checks passed - full pre-push gate passed ## Post-Deploy Monitoring & Validation - open project homes from project and channel entry points in the first staging Desktop session - healthy signals: one channel timeline/composer, stable repository context, bounded project agent history, and reversible workspace sheets - failure signals: duplicate channel surfaces, stale repository selection, unrelated DM history, or sheets replacing the channel route; mitigate by reverting this PR Signed-off-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Co-authored-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> --- .../src/app/routes/projects.$projectId.tsx | 15 +- .../channels/ui/ChannelPane.helpers.test.mjs | 31 +- .../channels/ui/ChannelPane.helpers.ts | 28 ++ .../src/features/channels/ui/ChannelPane.tsx | 38 +- .../features/channels/ui/useChannelIntro.tsx | 12 +- .../lib/projectAgentConversation.test.mjs | 70 ++++ .../projects/lib/projectAgentConversation.ts | 18 +- .../projects/lib/projectCollection.test.mjs | 33 +- .../projects/lib/projectCollection.ts | 23 +- .../projects/lib/projectDetailSearch.test.mjs | 72 ++++ .../projects/lib/projectDetailSearch.ts | 65 +++ .../projects/lib/projectHomeChannel.test.mjs | 12 +- .../projects/lib/projectHomeChannel.ts | 4 +- .../projects/lib/projectHomeSummary.test.mjs | 10 + .../projects/lib/projectHomeSummary.ts | 6 + .../lib/projectHomeWorkspaceSheet.test.mjs | 22 ++ .../projects/lib/projectHomeWorkspaceSheet.ts | 33 ++ .../lib/projectRelatedChannels.test.mjs | 134 +++++++ .../projects/lib/projectRelatedChannels.ts | 79 ++++ .../projects/ui/ProjectAgentChatPanel.tsx | 226 ++++++----- .../projects/ui/ProjectChannelHome.tsx | 336 ++++++++++++++++ .../projects/ui/ProjectChannelManagement.tsx | 78 ++++ .../projects/ui/ProjectDetailChrome.tsx | 109 ++--- .../projects/ui/ProjectDetailScreen.tsx | 39 +- .../projects/ui/ProjectHomeCodebasePanel.tsx | 126 ++++++ .../projects/ui/ProjectHomeColumn.tsx | 67 ++++ .../projects/ui/ProjectHomeContextPanel.tsx | 373 ++++++++++++++++++ .../projects/ui/ProjectHomeWorkspaceSheet.tsx | 150 +++++++ .../ui/ProjectRepositoryManagement.tsx | 27 +- .../projects/ui/ProjectWorkspaceTabList.tsx | 86 ++-- .../projects/ui/ProjectWorkspaceTabs.tsx | 4 +- .../src/features/projects/ui/ProjectsView.tsx | 1 + .../projects/useAddProjectChannel.test.mjs | 144 +++++++ .../features/projects/useAddProjectChannel.ts | 201 ++++++++++ .../useHealProjectHomeRepositories.ts | 62 +++ .../sidebar/ui/CreateChannelDialog.tsx | 15 +- .../sidebar/ui/SidebarProjectsSection.tsx | 150 ++++--- .../sidebar/ui/listSidebarProjects.ts | 11 + desktop/src/testing/e2eBridge.ts | 31 +- desktop/tests/e2e/channel-browser.spec.ts | 3 + .../e2e/entity-link-recipient-cards.spec.ts | 5 +- .../tests/e2e/project-commit-detail.spec.ts | 299 ++++++++++---- .../tests/e2e/project-issue-comments.spec.ts | 5 +- desktop/tests/e2e/project-pr-review.spec.ts | 54 ++- .../tests/e2e/projects-v3-screenshots.spec.ts | 25 +- desktop/tests/e2e/terminal-wheel.spec.ts | 1 + 46 files changed, 2915 insertions(+), 418 deletions(-) create mode 100644 desktop/src/features/projects/lib/projectDetailSearch.test.mjs create mode 100644 desktop/src/features/projects/lib/projectDetailSearch.ts create mode 100644 desktop/src/features/projects/lib/projectHomeSummary.test.mjs create mode 100644 desktop/src/features/projects/lib/projectHomeSummary.ts create mode 100644 desktop/src/features/projects/lib/projectHomeWorkspaceSheet.test.mjs create mode 100644 desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts create mode 100644 desktop/src/features/projects/ui/ProjectChannelHome.tsx create mode 100644 desktop/src/features/projects/ui/ProjectChannelManagement.tsx create mode 100644 desktop/src/features/projects/ui/ProjectHomeCodebasePanel.tsx create mode 100644 desktop/src/features/projects/ui/ProjectHomeColumn.tsx create mode 100644 desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx create mode 100644 desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx create mode 100644 desktop/src/features/projects/useAddProjectChannel.test.mjs create mode 100644 desktop/src/features/projects/useAddProjectChannel.ts create mode 100644 desktop/src/features/projects/useHealProjectHomeRepositories.ts diff --git a/desktop/src/app/routes/projects.$projectId.tsx b/desktop/src/app/routes/projects.$projectId.tsx index 34af176adba..c13f21ec721 100644 --- a/desktop/src/app/routes/projects.$projectId.tsx +++ b/desktop/src/app/routes/projects.$projectId.tsx @@ -1,8 +1,8 @@ import * as React from "react"; import { createFileRoute, useLocation } from "@tanstack/react-router"; +import { parseProjectDetailSearch } from "@/features/projects/lib/projectDetailSearch"; import { usePreviewFeatureWarning } from "@/shared/features"; -import { isEntityLinkTab } from "@/shared/lib/entityLink"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; const ProjectDetailScreen = React.lazy(async () => { @@ -12,18 +12,7 @@ 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() { diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs index e4f8aa65e18..714b21c5b01 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getChannelIntroKind } from "./ChannelPane.helpers.ts"; +import { + getChannelIntroKind, + shouldUseFocusIdleDrawer, +} from "./ChannelPane.helpers.ts"; function channel(overrides = {}) { return { @@ -12,6 +15,32 @@ function channel(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"); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index 2a645ba3876..8ef2ca91cb5 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -3,6 +3,34 @@ import type { TimelineMessage } from "@/features/messages/types"; import type { Channel } from "@/shared/api/types"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; +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, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index beb9a992bc1..1fa03c1e2d2 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -45,7 +45,10 @@ import { WelcomeComposerGuidanceLayer, } from "@/features/channels/ui/WelcomeComposerBanner"; import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; -import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers"; +import { + mentionsKnownAgent, + 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"; @@ -464,10 +467,25 @@ export const ChannelPane = React.memo(function ChannelPane({ threadViewMode === "focus" && useSplitAuxiliaryPane && (Boolean(threadHeadMessage) || shouldShowThreadSkeleton); - const useFocusIdleDrawer = - useSplitAuxiliaryPane && - Boolean(idleAuxiliaryPanel) && - Boolean(onCloseIdleAuxiliaryPanel); + const selectedAgent = React.useMemo( + () => + agentSessionSelection.resolveSelectedAgentSession({ + agentSessionAgents, + openAgentSessionPubkey, + profilePanelPubkey, + profiles, + }), + [agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles], + ); + const useFocusIdleDrawer = shouldUseFocusIdleDrawer({ + channelManagementOpen, + hasAgentSession: Boolean(activeChannel && selectedAgent), + hasIdleAuxiliaryPanel: Boolean(idleAuxiliaryPanel), + hasIdlePanelCloseHandler: Boolean(onCloseIdleAuxiliaryPanel), + hasProfilePanel: Boolean(profilePanelPubkey), + hasThreadSurface: Boolean(threadHeadMessage) || shouldShowThreadSkeleton, + useSplitAuxiliaryPane, + }); const { channelIsCovered, markExitComplete } = useFocusDrawerPresence( useFocusThreadDrawer || useFocusIdleDrawer, useFocusThreadDrawer @@ -481,16 +499,6 @@ export const ChannelPane = React.memo(function ChannelPane({ onExternalTargetResolved: onThreadScrollTargetResolved, onModeChange: markExitComplete, }); - const selectedAgent = React.useMemo( - () => - agentSessionSelection.resolveSelectedAgentSession({ - agentSessionAgents, - openAgentSessionPubkey, - profilePanelPubkey, - profiles, - }), - [agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles], - ); const hasSplitAuxiliaryPane = useSplitAuxiliaryPane && (channelManagementOpen || diff --git a/desktop/src/features/channels/ui/useChannelIntro.tsx b/desktop/src/features/channels/ui/useChannelIntro.tsx index 910d836ff08..5e0617abc4a 100644 --- a/desktop/src/features/channels/ui/useChannelIntro.tsx +++ b/desktop/src/features/channels/ui/useChannelIntro.tsx @@ -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"; @@ -43,6 +45,8 @@ export function useChannelIntro({ onOpenMembers?: () => void; onWelcomeAddAgent?: () => void; }) { + const projectHome = useIsProjectHomeChannel(activeChannel?.id); + return React.useMemo(() => { if (!activeChannel || activeChannel.channelType === "dm") { return null; @@ -81,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 @@ -124,9 +128,12 @@ export function useChannelIntro({ return { actions, - channelKindLabel: getChannelIntroKind(activeChannel), + channelKindLabel: getChannelIntroKind(activeChannel, projectHome), channelName: activeChannel.name, description: getChannelIntroDescription(activeChannel), + icon: projectHome ? ( + + ) : undefined, }; }, [ activeChannel, @@ -136,5 +143,6 @@ export function useChannelIntro({ onCreateChannel, onOpenMembers, onWelcomeAddAgent, + projectHome, ]); } 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/projectCollection.test.mjs b/desktop/src/features/projects/lib/projectCollection.test.mjs index d504234e131..801f249befa 100644 --- a/desktop/src/features/projects/lib/projectCollection.test.mjs +++ b/desktop/src/features/projects/lib/projectCollection.test.mjs @@ -75,9 +75,9 @@ function standaloneRepo(overrides = {}) { }; } -test("absorbStandaloneProjectRepositories folds a home-channel repo into the project", () => { +test("absorbStandaloneProjectRepositories folds an authorized home-channel repo into the project", () => { const project = explicitProject(); - const repoCard = standaloneRepo(); + const repoCard = standaloneRepo({ repository: { maintainers: [OWNER] } }); const folded = absorbStandaloneProjectRepositories([project, repoCard]); assert.equal(folded.length, 1); @@ -87,6 +87,16 @@ test("absorbStandaloneProjectRepositories folds a home-channel repo into the pro 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({ @@ -115,14 +125,25 @@ test("absorbStandaloneProjectRepositories keeps an unrelated standalone repo", ( ); }); -test("homeRepositoriesToBind lists absorbed channel repos missing from the signed project", () => { +test("homeRepositoriesToBind lists authorized absorbed channel repos missing from the signed project", () => { + const repo = standaloneRepo({ repository: { maintainers: [OWNER] } }); const project = explicitProject({ - repositories: [standaloneRepo().repositories[0]], - repositoryAddresses: [standaloneRepo().projectAddress], + repositories: [repo.repositories[0]], + repositoryAddresses: [repo.projectAddress], }); const pending = homeRepositoriesToBind(project, []); assert.equal(pending.length, 1); - assert.equal(pending[0].repoAddress, standaloneRepo().projectAddress); + 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", () => { diff --git a/desktop/src/features/projects/lib/projectCollection.ts b/desktop/src/features/projects/lib/projectCollection.ts index 73924c06f4a..ea329855f09 100644 --- a/desktop/src/features/projects/lib/projectCollection.ts +++ b/desktop/src/features/projects/lib/projectCollection.ts @@ -19,13 +19,28 @@ function withAbsorbedRepository( }; } +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, + (project) => + project.projectChannelId === repository.channelId && + repositoryAuthorizesProjectOwner(project, repository), ) : undefined; if (channelHost) return channelHost; @@ -41,8 +56,10 @@ function repositoryBelongsOnProjectHome( ): boolean { return Boolean( (repository.channelId && - repository.channelId === project.projectChannelId) || - (repository.owner === project.owner && repository.dtag === project.dtag), + repository.channelId === project.projectChannelId && + repositoryAuthorizesProjectOwner(project, repository)) || + (repository.owner.toLowerCase() === project.owner.toLowerCase() && + repository.dtag === project.dtag), ); } 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..d9d667d73d0 --- /dev/null +++ b/desktop/src/features/projects/lib/projectDetailSearch.test.mjs @@ -0,0 +1,72 @@ +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", + thread: "abc123", + agentSession: "def456", + channelManagement: "1", + extra: "dropped", + }); + + assert.equal(search.repositoryId, "30617:owner:buzz"); + assert.equal(search.tab, "files"); + 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, + ); +}); + +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..cfc7ece0990 --- /dev/null +++ b/desktop/src/features/projects/lib/projectDetailSearch.ts @@ -0,0 +1,65 @@ +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), + 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; + issueId?: string; + projectId: string; + pullRequestId?: string; + repositoryId?: string; + tab?: string; +}): boolean { + if ( + input.repositoryId || + input.tab || + input.issueId || + input.pullRequestId || + input.commitHash + ) { + 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 index 5c7eeb7335c..2903873b40f 100644 --- a/desktop/src/features/projects/lib/projectHomeChannel.test.mjs +++ b/desktop/src/features/projects/lib/projectHomeChannel.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { isProjectHomeChannel } from "./projectHomeChannel.ts"; +import { + hasAuthoritativeHomeBinding, + isProjectHomeChannel, +} from "./projectHomeChannel.ts"; const OWNER = "a".repeat(64); const MAINTAINER = "b".repeat(64); @@ -42,6 +45,13 @@ test("isProjectHomeChannel accepts a repository that authorizes the project owne ); }); +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: [] })]), diff --git a/desktop/src/features/projects/lib/projectHomeChannel.ts b/desktop/src/features/projects/lib/projectHomeChannel.ts index fa408467fe4..7edc9a3b6aa 100644 --- a/desktop/src/features/projects/lib/projectHomeChannel.ts +++ b/desktop/src/features/projects/lib/projectHomeChannel.ts @@ -10,7 +10,9 @@ export type ProjectHomeCandidate = { }>; }; -function hasAuthoritativeHomeBinding(project: ProjectHomeCandidate): boolean { +export function hasAuthoritativeHomeBinding( + project: ProjectHomeCandidate, +): boolean { const channelId = project.projectChannelId; if (!channelId) return false; 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/projectHomeWorkspaceSheet.test.mjs b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.test.mjs new file mode 100644 index 00000000000..89d23eb2217 --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.test.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isProjectHomeWorkspaceSheetTab, + 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"); +}); diff --git a/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts new file mode 100644 index 00000000000..93a6675990a --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts @@ -0,0 +1,33 @@ +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]; +} diff --git a/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs b/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs index ef94588ccc3..ad1795ad735 100644 --- a/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs +++ b/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs @@ -3,6 +3,8 @@ import { test } from "node:test"; import { collectProjectRelatedChannelRows, + listProjectBoundChannels, + listProjectChildChannels, projectRelatedChannelRowKey, uniqueProjectRelatedChannelCount, } from "./projectRelatedChannels.ts"; @@ -183,3 +185,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..525eb700c26 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; @@ -57,6 +58,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 +87,68 @@ export function uniqueProjectRelatedChannelCount( export function projectRelatedChannelRowKey(row: ProjectRelatedChannelRow) { return `${row.channelId}:${row.projectId}:${row.repositoryId ?? "project"}`; } + +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/ui/ProjectAgentChatPanel.tsx b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx index 14013a6157a..1820dc17408 100644 --- a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx @@ -24,6 +24,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 +46,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(); @@ -119,11 +124,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 +155,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 +230,7 @@ export function ProjectAgentChatPanel({ [ contextPayload, conversation, + homeChannel, identityQuery.data?.pubkey, isSending, openDmMutation, @@ -225,98 +247,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/ProjectChannelHome.tsx b/desktop/src/features/projects/ui/ProjectChannelHome.tsx new file mode 100644 index 00000000000..a0b931d9e4b --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectChannelHome.tsx @@ -0,0 +1,336 @@ +import { useSearch } from "@tanstack/react-router"; +import { Info } 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, + projectHomeWorkspaceSheetTitle, + type ProjectHomeWorkspaceSheetTab, +} from "@/features/projects/lib/projectHomeWorkspaceSheet"; +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 { 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 } 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({ + project, + projects, +}: { + project: Project; + projects: Project[]; +}) { + 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 summaryWidth = useThreadPanelWidth(undefined, { + 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 summaryVisible = summaryOpen && !workspaceSheetOpen; + + const openWorkspaceSheet = React.useCallback( + (tab: ProjectHomeWorkspaceSheetTab, repositoryId?: string) => { + if (repositoryId) { + setWorkspaceRepositoryId(repositoryId); + } + setWorkspaceSheetTab((current) => (current === tab ? null : tab)); + }, + [], + ); + const closeWorkspaceSheet = React.useCallback(() => { + 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) => { + setWorkspaceRepositoryId(repositoryId); + setWorkspaceSheetTab("files"); + }, []); + const handleToggleFilesSheet = React.useCallback(() => { + if (!workspaceRepository) { + handleAddFiles(); + return; + } + openWorkspaceSheet("files", workspaceRepository.id); + }, [handleAddFiles, openWorkspaceSheet, workspaceRepository]); + 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 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 ? ( + + } + > + + + ) : ( +
+

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

+
+ )} +
+ + + {summaryVisible ? ( + setSummaryOpen(false)} + onResetWidth={summaryWidth.onResetWidth} + onResizeStart={summaryWidth.onResizeStart} + testId="project-home-summary-column" + title="Overview" + widthPx={summaryWidth.widthPx} + > + { + void goChannel(channelId); + }} + onOpenRepository={handleOpenRepository} + onOpenWorkspace={handleOpenWorkspace} + onRepositoryChange={handleRepositoryChange} + project={project} + projects={projects} + /> + + ) : null} + +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectChannelManagement.tsx b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx new file mode 100644 index 00000000000..dbb02fd462c --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx @@ -0,0 +1,78 @@ +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; + + if (!canEdit) return null; + + return ( + <> + { + 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" + /> + + + ); +} 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/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index b7f39a8cf33..620edbc2129 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"; @@ -282,15 +285,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 +678,24 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { /> ); } + const showChannelHome = + hasAuthoritativeHomeBinding(project) && + !wantsProjectRepositorySurface({ + commitHash, + 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 +864,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { actions={repositoryPanelAction} activeTabCrumb={activeTabCrumb} activeWorkItemCrumb={activeWorkItemCrumb} - onGoProjectHome={handleGoToProjectHome} + onGoProjectHome={goChannelHome} onGoProjects={() => { void goProjects(); }} @@ -900,6 +930,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/ProjectHomeCodebasePanel.tsx b/desktop/src/features/projects/ui/ProjectHomeCodebasePanel.tsx new file mode 100644 index 00000000000..1e883a12b0f --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeCodebasePanel.tsx @@ -0,0 +1,126 @@ +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, + onOpenCommit, + onRepositoryAdded, + onSelectRepository, + project, + projects, + repository, +}: { + identityPubkey?: string; + 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..b627ca26ab9 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeColumn.tsx @@ -0,0 +1,67 @@ +import type * as React from "react"; + +import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; +import { + AuxiliaryPanel, + AuxiliaryPanelBody, + AuxiliaryPanelHeader, + AuxiliaryPanelHeaderGroup, + AuxiliaryPanelHeaderTitleBlock, +} from "@/shared/layout/AuxiliaryPanel"; +import { cn } from "@/shared/lib/cn"; + +export function ProjectHomeColumn({ + bodyClassName, + canResetWidth, + children, + onClose, + onResetWidth, + onResizeStart, + testId, + title, + widthPx, +}: { + bodyClassName?: string; + canResetWidth: boolean; + children: React.ReactNode; + onClose: () => void; + onResetWidth: () => void; + onResizeStart: (event: React.PointerEvent) => void; + testId: string; + title: string; + widthPx: number; +}) { + return ( + +
+ + + + + + } + > + + {children} + + +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx b/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx new file mode 100644 index 00000000000..c9ef1d7d5ba --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx @@ -0,0 +1,373 @@ +import { + CircleDot, + FileCode2, + FolderGit2, + GitCommitHorizontal, + GitPullRequest, + Hash, + Users, +} from "lucide-react"; +import type * 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 { PROJECT_CONTEXT_ACTION_BUTTON_CLASS } from "./projectContextActionStyles"; + +function ContextSection({ + children, + headerAction, + testId, + title, +}: { + children: React.ReactNode; + headerAction?: React.ReactNode; + testId?: string; + title?: string; +}) { + return ( +
+ {title || headerAction ? ( +
+ {title ? ( +

+ {title} +

+ ) : ( + + )} + {headerAction} +
+ ) : null} + {children} +
+ ); +} + +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 count = presentContextCount(channel.memberCount); + 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.tsx b/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx new file mode 100644 index 00000000000..1449ea84a4d --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx @@ -0,0 +1,150 @@ +import * as React from "react"; + +import { + useProjectIssuesQuery, + useProjectPullRequestsQuery, + useProjectRepoSnapshotQuery, + 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 { ActivityPanel, ContributorsPanel } from "./ProjectDetailFeedPanels"; +import { ProjectHomeCodebasePanel } from "./ProjectHomeCodebasePanel"; +import { ProjectIssuesPanel } from "./ProjectIssuesPanel"; +import { PullRequestsPanel } from "./ProjectPullRequestsPanel"; +import { useProjectDetailPeople } from "./useProjectDetailPeople"; + +export function ProjectHomeWorkspaceSheet({ + identityPubkey, + onOpenCommit, + onRepositoryAdded, + onSelectRepository, + project, + projects, + repository, + tab, +}: { + identityPubkey?: string; + onOpenCommit: (commitHash: string) => void; + onRepositoryAdded: (repositoryId: string) => void; + onSelectRepository: (repositoryId: string) => void; + project: Project; + projects: Project[]; + repository: Project["repositories"][number]; + tab: ProjectHomeWorkspaceSheetTab; +}) { + const [selectedIssueId, setSelectedIssueId] = React.useState( + null, + ); + const [selectedPullRequestId, setSelectedPullRequestId] = React.useState< + string | null + >(null); + + const issuesQuery = useProjectIssuesQuery(repository); + const pullRequestsQuery = useProjectPullRequestsQuery(repository); + const issues = issuesQuery.data ?? []; + 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 contributorPubkeysByGitIdentity = React.useMemo( + () => + gitContributorPubkeysFromCommits(snapshot?.commits ?? [], pullRequests), + [pullRequests, snapshot?.commits], + ); + const selectedPullRequest = + pullRequests.find( + (pullRequest) => pullRequest.id === selectedPullRequestId, + ) ?? null; + + let body: React.ReactNode; + switch (tab) { + case "issues": + body = ( + + ); + break; + case "prs": + body = ( + + ); + break; + case "commits": + body = ( + onOpenCommit(commit.hash)} + profiles={people.profiles} + project={repository} + projectId={project.id} + pullRequests={pullRequests} + repoContributors={snapshot?.contributors ?? []} + snapshot={snapshot} + viewerGitIdentity={people.viewerGitIdentity} + /> + ); + break; + case "files": + body = ( + + ); + break; + case "contributors": + body = ( + + ); + break; + } + + return ( +
+ {body} +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx index fd08d490511..535c15b0dd7 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,7 +142,7 @@ export function ProjectRepositoryManagement({ project={project} repositories={attachCandidates} /> - {canEdit ? ( + {canEdit && !hideTriggers ? ( + + + 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..64941961231 100644 --- a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx @@ -119,6 +119,7 @@ export function WorkspaceTabs({ onSelectedIssueIdChange, onSelectedPullRequestIdChange, onSelectedTabChange, + onBack, onOpenMergeRecoveryTerminal, snapshot, snapshotError, @@ -170,6 +171,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; @@ -412,7 +414,7 @@ export function WorkspaceTabs({ }`} data-testid="project-workspace-tab-menu" > - +
{updatePullRequestAction ? (
); diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 96ca92ab1fe..5bce6a71ff7 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -13,6 +13,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[]; @@ -41,10 +42,13 @@ export type ChannelPaneProps = { header?: React.ReactNode; /** * Idle-state body for the right auxiliary pane (project extras, etc.). - * Shown only when no thread, profile, agent session, or channel-management - * panel is open — the same slot as those panels. + * 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. */ diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index f7d67082704..9c2d25dd7cf 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -1,3 +1,4 @@ +// biome-ignore-all format: line-count ratchet requires compact forwarding in this legacy component import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useAppShell } from "@/app/AppShellContext"; @@ -7,14 +8,24 @@ import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandle import { useMessageEventProfilePubkeys } from "@/features/channels/useMessageEventProfilePubkeys"; import { useMessageOwnerProfiles } from "@/features/channels/useMessageOwnerProfiles"; import { useThreadTargetSync } from "@/features/channels/useThreadTargetSync"; -import * as channelHooks from "@/features/channels/hooks"; -import * as readStateFormat from "@/features/channels/readState/readStateFormat"; +import { + useChannelMembersQuery, + useJoinChannelMutation, +} from "@/features/channels/hooks"; +import { + MSG_PREFIX, + THREAD_PREFIX, +} from "@/features/channels/readState/readStateFormat"; import { ChannelScreenEmptyState } from "@/features/channels/ui/ChannelScreenEmptyState"; import { ChannelScreenHeader } from "@/features/channels/ui/ChannelScreenHeader"; import { WelcomeAgentCreateDialog } from "@/features/channels/ui/WelcomeAgentCreateDialog"; import { ForumChannelContent } from "@/features/channels/ui/ForumChannelContent"; import { MembersSidebar } from "@/features/channels/ui/MembersSidebar"; -import * as agentHooks from "@/features/agents/hooks"; +import { + useManagedAgentsQuery, + usePersonasQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; import { mergeChannelKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { pickWelcomeGuideAgent } from "@/features/onboarding/welcomeGuide"; @@ -34,7 +45,10 @@ import { import { buildMessageComposerEditTarget } from "@/features/messages/lib/draftMentionRefs"; import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog"; -import * as threading from "@/features/messages/lib/threading"; +import { + getThreadReference, + isThreadReply, +} from "@/features/messages/lib/threading"; import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache"; import { resolveTimelineLoadingLatch, @@ -49,7 +63,10 @@ import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useRelaySelfQuery } from "@/features/moderation/hooks"; import type { RelayEvent, RespondToMode } from "@/shared/api/types"; import { ChannelScreenLoadingFallback } from "@/features/channels/ui/ChannelScreenLoadingFallback"; -import * as huddleMessages from "@/features/channels/ui/useHuddleChannelMessages"; +import { + useHuddleChannelMessages, + useIsHuddleTranscript, +} from "@/features/channels/ui/useHuddleChannelMessages"; import { useHuddleReadMarker } from "@/features/channels/ui/useHuddleReadMarker"; import { useHuddleThreadIsolation } from "@/features/channels/ui/useHuddleThreadIsolation"; import { AgentSessionProvider } from "@/shared/context/AgentSessionContext"; @@ -71,26 +88,20 @@ import { useChannelRouteTarget } from "./useChannelRouteTarget"; import { useChannelOpenReadState } from "./useChannelOpenReadState"; import { useChannelUnreadState } from "./useChannelUnreadState"; import type { ChannelScreenProps } from "./ChannelScreen.types"; -import { GuardedChannelPane } from "./GuardedChannelPane"; -import { useNavigationGuard } from "./useNavigationGuard"; -import * as searchForwarding from "./searchTargetForwarding"; +import { GuardedChannelPane } from "./GuardedChannelPane"; import { useNavigationGuard } from "./useNavigationGuard"; import * as searchForwarding from "./searchTargetForwarding"; const EMPTY_RELAY_EVENTS: RelayEvent[] = []; export function ChannelScreen({ activeChannel, autoSendDraftKey, currentIdentity, currentProfile, - headerEndActions, - idleAuxiliaryPanel, + headerEndActions, idleAuxiliaryPanel, + idleAuxiliaryHeaderActions, idleAuxiliaryOverridesThread, idleAuxiliaryTitle, - onAddFiles, - onCloseIdleAuxiliaryPanel, - onCloseForumPost, - onSelectForumPost, - selectedForumPostId, - targetForumReplyId, - targetMessageEvents, - targetMessageId, + onAddFiles, onCloseIdleAuxiliaryPanel, + onCloseForumPost, onSelectForumPost, + selectedForumPostId, targetForumReplyId, + targetMessageEvents, targetMessageId, ...searchTarget }: ChannelScreenProps) { const queryClient = useQueryClient(); @@ -162,8 +173,7 @@ export function ChannelScreen({ const mainInsetRef = useMainInsetRef(); const currentPubkey = currentIdentity?.pubkey; const activeChannelId = activeChannel?.id ?? null; - const isHuddleTranscript = - huddleMessages.useIsHuddleTranscript(activeChannelId); + const isHuddleTranscript = useIsHuddleTranscript(activeChannelId); const relaySelfPubkey = useRelaySelfQuery(activeChannel !== null).data; const requireThreadEditResolutionRef = React.useRef<() => boolean>( () => true, @@ -206,7 +216,7 @@ export function ChannelScreen({ const messages = messagesQuery.data; if (!messages) return null; for (let index = messages.length - 1; index >= 0; index -= 1) { - if (threading.getThreadReference(messages[index].tags).parentId === null) + if (getThreadReference(messages[index].tags).parentId === null) return messages[index]; } return null; @@ -225,8 +235,7 @@ export function ChannelScreen({ return; } setContextParentResolver((contextId) => - contextId.startsWith(readStateFormat.THREAD_PREFIX) || - contextId.startsWith(readStateFormat.MSG_PREFIX) + contextId.startsWith(THREAD_PREFIX) || contextId.startsWith(MSG_PREFIX) ? activeChannelId : null, ); @@ -246,14 +255,13 @@ export function ChannelScreen({ const toggleReactionMutation = useToggleReactionMutation(); const deleteMessageMutation = useDeleteMessageMutation(activeChannel); const editMessageMutation = useEditMessageMutation(activeChannel); - const joinChannelMutation = - channelHooks.useJoinChannelMutation(activeChannelId); + const joinChannelMutation = useJoinChannelMutation(activeChannelId); const { resolvedMessages, threadSummaries, threadRepliesError: huddleThreadRepliesError, onRetryThreadReplies: onRetryHuddleThreadReplies, - } = huddleMessages.useHuddleChannelMessages({ + } = useHuddleChannelMessages({ activeChannel, isHuddleTranscript, messages: messagesQuery.data ?? EMPTY_RELAY_EVENTS, @@ -299,11 +307,9 @@ export function ChannelScreen({ : [], [activeChannel], ); - const channelMembersQuery = channelHooks.useChannelMembersQuery( - activeChannel?.id ?? null, - ); + const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null); const channelMembers = channelMembersQuery.data; - const managedAgentsQuery = agentHooks.useManagedAgentsQuery(); + const managedAgentsQuery = useManagedAgentsQuery(); const managedAgents = managedAgentsQuery.data ?? []; const welcomeGuideAgent = React.useMemo( () => pickWelcomeGuideAgent(managedAgents), @@ -314,7 +320,7 @@ export function ChannelScreen({ currentIdentity, welcomeGuideAgent, }); - const relayAgentsQuery = agentHooks.useRelayAgentsQuery(); + const relayAgentsQuery = useRelayAgentsQuery(); const relayAgents = relayAgentsQuery.data ?? []; const knownAgentPubkeys = React.useMemo( () => @@ -381,7 +387,7 @@ export function ChannelScreen({ } return pubkeys; }, [knownAgentPubkeys, messageProfiles, communityAgentPubkeys]); - const personasQuery = agentHooks.usePersonasQuery(); + const personasQuery = usePersonasQuery(); const { personaLookup, respondToLookup } = React.useMemo(() => { const agents = managedAgentsQuery.data ?? []; const personaById = new Map( @@ -498,8 +504,7 @@ export function ChannelScreen({ editMessageMutation, editTargetId, editTargetIsThreadReply: - editTargetMessage !== null && - threading.isThreadReply(editTargetMessage.tags ?? []), + editTargetMessage !== null && isThreadReply(editTargetMessage.tags ?? []), expandedThreadReplyIds, getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, @@ -816,170 +821,165 @@ export function ChannelScreen({ activeChannel.channelType === "forum" ? ( searchForwarding.renderSearchAwareForum( , searchTarget, ) ) : ( - } + fallback={} > {searchForwarding.renderSearchAwareChannel( - knownAgentPubkeys.has(pubkey) || - !!messageProfiles?.[pubkey]?.isAgent, - ) - : null - } - followThreadById={followThread} - unfollowThreadById={unfollowThread} - isFollowingThreadById={isFollowingThread} - isMessageUnreadById={isMessageUnread} - isFollowingThread={isNotifiedForEffectiveThread} - isSending={sendMessageMutation.isPending} - isSinglePanelView={isSinglePanelView} - isTimelineLoading={isTimelineLoading} - messages={timelineMessages} - threadSummaries={threadSummaries} - huddleThreadRepliesError={huddleThreadRepliesError} - onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} - onCancelEdit={handleCancelEdit} - onCancelThreadReply={handleCancelThreadReply} - onChannelManagementDeleted={handleChannelManagementDeleted} - onFollowThread={ - effectiveOpenThreadHeadId != null && - !isNotifiedForEffectiveThread - ? () => followThread(effectiveOpenThreadHeadId) - : undefined - } - onUnfollowThread={ - effectiveOpenThreadHeadId != null && - isNotifiedForEffectiveThread - ? () => unfollowThread(effectiveOpenThreadHeadId) - : undefined - } - onCloseAgentSession={handleCloseAgentSession} - onBackFromAgentSession={ - hasAgentSessionReturnTarget - ? handleBackFromAgentSession - : undefined - } - {...{ onCloseIdleAuxiliaryPanel }} - onCloseChannelManagement={handleCloseChannelManagement} - onCloseThread={handleCloseThread} - onDelete={ - activeChannel?.archivedAt ? undefined : handleDelete - } - onEdit={activeChannel?.archivedAt ? undefined : handleEdit} - onEditSave={ - activeChannel?.archivedAt ? undefined : handleEditSave - } - onMarkUnread={handleMessageMarkUnread} - onMarkRead={handleMessageMarkRead} - onExpandThreadReplies={handleExpandThreadReplies} - onOpenAgentSession={handleOpenAgentSession} - onOpenDm={handleOpenDm} - onOpenProfilePanel={handleOpenProfilePanel} - onResetThreadPanelWidth={handleThreadPanelWidthReset} - onCloseProfilePanel={handleCloseProfilePanel} - onOpenThread={handleOpenThreadAndCloseAgentSession} - onSelectThreadReplyTarget={handleSelectThreadReplyTarget} - onSendMessage={handleSendMessage} - onSendToChannel={handleSendToChannel} - onSendVideoReviewComment={effectiveSendVideoReviewComment} - onSendThreadReply={handleSendThreadReply} - onThreadScrollTargetResolved={() => - setThreadScrollTargetId(null) - } - onThreadPanelResizeStart={handleThreadPanelResizeStart} - onTargetReached={() => - clearMessageRouteTarget({ replace: true }) - } - onToggleReaction={effectiveToggleReaction} - openAgentSessionChannelId={openAgentSessionChannelId} - openAgentSessionPubkey={openAgentSessionPubkey} - openThreadHeadId={effectiveOpenThreadHeadId} - shouldShowThreadSkeleton={shouldShowThreadSkeleton} - onProfilePanelViewChange={setProfilePanelView} - onProfilePanelTabChange={setProfilePanelTab} - profilePanelPubkey={profilePanelPubkey} - profilePanelTab={profilePanelTab} - profilePanelView={profilePanelView} - personaLookup={personaLookup} - profiles={messageProfiles} - ownerProfiles={messageOwnerProfiles} - firstUnreadMessageId={firstUnreadMessageId} - unreadCount={unreadCount} - targetMessageId={mainTimelineTargetMessageId} - threadAllMessages={displayedThreadAllMessages} - threadHeadMessage={displayedThreadHeadMessage} - threadMessages={displayedThreadMessages} - threadMessagesPending={threadRepliesQuery.isPending} - threadMessagesError={threadRepliesQuery.isError} - onRetryThreadReplies={() => { - void threadRepliesQuery.refetch(); - }} - threadPanelWidthPx={threadPanelWidthPx} - threadTypingPubkeys={threadTypingPubkeys} - threadReplyTargetMessage={displayedThreadReplyTargetMessage} - threadScrollTargetId={threadScrollTargetId} - threadUnreadCounts={threadUnreadCounts} - threadReplyUnreadCounts={threadReplyUnreadCounts} - threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId} - isJoining={joinChannelMutation.isPending} - onJoinChannel={joinChannelMutation.mutateAsync} + activeChannel={activeChannel} + activityAgents={channelAgentSessionAgents} + agentPubkeys={agentPubkeys} + agentPubkeysPending={agentPubkeysPending} + agentSessionAgents={agentSessionAgents} + autoSendDraftKey={autoSendDraftKey} + onAutoSendComplete={clearAutoSend} + botTypingEntries={botTypingEntries} + channelManagementOpen={channelManagementOpen} + currentPubkey={currentPubkey} + canResetThreadPanelWidth={canResetThreadPanelWidth} + fetchOlder={fetchOlder} + header={channelHeader} + {...{ idleAuxiliaryHeaderActions, idleAuxiliaryOverridesThread, idleAuxiliaryPanel, idleAuxiliaryTitle, hasOlderMessages, historyExhausted }} + {...{ onAddFiles }} + onAddAgent={handleOpenAddBot} + onBrowseChannels={openBrowseChannels} + onCreateChannel={openCreateChannel} + onOpenMembers={handleOpenMembersSidebar} + isFetchingOlder={isFetchingOlder} + isHuddleTranscript={isHuddleTranscript} + entranceMessageId={welcomeEntranceMessageId} + onEntranceMessageComplete={handleWelcomeEntranceComplete} + welcomeKickoffStage={welcomeKickoffStage} + welcomeKickoffSettingUp={welcomeKickoffSettingUp} + editTarget={ + editTargetMessage + ? buildMessageComposerEditTarget( + editTargetMessage, + messageProfiles, + (pubkey) => + knownAgentPubkeys.has(pubkey) || + !!messageProfiles?.[pubkey]?.isAgent, + ) + : null + } + followThreadById={followThread} + unfollowThreadById={unfollowThread} + isFollowingThreadById={isFollowingThread} + isMessageUnreadById={isMessageUnread} + isFollowingThread={isNotifiedForEffectiveThread} + isSending={sendMessageMutation.isPending} + isSinglePanelView={isSinglePanelView} + isTimelineLoading={isTimelineLoading} + messages={timelineMessages} + threadSummaries={threadSummaries} + huddleThreadRepliesError={huddleThreadRepliesError} + onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} + onCancelEdit={handleCancelEdit} + onCancelThreadReply={handleCancelThreadReply} + onChannelManagementDeleted={handleChannelManagementDeleted} + onFollowThread={ + effectiveOpenThreadHeadId != null && + !isNotifiedForEffectiveThread + ? () => followThread(effectiveOpenThreadHeadId) + : undefined + } + onUnfollowThread={ + effectiveOpenThreadHeadId != null && + isNotifiedForEffectiveThread + ? () => unfollowThread(effectiveOpenThreadHeadId) + : undefined + } + onCloseAgentSession={handleCloseAgentSession} + onBackFromAgentSession={ + hasAgentSessionReturnTarget + ? handleBackFromAgentSession + : undefined + } + {...{ onCloseIdleAuxiliaryPanel }} + onCloseChannelManagement={handleCloseChannelManagement} + onCloseThread={handleCloseThread} + onDelete={ + activeChannel?.archivedAt ? undefined : handleDelete + } + onEdit={activeChannel?.archivedAt ? undefined : handleEdit} + onEditSave={ + activeChannel?.archivedAt ? undefined : handleEditSave + } + onMarkUnread={handleMessageMarkUnread} + onMarkRead={handleMessageMarkRead} + onExpandThreadReplies={handleExpandThreadReplies} + onOpenAgentSession={handleOpenAgentSession} + onOpenDm={handleOpenDm} + onOpenProfilePanel={handleOpenProfilePanel} + onResetThreadPanelWidth={handleThreadPanelWidthReset} + onCloseProfilePanel={handleCloseProfilePanel} + onOpenThread={handleOpenThreadAndCloseAgentSession} + onSelectThreadReplyTarget={handleSelectThreadReplyTarget} + onSendMessage={handleSendMessage} + onSendToChannel={handleSendToChannel} + onSendVideoReviewComment={effectiveSendVideoReviewComment} + onSendThreadReply={handleSendThreadReply} + onThreadScrollTargetResolved={() => + setThreadScrollTargetId(null) + } + onThreadPanelResizeStart={handleThreadPanelResizeStart} + onTargetReached={() => + clearMessageRouteTarget({ replace: true }) + } + onToggleReaction={effectiveToggleReaction} + openAgentSessionChannelId={openAgentSessionChannelId} + openAgentSessionPubkey={openAgentSessionPubkey} + openThreadHeadId={effectiveOpenThreadHeadId} + shouldShowThreadSkeleton={shouldShowThreadSkeleton} + onProfilePanelViewChange={setProfilePanelView} + onProfilePanelTabChange={setProfilePanelTab} + profilePanelPubkey={profilePanelPubkey} + profilePanelTab={profilePanelTab} + profilePanelView={profilePanelView} + personaLookup={personaLookup} + profiles={messageProfiles} + ownerProfiles={messageOwnerProfiles} + firstUnreadMessageId={firstUnreadMessageId} + unreadCount={unreadCount} + targetMessageId={mainTimelineTargetMessageId} + threadAllMessages={displayedThreadAllMessages} + threadHeadMessage={displayedThreadHeadMessage} + threadMessages={displayedThreadMessages} + threadMessagesPending={threadRepliesQuery.isPending} + threadMessagesError={threadRepliesQuery.isError} + onRetryThreadReplies={() => { + void threadRepliesQuery.refetch(); + }} + threadPanelWidthPx={threadPanelWidthPx} + threadTypingPubkeys={threadTypingPubkeys} + threadReplyTargetMessage={displayedThreadReplyTargetMessage} + threadScrollTargetId={threadScrollTargetId} + threadUnreadCounts={threadUnreadCounts} + threadReplyUnreadCounts={threadReplyUnreadCounts} + threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId} + isJoining={joinChannelMutation.isPending} + onJoinChannel={joinChannelMutation.mutateAsync} typingPubkeys={humanTypingPubkeys} />, searchTarget, diff --git a/desktop/src/features/channels/ui/ChannelScreen.types.ts b/desktop/src/features/channels/ui/ChannelScreen.types.ts index 1ba7e7ebefe..0a465331399 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.types.ts +++ b/desktop/src/features/channels/ui/ChannelScreen.types.ts @@ -6,6 +6,7 @@ import type { Profile, RelayEvent, } from "@/shared/api/types"; +import type { IdleAuxiliaryHeaderControls } from "./IdleAuxiliaryPanel"; export type ChannelScreenProps = { activeChannel: Channel | null; @@ -19,6 +20,8 @@ export type ChannelScreenProps = { currentIdentity?: Identity; currentProfile?: Profile; idleAuxiliaryPanel?: ReactNode; + idleAuxiliaryHeaderActions?: IdleAuxiliaryHeaderControls; + idleAuxiliaryOverridesThread?: boolean; idleAuxiliaryTitle?: string; headerEndActions?: ReactNode; onAddFiles?: () => void; diff --git a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx index adfb4d37251..626dbbddc27 100644 --- a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx +++ b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx @@ -231,7 +231,7 @@ 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={label} data-testid="focus-thread-drawer" diff --git a/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx b/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx index f254efb0989..7a9c454554e 100644 --- a/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx +++ b/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx @@ -4,13 +4,21 @@ import { AuxiliaryPanel, AuxiliaryPanelBody, AuxiliaryPanelHeader, + AuxiliaryPanelHeaderActions, AuxiliaryPanelHeaderGroup, AuxiliaryPanelTitle, } from "@/shared/layout/AuxiliaryPanel"; +export type IdleAuxiliaryHeaderControls = { + actions?: React.ReactNode; + backLabel?: string; + onBack?: () => void; +}; + export function IdleAuxiliaryPanel({ canResetWidth, children, + headerControls, isFocusDrawer = false, isSinglePanelView, onClose, @@ -22,6 +30,7 @@ export function IdleAuxiliaryPanel({ }: { canResetWidth: boolean; children: React.ReactNode; + headerControls?: IdleAuxiliaryHeaderControls; isFocusDrawer?: boolean; isSinglePanelView: boolean; onClose: () => void; @@ -50,9 +59,18 @@ export function IdleAuxiliaryPanel({ widthPx={widthPx} header={ - + {title} + {headerControls?.actions ? ( + + {headerControls.actions} + + ) : null} } > diff --git a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx index d46de8eb7d7..68f68890bbf 100644 --- a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx +++ b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx @@ -11,6 +11,7 @@ type RightAuxiliaryPaneProps = { detached?: boolean; onResetWidth: () => void; onResizeStart: (event: React.PointerEvent) => void; + showResizeIndicator?: boolean; testId?: string; widthPx: number; }; @@ -23,6 +24,7 @@ export function RightAuxiliaryPane({ detached = false, onResetWidth, onResizeStart, + showResizeIndicator = true, testId, widthPx, }: RightAuxiliaryPaneProps) { @@ -56,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 5e0617abc4a..3374fb7a654 100644 --- a/desktop/src/features/channels/ui/useChannelIntro.tsx +++ b/desktop/src/features/channels/ui/useChannelIntro.tsx @@ -107,9 +107,9 @@ export function useChannelIntro({ 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", }); @@ -118,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", @@ -131,6 +131,7 @@ export function useChannelIntro({ channelKindLabel: getChannelIntroKind(activeChannel, projectHome), channelName: activeChannel.name, description: getChannelIntroDescription(activeChannel), + hideBeginning: projectHome, icon: projectHome ? ( ) : undefined, diff --git a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx index c69fd46a32d..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,13 +51,15 @@ 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} diff --git a/desktop/src/features/projects/createProject.ts b/desktop/src/features/projects/createProject.ts index f11a7905303..8adfe777a96 100644 --- a/desktop/src/features/projects/createProject.ts +++ b/desktop/src/features/projects/createProject.ts @@ -33,6 +33,7 @@ export type CreateProjectInput = { channelVisibility?: ChannelVisibility; projectVisibility?: ProjectListingVisibility; agents?: readonly CreateChannelManagedAgentInput[]; + templateId?: string; }; export type CreateProjectResult = { diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index 4e26287f65e..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 }>; @@ -213,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/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/projectDetailSearch.test.mjs b/desktop/src/features/projects/lib/projectDetailSearch.test.mjs index d9d667d73d0..d64cab98296 100644 --- a/desktop/src/features/projects/lib/projectDetailSearch.test.mjs +++ b/desktop/src/features/projects/lib/projectDetailSearch.test.mjs @@ -10,6 +10,7 @@ test("parseProjectDetailSearch keeps forge params and channel panel params", () const search = parseProjectDetailSearch({ repositoryId: "30617:owner:buzz", tab: "files", + filePath: "src/main.ts", thread: "abc123", agentSession: "def456", channelManagement: "1", @@ -18,6 +19,7 @@ test("parseProjectDetailSearch keeps forge params and channel panel params", () assert.equal(search.repositoryId, "30617:owner:buzz"); assert.equal(search.tab, "files"); + assert.equal(search.filePath, "src/main.ts"); assert.equal(search.thread, "abc123"); assert.equal(search.agentSession, "def456"); assert.equal(search.channelManagement, "1"); @@ -60,6 +62,13 @@ test("wantsProjectRepositorySurface is true for repo, tab, or work-item params", }), true, ); + assert.equal( + wantsProjectRepositorySurface({ + filePath: "src/main.ts", + projectId: "30621:owner:platform", + }), + true, + ); }); test("wantsProjectRepositorySurface is true for a legacy kind:30617 project id", () => { diff --git a/desktop/src/features/projects/lib/projectDetailSearch.ts b/desktop/src/features/projects/lib/projectDetailSearch.ts index cfc7ece0990..460ade0400c 100644 --- a/desktop/src/features/projects/lib/projectDetailSearch.ts +++ b/desktop/src/features/projects/lib/projectDetailSearch.ts @@ -22,6 +22,7 @@ function nonEmptyString(value: unknown): string | undefined { export function parseProjectDetailSearch(search: Record) { return { commitHash: optionalSearchString(search.commitHash), + filePath: optionalSearchString(search.filePath), pullRequestId: optionalSearchString(search.pullRequestId), issueId: optionalSearchString(search.issueId), repositoryId: optionalSearchString(search.repositoryId), @@ -46,6 +47,7 @@ export function parseProjectDetailSearch(search: Record) { */ export function wantsProjectRepositorySurface(input: { commitHash?: string; + filePath?: string; issueId?: string; projectId: string; pullRequestId?: string; @@ -57,7 +59,8 @@ export function wantsProjectRepositorySurface(input: { input.tab || input.issueId || input.pullRequestId || - input.commitHash + input.commitHash || + input.filePath ) { return true; } diff --git a/desktop/src/features/projects/lib/projectHomeChannel.test.mjs b/desktop/src/features/projects/lib/projectHomeChannel.test.mjs index 2903873b40f..a7c02ccb6ae 100644 --- a/desktop/src/features/projects/lib/projectHomeChannel.test.mjs +++ b/desktop/src/features/projects/lib/projectHomeChannel.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + findProjectHomeByChannelId, hasAuthoritativeHomeBinding, isProjectHomeChannel, } from "./projectHomeChannel.ts"; @@ -78,3 +79,18 @@ test("isProjectHomeChannel is false for unbound channels", () => { assert.equal(isProjectHomeChannel("channel-z", [project()]), false); assert.equal(isProjectHomeChannel(null, [project()]), false); }); + +test("findProjectHomeByChannelId prefers the oldest listed home", () => { + const base = { + createdAt: 0, + legacy: false, + projectChannelId: "channel-a", + visibility: "listed", + }; + const selected = findProjectHomeByChannelId("channel-a", [ + { ...base, createdAt: 200, id: "later" }, + { ...base, createdAt: 50, id: "hidden", visibility: "unlisted" }, + { ...base, createdAt: 100, id: "original" }, + ]); + assert.equal(selected?.id, "original"); +}); diff --git a/desktop/src/features/projects/lib/projectHomeChannel.ts b/desktop/src/features/projects/lib/projectHomeChannel.ts index 7edc9a3b6aa..091a4409bfa 100644 --- a/desktop/src/features/projects/lib/projectHomeChannel.ts +++ b/desktop/src/features/projects/lib/projectHomeChannel.ts @@ -1,4 +1,23 @@ import { useProjectsQuery } from "@/features/projects/hooks"; +import type { Project } from "@/features/projects/projectModels"; + +/** Resolves the canonical visible project home for a channel. */ +export function findProjectHomeByChannelId( + channelId: string | null | undefined, + projects: readonly Project[], +): Project | null { + if (!channelId) return null; + const matching = projects + .filter( + (project) => !project.legacy && project.projectChannelId === channelId, + ) + .sort((left, right) => left.createdAt - right.createdAt); + return ( + matching.find((project) => project.visibility !== "unlisted") ?? + matching[0] ?? + null + ); +} export type ProjectHomeCandidate = { owner: string; diff --git a/desktop/src/features/projects/lib/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 index 89d23eb2217..68ee2d82393 100644 --- a/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.test.mjs +++ b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { isProjectHomeWorkspaceSheetTab, + projectHomeWorkspaceSheetExpandTab, projectHomeWorkspaceSheetTitle, } from "./projectHomeWorkspaceSheet.ts"; @@ -20,3 +21,14 @@ test("projectHomeWorkspaceSheetTitle matches overview row labels", () => { assert.equal(projectHomeWorkspaceSheetTitle("files"), "Files"); assert.equal(projectHomeWorkspaceSheetTitle("contributors"), "People"); }); + +test("projectHomeWorkspaceSheetExpandTab keeps the selected repository menu", () => { + assert.equal(projectHomeWorkspaceSheetExpandTab("issues"), "issues"); + assert.equal(projectHomeWorkspaceSheetExpandTab("prs"), "prs"); + assert.equal(projectHomeWorkspaceSheetExpandTab("commits"), "commits"); + assert.equal(projectHomeWorkspaceSheetExpandTab("files"), "files"); + assert.equal( + projectHomeWorkspaceSheetExpandTab("contributors"), + "contributors", + ); +}); diff --git a/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts index 93a6675990a..bf3ce4f65dc 100644 --- a/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts +++ b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts @@ -31,3 +31,10 @@ export function projectHomeWorkspaceSheetTitle( ): string { return WORKSPACE_SHEET_TITLES[tab]; } + +/** Repository workspace tab to open when expanding a home-channel sheet. */ +export function projectHomeWorkspaceSheetExpandTab( + tab: ProjectHomeWorkspaceSheetTab, +): ProjectHomeWorkspaceSheetTab { + return tab; +} diff --git a/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs b/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs index ad1795ad735..e6fc3196f20 100644 --- a/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs +++ b/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { + collapseProjectRelatedChannelRows, collectProjectRelatedChannelRows, listProjectBoundChannels, listProjectChildChannels, @@ -106,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([ diff --git a/desktop/src/features/projects/lib/projectRelatedChannels.ts b/desktop/src/features/projects/lib/projectRelatedChannels.ts index 525eb700c26..d5166a48536 100644 --- a/desktop/src/features/projects/lib/projectRelatedChannels.ts +++ b/desktop/src/features/projects/lib/projectRelatedChannels.ts @@ -20,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; @@ -88,6 +96,40 @@ 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; 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/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/projectModels.ts b/desktop/src/features/projects/projectModels.ts index a89ddcd689e..21afa771d7c 100644 --- a/desktop/src/features/projects/projectModels.ts +++ b/desktop/src/features/projects/projectModels.ts @@ -49,6 +49,11 @@ export type Project = { legacy: boolean; }; +/** True for an announced NIP-MP project, excluding repository-only read models. */ +export function isExplicitProject(project: Project): boolean { + return !project.legacy; +} + type BuildProjectReadModelsInput = { projectEvents: RelayEvent[]; repositoryEvents: RelayEvent[]; diff --git a/desktop/src/features/projects/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={ + + + + 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 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 3cd085e0bf8..77210c82dcf 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 { projectAgentMembershipInput, restoreProjectsAgentConversation, @@ -103,7 +104,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] : [], ); diff --git a/desktop/src/features/projects/ui/ProjectChannelHome.tsx b/desktop/src/features/projects/ui/ProjectChannelHome.tsx index e554a8bdde6..d7b0d766fad 100644 --- a/desktop/src/features/projects/ui/ProjectChannelHome.tsx +++ b/desktop/src/features/projects/ui/ProjectChannelHome.tsx @@ -1,5 +1,5 @@ import { useSearch } from "@tanstack/react-router"; -import { Info } from "lucide-react"; +import { Maximize2, Plus } from "lucide-react"; import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; @@ -9,14 +9,17 @@ 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"; @@ -27,7 +30,11 @@ import { ProjectContextRail } from "./ProjectContextRail"; import { ProjectDetailChrome } from "./ProjectDetailChrome"; import { ProjectHomeColumn } from "./ProjectHomeColumn"; import { ProjectHomeContextPanel } from "./ProjectHomeContextPanel"; -import { ProjectHomeWorkspaceSheet } from "./ProjectHomeWorkspaceSheet"; +import { + ProjectHomeWorkspaceSheet, + type ProjectHomeWorkspaceCreateAction, + type ProjectHomeWorkspaceDetail, +} from "./ProjectHomeWorkspaceSheet"; import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; const EMPTY_TARGET_MESSAGE_EVENTS: RelayEvent[] = []; @@ -78,11 +85,17 @@ function ProjectHomeHeaderToggle({ } export function ProjectChannelHome({ + autoSendDraftKey, project, projects, + targetMessageEvents = EMPTY_TARGET_MESSAGE_EVENTS, + targetMessageId, }: { + autoSendDraftKey?: string | null; project: Project; projects: Project[]; + targetMessageEvents?: RelayEvent[]; + targetMessageId?: string | null; }) { const { goChannel, goProject, goProjects } = useAppNavigation(); const sidebar = useOptionalSidebar(); @@ -100,7 +113,12 @@ export function ProjectChannelHome({ const [workspaceRepositoryId, setWorkspaceRepositoryId] = React.useState< string | null >(null); + const [workspaceCreateAction, setWorkspaceCreateAction] = + React.useState(null); + const [workspaceDetail, setWorkspaceDetail] = + React.useState(null); const summaryWidth = useThreadPanelWidth(undefined, { + minWidthPx: SIDEBAR_WIDTH_MIN, sessionKey: PROJECT_HOME_SUMMARY_WIDTH_KEY, }); const homeChannel = @@ -116,6 +134,12 @@ export function ProjectChannelHome({ 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( @@ -123,11 +147,15 @@ export function ProjectChannelHome({ if (repositoryId) { setWorkspaceRepositoryId(repositoryId); } + setWorkspaceCreateAction(null); + setWorkspaceDetail(null); setWorkspaceSheetTab((current) => (current === tab ? null : tab)); }, [], ); const closeWorkspaceSheet = React.useCallback(() => { + setWorkspaceCreateAction(null); + setWorkspaceDetail(null); setWorkspaceSheetTab(null); }, []); const handleOpenWorkspace = React.useCallback( @@ -153,16 +181,19 @@ export function ProjectChannelHome({ setAddRepositoryOpen(true); }, []); const handleFilesAdded = React.useCallback((repositoryId: string) => { + setWorkspaceCreateAction(null); + setWorkspaceDetail(null); setWorkspaceRepositoryId(repositoryId); setWorkspaceSheetTab("files"); }, []); - const handleToggleFilesSheet = React.useCallback(() => { - if (!workspaceRepository) { - handleAddFiles(); - return; - } - openWorkspaceSheet("files", workspaceRepository.id); - }, [handleAddFiles, openWorkspaceSheet, workspaceRepository]); + const handleWorkspaceRepositoryChange = React.useCallback( + (repositoryId: string) => { + setWorkspaceCreateAction(null); + setWorkspaceDetail(null); + setWorkspaceRepositoryId(repositoryId); + }, + [], + ); useHealProjectHomeRepositories(project, identityQuery.data?.pubkey); const handleOpenCommit = React.useCallback( (commitHash: string) => { @@ -175,53 +206,64 @@ export function ProjectChannelHome({ }, [goProject, project.id, workspaceRepository], ); - const workspaceSheet = React.useMemo( - () => - workspaceSheetOpen && workspaceSheetTab && workspaceRepository ? ( - - ) : null, - [ - handleFilesAdded, - handleOpenCommit, - identityQuery.data?.pubkey, - project, - projects, - workspaceRepository, - workspaceSheetOpen, - workspaceSheetTab, - ], - ); + 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 ( -
- +
+ { @@ -233,116 +275,162 @@ export function ProjectChannelHome({ }} open={summaryVisible} testId="project-home-drawer-toggle" - > - - - - - } - activeTabCrumb={null} - activeWorkItemCrumb={null} - onGoProjectHome={() => undefined} - onGoProjects={() => { - void goProjects(); - }} - project={project} - /> - {waitingForChannel ? ( - - ) : homeChannel ? ( - } - > - undefined} + onGoProjects={() => { + void goProjects(); + }} + project={project} + /> + {waitingForChannel ? ( + + ) : homeChannel ? ( + } - onAddFiles={handleAddFiles} - onCloseIdleAuxiliaryPanel={closeWorkspaceSheet} - onCloseForumPost={ignoreForumPost} - onSelectForumPost={ignoreForumPostSelect} - selectedForumPostId={null} - targetForumReplyId={null} - targetMessageEvents={EMPTY_TARGET_MESSAGE_EVENTS} - targetMessageId={search.messageId ?? null} - /> - - ) : ( -
-

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

-
- )} + > + + {workspaceCreateAction ? ( + + + + + + {workspaceCreateAction.label} + + + ) : null} + + + + + {expandLabel} + + + ), + backLabel: workspaceDetail?.backLabel, + onBack: workspaceDetail?.onBack, + }} + 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} +
- - - {summaryVisible ? ( - setSummaryOpen(false)} - onResetWidth={summaryWidth.onResetWidth} - onResizeStart={summaryWidth.onResizeStart} - testId="project-home-summary-column" - title="Overview" - widthPx={summaryWidth.widthPx} - > - { - void goChannel(channelId); - }} - onOpenRepository={handleOpenRepository} - onOpenWorkspace={handleOpenWorkspace} - onRepositoryChange={handleRepositoryChange} - project={project} - projects={projects} - /> - - ) : null} - -
+ ); } diff --git a/desktop/src/features/projects/ui/ProjectChannelManagement.tsx b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx index dbb02fd462c..9c774c9a3b2 100644 --- a/desktop/src/features/projects/ui/ProjectChannelManagement.tsx +++ b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx @@ -41,33 +41,37 @@ export function ProjectChannelManagement({ ? project.owner : undefined; - if (!canEdit) return null; - return ( <> - { - 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" - /> + {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} + ) : title ? ( +

{title}

) : ( )} - {headerAction} + {headerAction ? ( + + {headerAction} + + ) : null}
) : null} - {children} + {!collapsible || expanded ? children : null} ); } @@ -69,11 +96,11 @@ function ContextRowContent({ }) { return ( <> - + {icon} {children} - + {count ?? ""} @@ -103,8 +130,9 @@ function ContextNavButton({ + ) : 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 535c15b0dd7..c31db004e55 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx @@ -142,18 +142,24 @@ export function ProjectRepositoryManagement({ project={project} repositories={attachCandidates} /> - {canEdit && !hideTriggers ? ( + {!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 065a97e5e35..58b6dd93364 100644 --- a/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx @@ -34,6 +34,9 @@ export function ProjectTabsList({ + + Overview + Files diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx index 64941961231..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, @@ -143,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; @@ -352,13 +354,13 @@ export function WorkspaceTabs({ const sectionHeader = selectedTab === "files" && files.length > 0 ? ( ) : selectedTab === "activity" && !selectedCommitHash ? ( @@ -369,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" /> @@ -383,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" ? ( @@ -442,7 +444,10 @@ export function WorkspaceTabs({ > {sectionHeader} - + -
- No local checkout found. -
-
+ ) : ( ; }; @@ -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..6f34d053957 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,65 @@ 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, + onCreateProject, + onCreatePullRequest, }: { - children: React.ReactNode; - onClick: () => void; - testId?: string; + action: Exclude; + canCreateTarget: boolean; + onAddChannel: () => void; + onAddRepository: () => void; + onCreateIssue: () => void; + onCreateProject: () => void; + onCreatePullRequest: () => void; }) { + const actionHandler = + action.kind === "issue" + ? onCreateIssue + : action.kind === "pullRequest" + ? onCreatePullRequest + : action.kind === "project" + ? onCreateProject + : action.kind === "channel" + ? onAddChannel + : onAddRepository; + const requiresProject = + action.kind === "channel" || action.kind === "repository"; return ( ); } @@ -113,7 +141,9 @@ function OverviewStatRow({ {label} - {count} + + {count} + ); } @@ -128,59 +158,61 @@ 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 +228,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 +268,35 @@ 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 5a6bbf7473e..f33659bcfc5 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -1,9 +1,10 @@ -import { Search } 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, @@ -17,17 +18,18 @@ import { } from "@/features/projects/hooks"; import { useRepositoryActivitySummariesQuery } from "@/features/projects/repositoryActivityHooks"; import { 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 { 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,7 +44,6 @@ import { import { ProjectsOverviewChromeActions } from "@/features/projects/ui/ProjectsOverviewChromeActions"; import { ProjectContextRail } from "@/features/projects/ui/ProjectContextRail"; import { - openAppSearch, projectsSectionIcon, projectsSectionTitle, } from "@/features/projects/ui/projectsSectionMeta"; @@ -55,42 +56,29 @@ 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 +89,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 +119,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 +134,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 +149,13 @@ export function ProjectsView() { useProjectPanelWidths("chat"); const activitySummariesQuery = useProjectActivitySummariesQuery(projects); const repositoryActivitySummariesQuery = useRepositoryActivitySummariesQuery( - filter === "repositories" ? projects : [], + filter === "repositories" ? projectReadModels : [], ); - 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(), + 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( @@ -188,6 +178,8 @@ export function ProjectsView() { memberChannelIds, ); const [createProjectOpen, setCreateProjectOpen] = 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,8 +223,63 @@ 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 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 handleViewModeChange = React.useCallback( (nextViewMode: ProjectsViewMode) => { @@ -242,30 +289,6 @@ export function ProjectsView() { [], ); - const handleRepositoryScopeChange = React.useCallback( - (scope: ProjectsRepositoryScope) => { - setRepositoryScope(scope); - writeStoredRepositoryScope(scope); - }, - [], - ); - - const handlePullRequestScopeChange = React.useCallback( - (scope: ProjectsWorkItemScope) => { - setPullRequestScope(scope); - writeStoredPullRequestScope(scope); - }, - [], - ); - - const handleIssueScopeChange = React.useCallback( - (scope: ProjectsWorkItemScope) => { - setIssueScope(scope); - writeStoredIssueScope(scope); - }, - [], - ); - const handleSortChange = React.useCallback((nextSort: ProjectsSort) => { setSort(nextSort); writeStoredSort(nextSort); @@ -281,16 +304,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 +311,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 +349,10 @@ export function ProjectsView() { return sortedProjects; }, [ activitySummariesQuery.data, - currentPubkey, filter, - localRepoNames, profiles, projects, - relayOrigin, - repositoryAccessInput, - repositoryScope, + searchQuery, sort, ]); @@ -353,7 +360,7 @@ export function ProjectsView() { if (filter !== "repositories") return []; const repositories = [ ...new Map( - projects + projectReadModels .flatMap((project) => project.repositories.map((repository) => ({ project, @@ -364,40 +371,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 +394,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 +468,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,7 +565,7 @@ export function ProjectsView() { ); } - if (projects.length === 0) { + if (projectReadModels.length === 0) { return ; } @@ -603,7 +573,6 @@ export function ProjectsView() { ); @@ -675,22 +636,28 @@ 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 +689,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} > @@ -765,8 +751,6 @@ export function ProjectsView() { } else { toast.success(`Project "${result.project.name}" created.`); } - handleRepositoryScopeChange("all"); - handleFilterChange("projects"); await goProject(result.project.id); }} onOpenChange={setCreateProjectOpen} @@ -801,6 +785,14 @@ export function ProjectsView() { open={createIssueOpen} projects={projects} /> +
- -
- -
+
{filter === "all" ? ( - +
{activityFeed}
@@ -856,7 +837,7 @@ export function ProjectsView() { ) : ( <> ) : filter === "channels" ? ( - + ) : filter === "projects" ? ( projectItems ) : ( @@ -948,11 +942,12 @@ export function ProjectsView() {