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.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs index 714b21c5b01..10a948e7053 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { getChannelIntroKind, + shouldPrioritizeIdleAuxiliary, shouldUseFocusIdleDrawer, } from "./ChannelPane.helpers.ts"; @@ -56,3 +57,9 @@ test("getChannelIntroKind keeps private and ephemeral labels for other streams", "ephemeral channel", ); }); + +test("idle auxiliary priority does not depend on thread layout mode", () => { + assert.equal(shouldPrioritizeIdleAuxiliary(true, true), true); + assert.equal(shouldPrioritizeIdleAuxiliary(true, false), false); + assert.equal(shouldPrioritizeIdleAuxiliary(false, true), false); +}); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index 8ef2ca91cb5..695fef166fe 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -63,6 +63,14 @@ export function getChannelIntroDescription(channel: Channel): string | null { ); } +/** Whether a caller-owned auxiliary sheet should render ahead of a thread. */ +export function shouldPrioritizeIdleAuxiliary( + overrideThread: boolean, + hasIdleAuxiliary: boolean, +) { + return overrideThread && hasIdleAuxiliary; +} + export function isWelcomeSetupSystemMessage(message: TimelineMessage) { if (message.kind !== KIND_SYSTEM_MESSAGE) { return false; diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 1fa03c1e2d2..679b4c38041 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -47,6 +47,7 @@ import { import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; import { mentionsKnownAgent, + shouldPrioritizeIdleAuxiliary, shouldUseFocusIdleDrawer, } from "@/features/channels/ui/ChannelPane.helpers"; import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; @@ -83,6 +84,8 @@ export const ChannelPane = React.memo(function ChannelPane({ fetchOlder, header, idleAuxiliaryPanel = null, + idleAuxiliaryHeaderActions, + idleAuxiliaryOverridesThread = false, idleAuxiliaryTitle = "", hasOlderMessages, historyExhausted, @@ -262,7 +265,6 @@ export const ChannelPane = React.memo(function ChannelPane({ onEdit(target); return true; }, [findLastOwnEditable, messages, onEdit]); - const handleEditLastOwnThreadMessage = React.useCallback((): boolean => { if (!onEdit) return false; const scope: TimelineMessage[] = []; @@ -283,7 +285,6 @@ export const ChannelPane = React.memo(function ChannelPane({ currentPubkey, relaySelfQuery.data, ); - const isComposerDisabled = !activeChannel?.isMember || activeChannel.archivedAt !== null || @@ -293,7 +294,6 @@ export const ChannelPane = React.memo(function ChannelPane({ isSending; const knownAgentPubkeys = React.useMemo(() => { const pubkeys = new Set(); - for (const pubkey of agentPubkeys ?? []) { pubkeys.add(pubkey.toLowerCase()); } @@ -303,7 +303,6 @@ export const ChannelPane = React.memo(function ChannelPane({ for (const agent of activityAgents) { pubkeys.add(agent.pubkey.toLowerCase()); } - return pubkeys; }, [activityAgents, agentPubkeys, agentSessionAgents]); const handleSendMessage = React.useCallback( @@ -322,7 +321,6 @@ export const ChannelPane = React.memo(function ChannelPane({ isActiveWelcomeChannel && (containsWelcomePersonaMention(content) || mentionsKnownAgent(mentionPubkeys, knownAgentPubkeys)); - messageTimelineRef.current?.scrollToBottomOnNextUpdate(); await onSendMessage( content, @@ -332,7 +330,6 @@ export const ChannelPane = React.memo(function ChannelPane({ threadContext, forceRest, ); - if ( channelId && channelId !== activeChannelId && @@ -341,7 +338,6 @@ export const ChannelPane = React.memo(function ChannelPane({ ) { await goChannel(channelId, { replace: true }); } - if (shouldCompleteWelcomeBanner) { completeWelcomeComposerBanner(); } @@ -395,7 +391,6 @@ export const ChannelPane = React.memo(function ChannelPane({ }), [activeChannel, currentPubkey, profiles], ); - const handleWelcomeAddAgent = React.useCallback(() => { onAddAgent?.({ beforeSend: () => @@ -438,7 +433,6 @@ export const ChannelPane = React.memo(function ChannelPane({ for (const message of threadAllMessages) { messagesById.set(message.id, message); } - return buildVideoReviewPresentationByMessageId({ channelId: activeChannel?.id ?? null, channelName: activeChannel?.name, @@ -459,7 +453,6 @@ export const ChannelPane = React.memo(function ChannelPane({ threadAllMessages, threadHeadMessage, ]); - const isOverlay = useIsThreadPanelOverlay(); const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay; const threadViewMode = useThreadViewMode(); @@ -477,6 +470,8 @@ export const ChannelPane = React.memo(function ChannelPane({ }), [agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles], ); + const hasIdleAuxiliary = + Boolean(idleAuxiliaryPanel) && Boolean(onCloseIdleAuxiliaryPanel); const useFocusIdleDrawer = shouldUseFocusIdleDrawer({ channelManagementOpen, hasAgentSession: Boolean(activeChannel && selectedAgent), @@ -486,11 +481,17 @@ export const ChannelPane = React.memo(function ChannelPane({ hasThreadSurface: Boolean(threadHeadMessage) || shouldShowThreadSkeleton, useSplitAuxiliaryPane, }); + const priorityIdleAuxiliary = shouldPrioritizeIdleAuxiliary( + idleAuxiliaryOverridesThread, + hasIdleAuxiliary, + ); const { channelIsCovered, markExitComplete } = useFocusDrawerPresence( useFocusThreadDrawer || useFocusIdleDrawer, - useFocusThreadDrawer - ? onCloseThread - : (onCloseIdleAuxiliaryPanel ?? onCloseThread), + priorityIdleAuxiliary + ? (onCloseIdleAuxiliaryPanel ?? onCloseThread) + : useFocusThreadDrawer + ? onCloseThread + : (onCloseIdleAuxiliaryPanel ?? onCloseThread), ); const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } = useThreadViewModeSwitch({ @@ -550,6 +551,25 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : ( wrapAux(panel, "idle-auxiliary-panel") ); + const idleAuxiliarySurface = + idleAuxiliaryPanel && onCloseIdleAuxiliaryPanel + ? wrapIdlePanel( + + {idleAuxiliaryPanel} + , + ) + : null; const threadHeaderLeading = useSplitAuxiliaryPane ? ( ) : undefined; @@ -576,7 +596,6 @@ export const ChannelPane = React.memo(function ChannelPane({ data-testid="channel-shared-header-backdrop" /> ) : null} - {!isSinglePanelView ? (
) : null} - - {/* - * `AnimatePresence` keeps the focus thread drawer mounted through its exit - * animation — without it the drawer's own existence condition - * (`useFocusThreadDrawer`, which is derived from `threadHeadMessage`) goes - * false on the same frame as the close, and there is nothing left to - * animate. It can hold the real thread through the exit rather than a - * frozen snapshot because the panel is fully prop-driven. - */} - + {/* Serialize replacements so focus drawers keep one travel direction. */} + {channelManagementOpen && activeChannel ? ( + ) : priorityIdleAuxiliary && idleAuxiliarySurface ? ( + idleAuxiliarySurface ) : threadHeadMessage ? ( (() => { const panel = ( @@ -974,23 +987,9 @@ export const ChannelPane = React.memo(function ChannelPane({ ); return wrapAux(panel, "user-profile-panel"); })() - ) : idleAuxiliaryPanel && onCloseIdleAuxiliaryPanel ? ( - wrapIdlePanel( - - {idleAuxiliaryPanel} - , - ) - ) : null} + ) : ( + idleAuxiliarySurface + )} ); diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 68fab10d487..83a9794e5aa 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -14,6 +14,7 @@ import type { } from "@/features/profile/ui/UserProfilePanel"; import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; import type { Channel } from "@/shared/api/types"; +import type { IdleAuxiliaryHeaderControls } from "./IdleAuxiliaryPanel"; export type ChannelPaneProps = { activeChannel: Channel | null; activityAgents?: BotActivityAgent[]; @@ -48,10 +49,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 b01e9f9d265..1a92135e9c3 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -90,6 +90,8 @@ export function ChannelScreen({ currentProfile, headerEndActions, idleAuxiliaryPanel, + idleAuxiliaryHeaderActions, + idleAuxiliaryOverridesThread, idleAuxiliaryTitle, onAddFiles, onCloseIdleAuxiliaryPanel, @@ -853,6 +855,8 @@ export function ChannelScreen({ fetchOlder={fetchOlder} header={channelHeader} idleAuxiliaryPanel={idleAuxiliaryPanel} + idleAuxiliaryHeaderActions={idleAuxiliaryHeaderActions} + idleAuxiliaryOverridesThread={idleAuxiliaryOverridesThread} idleAuxiliaryTitle={idleAuxiliaryTitle} hasOlderMessages={hasOlderMessages} historyExhausted={historyExhausted} diff --git a/desktop/src/features/channels/ui/ChannelScreen.types.ts b/desktop/src/features/channels/ui/ChannelScreen.types.ts index 223b69b8092..e5550667044 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 7966ccda9b2..b00b612648b 100644 --- a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx +++ b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx @@ -221,7 +221,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 1820dc17408..50449df4f9f 100644 --- a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx @@ -9,6 +9,7 @@ import { normalizeRelayUrl } from "@/features/communities/communityStorage"; import { useCommunities } from "@/features/communities/useCommunities"; import type { ProjectDetailAgentContext } from "@/features/projects/lib/projectDetailAgentContext"; import { projectDetailAgentContextBlock } from "@/features/projects/lib/projectDetailAgentContext"; +import { pickDefaultProjectsAgent } from "@/features/projects/lib/projectAgentSelection"; import { restoreProjectsAgentConversation, submitProjectAgentMessage, @@ -102,7 +103,8 @@ export function ProjectAgentChatPanel({ const profileQuery = useProfileQuery(); const openDmMutation = useOpenDmMutation(); const startAgentMutation = useStartManagedAgentMutation(); - const selectedAgent = conversation?.agent ?? candidates[0] ?? null; + const selectedAgent = + conversation?.agent ?? pickDefaultProjectsAgent(candidates); const candidateProfilesQuery = useUsersBatchQuery( selectedAgent ? [selectedAgent.pubkey] : [], ); diff --git a/desktop/src/features/projects/ui/ProjectChannelHome.tsx b/desktop/src/features/projects/ui/ProjectChannelHome.tsx index a0b931d9e4b..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,14 +206,33 @@ export function ProjectChannelHome({ }, [goProject, project.id, workspaceRepository], ); + const handleExpandWorkspace = React.useCallback(() => { + if (!workspaceRepository || !workspaceSheetTab) return; + void goProject(project.id, { + repositoryId: workspaceRepository.id, + ...workspaceDetail?.navigation, + tab: projectHomeWorkspaceSheetExpandTab(workspaceSheetTab), + }); + }, [ + goProject, + project.id, + workspaceDetail?.navigation, + workspaceRepository, + workspaceSheetTab, + ]); + const expandLabel = workspaceSheetTab + ? `Open ${projectHomeWorkspaceSheetTitle(workspaceSheetTab)} in repository` + : "Open in repository"; const workspaceSheet = workspaceSheetOpen && workspaceSheetTab && workspaceRepository ? (
- +
+ { @@ -221,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 ? ( 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() {