feat(desktop): handle project work from Inbox (#3117)

## Summary

Pull requests and issues that mention you now appear as
repository-scoped Inbox conversations, so project work can be reviewed
without first navigating to Projects.

Opening a project item resolves its current canonical state and reuses
the existing review, comment, merge, and issue actions. Repository-aware
grouping keeps identical event IDs from different repositories separate,
while loading, missing-data, and partial-query states avoid exposing
stale actions.

### Related issue

None found.

### Testing

- `node --import ./test-loader.mjs --experimental-strip-types --test
src/features/home/lib/projectInbox.test.mjs` — 6 tests passed
- `CI=1 pnpm exec playwright test tests/e2e/project-inbox.spec.ts
--project=smoke` — passed
- Pre-push desktop, mobile, Tauri, and Rust checks — passed

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
thomaspblock
2026-07-27 16:14:22 +02:00
committed by GitHub
parent 87b3fcd3c0
commit c5c4f390b6
17 changed files with 952 additions and 28 deletions

View File

@@ -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]

View File

@@ -98,6 +98,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",

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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);
});

View File

@@ -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<FeedItem, "tags">, 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<FeedItem, "id" | "kind" | "tags">,
): { 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<Project> | 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;
}

View File

@@ -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;

View File

@@ -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<void>;
};
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 (
<ProjectInboxDetail
isSinglePanelView={props.isSinglePanelView}
item={props.item}
onBack={props.onBack}
profiles={props.profiles}
/>
);
}
return <InboxMessageDetailPane {...props} />;
}
function InboxMessageDetailPane({
agentPubkeys,
canDelete,
canOpenChannel,

View File

@@ -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" },

View File

@@ -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 (
<section className="flex min-h-0 min-w-0 flex-col bg-background/60">
{onBack ? (
<div className="flex min-h-13 items-center px-5 py-2">
<Button
aria-label="Back to Inbox"
onClick={onBack}
size="icon"
type="button"
variant="ghost"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
<p className="text-sm text-muted-foreground">{message}</p>
{onRetry ? (
<Button onClick={onRetry} size="sm" type="button" variant="outline">
Retry
</Button>
) : null}
</div>
</section>
);
}
/** 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 (
<ProjectInboxStatus
message={
error
? "Could not load this project item."
: isLoading
? "Loading project item…"
: "This project item could not be found."
}
onBack={onBack}
onRetry={
error
? () => {
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 (
<ProjectInboxStatus
message="Some project activity could not be loaded. Actions are unavailable until the item is current."
onBack={onBack}
onRetry={() => void projectsWorkItemsQuery.refetch()}
/>
);
}
return (
<ProjectInboxDetailPane
isSinglePanelView={isSinglePanelView}
onBack={onBack}
onOpenProject={() => {
const workItemId =
workItem.type === "pull-request"
? { pullRequestId: workItem.pullRequest.id }
: { issueId: workItem.issue.id };
void goProject(workItem.project.id, workItemId);
}}
profiles={profiles}
workItem={workItem}
/>
);
}

View File

@@ -0,0 +1,178 @@
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 {
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;
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 [detailContentRef, detailContentWidth] =
useElementWidth<HTMLDivElement>();
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;
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 (
<section
className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-background/60"
data-testid="home-project-inbox-detail"
>
<TopChromeInsetHeader flush transparent>
<div className="px-5 py-2">
<div className="flex min-h-9 min-w-0 items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1">
{isSinglePanelView && onBack ? (
<Button
aria-label="Back to Inbox"
onClick={onBack}
size="icon"
type="button"
variant="ghost"
>
<ArrowLeft className="h-4 w-4" />
</Button>
) : null}
<UserAvatar
avatarUrl={authorAvatarUrl}
className="shrink-0"
displayName={authorLabel}
size="sm"
testId="project-inbox-author-avatar"
/>
<h2
className="min-w-0 translate-y-px truncate text-sm font-semibold leading-5 tracking-tight text-foreground"
title={`${inboxTitle} · ${workItem.project.name}`}
>
{inboxTitle}
</h2>
</div>
<Button
aria-label="Open project"
className="shrink-0"
onClick={onOpenProject}
size={showSideRail ? "sm" : "icon"}
title="Open project"
type="button"
variant="ghost"
>
<ExternalLink className="h-4 w-4" />
{showSideRail ? "Open project" : null}
</Button>
</div>
</div>
</TopChromeInsetHeader>
<div
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
ref={detailContentRef}
>
<div className="p-3">
<div
className="overflow-hidden rounded-xl border border-border/60 bg-card"
data-testid="project-inbox-work-item-card"
>
{workItem.type === "pull-request" ? (
<div
className={cn(
"grid",
showSideRail && "grid-cols-[minmax(0,1fr)_18rem]",
)}
data-testid="project-inbox-work-item-layout"
>
<div className="min-w-0">
<PullRequestDetailHeader
profiles={profiles}
pullRequest={workItem.pullRequest}
/>
<ProjectPullRequestDetail
mode="conversation"
onOpenTerminal={handleOpenMergeRecoveryTerminal}
profiles={profiles}
project={workItem.project}
pullRequest={workItem.pullRequest}
/>
</div>
<PullRequestMetaRail
profiles={profiles}
project={workItem.project}
pullRequest={workItem.pullRequest}
stacked={!showSideRail}
/>
</div>
) : (
<ProjectIssueDetail
issue={workItem.issue}
profiles={profiles}
project={workItem.project}
stackMetaRail={!showSideRail}
/>
)}
</div>
</div>
</div>
</section>
);
}

View File

@@ -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 (
<article
className="group/feed-item flex min-w-0 items-center justify-between gap-3 p-3 transition-colors hover:bg-muted/35"
data-project-event-id={eventId}
data-testid={testId}
>
<div className="min-w-0 flex-1 space-y-1">

View File

@@ -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 {
ProjectFeedRow,
@@ -158,14 +159,17 @@ function IssueRow({
);
}
function IssueDetail({
/** Full issue conversation and comment composer. */
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 });
@@ -198,7 +202,12 @@ function IssueDetail({
);
return (
<div className="grid xl:grid-cols-[minmax(0,1fr)_18rem]">
<div
className={cn(
"grid",
!stackMetaRail && "xl:grid-cols-[minmax(0,1fr)_18rem]",
)}
>
<div className="min-w-0 divide-y divide-border/50">
<header className="space-y-3 p-4">
<div className="min-w-0">
@@ -253,7 +262,11 @@ function IssueDetail({
</section>
</div>
<IssueMetaRail issue={issue} profiles={profiles} />
<IssueMetaRail
issue={issue}
profiles={profiles}
stacked={stackMetaRail}
/>
</div>
);
}
@@ -263,16 +276,23 @@ function IssueDetail({
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 (
<aside className="space-y-6 border-t border-border/60 p-4 xl:border-l xl:border-t-0">
<aside
className={cn(
"space-y-6 border-border/60 p-4",
stacked ? "border-t" : "border-t xl:border-l xl:border-t-0",
)}
>
<OverviewRailSection title="Status">
<span
className={`inline-flex items-center gap-1.5 rounded-md border border-border/60 px-2.5 py-1 text-xs font-medium ${status.className}`}
@@ -357,7 +377,7 @@ export function ProjectIssuesPanel({
if (selectedIssue) {
return (
<IssueDetail
<ProjectIssueDetail
issue={selectedIssue}
profiles={profiles}
project={project}

View File

@@ -35,6 +35,7 @@ import { canReviewProjectPullRequest } from "@/features/projects/pullRequestRevi
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { ChannelMember } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import {
ProjectFeedRow,
@@ -252,6 +253,7 @@ function PullRequestRow({
return (
<ProjectFeedRow
eventId={pullRequest.id}
meta={
<>
<ProfileIdentityButton
@@ -393,10 +395,12 @@ export function PullRequestMetaRail({
profiles,
project,
pullRequest,
stacked = false,
}: {
profiles?: UserProfileLookup;
project: Project;
pullRequest: ProjectPullRequest;
stacked?: boolean;
}) {
const identityQuery = useIdentityQuery();
const authorProfile = profileForPubkey(pullRequest.author, profiles);
@@ -414,7 +418,12 @@ export function PullRequestMetaRail({
Boolean(viewer) && (isAuthor || isOwner || isManagedAgentOwner);
return (
<aside className="min-w-0 space-y-6 border-t border-border/60 p-4 xl:border-l xl:border-t-0">
<aside
className={cn(
"min-w-0 space-y-6 border-border/60 p-4",
stacked ? "border-t" : "border-t xl:border-l xl:border-t-0",
)}
>
<OverviewRailSection title="Status">
<span
className={`inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium text-white ${pullRequestStatusBadgeClassName(pullRequest.status)}`}
@@ -488,7 +497,8 @@ export function PullRequestMetaRail({
);
}
function PullRequestDetail({
/** Full pull-request conversation, review actions, and comment composer. */
export function ProjectPullRequestDetail({
mode,
onOpenInlineComment,
onOpenCommit,
@@ -951,7 +961,7 @@ export function PullRequestsPanel({
if (selectedPullRequest) {
return (
<PullRequestDetail
<ProjectPullRequestDetail
mode={mode}
onOpenInlineComment={onOpenInlineComment}
onOpenCommit={onOpenCommit}

View File

@@ -6823,7 +6823,24 @@ async function handleGetFeed(
// For e2e, return a minimal feed structure with mentions.
const limit = args.limit ?? 50;
const mentionEvents = await relayQuery(config, [
{ kinds: [9, 40002, 45001, 45003], "#p": [identity.pubkey], limit },
{
kinds: [
9,
40002,
1,
45001,
45003,
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,
],
"#p": [identity.pubkey],
limit,
},
]);
// Look up channel names for feed items

View File

@@ -0,0 +1,117 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
const BUZZ_REPO_ADDRESS = `30617:${DEFAULT_MOCK_PUBKEY}:buzz`;
test("Buzz Git pull request renders and stays actionable in Inbox", async ({
page,
}) => {
await page.addInitScript(() => {
window.localStorage.setItem(
"buzz-feature-overrides-v1",
JSON.stringify({ projects: true }),
);
});
await installMockBridge(page);
await page.setViewportSize({ width: 1024, height: 720 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
await page.getByRole("button", { name: "Repositories", exact: true }).click();
await page
.locator(
'[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
)
.first()
.click();
await page.getByRole("tab", { name: "Pull Request" }).click();
const alicePullRequest = page
.getByTestId("project-pull-request-row")
.filter({ hasText: "alice" })
.first();
await expect(alicePullRequest).toBeVisible({ timeout: 10_000 });
const pullRequestId = await alicePullRequest.getAttribute(
"data-project-event-id",
);
expect(pullRequestId).toBeTruthy();
await page.getByRole("button", { name: "Inbox", exact: true }).click();
await page.evaluate(
({ author, id, repoAddress, viewer }) => {
window.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?.({
id,
kind: 1618,
pubkey: author,
content: "Inbox rendering verification",
created_at: Math.floor(Date.now() / 1000) + 1,
channel_id: null,
channel_name: "",
channel_type: null,
tags: [
["a", repoAddress],
["p", viewer],
["subject", "Inbox rendering verification"],
],
category: "mention",
});
},
{
author: TEST_IDENTITIES.alice.pubkey,
id: pullRequestId as string,
repoAddress: BUZZ_REPO_ADDRESS,
viewer: DEFAULT_MOCK_PUBKEY,
},
);
const inboxRow = page.getByTestId(`home-inbox-item-${pullRequestId}`);
await expect(inboxRow).toBeVisible({ timeout: 10_000 });
await inboxRow.locator(":scope > div").first().click();
const detail = page.getByTestId("home-project-inbox-detail");
const card = page.getByTestId("project-inbox-work-item-card");
const layout = page.getByTestId("project-inbox-work-item-layout");
await expect(detail).toBeVisible();
await expect(card).toBeVisible();
await expect(
detail.locator('[data-testid^="project-inbox-author-avatar-"]'),
).toBeVisible();
await expect(
detail.getByRole("heading", {
name: "alice sent you a pull request",
exact: true,
}),
).toBeVisible();
await expect(page.getByText("Inbox rendering verification")).toBeVisible();
await expect(
page.getByRole("button", { name: "Approve", exact: true }),
).toBeVisible();
const commentComposer = page.getByTestId(
"project-pull-request-comment-composer",
);
await commentComposer
.getByRole("button", { name: "Comment", exact: true })
.click();
await expect(
page.getByRole("menuitemradio", { name: "Request changes" }),
).toBeVisible();
await page.keyboard.press("Escape");
await expect(
page.getByRole("button", { name: "Merge", exact: true }),
).toBeVisible();
const columnCount = await layout.evaluate(
(element) =>
getComputedStyle(element).gridTemplateColumns.split(" ").filter(Boolean)
.length,
);
expect(columnCount).toBe(1);
await waitForAnimations(page);
await detail.screenshot({
path: "test-results/project-inbox/01-pull-request-detail.png",
});
});