From a21b7da4bd7131d45845bca881f1d4f32bc167cf Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Thu, 23 Jul 2026 16:56:13 +0200 Subject: [PATCH 1/3] feat(desktop): handle Buzz Git work from Inbox Surface repository-scoped pull requests and issues in Inbox while reusing canonical project actions and preserving permission and partial-data safety. --- crates/buzz-db/src/feed.rs | 19 +- desktop/src-tauri/src/commands/messages.rs | 15 +- desktop/src/features/home/lib/inbox.ts | 87 ++++++- .../src/features/home/lib/inboxViewHelpers.ts | 7 + .../features/home/lib/projectInbox.test.mjs | 235 ++++++++++++++++++ desktop/src/features/home/lib/projectInbox.ts | 99 ++++++++ desktop/src/features/home/ui/HomeView.tsx | 4 +- .../src/features/home/ui/InboxDetailPane.tsx | 20 +- .../src/features/home/ui/InboxListPane.tsx | 1 + .../features/home/ui/ProjectInboxDetail.tsx | 129 ++++++++++ .../home/ui/ProjectInboxDetailPane.tsx | 131 ++++++++++ .../projects/ui/ProjectIssuesPanel.tsx | 5 +- .../projects/ui/ProjectPullRequestsPanel.tsx | 5 +- 13 files changed, 734 insertions(+), 23 deletions(-) create mode 100644 desktop/src/features/home/lib/projectInbox.test.mjs create mode 100644 desktop/src/features/home/lib/projectInbox.ts create mode 100644 desktop/src/features/home/ui/ProjectInboxDetail.tsx create mode 100644 desktop/src/features/home/ui/ProjectInboxDetailPane.tsx diff --git a/crates/buzz-db/src/feed.rs b/crates/buzz-db/src/feed.rs index 7cc02d8011..511a2a6083 100644 --- a/crates/buzz-db/src/feed.rs +++ b/crates/buzz-db/src/feed.rs @@ -34,9 +34,10 @@ use sqlx::{PgPool, QueryBuilder}; use uuid::Uuid; use buzz_core::kind::{ - KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_JOB_PROGRESS, KIND_JOB_REQUEST, KIND_JOB_RESULT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, - KIND_WORKFLOW_APPROVAL_REQUESTED, + KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_GIT_ISSUE, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, + KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, + KIND_JOB_PROGRESS, KIND_JOB_REQUEST, KIND_JOB_RESULT, KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_WORKFLOW_APPROVAL_REQUESTED, }; use buzz_core::{CommunityId, StoredEvent}; @@ -103,7 +104,9 @@ fn build_mentions_query( qb.push(" AND e.deleted_at IS NULL"); qb.push(format!( " AND e.kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, \ - {KIND_FORUM_POST}, {KIND_FORUM_COMMENT})" + {KIND_TEXT_NOTE}, {KIND_FORUM_POST}, {KIND_FORUM_COMMENT}, {KIND_GIT_PULL_REQUEST}, \ + {KIND_GIT_PR_UPDATE}, {KIND_GIT_ISSUE}, {KIND_GIT_STATUS_OPEN}, \ + {KIND_GIT_STATUS_MERGED}, {KIND_GIT_STATUS_CLOSED}, {KIND_GIT_STATUS_DRAFT})" )); push_visible_channel_filter(&mut qb, "e.channel_id", accessible_channel_ids); if let Some(s) = since { @@ -252,7 +255,7 @@ mod tests { use nostr::{EventBuilder, Keys, Kind, Tag}; use uuid::Uuid; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") @@ -777,6 +780,12 @@ mod tests { sql.contains("AND m.community_id = "), "mentions feed must also bind event_mentions.community_id: {sql}" ); + assert!( + sql.contains(&KIND_GIT_PULL_REQUEST.to_string()) + && sql.contains(&KIND_GIT_ISSUE.to_string()) + && sql.contains(&KIND_TEXT_NOTE.to_string()), + "mentions feed must include Buzz Git roots and comments: {sql}" + ); } #[test] diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 1928d69d23..afe94cdfe6 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -68,7 +68,20 @@ pub async fn get_feed( // Mentions: messages that reference me via #p. let mut mention_filter = serde_json::json!({ - "kinds": [9, 40002, 1, 45001, 45003], + "kinds": [ + 9, + 40002, + 1, + 45001, + 45003, + buzz_core_pkg::kind::KIND_GIT_PULL_REQUEST, + buzz_core_pkg::kind::KIND_GIT_PR_UPDATE, + buzz_core_pkg::kind::KIND_GIT_ISSUE, + buzz_core_pkg::kind::KIND_GIT_STATUS_OPEN, + buzz_core_pkg::kind::KIND_GIT_STATUS_MERGED, + buzz_core_pkg::kind::KIND_GIT_STATUS_CLOSED, + buzz_core_pkg::kind::KIND_GIT_STATUS_DRAFT, + ], "#p": [my_pubkey], "limit": cap, }); diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index b4c05ef809..a34ea1b66f 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -6,6 +6,10 @@ import { getThreadReference, isBroadcastReply, } from "@/features/messages/lib/threading"; +import { + getProjectInboxReference, + isProjectInboxItem, +} from "@/features/home/lib/projectInbox"; import type { TimelineReaction } from "@/features/messages/types"; import type { Channel, @@ -18,6 +22,7 @@ import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; export type InboxFilter = | "all" + | "project" | "mention" | "thread" | "needs_action" @@ -29,10 +34,10 @@ export type InboxFilter = export type InboxItem = { avatarUrl: string | null; /** - * Stable conversation identity: `rootId ?? parentId ?? event.id` for the - * thread group. Does NOT change when a new reply advances the representative - * latest event. Use this for lifecycle continuity: scroll gating, draft - * keys, local-reply storage, and selection identity. + * Stable conversation identity: the NIP-10 root for messages, or a + * repository-scoped root for Buzz Git work. Does NOT change when a new reply + * advances the representative latest event. Use this for lifecycle + * continuity: scroll gating, draft keys, local-reply storage, and selection. */ conversationId: string; id: string; @@ -136,7 +141,33 @@ function diffInDays(from: Date, to: Date) { ); } -function feedHeadline(item: FeedItem) { +function tagValue(item: FeedItem, name: string) { + return item.tags.find((tag) => tag[0] === name)?.[1]?.trim() || null; +} + +function projectRootItem(item: FeedItem, groupItems: readonly FeedItem[]) { + return ( + groupItems.find( + (candidate) => candidate.kind === 1618 || candidate.kind === 1621, + ) ?? item + ); +} + +function projectTypeLabel(item: FeedItem) { + if (item.kind === 1618) return "Pull request"; + if (item.kind === 1621) return "Issue"; + return "Project update"; +} + +function feedHeadline(item: FeedItem, groupItems: readonly FeedItem[] = []) { + if (isProjectInboxItem(item)) { + const root = projectRootItem(item, groupItems); + return ( + (tagValue(root, "subject") ?? root.content.trim().split("\n")[0]) || + projectTypeLabel(root) + ); + } + switch (item.kind) { case 40007: return "Reminder"; @@ -242,6 +273,14 @@ function resolveGroupChannel( export function getInboxTypeLabel(item: InboxItem): InboxTypeLabel { const channelName = item.channelLabel; + if (item.groupItems.some(isProjectInboxItem)) { + const root = projectRootItem(item.item, item.groupItems); + return { + text: projectTypeLabel(root), + channelLabel: null, + }; + } + if (item.item.channelType === "dm") { return { text: item.senderLabel ? `DM from ${item.senderLabel}` : "DM", @@ -300,23 +339,50 @@ function categoryPriority(category: FeedItemCategory) { } function getInboxThreadKey(item: FeedItem) { + const projectReference = getProjectInboxReference(item); + if (projectReference) { + return `project:${projectReference.repoAddress}:${projectReference.rootId}`; + } + const thread = getThreadReference(item.tags); return thread.rootId ?? thread.parentId ?? item.id; } +function getStableConversationId(item: FeedItem) { + return getInboxItemConversationId(item); +} + /** - * Returns the stable conversation ID for any FeedItem or relay event: the - * NIP-10 root tag id, falling back to parent-reply tag id, then event id. + * Returns the stable conversation ID for any FeedItem or relay event. Buzz Git + * roots include their repository coordinate; messages use the NIP-10 root, + * parent-reply tag, then event id. * This is the same derivation used by `buildInboxItems` for `conversationId`. */ export function getInboxConversationId( tags: string[][], eventId: string, + kind?: number, ): string { + if (kind !== undefined) { + const projectReference = getProjectInboxReference({ + id: eventId, + kind, + tags, + }); + if (projectReference) { + return `project:${projectReference.repoAddress}:${projectReference.rootId}`; + } + } + const thread = getThreadReference(tags); return thread.rootId ?? thread.parentId ?? eventId; } +/** Returns the stable conversation identity for a complete Inbox feed item. */ +export function getInboxItemConversationId(item: FeedItem) { + return getInboxConversationId(item.tags, item.id, item.kind); +} + function formatInboxTimestamp(unixSeconds: number) { const date = new Date(unixSeconds * 1_000); const now = new Date(); @@ -436,7 +502,7 @@ export function buildInboxItems({ group.items.push(item); group.latestActivityAt = Math.max(group.latestActivityAt, item.createdAt); - if (item.id === threadKey) { + if (item.id === getStableConversationId(item)) { group.rootItem = item; } @@ -447,7 +513,8 @@ export function buildInboxItems({ .sort( ([, left], [, right]) => right.latestActivityAt - left.latestActivityAt, ) - .map(([conversationId, group]) => { + .map(([, group]) => { + const conversationId = getStableConversationId(group.items[0]); const latestItem = group.items.reduce((latest, current) => current.createdAt > latest.createdAt ? current : latest, ); @@ -461,7 +528,7 @@ export function buildInboxItems({ profiles, preferResolvedSelfLabel: true, }); - const subject = feedHeadline(item); + const subject = feedHeadline(item, group.items); const preview = feedPreview(item); const { mentionNames, mentionPubkeysByName } = resolveMentionProps( item.tags, diff --git a/desktop/src/features/home/lib/inboxViewHelpers.ts b/desktop/src/features/home/lib/inboxViewHelpers.ts index e87671b9fa..869dbf1818 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.ts +++ b/desktop/src/features/home/lib/inboxViewHelpers.ts @@ -3,6 +3,7 @@ import { type InboxContextMessage, type InboxFilter, } from "@/features/home/lib/inbox"; +import { isProjectInboxItem } from "@/features/home/lib/projectInbox"; import { getChannelIdFromTags, getThreadReference, @@ -39,6 +40,12 @@ export function matchesInboxFilter( ); } + if (filter === "project") { + return [item.item, ...(item.groupItems ?? [])].some( + (groupItem) => groupItem && isProjectInboxItem(groupItem), + ); + } + return item.categories.includes(filter); } diff --git a/desktop/src/features/home/lib/projectInbox.test.mjs b/desktop/src/features/home/lib/projectInbox.test.mjs new file mode 100644 index 0000000000..040f54e847 --- /dev/null +++ b/desktop/src/features/home/lib/projectInbox.test.mjs @@ -0,0 +1,235 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildInboxItems, getInboxTypeLabel } from "./inbox.ts"; +import { matchesInboxFilter } from "./inboxViewHelpers.ts"; +import { + getProjectInboxReference, + isProjectInboxItem, + resolveProjectInboxWorkItem, +} from "./projectInbox.ts"; + +const OWNER = "a".repeat(64); +const REVIEWER = "b".repeat(64); +const REPO_ADDRESS = `30617:${OWNER}:buzz`; +const PR_ID = "c".repeat(64); +const ISSUE_ID = "d".repeat(64); + +function feedItem(overrides = {}) { + return { + id: PR_ID, + kind: 1618, + pubkey: OWNER, + content: "Inbox support", + createdAt: 1_700_000_000, + channelId: null, + channelName: "", + tags: [ + ["a", REPO_ADDRESS], + ["p", REVIEWER], + ["subject", "Add project work items to Inbox"], + ], + category: "mention", + ...overrides, + }; +} + +const project = { + id: "buzz", + name: "Buzz", + owner: OWNER, + repoAddress: REPO_ADDRESS, +}; + +const pullRequest = { + id: PR_ID, + author: OWNER, + title: "Add project work items to Inbox", +}; + +const issue = { + id: ISSUE_ID, + author: REVIEWER, + title: "Inbox issue", +}; + +test("recognizes project roots and project thread activity", () => { + assert.equal(isProjectInboxItem(feedItem()), true); + assert.equal( + isProjectInboxItem( + feedItem({ + id: "e".repeat(64), + kind: 1, + tags: [ + ["a", REPO_ADDRESS], + ["e", PR_ID, "", "root"], + ["p", REVIEWER], + ], + }), + ), + true, + ); + assert.equal( + isProjectInboxItem( + feedItem({ + kind: 9, + tags: [ + ["a", REPO_ADDRESS], + ["e", PR_ID, "", "root"], + ], + }), + ), + false, + ); +}); + +test("resolves the canonical project root from status and comment events", () => { + assert.deepEqual(getProjectInboxReference(feedItem()), { + repoAddress: REPO_ADDRESS, + rootId: PR_ID, + }); + assert.deepEqual( + getProjectInboxReference( + feedItem({ + id: "f".repeat(64), + kind: 1631, + tags: [ + ["a", REPO_ADDRESS], + ["e", PR_ID, "", "root"], + ], + }), + ), + { + repoAddress: REPO_ADDRESS, + rootId: PR_ID, + }, + ); +}); + +test("matches a selected inbox event to its canonical pull request or issue", () => { + const workItems = { + pullRequests: { + items: [{ project, pullRequest }], + failedSections: [], + }, + issues: { + items: [{ project, issue }], + failedSections: [], + }, + }; + + assert.deepEqual(resolveProjectInboxWorkItem(feedItem(), workItems), { + type: "pull-request", + project, + pullRequest, + }); + assert.deepEqual( + resolveProjectInboxWorkItem( + feedItem({ + id: ISSUE_ID, + kind: 1621, + tags: [ + ["a", REPO_ADDRESS], + ["p", OWNER], + ["subject", "Inbox issue"], + ], + }), + workItems, + ), + { + type: "issue", + project, + issue, + }, + ); +}); + +test("presents project work with its canonical subject and project filter", () => { + const [item] = buildInboxItems({ + feed: { + feed: { + mentions: [feedItem()], + needsAction: [], + activity: [], + agentActivity: [], + }, + meta: { since: 0, total: 1, generatedAt: 1_700_000_000 }, + }, + }); + + assert.equal(item.subject, "Add project work items to Inbox"); + assert.deepEqual(getInboxTypeLabel(item), { + text: "Pull request", + channelLabel: null, + }); + assert.equal(matchesInboxFilter(item, "project"), true); + assert.equal( + matchesInboxFilter( + { + ...item, + item: feedItem({ kind: 9, tags: [["h", "channel-id"]] }), + groupItems: [feedItem({ kind: 9, tags: [["h", "channel-id"]] })], + }, + "project", + ), + false, + ); +}); + +test("groups uppercase NIP-34 pull request updates with their root", () => { + const update = feedItem({ + id: "f".repeat(64), + kind: 1619, + createdAt: 1_700_000_100, + content: "Pushed another commit", + tags: [ + ["a", REPO_ADDRESS], + ["E", PR_ID], + ["p", REVIEWER], + ], + }); + const items = buildInboxItems({ + feed: { + feed: { + mentions: [feedItem(), update], + needsAction: [], + activity: [], + agentActivity: [], + }, + meta: { since: 0, total: 2, generatedAt: 1_700_000_100 }, + }, + }); + + assert.equal(items.length, 1); + assert.equal(items[0].id, update.id); + assert.equal(items[0].subject, "Add project work items to Inbox"); +}); + +test("does not group project events from different repositories", () => { + const otherRepoAddress = `30617:${"e".repeat(64)}:other`; + const items = buildInboxItems({ + feed: { + feed: { + mentions: [ + feedItem(), + feedItem({ + id: "f".repeat(64), + kind: 1619, + tags: [ + ["a", otherRepoAddress], + ["E", PR_ID], + ["p", REVIEWER], + ], + }), + ], + needsAction: [], + activity: [], + agentActivity: [], + }, + meta: { since: 0, total: 2, generatedAt: 1_700_000_100 }, + }, + }); + + assert.equal(items.length, 2); + assert.notEqual(items[0].conversationId, items[1].conversationId); +}); diff --git a/desktop/src/features/home/lib/projectInbox.ts b/desktop/src/features/home/lib/projectInbox.ts new file mode 100644 index 0000000000..a22b355214 --- /dev/null +++ b/desktop/src/features/home/lib/projectInbox.ts @@ -0,0 +1,99 @@ +import type { + Project, + ProjectIssue, + ProjectPullRequest, +} from "@/features/projects/hooks"; +import type { ProjectsWorkItemsResult } from "@/features/projects/projectWorkItems"; +import type { FeedItem } from "@/shared/api/types"; +import { + KIND_GIT_ISSUE, + KIND_GIT_PR_UPDATE, + KIND_GIT_PULL_REQUEST, + KIND_GIT_STATUS_CLOSED, + KIND_GIT_STATUS_DRAFT, + KIND_GIT_STATUS_MERGED, + KIND_GIT_STATUS_OPEN, + KIND_TEXT_NOTE, +} from "@/shared/constants/kinds"; + +const PROJECT_ROOT_KINDS = new Set([KIND_GIT_PULL_REQUEST, KIND_GIT_ISSUE]); +const PROJECT_ACTIVITY_KINDS = new Set([ + KIND_TEXT_NOTE, + KIND_GIT_PR_UPDATE, + KIND_GIT_STATUS_OPEN, + KIND_GIT_STATUS_MERGED, + KIND_GIT_STATUS_CLOSED, + KIND_GIT_STATUS_DRAFT, +]); +const REPO_ADDRESS_PATTERN = /^30617:[0-9a-f]{64}:.+$/i; + +export type ProjectInboxWorkItem = + | { + type: "pull-request"; + project: Project; + pullRequest: ProjectPullRequest; + } + | { + type: "issue"; + project: Project; + issue: ProjectIssue; + }; + +function tagValue(item: Pick, name: string) { + return item.tags.find( + (tag) => tag[0] === name && typeof tag[1] === "string" && tag[1].length > 0, + )?.[1]; +} + +/** Returns the canonical Buzz Git repository and root event for an Inbox row. */ +export function getProjectInboxReference( + item: Pick, +): { repoAddress: string; rootId: string } | null { + const repoAddress = tagValue(item, "a"); + if (!repoAddress || !REPO_ADDRESS_PATTERN.test(repoAddress)) { + return null; + } + + if (PROJECT_ROOT_KINDS.has(item.kind)) { + return { repoAddress, rootId: item.id }; + } + + if (!PROJECT_ACTIVITY_KINDS.has(item.kind)) { + return null; + } + + const rootId = tagValue(item, "e") ?? tagValue(item, "E"); + return rootId ? { repoAddress, rootId } : null; +} + +/** Whether a feed event belongs to a Buzz Git pull request or issue thread. */ +export function isProjectInboxItem(item: FeedItem) { + return getProjectInboxReference(item) !== null; +} + +/** Resolves an Inbox event to the current canonical Buzz Git work item. */ +export function resolveProjectInboxWorkItem( + item: FeedItem, + workItems: ProjectsWorkItemsResult | undefined, +): ProjectInboxWorkItem | null { + const reference = getProjectInboxReference(item); + if (!reference || !workItems) { + return null; + } + + const pullRequestEntry = workItems.pullRequests.items.find( + ({ project, pullRequest }) => + project.repoAddress === reference.repoAddress && + pullRequest.id === reference.rootId, + ); + if (pullRequestEntry) { + return { type: "pull-request", ...pullRequestEntry }; + } + + const issueEntry = workItems.issues.items.find( + ({ issue, project }) => + project.repoAddress === reference.repoAddress && + issue.id === reference.rootId, + ); + return issueEntry ? { type: "issue", ...issueEntry } : null; +} diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 992702f431..2bcb231e9d 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -14,7 +14,7 @@ import { type InboxReply, buildInboxItems, formatInboxFullTimestamp, - getInboxConversationId, + getInboxItemConversationId, } from "@/features/home/lib/inbox"; import { useInboxSelectionAnchor } from "@/features/home/useInboxSelectionAnchor"; import { @@ -413,7 +413,7 @@ export function HomeView({ // correct row selected (by conversationId) even after the anchor event has // been displaced from groupItems by a newer representative. const latchedConversationId = activeLatchedItem - ? getInboxConversationId(activeLatchedItem.tags, activeLatchedItem.id) + ? getInboxItemConversationId(activeLatchedItem) : null; const selectedConversationId = selectedItemFromAll?.conversationId ?? latchedConversationId; diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 23a1748225..5d6d98323f 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -6,6 +6,8 @@ import type { InboxItem, InboxReply, } from "@/features/home/lib/inbox"; +import { getProjectInboxReference } from "@/features/home/lib/projectInbox"; +import { ProjectInboxDetail } from "@/features/home/ui/ProjectInboxDetail"; import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar"; import { useCommunities } from "@/features/communities/useCommunities"; import { formatInboxTypeLabel } from "@/features/home/lib/inbox"; @@ -102,7 +104,23 @@ type InboxDetailPaneProps = { ) => Promise; }; -export function InboxDetailPane({ +/** Routes Inbox selections to their canonical message or Buzz Git detail. */ +export function InboxDetailPane(props: InboxDetailPaneProps) { + if (props.item && getProjectInboxReference(props.item.item)) { + return ( + + ); + } + + return ; +} + +function InboxMessageDetailPane({ agentPubkeys, canDelete, canOpenChannel, diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index b92008e7a2..603ebc2beb 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -50,6 +50,7 @@ import { VirtualizedList } from "@/shared/ui/VirtualizedList"; const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [ { value: "all", label: "All" }, + { value: "project", label: "Projects" }, { value: "mention", label: "Mentions" }, { value: "thread", label: "Threads" }, { value: "needs_action", label: "Needs Action" }, diff --git a/desktop/src/features/home/ui/ProjectInboxDetail.tsx b/desktop/src/features/home/ui/ProjectInboxDetail.tsx new file mode 100644 index 0000000000..c3d17e687e --- /dev/null +++ b/desktop/src/features/home/ui/ProjectInboxDetail.tsx @@ -0,0 +1,129 @@ +import { ArrowLeft } from "lucide-react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import type { InboxItem } from "@/features/home/lib/inbox"; +import { resolveProjectInboxWorkItem } from "@/features/home/lib/projectInbox"; +import { ProjectInboxDetailPane } from "@/features/home/ui/ProjectInboxDetailPane"; +import { + useProjectsQuery, + useProjectsWorkItemsQuery, +} from "@/features/projects/hooks"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { Button } from "@/shared/ui/button"; + +type ProjectInboxDetailProps = { + isSinglePanelView?: boolean; + item: InboxItem; + onBack?: () => void; + profiles?: UserProfileLookup; +}; + +function ProjectInboxStatus({ + message, + onBack, + onRetry, +}: { + message: string; + onBack?: () => void; + onRetry?: () => void; +}) { + return ( +
+ {onBack ? ( +
+ +
+ ) : null} +
+

{message}

+ {onRetry ? ( + + ) : null} +
+
+ ); +} + +/** Resolves and renders the live Buzz Git object selected from Inbox. */ +export function ProjectInboxDetail({ + isSinglePanelView = false, + item, + onBack, + profiles, +}: ProjectInboxDetailProps) { + const { goProject } = useAppNavigation(); + const projectsQuery = useProjectsQuery(); + const projectsWorkItemsQuery = useProjectsWorkItemsQuery( + projectsQuery.data ?? [], + ); + const workItem = resolveProjectInboxWorkItem( + item.item, + projectsWorkItemsQuery.data, + ); + + if (!workItem) { + const error = projectsQuery.error ?? projectsWorkItemsQuery.error; + const isLoading = + projectsQuery.isLoading || projectsWorkItemsQuery.isLoading; + return ( + { + void projectsQuery.refetch(); + void projectsWorkItemsQuery.refetch(); + } + : undefined + } + /> + ); + } + + const failedSections = + workItem.type === "pull-request" + ? projectsWorkItemsQuery.data?.pullRequests.failedSections + : projectsWorkItemsQuery.data?.issues.failedSections; + if (failedSections && failedSections.length > 0) { + return ( + void projectsWorkItemsQuery.refetch()} + /> + ); + } + + return ( + { + const workItemId = + workItem.type === "pull-request" + ? { pullRequestId: workItem.pullRequest.id } + : { issueId: workItem.issue.id }; + void goProject(workItem.project.id, workItemId); + }} + profiles={profiles} + workItem={workItem} + /> + ); +} diff --git a/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx b/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx new file mode 100644 index 0000000000..42d883e8cb --- /dev/null +++ b/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx @@ -0,0 +1,131 @@ +import { ArrowLeft, ExternalLink } from "lucide-react"; +import * as React from "react"; + +import { useCommunities } from "@/features/communities/useCommunities"; +import type { ProjectInboxWorkItem } from "@/features/home/lib/projectInbox"; +import { ProjectIssueDetail } from "@/features/projects/ui/ProjectIssuesPanel"; +import { + ProjectPullRequestDetail, + PullRequestDetailHeader, + PullRequestMetaRail, +} from "@/features/projects/ui/ProjectPullRequestsPanel"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { openProjectMergeRecoveryTerminal } from "@/shared/api/projectGit"; +import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader"; +import { Button } from "@/shared/ui/button"; + +type ProjectInboxDetailPaneProps = { + isSinglePanelView?: boolean; + onBack?: () => void; + onOpenProject: () => void; + profiles?: UserProfileLookup; + workItem: ProjectInboxWorkItem; +}; + +/** Renders a canonical Buzz Git work item with its existing project actions. */ +export function ProjectInboxDetailPane({ + isSinglePanelView = false, + onBack, + onOpenProject, + profiles, + workItem, +}: ProjectInboxDetailPaneProps) { + const { activeCommunity } = useCommunities(); + const handleOpenMergeRecoveryTerminal = React.useCallback( + async (input: { + expectedCommit: string; + sourceBranch: string; + sourceCloneUrl: string; + targetBranch: string; + }) => { + if (workItem.type !== "pull-request") { + throw new Error("Merge recovery is only available for pull requests."); + } + const targetCloneUrl = workItem.project.cloneUrls[0]; + if (!targetCloneUrl) { + throw new Error("This project has no clone URL."); + } + return openProjectMergeRecoveryTerminal({ + ...input, + projectDtag: workItem.project.dtag, + reposDir: activeCommunity?.reposDir, + targetCloneUrl, + }); + }, + [activeCommunity?.reposDir, workItem], + ); + + return ( +
+ +
+
+ {isSinglePanelView && onBack ? ( + + ) : null} +
+

+ {workItem.project.name} +

+

+ {workItem.type === "pull-request" ? "Pull request" : "Issue"} +

+
+
+ +
+
+ +
+ {workItem.type === "pull-request" ? ( +
+
+ + +
+ +
+ ) : ( + + )} +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx index 861645a248..2fd8eea100 100644 --- a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx @@ -165,7 +165,8 @@ function IssueRow({ ); } -function IssueDetail({ +/** Full issue conversation and comment composer. */ +export function ProjectIssueDetail({ issue, profiles, project, @@ -372,7 +373,7 @@ export function ProjectIssuesPanel({ if (selectedIssue) { return ( - Date: Thu, 23 Jul 2026 23:56:17 +0200 Subject: [PATCH 2/3] fix(desktop): polish project Inbox detail layout Keep project work items readable at narrow widths and present their sender and actions in a familiar project card. --- desktop/playwright.config.ts | 1 + .../home/ui/ProjectInboxDetailPane.tsx | 157 ++++++++++++------ .../features/projects/ui/ProjectFeedRow.tsx | 3 + .../projects/ui/ProjectIssuesPanel.tsx | 25 ++- .../projects/ui/ProjectPullRequestsPanel.tsx | 11 +- desktop/src/testing/e2eBridge.ts | 19 ++- desktop/tests/e2e/project-inbox.spec.ts | 110 ++++++++++++ 7 files changed, 266 insertions(+), 60 deletions(-) create mode 100644 desktop/tests/e2e/project-inbox.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index e3745338a2..131e845b44 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -94,6 +94,7 @@ export default defineConfig({ "**/inbox-reactions.spec.ts", "**/send-channel-binding.spec.ts", "**/project-commit-detail.spec.ts", + "**/project-inbox.spec.ts", "**/project-pr-review.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", "**/drafts-screenshots.spec.ts", diff --git a/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx b/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx index 42d883e8cb..5b80c13cb9 100644 --- a/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx +++ b/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx @@ -9,10 +9,17 @@ import { PullRequestDetailHeader, PullRequestMetaRail, } from "@/features/projects/ui/ProjectPullRequestsPanel"; -import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; import { openProjectMergeRecoveryTerminal } from "@/shared/api/projectGit"; +import { useElementWidth } from "@/shared/hooks/use-mobile"; import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader"; +import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; type ProjectInboxDetailPaneProps = { isSinglePanelView?: boolean; @@ -31,6 +38,19 @@ export function ProjectInboxDetailPane({ workItem, }: ProjectInboxDetailPaneProps) { const { activeCommunity } = useCommunities(); + const [detailContentRef, detailContentWidth] = + useElementWidth(); + const showSideRail = detailContentWidth >= 760; + const authorPubkey = + workItem.type === "pull-request" + ? workItem.pullRequest.author + : workItem.issue.author; + const authorLabel = resolveUserLabel({ profiles, pubkey: authorPubkey }); + const authorAvatarUrl = + profiles?.[normalizePubkey(authorPubkey)]?.avatarUrl ?? null; + const inboxTitle = `${authorLabel} sent you ${ + workItem.type === "pull-request" ? "a pull request" : "an issue" + }`; const handleOpenMergeRecoveryTerminal = React.useCallback( async (input: { expectedCommit: string; @@ -61,70 +81,97 @@ export function ProjectInboxDetailPane({ data-testid="home-project-inbox-detail" > -
-
- {isSinglePanelView && onBack ? ( - + ) : null} + +

- - - ) : null} -
-

- {workItem.project.name} -

-

- {workItem.type === "pull-request" ? "Pull request" : "Issue"} -

+ {inboxTitle} +

+
-
-
- {workItem.type === "pull-request" ? ( -
-
- - +
+
+ {workItem.type === "pull-request" ? ( +
+
+ + +
+ +
+ ) : ( + -
- + )}
- ) : ( - - )} +
); diff --git a/desktop/src/features/projects/ui/ProjectFeedRow.tsx b/desktop/src/features/projects/ui/ProjectFeedRow.tsx index ae5a47798f..4747d03519 100644 --- a/desktop/src/features/projects/ui/ProjectFeedRow.tsx +++ b/desktop/src/features/projects/ui/ProjectFeedRow.tsx @@ -7,6 +7,7 @@ import type * as React from "react"; * trailing cluster (hash, id, comment count) on the right. */ export function ProjectFeedRow({ + eventId, meta, onOpen, statusIcon, @@ -14,6 +15,7 @@ export function ProjectFeedRow({ title, trailing, }: { + eventId?: string; meta: React.ReactNode; onOpen?: () => void; statusIcon?: React.ReactNode; @@ -24,6 +26,7 @@ export function ProjectFeedRow({ return (
diff --git a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx index 2fd8eea100..2423f92965 100644 --- a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx @@ -15,6 +15,7 @@ import { } from "@/features/profile/lib/identity"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import type { ChannelMember } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { Markdown } from "@/shared/ui/markdown"; import { @@ -170,10 +171,12 @@ export function ProjectIssueDetail({ issue, profiles, project, + stackMetaRail = false, }: { issue: ProjectIssue; profiles?: UserProfileLookup; project: Project; + stackMetaRail?: boolean; }) { const commentMutation = useCreateProjectIssueCommentMutation(project); const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author }); @@ -206,7 +209,12 @@ export function ProjectIssueDetail({ ); return ( -
+
@@ -269,7 +277,11 @@ export function ProjectIssueDetail({
- +
); } @@ -279,16 +291,23 @@ export function ProjectIssueDetail({ function IssueMetaRail({ issue, profiles, + stacked = false, }: { issue: ProjectIssue; profiles?: UserProfileLookup; + stacked?: boolean; }) { const authorProfile = profiles?.[normalizePubkey(issue.author)]; const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author }); const status = issueStatusVisual(issue.status); return ( -