From da197f53f3b111c63411ec3dff5298981cd097b0 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Thu, 27 Aug 2026 18:52:37 -0400 Subject: [PATCH] feat(collections): refine activity and member actions Signed-off-by: John Tennant --- .../channels/ui/ChannelScreenHeader.tsx | 2 +- .../collections/derivedLinks.test.mjs | 91 +++++++---- .../src/features/collections/derivedLinks.ts | 90 +++++++---- desktop/src/features/collections/hooks.ts | 60 +++++-- .../collections/memberDisplay.test.mjs | 37 +++++ .../src/features/collections/memberDisplay.ts | 16 +- .../messageCollectionAction.test.mjs | 58 +++++++ .../collections/messageCollectionAction.ts | 51 ++++++ .../ui/AddCollectionReferenceForm.tsx | 19 +-- .../collections/ui/CollectionDetailRows.tsx | 85 +++++++--- .../collections/ui/CollectionDetailScreen.tsx | 148 +++++++++++------- .../features/messages/ui/MessageActionBar.tsx | 65 ++------ desktop/tests/e2e/collections.spec.ts | 94 +++++++---- docs/collections.md | 2 + 14 files changed, 553 insertions(+), 265 deletions(-) create mode 100644 desktop/src/features/collections/messageCollectionAction.test.mjs create mode 100644 desktop/src/features/collections/messageCollectionAction.ts diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 8e5378922a..71e536f486 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -92,7 +92,7 @@ export function ChannelScreenHeader({ reference={{ type: "channel", channel_id: activeChannel.id }} /> diff --git a/desktop/src/features/collections/derivedLinks.test.mjs b/desktop/src/features/collections/derivedLinks.test.mjs index 2c9931299a..a3aa73e7cd 100644 --- a/desktop/src/features/collections/derivedLinks.test.mjs +++ b/desktop/src/features/collections/derivedLinks.test.mjs @@ -11,6 +11,7 @@ import { deduplicateDerivedCollectionLinks, deduplicateDerivedCollectionWarnings, extractGitHubPullRequestLinks, + filterCollectionActivityByType, githubPullRequestToDerived, groupCalendarMeetingActivity, projectCollectionActivity, @@ -62,7 +63,7 @@ test("projects low-signal agent chats while retaining sourced artifacts", () => ); }); -test("moves mentioned PR sources out of generic conversation activity", () => { +test("replaces mentioned PR conversations with one consolidated PR card", () => { const sourceMessage = { activityType: "channel-message", authorPubkey: "human", @@ -82,20 +83,54 @@ test("moves mentioned PR sources out of generic conversation activity", () => { label: "PR #1", url: "https://github.com/block/buzz/pull/1", }; - const review = { - ...pullRequest, - activityType: "github-pr-activity", - kind: "PR review", - }; - assert.deepEqual( - projectCollectionActivity([sourceMessage, pullRequest, review], { + projectCollectionActivity([sourceMessage, pullRequest], { human: false, }), - [review], + [pullRequest], ); }); +test("filters projected activity into compact content types", () => { + const item = (activityType, label = activityType) => ({ + activityType, + createdAt: 1, + kind: activityType, + label, + sourceMemberId: "source", + url: label, + }); + const activity = [ + item("channel-message"), + { ...item("github-pr"), pullRequestActivity: [{ kind: "review" }] }, + item("calendar-meeting"), + item("calendar-document"), + item("document-edit"), + item("document-comment"), + ]; + + assert.deepEqual( + filterCollectionActivityByType(activity, "pull-requests").map( + ({ activityType }) => activityType, + ), + ["github-pr"], + ); + assert.deepEqual( + filterCollectionActivityByType(activity, "meetings").map( + ({ activityType }) => activityType, + ), + ["calendar-meeting", "calendar-document"], + ); + assert.deepEqual( + filterCollectionActivityByType(activity, "documents").map( + ({ activityType }) => activityType, + ), + ["document-edit", "document-comment"], + ); + assert.equal(filterCollectionActivityByType(activity, "messages").length, 1); + assert.deepEqual(filterCollectionActivityByType(activity, "all"), activity); +}); + test("deduplicates identical warnings while preserving attachment failures", () => { const warning = { failureSourceUrl: "https://docs.google.com/document/d/one", @@ -186,7 +221,7 @@ test("preserves every explicit source when duplicate PR mentions collapse", () = })); assert.deepEqual( projectCollectionActivity([...messages, pullRequest], { human: false }), - [], + [pullRequest], ); }); @@ -226,7 +261,7 @@ test("suppresses matching PR mention rows across sources but keeps unrelated cha projectCollectionActivity([channelMention, unrelated, pullRequest], { human: false, }), - [unrelated], + [unrelated, pullRequest], ); }); @@ -259,20 +294,12 @@ test("suppresses a PR conversation without hiding another thread from the same c sourceMemberIds: ["channel-source"], url: "https://github.com/block/buzz/pull/7", }; - const stateChange = { - ...pullRequest, - activityType: "github-pr-activity", - createdAt: 2, - kind: "PR review", - url: `${pullRequest.url}#pullrequestreview-1`, - }; - assert.deepEqual( projectCollectionActivity( - [prConversation, unrelatedConversation, pullRequest, stateChange], + [prConversation, unrelatedConversation, pullRequest], { human: false }, ), - [unrelatedConversation, stateChange], + [unrelatedConversation, pullRequest], ); }); @@ -283,7 +310,7 @@ test("names channel-derived live thread provenance", () => { ); }); -test("maps live PR status and recent activity onto a sourced mention", () => { +test("consolidates live PR status and recent activity onto one sourced card", () => { const url = "https://github.com/block/buzz/pull/4242"; const mention = { activityType: "github-pr", @@ -324,7 +351,7 @@ test("maps live PR status and recent activity onto a sourced mention", () => { ], }); - assert.equal(derived.length, 2); + assert.equal(derived.length, 1); assert.deepEqual( derived.map((item) => ({ activityType: item.activityType, @@ -333,6 +360,7 @@ test("maps live PR status and recent activity onto a sourced mention", () => { actorIdentity: item.actorIdentity, label: item.label, provenanceLabel: item.provenanceLabel, + pullRequestActivity: item.pullRequestActivity, stateLabel: item.stateLabel, })), [ @@ -343,17 +371,16 @@ test("maps live PR status and recent activity onto a sourced mention", () => { actorIdentity: "github:author", label: "Live collections activity feed", provenanceLabel: "Design thread → mentioned PR", + pullRequestActivity: [ + { + actorLabel: "reviewer", + createdAt: 1_787_837_400, + kind: "review", + stateLabel: "approved", + }, + ], stateLabel: "open", }, - { - activityType: "github-pr-activity", - actorLabel: "reviewer", - actorAvatarUrl: "https://avatars.githubusercontent.com/u/2?v=4", - actorIdentity: "github:reviewer", - label: "Live collections activity feed", - provenanceLabel: "Design thread → mentioned PR", - stateLabel: "approved", - }, ], ); }); diff --git a/desktop/src/features/collections/derivedLinks.ts b/desktop/src/features/collections/derivedLinks.ts index a10da38dd1..b370d76b0f 100644 --- a/desktop/src/features/collections/derivedLinks.ts +++ b/desktop/src/features/collections/derivedLinks.ts @@ -80,8 +80,7 @@ export type DerivedCollectionActivity = DerivedCollectionLink & { | "channel-message" | "document-comment" | "document-edit" - | "github-pr" - | "github-pr-activity"; + | "github-pr"; actorLabel?: string; actorAvatarUrl?: string; actorIdentity?: string; @@ -92,6 +91,12 @@ export type DerivedCollectionActivity = DerivedCollectionLink & { derivedFromSource?: boolean; eventId?: string; provenanceLabel?: string; + pullRequestActivity?: Array<{ + actorLabel?: string; + createdAt: number; + kind: CollectionGithubPullRequestActivity["kind"]; + stateLabel?: string; + }>; sourceEventIds?: string[]; sourceMemberIds?: string[]; sourceUrl?: string; @@ -105,6 +110,35 @@ export type DerivedCollectionActivity = DerivedCollectionLink & { }>; }; +export type CollectionActivityTypeFilter = + | "all" + | "documents" + | "meetings" + | "messages" + | "pull-requests"; + +/** Keep collection activity belonging to the selected presentation category. */ +export function filterCollectionActivityByType( + activity: readonly DerivedCollectionActivity[], + filter: CollectionActivityTypeFilter, +): DerivedCollectionActivity[] { + if (filter === "all") return [...activity]; + return activity.filter((item) => { + if (filter === "messages") return item.activityType === "channel-message"; + if (filter === "pull-requests") return item.activityType === "github-pr"; + if (filter === "meetings") { + return ( + item.activityType === "calendar-meeting" || + item.activityType === "calendar-document" + ); + } + return ( + item.activityType === "document-edit" || + item.activityType === "document-comment" + ); + }); +} + const WEB_URL_PATTERN = /https?:\/\/[^\s<>"']+/giu; const TRAILING_URL_PUNCTUATION = /[),.;:!?\]}]+$/u; export const COLLECTION_PR_LIMIT_PER_SOURCE = 4; @@ -172,6 +206,19 @@ export function githubPullRequestToDerived( pullRequest: CollectionGithubPullRequest, ): DerivedCollectionActivity[] { const label = pullRequest.title ?? mention.label; + const pullRequestActivity = pullRequest.activity.flatMap((item) => { + const createdAt = parseActivityTimestamp(item.created_at); + return createdAt === null + ? [] + : [ + { + actorLabel: item.author ?? undefined, + createdAt, + kind: item.kind, + stateLabel: item.state ?? undefined, + }, + ]; + }); const status: DerivedCollectionActivity = { ...mention, actorLabel: pullRequest.author ?? undefined, @@ -182,37 +229,14 @@ export function githubPullRequestToDerived( createdAt: parseActivityTimestamp(pullRequest.updated_at) ?? mention.createdAt, label, + actorIdentities: pullRequest.activity.flatMap((item) => + item.author ? [`github:${item.author.toLocaleLowerCase()}`] : [], + ), + pullRequestActivity, stateLabel: pullRequest.state ?? undefined, url: pullRequest.url, }; - const activity = pullRequest.activity.flatMap( - (item): DerivedCollectionActivity[] => { - const createdAt = parseActivityTimestamp(item.created_at); - if (createdAt === null) return []; - return [ - { - activityType: "github-pr-activity", - actorLabel: item.author ?? undefined, - actorAvatarUrl: item.author_avatar_url ?? undefined, - actorIdentity: item.author - ? `github:${item.author.toLocaleLowerCase()}` - : undefined, - channelId: mention.channelId, - createdAt, - eventId: mention.eventId, - kind: `PR ${item.kind}`, - label, - provenanceLabel: mention.provenanceLabel, - sourceMemberId: mention.sourceMemberId, - sourceMemberIds: mention.sourceMemberIds, - stateLabel: item.state ?? undefined, - threadRootId: mention.threadRootId, - url: item.url ?? pullRequest.url, - }, - ]; - }, - ); - return [status, ...activity]; + return [status]; } /** Keep the newest distinct sourced PR mentions within fixed discovery caps. */ @@ -344,8 +368,7 @@ export function deduplicateDerivedCollectionActivity( identity = `message:${item.eventId}`; } else if ( item.activityType === "document-edit" || - item.activityType === "document-comment" || - item.activityType === "github-pr-activity" + item.activityType === "document-comment" ) { identity = [ item.activityType, @@ -519,8 +542,7 @@ export function projectCollectionActivity( } const projected = activity.filter( (item) => - item.activityType !== "github-pr" && - (item.activityType !== "channel-message" || visibleMessages.has(item)), + item.activityType !== "channel-message" || visibleMessages.has(item), ); return groupCalendarMeetingActivity(projected); } diff --git a/desktop/src/features/collections/hooks.ts b/desktop/src/features/collections/hooks.ts index 6aec009d88..109e2aabe6 100644 --- a/desktop/src/features/collections/hooks.ts +++ b/desktop/src/features/collections/hooks.ts @@ -55,7 +55,16 @@ const COLLECTION_CALENDAR_ACTIVITY_DAYS = 30; export const COLLECTION_SOURCE_DISCOVERY_CONCURRENCY = 2; export const COLLECTION_PR_RESOLUTION_CONCURRENCY = 3; -function sourceLabel(member: CollectionMember): string { +function sourceLabel( + member: CollectionMember, + channelNamesById: ReadonlyMap, +): string { + if (member.reference.type === "channel") { + const channelName = channelNamesById + .get(member.reference.channel_id) + ?.trim(); + if (channelName) return channelName; + } if (member.label) return member.label; if (member.reference.type === "external") return member.reference.url; if (member.reference.type === "channel") return member.reference.channel_id; @@ -67,6 +76,7 @@ function sourceLabel(member: CollectionMember): string { function messageActivity( member: CollectionMember, events: Awaited>, + channelNamesById: ReadonlyMap, ): DerivedCollectionActivity[] { const reference = member.reference; if ( @@ -80,7 +90,7 @@ function messageActivity( return events.flatMap((event): DerivedCollectionActivity[] => { const thread = getThreadReference(event.tags); const compact = event.content.replace(/\s+/gu, " ").trim(); - const provenanceLabel = `${sourceLabel(member)} → ${ + const provenanceLabel = `${sourceLabel(member, channelNamesById)} → ${ reference.type === "thread" ? "thread activity" : "message activity" }`; const message: DerivedCollectionActivity = { @@ -110,7 +120,7 @@ function messageActivity( channelId, createdAt: event.created_at, eventId: event.id, - provenanceLabel: `${sourceLabel(member)} → mentioned PR`, + provenanceLabel: `${sourceLabel(member, channelNamesById)} → mentioned PR`, sourceMemberId: member.id, threadRootId: reference.type === "thread" ? reference.root_event_id : thread.rootId, @@ -146,6 +156,7 @@ async function mapWithConcurrency( async function resolveMentionedPullRequests( membersById: ReadonlyMap, items: DerivedCollectionActivity[], + channelNamesById: ReadonlyMap, ): Promise<{ items: DerivedCollectionActivity[]; warnings: DerivedCollectionWarning[]; @@ -157,7 +168,9 @@ async function resolveMentionedPullRequests( COLLECTION_PR_RESOLUTION_CONCURRENCY, async (mention) => { const member = membersById.get(mention.sourceMemberId); - const memberLabel = member ? sourceLabel(member) : mention.sourceMemberId; + const memberLabel = member + ? sourceLabel(member, channelNamesById) + : mention.sourceMemberId; try { const pullRequest = await resolveCollectionGithubPullRequest( mention.url, @@ -179,7 +192,7 @@ async function resolveMentionedPullRequests( error instanceof Error ? error.message : "GitHub PR activity is unavailable", - sourceLabel: `${sourceMember ? sourceLabel(sourceMember) : memberLabel} → ${mention.label}`, + sourceLabel: `${sourceMember ? sourceLabel(sourceMember, channelNamesById) : memberLabel} → ${mention.label}`, sourceMemberId, warningType: "github-pr" as const, }; @@ -277,6 +290,7 @@ export function useCollectionMutations(scope: CollectionScope | null) { async function discoverCollectionMemberActivity( member: CollectionMember, + channelNamesById: ReadonlyMap, ): Promise<{ items: DerivedCollectionActivity[]; warnings: DerivedCollectionWarning[]; @@ -317,7 +331,7 @@ async function discoverCollectionMemberActivity( .slice(0, 120) .join(""), provenanceLabel: collectionChannelThreadProvenance( - sourceLabel(member), + sourceLabel(member, channelNamesById), ), sourceMemberId: member.id, threadRootId: root.id, @@ -327,7 +341,10 @@ async function discoverCollectionMemberActivity( }, ); return { - items: [...threadActivity, ...messageActivity(member, messages)], + items: [ + ...threadActivity, + ...messageActivity(member, messages, channelNamesById), + ], warnings: [], }; } @@ -339,14 +356,18 @@ async function discoverCollectionMemberActivity( { limit: COLLECTION_CHANNEL_DISCOVERY_LIMIT }, ); return { - items: messageActivity(member, [root, ...replies.events]), + items: messageActivity( + member, + [root, ...replies.events], + channelNamesById, + ), warnings: [], }; } if (member.reference.type === "message") { const event = await getEventById(member.reference.event_id); return { - items: messageActivity(member, [event]), + items: messageActivity(member, [event], channelNamesById), warnings: [], }; } @@ -369,21 +390,21 @@ async function discoverCollectionMemberActivity( ...link, activityType: "calendar-document", createdAt: null, - provenanceLabel: `${sourceLabel(member)} → attached document`, + provenanceLabel: `${sourceLabel(member, channelNamesById)} → attached document`, sourceMemberId: member.id, sourceUrl: calendarUrl, }), ), ...calendarDocumentActivityToDerived( member.id, - sourceLabel(member), + sourceLabel(member, channelNamesById), activity.activities, ), ], warnings: activity.errors.map((error) => ({ failureSourceUrl: error.source_url, message: error.message, - sourceLabel: sourceLabel(member), + sourceLabel: sourceLabel(member, channelNamesById), sourceMemberId: member.id, warningType: "calendar" as const, })), @@ -394,6 +415,7 @@ async function discoverCollectionMemberActivity( export function useDerivedCollectionLinks( members: readonly CollectionMember[], + channelNamesById: ReadonlyMap = new Map(), ) { const sources = members.filter( (member) => @@ -407,7 +429,11 @@ export function useDerivedCollectionLinks( enabled: sources.length > 0, queryKey: [ "collection-derived-links", - sources.map((member) => [member.id, member.label, member.reference]), + sources.map((member) => [ + member.id, + sourceLabel(member, channelNamesById), + member.reference, + ]), ], queryFn: async () => { const sourceResults = await mapWithConcurrency( @@ -415,7 +441,10 @@ export function useDerivedCollectionLinks( COLLECTION_SOURCE_DISCOVERY_CONCURRENCY, async (member) => { try { - return await discoverCollectionMemberActivity(member); + return await discoverCollectionMemberActivity( + member, + channelNamesById, + ); } catch (error) { return { items: [], @@ -425,7 +454,7 @@ export function useDerivedCollectionLinks( error instanceof Error ? error.message : "Source activity is unavailable", - sourceLabel: sourceLabel(member), + sourceLabel: sourceLabel(member, channelNamesById), sourceMemberId: member.id, warningType: member.reference.type === "external" @@ -441,6 +470,7 @@ export function useDerivedCollectionLinks( const pullRequests = await resolveMentionedPullRequests( new Map(sources.map((member) => [member.id, member])), baseItems, + channelNamesById, ); return { items: pullRequests.items, diff --git a/desktop/src/features/collections/memberDisplay.test.mjs b/desktop/src/features/collections/memberDisplay.test.mjs index d39efce45f..6b7f1f2250 100644 --- a/desktop/src/features/collections/memberDisplay.test.mjs +++ b/desktop/src/features/collections/memberDisplay.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + collectionMemberDisplayLabel, collectionMemberMatches, collectionReferenceIdentity, } from "./memberDisplay.ts"; @@ -39,3 +40,39 @@ test("collection member filter searches labels and respects type", () => { ); assert.equal(collectionMemberMatches(member, "brief", "channel"), false); }); + +test("resolved channel names override cached compatibility labels", () => { + const member = { + id: "member-1", + collection_id: "collection-1", + reference: { type: "channel", channel_id: "channel-a" }, + label: "buzz-development (cross-team implementation)", + added_at: "2026-08-27T14:00:00Z", + }; + const channelNames = new Map([["channel-a", "buzz-development"]]); + + assert.equal( + collectionMemberDisplayLabel(member, channelNames), + "buzz-development", + ); + assert.equal( + collectionMemberMatches(member, "buzz-development", "all", channelNames), + true, + ); + assert.equal( + collectionMemberMatches(member, "cross-team", "all", channelNames), + false, + ); +}); + +test("stored labels remain a fallback for unresolved members", () => { + const member = { + id: "member-1", + collection_id: "collection-1", + reference: { type: "external", url: "https://example.com/spec" }, + label: "Legacy design brief", + added_at: "2026-08-27T14:00:00Z", + }; + + assert.equal(collectionMemberDisplayLabel(member), "Legacy design brief"); +}); diff --git a/desktop/src/features/collections/memberDisplay.ts b/desktop/src/features/collections/memberDisplay.ts index 2c7a5d3fe1..2c64bcd5a5 100644 --- a/desktop/src/features/collections/memberDisplay.ts +++ b/desktop/src/features/collections/memberDisplay.ts @@ -34,15 +34,29 @@ export function collectionReferenceIdentity( } } +export function collectionMemberDisplayLabel( + member: CollectionMember, + channelNamesById: ReadonlyMap = new Map(), +): string { + if (member.reference.type === "channel") { + const channelName = channelNamesById + .get(member.reference.channel_id) + ?.trim(); + if (channelName) return channelName; + } + return member.label?.trim() || collectionReferenceIdentity(member.reference); +} + export function collectionMemberMatches( member: CollectionMember, search: string, type: CollectionReferenceType | "all", + channelNamesById: ReadonlyMap = new Map(), ): boolean { if (type !== "all" && member.reference.type !== type) return false; const query = search.trim().toLocaleLowerCase(); if (!query) return true; - return `${member.label ?? ""} ${collectionReferenceIdentity(member.reference)}` + return `${collectionMemberDisplayLabel(member, channelNamesById)} ${collectionReferenceIdentity(member.reference)}` .toLocaleLowerCase() .includes(query); } diff --git a/desktop/src/features/collections/messageCollectionAction.test.mjs b/desktop/src/features/collections/messageCollectionAction.test.mjs new file mode 100644 index 0000000000..77012d3e2f --- /dev/null +++ b/desktop/src/features/collections/messageCollectionAction.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { messageCollectionAction } from "./messageCollectionAction.ts"; + +const channelId = "channel-1"; + +test("a root event exposes only the thread Collection action", () => { + assert.deepEqual( + messageCollectionAction(channelId, { + depth: 0, + id: "root-1", + rootId: null, + tags: [], + }), + { + menuLabel: "Add thread to Collection", + reference: { + type: "thread", + channel_id: channelId, + root_event_id: "root-1", + }, + type: "thread", + }, + ); +}); + +test("a reply exposes only its direct message Collection action", () => { + assert.deepEqual( + messageCollectionAction(channelId, { + depth: 1, + id: "reply-1", + rootId: "root-1", + tags: [["e", "root-1", "", "root"]], + }), + { + menuLabel: "Add message to Collection", + reference: { + type: "message", + channel_id: channelId, + event_id: "reply-1", + }, + type: "message", + }, + ); +}); + +test("an explicit self root remains a thread action even when nested metadata is present", () => { + assert.equal( + messageCollectionAction(channelId, { + depth: 1, + id: "root-1", + rootId: "root-1", + tags: [["e", "root-1", "", "root"]], + }).type, + "thread", + ); +}); diff --git a/desktop/src/features/collections/messageCollectionAction.ts b/desktop/src/features/collections/messageCollectionAction.ts new file mode 100644 index 0000000000..2a1406fd3c --- /dev/null +++ b/desktop/src/features/collections/messageCollectionAction.ts @@ -0,0 +1,51 @@ +import { getThreadReference } from "@/features/messages/lib/threading"; +import type { TimelineMessage } from "@/features/messages/types"; + +import type { CollectionReference } from "./types"; + +type CollectionActionMessage = Pick< + TimelineMessage, + "depth" | "id" | "rootId" | "tags" +>; + +export type MessageCollectionAction = { + menuLabel: "Add message to Collection" | "Add thread to Collection"; + reference: CollectionReference; + type: "message" | "thread"; +}; + +/** + * Derives the single Collection action appropriate for a rendered message. + * Thread roots represent the whole thread; replies represent only themselves. + */ +export function messageCollectionAction( + channelId: string, + message: CollectionActionMessage, +): MessageCollectionAction { + const threadRootId = + message.rootId ?? + getThreadReference(message.tags ?? []).rootId ?? + (message.depth === 0 ? message.id : null); + + if (threadRootId === message.id) { + return { + menuLabel: "Add thread to Collection", + reference: { + type: "thread", + channel_id: channelId, + root_event_id: message.id, + }, + type: "thread", + }; + } + + return { + menuLabel: "Add message to Collection", + reference: { + type: "message", + channel_id: channelId, + event_id: message.id, + }, + type: "message", + }; +} diff --git a/desktop/src/features/collections/ui/AddCollectionReferenceForm.tsx b/desktop/src/features/collections/ui/AddCollectionReferenceForm.tsx index 7238903193..fb61272736 100644 --- a/desktop/src/features/collections/ui/AddCollectionReferenceForm.tsx +++ b/desktop/src/features/collections/ui/AddCollectionReferenceForm.tsx @@ -33,7 +33,6 @@ export function AddCollectionReferenceForm({ const [coordinate, setCoordinate] = React.useState(""); const [eventId, setEventId] = React.useState(""); const [url, setUrl] = React.useState(""); - const [label, setLabel] = React.useState(""); const submit = async (event: React.FormEvent) => { event.preventDefault(); @@ -48,14 +47,13 @@ export function AddCollectionReferenceForm({ return; } try { - await onAdd(result.reference, label.trim() || null); + await onAdd(result.reference, null); } catch { return; } setCoordinate(""); setEventId(""); setUrl(""); - setLabel(""); }; return ( @@ -63,7 +61,7 @@ export function AddCollectionReferenceForm({ className="mb-5 grid gap-3 rounded-xl border bg-card p-4" onSubmit={(event) => void submit(event)} > -
+
-
{type === "external" ? ( diff --git a/desktop/src/features/collections/ui/CollectionDetailRows.tsx b/desktop/src/features/collections/ui/CollectionDetailRows.tsx index 71d95ef357..91e8acc975 100644 --- a/desktop/src/features/collections/ui/CollectionDetailRows.tsx +++ b/desktop/src/features/collections/ui/CollectionDetailRows.tsx @@ -47,10 +47,9 @@ export function DerivedCollectionActivityRow({ activity.activityType === "document-edit" || activity.activityType === "document-comment"; const isPullRequest = activity.activityType === "github-pr"; - const isPullRequestActivity = activity.activityType === "github-pr-activity"; const identity = isMessage ? activity.eventId - : isDocumentActivity || isPullRequestActivity + : isDocumentActivity ? `${activity.url}:${activity.activityType}:${activity.createdAt}` : activity.url; const timestamp = activity.createdAt @@ -87,25 +86,8 @@ export function DerivedCollectionActivityRow({ {activity.label} {timestamp ? ` · ${timestamp}` : null}
- ) : isPullRequestActivity ? ( -
- {activity.actorLabel ?? "Unknown GitHub actor"}{" "} - {activity.kind.replace(/^PR /u, "")} - {activity.stateLabel ? ` · ${activity.stateLabel}` : null} - {timestamp ? ` · ${timestamp}` : null} -
) : isPullRequest ? ( -
- {activity.stateLabel ? ( - - {activity.stateLabel} - - ) : ( - "Mentioned PR" - )} - {activity.actorLabel ? ` by ${activity.actorLabel}` : null} - {timestamp ? ` · ${timestamp}` : null} -
+ ) : isMessage ? (
{actorLabel ?? "Unknown Buzz user"} @@ -172,6 +154,59 @@ export function DerivedCollectionActivityRow({ ); } +function PullRequestActivitySummary({ + activity, + timestamp, +}: { + activity: DerivedCollectionActivity; + timestamp: string | null; +}) { + const recent = activity.pullRequestActivity ?? []; + const counts = { comment: 0, merge: 0, review: 0 }; + for (const item of recent) counts[item.kind] += 1; + const countLabels = (["review", "comment", "merge"] as const).flatMap( + (kind) => + counts[kind] > 0 + ? [`${counts[kind]} ${kind}${counts[kind] === 1 ? "" : "s"}`] + : [], + ); + const latest = recent.reduce<(typeof recent)[number] | null>( + (current, item) => + current === null || item.createdAt > current.createdAt ? item : current, + null, + ); + const latestTimestamp = latest + ? new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(latest.createdAt * 1_000)) + : null; + + return ( +
+
+ {activity.stateLabel ? ( + {activity.stateLabel} + ) : ( + "Mentioned PR" + )} + {activity.actorLabel ? ` by ${activity.actorLabel}` : null} + {timestamp ? ` · ${timestamp}` : null} +
+ {countLabels.length > 0 ? ( +
{countLabels.join(" · ")}
+ ) : null} + {latest ? ( +
+ Latest: {latest.actorLabel ?? "Unknown GitHub actor"} {latest.kind} + {latest.stateLabel ? ` · ${latest.stateLabel}` : null} + {latestTimestamp ? ` · ${latestTimestamp}` : null} +
+ ) : null} +
+ ); +} + function ActivityTypeGlyph({ activity, }: { @@ -180,8 +215,7 @@ function ActivityTypeGlyph({ const Icon = activity.activityType === "calendar-meeting" ? CalendarDays - : activity.activityType === "github-pr" || - activity.activityType === "github-pr-activity" + : activity.activityType === "github-pr" ? GitPullRequest : activity.activityType === "calendar-document" ? CalendarDays @@ -251,23 +285,24 @@ function MeetingActivitySummary({ export function CollectionMemberRow({ canOpen, + displayLabel, isRemoving, member, onOpen, onRemove, }: { canOpen: boolean; + displayLabel: string; isRemoving: boolean; member: CollectionMember; onOpen: () => void; onRemove: () => Promise; }) { const identity = collectionReferenceIdentity(member.reference); - const label = member.label || identity; const content = (
- {label} + {displayLabel} {member.reference.type === "external" ? ( {new URL(member.reference.url).hostname} @@ -308,7 +343,7 @@ export function CollectionMemberRow({ ) : null}
diff --git a/desktop/src/features/collections/ui/CollectionDetailScreen.tsx b/desktop/src/features/collections/ui/CollectionDetailScreen.tsx index 41733a0f13..fc22e4b499 100644 --- a/desktop/src/features/collections/ui/CollectionDetailScreen.tsx +++ b/desktop/src/features/collections/ui/CollectionDetailScreen.tsx @@ -12,6 +12,7 @@ import * as React from "react"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useChannelsQuery } from "@/features/channels/hooks"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { truncatePubkey } from "@/shared/lib/pubkey"; import { useOpenEntityLink } from "@/shared/ui/markdown/entityLinks"; @@ -34,8 +35,8 @@ import { import { UserAvatar } from "@/shared/ui/UserAvatar"; import { COLLECTION_REFERENCE_TYPES, + collectionMemberDisplayLabel, collectionMemberMatches, - collectionReferenceIdentity, } from "../memberDisplay"; import { collectionMemberNavigationTarget } from "../memberNavigation"; import { @@ -43,7 +44,11 @@ import { useCollectionQuery, useDerivedCollectionLinks, } from "../hooks"; -import { projectCollectionActivity } from "../derivedLinks"; +import { + filterCollectionActivityByType, + projectCollectionActivity, + type CollectionActivityTypeFilter, +} from "../derivedLinks"; import type { CollectionReference, CollectionReferenceType } from "../types"; import { useCollectionScope } from "../useCollectionScope"; import { AddCollectionReferenceForm } from "./AddCollectionReferenceForm"; @@ -54,6 +59,16 @@ import { } from "./CollectionDetailRows"; const PEOPLE_PREVIEW_LIMIT = 8; +const ACTIVITY_TYPE_FILTERS: ReadonlyArray<{ + label: string; + value: CollectionActivityTypeFilter; +}> = [ + { label: "All activity", value: "all" }, + { label: "Messages & threads", value: "messages" }, + { label: "Pull requests", value: "pull-requests" }, + { label: "Meetings", value: "meetings" }, + { label: "Documents", value: "documents" }, +]; import { CollectionGlyph } from "./CollectionGlyph"; import { CollectionIconDialog } from "./CollectionIconDialog"; @@ -64,6 +79,7 @@ export function CollectionDetailScreen({ }) { const scope = useCollectionScope(); const collectionQuery = useCollectionQuery(scope, collectionId); + const channelsQuery = useChannelsQuery(); const { addMember, remove, removeMember, setIcon } = useCollectionMutations(scope); const navigate = useNavigate(); @@ -78,11 +94,23 @@ export function CollectionDetailScreen({ const [activeTab, setActiveTab] = React.useState< "overview" | "activity" | "sources" >("overview"); + const [activityType, setActivityType] = + React.useState("all"); const [type, setType] = React.useState( "all", ); const data = collectionQuery.data; - const derived = useDerivedCollectionLinks(data?.members ?? []); + const channelNamesById = React.useMemo( + () => + new Map( + (channelsQuery.data ?? []).map((channel) => [channel.id, channel.name]), + ), + [channelsQuery.data], + ); + const derived = useDerivedCollectionLinks( + data?.members ?? [], + channelNamesById, + ); const participantIdentities = [ ...new Set( derived.links.flatMap((activity) => @@ -107,10 +135,7 @@ export function CollectionDetailScreen({ const hiddenPeopleCount = peopleIdentities.length - visiblePeopleIdentities.length; const members = (data?.members ?? []).filter((member) => - collectionMemberMatches(member, search, type), - ); - const pullRequests = derived.links.filter( - (activity) => activity.activityType === "github-pr", + collectionMemberMatches(member, search, type, channelNamesById), ); const projectedActivity = projectCollectionActivity( derived.links, @@ -130,6 +155,10 @@ export function CollectionDetailScreen({ activity.actorIdentities?.includes(selectedPerson), ) : projectedActivity; + const visibleActivity = + activeTab === "activity" + ? filterCollectionActivityByType(filteredActivity, activityType) + : filteredActivity; const sourceGroups = [ { label: "Channels", @@ -192,7 +221,7 @@ export function CollectionDetailScreen({ const member = data?.members.find((candidate) => candidate.id === memberId); return { label: member - ? member.label || collectionReferenceIdentity(member.reference) + ? collectionMemberDisplayLabel(member, channelNamesById) : "source", memberId, }; @@ -403,9 +432,10 @@ export function CollectionDetailScreen({ const target = collectionMemberNavigationTarget( member.reference, ); - const label = - member.label || - collectionReferenceIdentity(member.reference); + const label = collectionMemberDisplayLabel( + member, + channelNamesById, + ); const canOpen = target !== null; return (
))} - - {pullRequests.length > 0 ? ( -
-
-

Pull requests

-

- Pull requests mentioned by explicit thread and message - sources. -

-
-
- {pullRequests.map((activity) => ( - {}} - onRemoveSource={() => { - const source = sourceDetails(activity.sourceMemberId); - return removeSource( - source.memberId, - source.label, - true, - ); - }} - /> - ))} -
-
- ) : null} ) : null} {activeTab !== "sources" ? (
-
-

- {activeTab === "overview" ? "Recent activity" : "Activity"} -

-

- Recent conversations and live context derived from your sources. -

+
+
+

+ {activeTab === "overview" ? "Recent activity" : "Activity"} +

+

+ Recent conversations and live context derived from your + sources. +

+
+ {activeTab === "activity" ? ( + + ) : null}
{(activeTab === "overview" - ? filteredActivity.slice(0, 5) - : filteredActivity + ? visibleActivity.slice(0, 5) + : visibleActivity ).map((activity) => ( ))} - {filteredActivity.length === 0 ? ( + {visibleActivity.length === 0 ? (
{selectedPerson ? "No activity from this person was found." - : derived.isFetching - ? "Loading activity…" - : data.members.length === 0 - ? "Add a source to discover collection activity." - : "No recent activity was found across linked sources."} + : activeTab === "activity" && activityType !== "all" + ? `No ${ACTIVITY_TYPE_FILTERS.find((filter) => filter.value === activityType)?.label.toLocaleLowerCase()} were found.` + : derived.isFetching + ? "Loading activity…" + : data.members.length === 0 + ? "Add a source to discover collection activity." + : "No recent activity was found across linked sources."}
) : null}
- {activeTab === "overview" && filteredActivity.length > 5 ? ( + {activeTab === "overview" && visibleActivity.length > 5 ? (