From 07d2ce6ffb94c902347ee1d5a67ee7808365cb90 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Sat, 22 Aug 2026 21:52:36 -0400 Subject: [PATCH 1/7] feat(projects): unify navigation and reviewed channel requests Open project homes consistently from channel and project routes, preserve file/work-item deep links, and let agents request owner-reviewed project channels. Signed-off-by: Thomas Petersen Co-authored-by: Thomas Petersen --- crates/buzz-acp/src/base_prompt.md | 3 +- crates/buzz-cli/src/agent_management.rs | 110 +++++- crates/buzz-cli/src/commands/projects.rs | 65 +++- crates/buzz-cli/src/lib.rs | 44 ++- .../src/app/navigation/useAppNavigation.ts | 2 + desktop/src/app/routes/ChannelRouteScreen.tsx | 20 + .../src/app/routes/projects.$projectId.tsx | 3 +- .../src/features/agents/observerRelayStore.ts | 23 ++ .../agents/ui/AgentManagementDialogs.tsx | 2 + .../src/features/channels/ui/ChannelPane.tsx | 2 + .../features/channels/ui/ChannelPane.types.ts | 2 + .../features/channels/ui/ChannelScreen.tsx | 2 + .../channels/ui/ChannelScreen.types.ts | 2 + .../channels/ui/IdleAuxiliaryPanel.tsx | 20 +- .../projects/lib/projectDetailSearch.test.mjs | 9 + .../projects/lib/projectDetailSearch.ts | 5 +- .../projects/lib/projectHomeChannel.test.mjs | 16 + .../projects/lib/projectHomeChannel.ts | 19 + .../lib/projectHomeWorkspaceSheet.test.mjs | 12 + .../projects/lib/projectHomeWorkspaceSheet.ts | 7 + .../projects/projectChannelRequest.test.mjs | 70 ++++ .../projects/projectChannelRequest.ts | 81 ++++ .../src/features/projects/projectModels.ts | 5 + .../projects/ui/ProjectChannelHome.tsx | 345 +++++++++++------- .../projects/ui/ProjectChannelManagement.tsx | 2 +- .../ui/ProjectChannelRequestDialog.tsx | 91 +++++ .../projects/ui/ProjectDetailScreen.tsx | 3 + .../projects/ui/ProjectHomeCodebasePanel.tsx | 7 + .../projects/ui/ProjectHomeColumn.tsx | 34 +- .../projects/ui/ProjectHomeContextPanel.tsx | 29 +- .../projects/ui/ProjectHomeWorkspaceSheet.tsx | 232 +++++++++++- .../ui/ProjectRepositoryManagement.tsx | 4 +- .../projects/ui/ProjectRepositoryPanel.tsx | 57 ++- .../projects/ui/ProjectWorkspaceTabs.tsx | 19 +- .../projects/ui/projectDetailHelpers.ts | 2 + .../projects/ui/projectPanelStyles.ts | 3 + .../ui/useRepositoryFilesNavigation.ts | 80 ++++ .../projects/useProjectChannelRequests.ts | 172 +++++++++ .../ui/SidebarProjectsSection.test.mjs | 22 ++ .../sidebar/ui/listSidebarProjects.ts | 21 +- desktop/src/shared/ui/DrawerPanelIcon.tsx | 3 + .../tests/e2e/project-commit-detail.spec.ts | 143 +++++++- 42 files changed, 1550 insertions(+), 243 deletions(-) create mode 100644 desktop/src/features/projects/projectChannelRequest.test.mjs create mode 100644 desktop/src/features/projects/projectChannelRequest.ts create mode 100644 desktop/src/features/projects/ui/ProjectChannelRequestDialog.tsx create mode 100644 desktop/src/features/projects/ui/useRepositoryFilesNavigation.ts create mode 100644 desktop/src/features/projects/useProjectChannelRequests.ts diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 485eb272332..d2775443671 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -23,7 +23,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ | `buzz feed` | `get` | | `buzz social` | `publish`, `notes` | | `buzz repos` | `create`, `get`, `list` | -| `buzz projects` | `create`, `get`, `list`, `add-repo` | +| `buzz projects` | `create`, `get`, `list`, `add-repo`, `add-channel` | | `buzz issues` | `create`, `get`, `list`, `status`, `assign` | | `buzz pr` | `open`, `update`, `get`, `list`, `status` | | `buzz upload` | `file` | @@ -39,6 +39,7 @@ A project is a named grouping (`kind:30621`) with a home channel. Creating a sec - If you are in a project's home channel, or a project with that name/slug already exists, do **not** run `buzz projects create`. `[Context]` includes a Project block when this channel is a project home — tasks, repositories, and files you create belong to that project. - To add a codebase: `buzz repos create --id --name "…" --channel `. `mkdir` in `REPOS/` is not a Buzz repository. - To add tasks: `buzz issues create --channel --subject "…" --content "…"`. That uses this project's repository and creates one bound to the channel if none exists. `--repo-owner` / `--repo-id` remain valid once a repository exists. Session todos and markdown plans do not appear on the project. +- To add another channel to this project: `buzz projects add-channel --home-channel --name "…" [--template "…"]`. This opens an owner-reviewed request in Buzz Desktop and uses the project-aware channel primitive after approval. Do **not** use `buzz channels create` for a channel that should belong to the current project, and do not claim the channel exists until the owner approves it. `buzz pr open`, `buzz issues create`, `buzz repos create`, and `buzz projects create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, repo, or project in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. diff --git a/crates/buzz-cli/src/agent_management.rs b/crates/buzz-cli/src/agent_management.rs index ce4059f8217..e5f25130694 100644 --- a/crates/buzz-cli/src/agent_management.rs +++ b/crates/buzz-cli/src/agent_management.rs @@ -6,7 +6,8 @@ use serde::Serialize; use crate::error::CliError; -const REQUEST_KIND: &str = "agent_management_request"; +const AGENT_REQUEST_KIND: &str = "agent_management_request"; +const PROJECT_CHANNEL_REQUEST_KIND: &str = "project_channel_request"; const MAX_NAME_CHARS: usize = 120; const MAX_PROMPT_CHARS: usize = 20_000; @@ -37,6 +38,20 @@ pub struct UpdateAgentDraft { pub respond_to: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateProjectChannelDraft { + pub home_channel_id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub visibility: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ttl_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub template_name: Option, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct ManagementRequest { @@ -88,6 +103,7 @@ fn build( keys: &Keys, owner: &PublicKey, channel_id: String, + request_kind: &'static str, action: &'static str, request: T, ) -> Result { @@ -95,13 +111,13 @@ fn build( let payload = ObserverEvent { seq: 0, timestamp: chrono::Utc::now().to_rfc3339(), - kind: REQUEST_KIND, + kind: request_kind, agent_index: None, channel_id: Some(channel_id), session_id: None, turn_id: None, payload: ManagementRequest { - request_type: REQUEST_KIND, + request_type: request_kind, action, request_id: request_id.clone(), request, @@ -138,7 +154,14 @@ pub fn build_create( display_name: required(draft.display_name, "display name", MAX_NAME_CHARS)?, system_prompt: required(draft.system_prompt, "system prompt", MAX_PROMPT_CHARS)?, }; - build(keys, owner, channel_id, "create", request) + build( + keys, + owner, + channel_id, + AGENT_REQUEST_KIND, + "create", + request, + ) } pub fn build_update( @@ -182,7 +205,50 @@ pub fn build_update( "include at least one field to update".into(), )); } - build(keys, owner, channel_id, "update", request) + build( + keys, + owner, + channel_id, + AGENT_REQUEST_KIND, + "update", + request, + ) +} + +pub fn build_project_channel( + keys: &Keys, + owner: &PublicKey, + draft: CreateProjectChannelDraft, +) -> Result { + let home_channel_id = required(draft.home_channel_id, "home channel", 128)?; + uuid::Uuid::parse_str(&home_channel_id) + .map_err(|_| CliError::Usage(format!("invalid channel UUID: {home_channel_id}")))?; + let visibility = required(draft.visibility, "visibility", 16)?; + if visibility != "open" && visibility != "private" { + return Err(CliError::Usage("visibility must be open or private".into())); + } + if draft.ttl_seconds == Some(0) { + return Err(CliError::Usage("ttl must be greater than zero".into())); + } + let request = CreateProjectChannelDraft { + home_channel_id: home_channel_id.clone(), + name: required(draft.name, "name", MAX_NAME_CHARS)?, + description: draft + .description + .map(|value| required(value, "description", 2_048)) + .transpose()?, + visibility, + ttl_seconds: draft.ttl_seconds, + template_name: optional(draft.template_name, "template")?, + }; + build( + keys, + owner, + home_channel_id, + PROJECT_CHANNEL_REQUEST_KIND, + "create", + request, + ) } #[cfg(test)] @@ -228,9 +294,9 @@ mod tests { .any(|tag| tag.first().map(String::as_str) == Some("h"))); let payload: serde_json::Value = decrypt_observer_payload(&owner, &built.event).unwrap(); - assert_eq!(payload["kind"], REQUEST_KIND); + assert_eq!(payload["kind"], AGENT_REQUEST_KIND); assert_eq!(payload["channelId"], CHANNEL); - assert_eq!(payload["payload"]["type"], REQUEST_KIND); + assert_eq!(payload["payload"]["type"], AGENT_REQUEST_KIND); assert_eq!(payload["payload"]["action"], "create"); assert_eq!( payload["payload"]["request"]["displayName"], @@ -274,4 +340,34 @@ mod tests { .unwrap_err(); assert!(error.to_string().contains("invalid channel UUID")); } + + #[test] + fn project_channel_request_is_owner_encrypted() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let built = build_project_channel( + &agent, + &owner.public_key(), + CreateProjectChannelDraft { + home_channel_id: CHANNEL.into(), + name: "release-planning".into(), + description: Some("Coordinate the next release.".into()), + visibility: "open".into(), + ttl_seconds: None, + template_name: Some("Release team".into()), + }, + ) + .unwrap(); + + let payload: serde_json::Value = decrypt_observer_payload(&owner, &built.event).unwrap(); + assert_eq!(payload["kind"], PROJECT_CHANNEL_REQUEST_KIND); + assert_eq!(payload["channelId"], CHANNEL); + assert_eq!(payload["payload"]["type"], PROJECT_CHANNEL_REQUEST_KIND); + assert_eq!(payload["payload"]["action"], "create"); + assert_eq!(payload["payload"]["request"]["homeChannelId"], CHANNEL); + assert_eq!( + payload["payload"]["request"]["templateName"], + "Release team" + ); + } } diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs index d01f3e68b8e..06d8a865dba 100644 --- a/crates/buzz-cli/src/commands/projects.rs +++ b/crates/buzz-cli/src/commands/projects.rs @@ -21,14 +21,58 @@ use buzz_sdk::{ build_delete_addressable, build_project, build_project_with_tags, ProjectMemberCoord, PROJECT_D_MAX_LEN, }; -use nostr::{Event, EventBuilder, Tag, Timestamp}; +use nostr::{Event, EventBuilder, PublicKey, Tag, Timestamp}; +use crate::agent_management::{build_project_channel, CreateProjectChannelDraft}; use crate::client::BuzzClient; use crate::commands::parse_write_response; use crate::commands::project_channel::repo_id_from_project_slug; use crate::commands::repos::{build_create_announcement, fetch_own_repo_announcement}; use crate::error::CliError; +async fn cmd_add_channel_draft( + client: &BuzzClient, + home_channel: String, + name: String, + description: Option, + visibility: String, + ttl_seconds: Option, + template_name: Option, +) -> Result<(), CliError> { + let owner_hex = client + .auth_tag_owner_hex() + .ok_or_else(|| CliError::Auth("project channel requests require BUZZ_AUTH_TAG".into()))?; + let owner = PublicKey::parse(&owner_hex) + .map_err(|error| CliError::Auth(format!("invalid owner attestation: {error}")))?; + let built = build_project_channel( + client.keys(), + &owner, + CreateProjectChannelDraft { + home_channel_id: home_channel, + name, + description, + visibility, + ttl_seconds, + template_name, + }, + )?; + let response = client.publish_ephemeral_event(built.event).await?; + let mut output: serde_json::Value = serde_json::from_str(&response) + .map_err(|error| CliError::Other(format!("invalid relay response: {error}")))?; + if let Some(object) = output.as_object_mut() { + object.insert("request_id".into(), built.request_id.into()); + object.insert("action".into(), "add-channel".into()); + object.insert("saved".into(), false.into()); + object.insert( + "message".into(), + "Project channel draft sent to Buzz Desktop for owner review. The channel is not created until the owner approves it." + .into(), + ); + } + println!("{output}"); + Ok(()) +} + // ── Buzz repo-ID grammar (bare --repo shorthand) ───────────────────────────── /// Pattern for a Buzz-hosted repo identifier (bare `--repo` shorthand). @@ -739,6 +783,25 @@ pub async fn dispatch(cmd: crate::ProjectsCmd, client: &BuzzClient) -> Result<() ProjectsCmd::Get { slug, owner } => cmd_get(client, &slug, owner.as_deref()).await, ProjectsCmd::List { owner, limit } => cmd_list(client, owner.as_deref(), limit).await, ProjectsCmd::AddRepo { slug, repo } => cmd_add_repo(client, &slug, &repo).await, + ProjectsCmd::AddChannel { + home_channel, + name, + description, + visibility, + ttl, + template, + } => { + cmd_add_channel_draft( + client, + home_channel, + name, + description, + visibility.to_string(), + ttl, + template, + ) + .await + } ProjectsCmd::RemoveRepo { slug, repo } => cmd_remove_repo(client, &slug, &repo).await, ProjectsCmd::Update { slug, diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index af198f4582c..d0155970fa2 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1334,6 +1334,28 @@ pub enum ProjectsCmd { #[arg(long = "repo", required = true)] repo: Vec, }, + /// Draft a project-linked channel for owner review in Buzz Desktop + #[command(name = "add-channel")] + AddChannel { + /// Project home channel UUID from the current ACP [Context] + #[arg(long)] + home_channel: String, + /// New channel name + #[arg(long)] + name: String, + /// Optional channel description + #[arg(long)] + description: Option, + /// Channel visibility + #[arg(long, value_enum, default_value = "open")] + visibility: ChannelVisibility, + /// Optional temporary-channel lifetime in seconds + #[arg(long)] + ttl: Option, + /// Optional Desktop channel-template name + #[arg(long)] + template: Option, + }, /// Remove one or more member repositories from a project #[command(name = "remove-repo")] RemoveRepo { @@ -2375,6 +2397,7 @@ mod tests { assert_eq!( names(&cmd, "projects"), vec![ + "add-channel", "add-repo", "create", "delete", @@ -2421,7 +2444,7 @@ mod tests { ("pack", 2), ("patches", 4), ("pr", 5), - ("projects", 7), + ("projects", 8), ("reactions", 3), ("repos", 5), ("social", 7), @@ -2492,6 +2515,25 @@ mod tests { // ── projects update mutation group ──────────────────────────────────────── + /// Project-channel requests accept the owner-review metadata. + #[test] + fn projects_add_channel_accepts_owner_review_fields() { + assert!(Cli::try_parse_from([ + "buzz", + "projects", + "add-channel", + "--home-channel", + "11111111-1111-4111-8111-111111111111", + "--name", + "release-planning", + "--visibility", + "private", + "--template", + "Release team", + ]) + .is_ok()); + } + /// Multiple independent fields must be accepted in the same invocation. #[test] fn projects_update_multi_field_is_accepted() { diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index c4776564e34..e9794cb9889 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -108,6 +108,7 @@ export function useAppNavigation() { projectId: string, behavior?: NavigationBehavior & { commitHash?: string; + filePath?: string; pullRequestId?: string; issueId?: string; repositoryId?: string; @@ -128,6 +129,7 @@ export function useAppNavigation() { ...(behavior?.commitHash ? { commitHash: behavior.commitHash } : {}), + ...(behavior?.filePath ? { filePath: behavior.filePath } : {}), ...(behavior?.pullRequestId ? { pullRequestId: behavior.pullRequestId } : {}), diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index d4626d2c6fa..93f41cbee30 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -12,6 +12,9 @@ import { isBroadcastReply, } from "@/features/messages/lib/threading"; import { useProfileQuery } from "@/features/profile/hooks"; +import { useProjectsQuery } from "@/features/projects/hooks"; +import { findProjectHomeByChannelId } from "@/features/projects/lib/projectHomeChannel"; +import { ProjectChannelHome } from "@/features/projects/ui/ProjectChannelHome"; import { useIdentityQuery } from "@/shared/api/hooks"; import { getEventById } from "@/shared/api/tauri"; import type { RelayEvent } from "@/shared/api/types"; @@ -108,6 +111,7 @@ export function ChannelRouteScreen({ const isHuddleTranscript = huddleWindowChannelId() !== null; const { closeForumPost, goForumPost } = useAppNavigation(); const channelsQuery = useChannelsQuery(); + const projectsQuery = useProjectsQuery(); const identityQuery = useIdentityQuery(); const profileQuery = useProfileQuery(); const channels = channelsQuery.data ?? []; @@ -126,6 +130,10 @@ export function ChannelRouteScreen({ memberChannel ?? openDirectoryQuery.data?.find((channel) => channel.id === channelId) ?? null; + const projectHome = findProjectHomeByChannelId( + channelId, + projectsQuery.data ?? [], + ); const [targetMessageEvents, setTargetMessageEvents] = React.useState< RelayEvent[] >(() => { @@ -218,6 +226,18 @@ export function ChannelRouteScreen({ ); } + if (projectHome && !isHuddleTranscript) { + return ( + + ); + } + return ( { @@ -34,6 +34,7 @@ function ProjectDetailRouteComponent() { void >(); +const projectChannelRequestListeners = new Set< + (agentPubkey: string, request: ProjectChannelRequest) => void +>(); // Normalized pubkeys of agents we are actively managing. Only events whose // "agent" tag matches an entry here will be decrypted (defense-in-depth). @@ -506,6 +513,12 @@ function processLiveObserverEvents( listener(agentPubkey, managementRequest); } } + const projectChannelRequest = parseProjectChannelRequest(parsed.payload); + if (projectChannelRequest) { + for (const listener of projectChannelRequestListeners) { + listener(agentPubkey, projectChannelRequest); + } + } if (parsed.kind === "session_config_captured") { void putAgentSessionConfig(agentPubkey, parsed.payload); onSessionConfigCaptured?.(agentPubkey); @@ -687,6 +700,15 @@ export function subscribeAgentManagementRequests( }; } +export function subscribeProjectChannelRequests( + listener: (agentPubkey: string, request: ProjectChannelRequest) => void, +) { + projectChannelRequestListeners.add(listener); + return () => { + projectChannelRequestListeners.delete(listener); + }; +} + export function subscribeControlResults( agentPubkey: string, listener: (frame: ControlResultFrame) => void, @@ -919,6 +941,7 @@ export function resetAgentObserverStore() { pendingUnknownAgentFrames.length = 0; latestLiveSessionByAgentChannel.clear(); agentManagementListeners.clear(); + projectChannelRequestListeners.clear(); onSessionConfigCaptured = null; connectionState = "idle"; errorMessage = null; diff --git a/desktop/src/features/agents/ui/AgentManagementDialogs.tsx b/desktop/src/features/agents/ui/AgentManagementDialogs.tsx index 27541889f26..6d6b68d8032 100644 --- a/desktop/src/features/agents/ui/AgentManagementDialogs.tsx +++ b/desktop/src/features/agents/ui/AgentManagementDialogs.tsx @@ -1,4 +1,5 @@ import { useAgentManagement } from "@/features/agents/useAgentManagement"; +import { ProjectChannelRequestDialog } from "@/features/projects/ui/ProjectChannelRequestDialog"; import { AgentCardDialogs } from "./AgentCardViewerDialog"; import { AgentDialog } from "./AgentDialog"; @@ -42,6 +43,7 @@ export function AgentManagementDialogs() { title="Edit agent" /> ) : null} + ); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 1fa03c1e2d2..531a13724be 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -83,6 +83,7 @@ export const ChannelPane = React.memo(function ChannelPane({ fetchOlder, header, idleAuxiliaryPanel = null, + idleAuxiliaryHeaderActions, idleAuxiliaryTitle = "", hasOlderMessages, historyExhausted, @@ -978,6 +979,7 @@ export const ChannelPane = React.memo(function ChannelPane({ wrapIdlePanel( void; diff --git a/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx b/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx index 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/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/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/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/ui/ProjectChannelHome.tsx b/desktop/src/features/projects/ui/ProjectChannelHome.tsx index a0b931d9e4b..d0c6cbdb08f 100644 --- a/desktop/src/features/projects/ui/ProjectChannelHome.tsx +++ b/desktop/src/features/projects/ui/ProjectChannelHome.tsx @@ -1,5 +1,5 @@ import { useSearch } from "@tanstack/react-router"; -import { Info } from "lucide-react"; +import { Maximize2, Plus } from "lucide-react"; import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; @@ -9,9 +9,11 @@ import { useProfileQuery } from "@/features/profile/hooks"; import type { Project } from "@/features/projects/hooks"; import { isProjectHomeWorkspaceSheetTab, + projectHomeWorkspaceSheetExpandTab, projectHomeWorkspaceSheetTitle, type ProjectHomeWorkspaceSheetTab, } from "@/features/projects/lib/projectHomeWorkspaceSheet"; +import { ProjectSelectionProvider } from "@/features/projects/lib/useProjectSelection"; import { useHealProjectHomeRepositories } from "@/features/projects/useHealProjectHomeRepositories"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { RelayEvent } from "@/shared/api/types"; @@ -27,7 +29,11 @@ import { ProjectContextRail } from "./ProjectContextRail"; import { ProjectDetailChrome } from "./ProjectDetailChrome"; import { ProjectHomeColumn } from "./ProjectHomeColumn"; import { ProjectHomeContextPanel } from "./ProjectHomeContextPanel"; -import { ProjectHomeWorkspaceSheet } from "./ProjectHomeWorkspaceSheet"; +import { + ProjectHomeWorkspaceSheet, + type ProjectHomeWorkspaceCreateAction, + type ProjectHomeWorkspaceDetail, +} from "./ProjectHomeWorkspaceSheet"; import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; const EMPTY_TARGET_MESSAGE_EVENTS: RelayEvent[] = []; @@ -78,11 +84,17 @@ function ProjectHomeHeaderToggle({ } export function ProjectChannelHome({ + autoSendDraftKey, project, projects, + targetMessageEvents = EMPTY_TARGET_MESSAGE_EVENTS, + targetMessageId, }: { + autoSendDraftKey?: string | null; project: Project; projects: Project[]; + targetMessageEvents?: RelayEvent[]; + targetMessageId?: string | null; }) { const { goChannel, goProject, goProjects } = useAppNavigation(); const sidebar = useOptionalSidebar(); @@ -100,6 +112,10 @@ export function ProjectChannelHome({ const [workspaceRepositoryId, setWorkspaceRepositoryId] = React.useState< string | null >(null); + const [workspaceCreateAction, setWorkspaceCreateAction] = + React.useState(null); + const [workspaceDetail, setWorkspaceDetail] = + React.useState(null); const summaryWidth = useThreadPanelWidth(undefined, { sessionKey: PROJECT_HOME_SUMMARY_WIDTH_KEY, }); @@ -123,11 +139,15 @@ export function ProjectChannelHome({ if (repositoryId) { setWorkspaceRepositoryId(repositoryId); } + setWorkspaceCreateAction(null); + setWorkspaceDetail(null); setWorkspaceSheetTab((current) => (current === tab ? null : tab)); }, [], ); const closeWorkspaceSheet = React.useCallback(() => { + setWorkspaceCreateAction(null); + setWorkspaceDetail(null); setWorkspaceSheetTab(null); }, []); const handleOpenWorkspace = React.useCallback( @@ -153,16 +173,19 @@ export function ProjectChannelHome({ setAddRepositoryOpen(true); }, []); const handleFilesAdded = React.useCallback((repositoryId: string) => { + setWorkspaceCreateAction(null); + setWorkspaceDetail(null); setWorkspaceRepositoryId(repositoryId); setWorkspaceSheetTab("files"); }, []); - const handleToggleFilesSheet = React.useCallback(() => { - if (!workspaceRepository) { - handleAddFiles(); - return; - } - openWorkspaceSheet("files", workspaceRepository.id); - }, [handleAddFiles, openWorkspaceSheet, workspaceRepository]); + const handleWorkspaceRepositoryChange = React.useCallback( + (repositoryId: string) => { + setWorkspaceCreateAction(null); + setWorkspaceDetail(null); + setWorkspaceRepositoryId(repositoryId); + }, + [], + ); useHealProjectHomeRepositories(project, identityQuery.data?.pubkey); const handleOpenCommit = React.useCallback( (commitHash: string) => { @@ -175,14 +198,33 @@ export function ProjectChannelHome({ }, [goProject, project.id, workspaceRepository], ); + const handleExpandWorkspace = React.useCallback(() => { + if (!workspaceRepository || !workspaceSheetTab) return; + void goProject(project.id, { + ...workspaceDetail?.navigation, + repositoryId: workspaceRepository.id, + tab: projectHomeWorkspaceSheetExpandTab(workspaceSheetTab), + }); + }, [ + goProject, + project.id, + workspaceDetail?.navigation, + workspaceRepository, + workspaceSheetTab, + ]); + const expandLabel = workspaceSheetTab + ? `Open ${projectHomeWorkspaceSheetTitle(workspaceSheetTab)} in repository` + : "Open in repository"; const workspaceSheet = workspaceSheetOpen && workspaceSheetTab && workspaceRepository ? (
- +
+ { @@ -221,116 +267,161 @@ export function ProjectChannelHome({ }} open={summaryVisible} testId="project-home-drawer-toggle" - > - - - - - } - activeTabCrumb={null} - activeWorkItemCrumb={null} - onGoProjectHome={() => undefined} - onGoProjects={() => { - void goProjects(); - }} - project={project} - /> - {waitingForChannel ? ( - - ) : homeChannel ? ( - } - > - undefined} + onGoProjects={() => { + void goProjects(); + }} + project={project} + /> + {waitingForChannel ? ( + + ) : homeChannel ? ( + } - onAddFiles={handleAddFiles} - onCloseIdleAuxiliaryPanel={closeWorkspaceSheet} - onCloseForumPost={ignoreForumPost} - onSelectForumPost={ignoreForumPostSelect} - selectedForumPostId={null} - targetForumReplyId={null} - targetMessageEvents={EMPTY_TARGET_MESSAGE_EVENTS} - targetMessageId={search.messageId ?? null} - /> - - ) : ( -
-

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

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

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

+
+ )} +
+ + + {summaryVisible ? ( + + { + void goChannel(channelId); + }} + onOpenRepository={handleOpenRepository} + onOpenWorkspace={handleOpenWorkspace} + onRepositoryChange={handleRepositoryChange} + project={project} + projects={projects} + /> + + ) : null} +
- - - {summaryVisible ? ( - setSummaryOpen(false)} - onResetWidth={summaryWidth.onResetWidth} - onResizeStart={summaryWidth.onResizeStart} - testId="project-home-summary-column" - title="Overview" - widthPx={summaryWidth.widthPx} - > - { - void goChannel(channelId); - }} - onOpenRepository={handleOpenRepository} - onOpenWorkspace={handleOpenWorkspace} - onRepositoryChange={handleRepositoryChange} - project={project} - projects={projects} - /> - - ) : null} - - + ); } diff --git a/desktop/src/features/projects/ui/ProjectChannelManagement.tsx b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx index dbb02fd462c..71d269a84a8 100644 --- a/desktop/src/features/projects/ui/ProjectChannelManagement.tsx +++ b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx @@ -64,7 +64,7 @@ export function ProjectChannelManagement({ /> + ) : 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 84bde732974..c31db004e55 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx @@ -142,7 +142,7 @@ export function ProjectRepositoryManagement({ project={project} repositories={attachCandidates} /> - {canEdit && !hideTriggers ? ( + {!hideTriggers ? ( diff --git a/desktop/src/features/projects/ui/ProjectSelectableGroup.tsx b/desktop/src/features/projects/ui/ProjectSelectableGroup.tsx index e49f80af620..6b5de4af824 100644 --- a/desktop/src/features/projects/ui/ProjectSelectableGroup.tsx +++ b/desktop/src/features/projects/ui/ProjectSelectableGroup.tsx @@ -44,6 +44,7 @@ export function ProjectSelectableGroup({
{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 (
+ } + 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} - void; onSelectSection: (section: ProjectsOverviewSection) => void; profiles?: UserProfileLookup; + projectReadModels: Project[]; projects: Project[]; pullRequests: ProjectPullRequest[]; + repositorySummaries?: Record; summaries?: Record; }; @@ -79,7 +81,7 @@ function OverviewActionButton({ }) { return ( ); } @@ -128,43 +132,40 @@ 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}

); @@ -179,8 +180,10 @@ export function ProjectsOverviewContextPanel({ onCreatePullRequest, onSelectSection, profiles, + projectReadModels, projects, pullRequests, + repositorySummaries, summaries, }: ProjectsOverviewContextPanelProps) { const selection = useProjectSelection(); @@ -196,11 +199,21 @@ 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" @@ -211,7 +224,10 @@ export function ProjectsOverviewContextPanel({ return (
@@ -239,32 +255,30 @@ export function ProjectsOverviewContextPanel({
)} {selectionPresentation ? null : ( - <> -
- {context.action ? ( - - - {context.action.label} - - ) : null} -
+ {context.action ? ( + - {context.stats.map((stat) => ( - onSelectSection(stat.section)} - /> - ))} -
-
+ + {context.action.label} + + ) : null} +
+ {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..910155c4eb7 100644 --- a/desktop/src/features/projects/ui/ProjectsSelectionCountMenu.tsx +++ b/desktop/src/features/projects/ui/ProjectsSelectionCountMenu.tsx @@ -1,4 +1,4 @@ -import { Bot, GitPullRequest, Link2, X } from "lucide-react"; +import { Bot, GitPullRequest, Link2, ListChecks, X } from "lucide-react"; import * as React from "react"; import { @@ -67,13 +67,26 @@ export function ProjectsSelectionCountMenu({ return (
-

- {presentation.title} -

-
+ + + + + + Selection + +

+ {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..9dec955af0d 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -1,4 +1,3 @@ -import { Search } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; @@ -17,17 +16,17 @@ import { } from "@/features/projects/hooks"; import { useRepositoryActivitySummariesQuery } from "@/features/projects/repositoryActivityHooks"; import { useCreateProjectMutation } from "@/features/projects/useCreateProject"; +import { isExplicitProject } from "@/features/projects/projectModels"; 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 +41,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"; @@ -60,37 +58,23 @@ import { ProjectsWorkspaceChrome } from "@/features/projects/ui/ProjectDetailChr 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 +85,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 +115,11 @@ export function ProjectsView() { useProjectsScrollIndicator(); const projectsQuery = useProjectsQuery(); const identityQuery = useIdentityQuery(); - const projects = projectsQuery.data ?? []; + const projectReadModels = projectsQuery.data ?? []; + const projects = React.useMemo( + () => projectReadModels.filter(isExplicitProject), + [projectReadModels], + ); const localRepositoriesQuery = useProjectLocalRepositoriesQuery( activeCommunity?.reposDir, ); @@ -140,9 +129,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 +144,9 @@ export function ProjectsView() { useProjectPanelWidths("chat"); const activitySummariesQuery = useProjectActivitySummariesQuery(projects); const repositoryActivitySummariesQuery = useRepositoryActivitySummariesQuery( - filter === "repositories" ? projects : [], - ); - const [repositoryScope, setRepositoryScope] = - React.useState(() => { - const storedScope = readStoredRepositoryScope(); - return filter === "projects" && - (storedScope === "buzz" || storedScope === "linked") - ? "all" - : storedScope; - }); - const [pullRequestScope, setPullRequestScope] = - React.useState(() => readStoredPullRequestScope()); - const [issueScope, setIssueScope] = React.useState( - () => readStoredIssueScope(), + filter === "repositories" ? projectReadModels : [], ); - const projectsWorkItemsQuery = useProjectsWorkItemsQuery(projects); + const projectsWorkItemsQuery = useProjectsWorkItemsQuery(projectReadModels); // One blobless clone per primary Buzz repository, only while the overview // header is visible. const snapshotProjects = React.useMemo( @@ -231,6 +212,25 @@ 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; @@ -242,30 +242,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 +257,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 +264,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 +302,10 @@ export function ProjectsView() { return sortedProjects; }, [ activitySummariesQuery.data, - currentPubkey, filter, - localRepoNames, profiles, projects, - relayOrigin, - repositoryAccessInput, - repositoryScope, + searchQuery, sort, ]); @@ -353,7 +313,7 @@ export function ProjectsView() { if (filter !== "repositories") return []; const repositories = [ ...new Map( - projects + projectReadModels .flatMap((project) => project.repositories.map((repository) => ({ project, @@ -364,40 +324,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 +347,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 +421,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 +518,7 @@ export function ProjectsView() { ); } - if (projects.length === 0) { + if (projectReadModels.length === 0) { return ; } @@ -630,16 +553,7 @@ export function ProjectsView() { const listHeaderBar = ( ); @@ -675,6 +589,7 @@ export function ProjectsView() { pullRequests={ projectsWorkItemsQuery.data?.pullRequests.items ?? EMPTY_ITEMS } + searchQuery={searchQuery} snapshots={repoSnapshotsQuery.data?.snapshots} /> @@ -689,8 +604,10 @@ export function ProjectsView() { onCreateProject: () => setCreateProjectOpen(true), onCreatePullRequest: () => setCreatePullRequestOpen(true), profiles, + projectReadModels, projects, pullRequests: contextPullRequests, + repositorySummaries: repositoryActivitySummariesQuery.data, summaries: activitySummariesQuery.data, }; const contextOpen = isNarrowProjectsLayout @@ -722,8 +639,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 +701,6 @@ export function ProjectsView() { } else { toast.success(`Project "${result.project.name}" created.`); } - handleRepositoryScopeChange("all"); - handleFilterChange("projects"); await goProject(result.project.id); }} onOpenChange={setCreateProjectOpen} @@ -822,33 +756,22 @@ export function ProjectsView() { )} data-testid="projects-page-tabs" > - -
- -
+
{filter === "all" ? ( - +
{activityFeed}
@@ -856,7 +779,7 @@ export function ProjectsView() { ) : ( <> ) : filter === "channels" ? ( - + ) : filter === "projects" ? ( projectItems ) : ( @@ -948,11 +884,12 @@ export function ProjectsView() {