mirror of
https://github.com/block/buzz.git
synced 2026-11-07 09:52:03 +00:00
feat(collections): refine activity and member actions
Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
@@ -92,7 +92,7 @@ export function ChannelScreenHeader({
|
||||
reference={{ type: "channel", channel_id: activeChannel.id }}
|
||||
/>
|
||||
<AddToCollection
|
||||
label={activeChannelTitle}
|
||||
label={activeChannel.name}
|
||||
reference={{ type: "channel", channel_id: activeChannel.id }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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, string>,
|
||||
): 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<ReturnType<typeof getChannelWindowEvents>>,
|
||||
channelNamesById: ReadonlyMap<string, string>,
|
||||
): 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<Input, Output>(
|
||||
async function resolveMentionedPullRequests(
|
||||
membersById: ReadonlyMap<string, CollectionMember>,
|
||||
items: DerivedCollectionActivity[],
|
||||
channelNamesById: ReadonlyMap<string, string>,
|
||||
): 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<string, string>,
|
||||
): 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<string, string> = 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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -34,15 +34,29 @@ export function collectionReferenceIdentity(
|
||||
}
|
||||
}
|
||||
|
||||
export function collectionMemberDisplayLabel(
|
||||
member: CollectionMember,
|
||||
channelNamesById: ReadonlyMap<string, string> = 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<string, string> = 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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
51
desktop/src/features/collections/messageCollectionAction.ts
Normal file
51
desktop/src/features/collections/messageCollectionAction.ts
Normal file
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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)}
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-3">
|
||||
<label
|
||||
className="grid gap-1 text-sm"
|
||||
htmlFor="collection-reference-type"
|
||||
@@ -87,19 +85,6 @@ export function AddCollectionReferenceForm({
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label
|
||||
className="grid gap-1 text-sm"
|
||||
htmlFor="collection-reference-label"
|
||||
>
|
||||
<span className="font-medium">Label</span>
|
||||
<Input
|
||||
aria-label="Reference label"
|
||||
id="collection-reference-label"
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
placeholder="Optional"
|
||||
value={label}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{type === "external" ? (
|
||||
|
||||
@@ -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({
|
||||
<span className="font-medium">{activity.label}</span>
|
||||
{timestamp ? ` · ${timestamp}` : null}
|
||||
</div>
|
||||
) : isPullRequestActivity ? (
|
||||
<div className="mt-1 text-sm">
|
||||
{activity.actorLabel ?? "Unknown GitHub actor"}{" "}
|
||||
{activity.kind.replace(/^PR /u, "")}
|
||||
{activity.stateLabel ? ` · ${activity.stateLabel}` : null}
|
||||
{timestamp ? ` · ${timestamp}` : null}
|
||||
</div>
|
||||
) : isPullRequest ? (
|
||||
<div className="mt-1 text-sm">
|
||||
{activity.stateLabel ? (
|
||||
<span className="font-medium capitalize">
|
||||
{activity.stateLabel}
|
||||
</span>
|
||||
) : (
|
||||
"Mentioned PR"
|
||||
)}
|
||||
{activity.actorLabel ? ` by ${activity.actorLabel}` : null}
|
||||
{timestamp ? ` · ${timestamp}` : null}
|
||||
</div>
|
||||
<PullRequestActivitySummary activity={activity} timestamp={timestamp} />
|
||||
) : isMessage ? (
|
||||
<div className="mt-1 text-sm">
|
||||
{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 (
|
||||
<div className="mt-1 space-y-1 text-sm">
|
||||
<div>
|
||||
{activity.stateLabel ? (
|
||||
<span className="font-medium capitalize">{activity.stateLabel}</span>
|
||||
) : (
|
||||
"Mentioned PR"
|
||||
)}
|
||||
{activity.actorLabel ? ` by ${activity.actorLabel}` : null}
|
||||
{timestamp ? ` · ${timestamp}` : null}
|
||||
</div>
|
||||
{countLabels.length > 0 ? (
|
||||
<div className="text-muted-foreground">{countLabels.join(" · ")}</div>
|
||||
) : null}
|
||||
{latest ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Latest: {latest.actorLabel ?? "Unknown GitHub actor"} {latest.kind}
|
||||
{latest.stateLabel ? ` · ${latest.stateLabel}` : null}
|
||||
{latestTimestamp ? ` · ${latestTimestamp}` : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<void>;
|
||||
}) {
|
||||
const identity = collectionReferenceIdentity(member.reference);
|
||||
const label = member.label || identity;
|
||||
const content = (
|
||||
<div className="flex items-center gap-2">
|
||||
<SourceTypeGlyph type={member.reference.type} />
|
||||
<span className="truncate text-sm font-medium">{label}</span>
|
||||
<span className="truncate text-sm font-medium">{displayLabel}</span>
|
||||
{member.reference.type === "external" ? (
|
||||
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{new URL(member.reference.url).hostname}
|
||||
@@ -308,7 +343,7 @@ export function CollectionMemberRow({
|
||||
) : null}
|
||||
<CollectionRowActions
|
||||
disabled={isRemoving}
|
||||
label={member.label ?? member.reference.type}
|
||||
label={displayLabel}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<CollectionActivityTypeFilter>("all");
|
||||
const [type, setType] = React.useState<CollectionReferenceType | "all">(
|
||||
"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 (
|
||||
<div
|
||||
@@ -536,54 +566,44 @@ export function CollectionDetailScreen({
|
||||
failed for {warning.sourceLabel}: {warning.message}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pullRequests.length > 0 ? (
|
||||
<section className="mb-8" data-testid="collection-pull-requests">
|
||||
<div className="mb-3">
|
||||
<h2 className="text-lg font-semibold">Pull requests</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pull requests mentioned by explicit thread and message
|
||||
sources.
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y rounded-xl border bg-card">
|
||||
{pullRequests.map((activity) => (
|
||||
<DerivedCollectionActivityRow
|
||||
activity={activity}
|
||||
actorAvatarUrl={activity.actorAvatarUrl}
|
||||
actorLabel={activity.actorLabel}
|
||||
key={`${activity.url}:${activity.sourceMemberId}`}
|
||||
onOpenMessage={() => {}}
|
||||
onRemoveSource={() => {
|
||||
const source = sourceDetails(activity.sourceMemberId);
|
||||
return removeSource(
|
||||
source.memberId,
|
||||
source.label,
|
||||
true,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{activeTab !== "sources" ? (
|
||||
<section className="mb-8" data-testid="collection-activity">
|
||||
<div className="mb-3">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{activeTab === "overview" ? "Recent activity" : "Activity"}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Recent conversations and live context derived from your sources.
|
||||
</p>
|
||||
<div className="mb-3 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{activeTab === "overview" ? "Recent activity" : "Activity"}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Recent conversations and live context derived from your
|
||||
sources.
|
||||
</p>
|
||||
</div>
|
||||
{activeTab === "activity" ? (
|
||||
<select
|
||||
aria-label="Filter activity by type"
|
||||
className="h-8 shrink-0 rounded-lg border border-input/40 bg-background px-2 text-sm"
|
||||
onChange={(event) =>
|
||||
setActivityType(
|
||||
event.target.value as CollectionActivityTypeFilter,
|
||||
)
|
||||
}
|
||||
value={activityType}
|
||||
>
|
||||
{ACTIVITY_TYPE_FILTERS.map((filter) => (
|
||||
<option key={filter.value} value={filter.value}>
|
||||
{filter.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="divide-y rounded-xl border bg-card">
|
||||
{(activeTab === "overview"
|
||||
? filteredActivity.slice(0, 5)
|
||||
: filteredActivity
|
||||
? visibleActivity.slice(0, 5)
|
||||
: visibleActivity
|
||||
).map((activity) => (
|
||||
<DerivedCollectionActivityRow
|
||||
activity={activity}
|
||||
@@ -618,19 +638,21 @@ export function CollectionDetailScreen({
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{filteredActivity.length === 0 ? (
|
||||
{visibleActivity.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-muted-foreground">
|
||||
{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."}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{activeTab === "overview" && filteredActivity.length > 5 ? (
|
||||
{activeTab === "overview" && visibleActivity.length > 5 ? (
|
||||
<Button
|
||||
className="mt-3"
|
||||
onClick={() => setActiveTab("activity")}
|
||||
@@ -673,6 +695,10 @@ export function CollectionDetailScreen({
|
||||
return (
|
||||
<CollectionMemberRow
|
||||
canOpen={target !== null}
|
||||
displayLabel={collectionMemberDisplayLabel(
|
||||
member,
|
||||
channelNamesById,
|
||||
)}
|
||||
isRemoving={removeMember.isPending}
|
||||
key={member.id}
|
||||
member={member}
|
||||
@@ -680,8 +706,10 @@ export function CollectionDetailScreen({
|
||||
onRemove={() =>
|
||||
removeSource(
|
||||
member.id,
|
||||
member.label ||
|
||||
collectionReferenceIdentity(member.reference),
|
||||
collectionMemberDisplayLabel(
|
||||
member,
|
||||
channelNamesById,
|
||||
),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
MailOpen,
|
||||
Pencil,
|
||||
SmilePlus,
|
||||
MessagesSquare,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -20,6 +19,7 @@ import { toast } from "sonner";
|
||||
|
||||
import { buildMessageLink } from "@/features/messages/lib/messageLink";
|
||||
import { collectionMessageLabel } from "@/features/collections/messageLabel";
|
||||
import { messageCollectionAction } from "@/features/collections/messageCollectionAction";
|
||||
import { AddToCollection } from "@/features/collections/ui/AddToCollection";
|
||||
import { MessageCollectionMembershipBadges } from "@/features/collections/ui/MessageCollectionMembershipBadges";
|
||||
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
|
||||
@@ -120,9 +120,8 @@ function MoreActionsMenu({
|
||||
}) {
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
|
||||
const [isReportDialogOpen, setIsReportDialogOpen] = React.useState(false);
|
||||
const [collectionTarget, setCollectionTarget] = React.useState<
|
||||
"message" | "thread" | null
|
||||
>(null);
|
||||
const [isCollectionDialogOpen, setIsCollectionDialogOpen] =
|
||||
React.useState(false);
|
||||
// Set true the moment the user picks "Edit message". The
|
||||
// `onCloseAutoFocus` handler on `DropdownMenuContent` reads it to
|
||||
// suppress Radix's default focus-restoration (which would yank focus
|
||||
@@ -147,11 +146,10 @@ function MoreActionsMenu({
|
||||
!message.pending &&
|
||||
message.kind !== KIND_HUDDLE_STARTED &&
|
||||
Boolean(channelId);
|
||||
const threadRootId =
|
||||
message.rootId ??
|
||||
getThreadReference(message.tags ?? []).rootId ??
|
||||
(message.depth === 0 ? message.id : null);
|
||||
const collectionLabel = collectionMessageLabel(message.body, message.author);
|
||||
const collectionAction = channelId
|
||||
? messageCollectionAction(channelId, message)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -302,21 +300,12 @@ function MoreActionsMenu({
|
||||
<FeatureGate feature="collections">
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
data-testid={`add-message-to-collection-${message.id}`}
|
||||
onSelect={() => setCollectionTarget("message")}
|
||||
data-testid={`add-${collectionAction?.type}-to-collection-${message.id}`}
|
||||
onSelect={() => setIsCollectionDialogOpen(true)}
|
||||
>
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
Add message to collection
|
||||
{collectionAction?.menuLabel}
|
||||
</DropdownMenuItem>
|
||||
{threadRootId ? (
|
||||
<DropdownMenuItem
|
||||
data-testid={`add-thread-to-collection-${message.id}`}
|
||||
onSelect={() => setCollectionTarget("thread")}
|
||||
>
|
||||
<MessagesSquare className="h-4 w-4" />
|
||||
Add thread to collection
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</FeatureGate>
|
||||
) : null}
|
||||
|
||||
@@ -356,36 +345,16 @@ function MoreActionsMenu({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{canAddToCollection && channelId && collectionTarget === "message" ? (
|
||||
{canAddToCollection && collectionAction && isCollectionDialogOpen ? (
|
||||
<AddToCollection
|
||||
label={collectionLabel}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) setCollectionTarget(null);
|
||||
}}
|
||||
label={
|
||||
collectionAction.type === "thread"
|
||||
? `Thread: ${collectionLabel}`
|
||||
: collectionLabel
|
||||
}
|
||||
onOpenChange={setIsCollectionDialogOpen}
|
||||
open
|
||||
reference={{
|
||||
type: "message",
|
||||
channel_id: channelId,
|
||||
event_id: message.id,
|
||||
}}
|
||||
trigger={false}
|
||||
/>
|
||||
) : null}
|
||||
{canAddToCollection &&
|
||||
channelId &&
|
||||
threadRootId &&
|
||||
collectionTarget === "thread" ? (
|
||||
<AddToCollection
|
||||
label={`Thread: ${collectionLabel}`}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) setCollectionTarget(null);
|
||||
}}
|
||||
open
|
||||
reference={{
|
||||
type: "thread",
|
||||
channel_id: channelId,
|
||||
root_event_id: threadRootId,
|
||||
}}
|
||||
reference={collectionAction.reference}
|
||||
trigger={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -159,13 +159,12 @@ test("creates a collection and adds supported reference types", async ({
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await expect(page.getByLabel("Reference label")).toHaveCount(0);
|
||||
await page.getByLabel("External URL").fill("https://example.com/brief");
|
||||
await page.getByLabel("Reference label").fill("Design brief");
|
||||
await page.getByRole("button", { name: "Add external link" }).click();
|
||||
await page.getByRole("tab", { name: "Sources" }).click();
|
||||
|
||||
const row = page.getByTestId("collection-member-collection-member-1");
|
||||
await expect(row).toContainText("Design brief");
|
||||
await expect(row).toContainText("https://example.com/brief");
|
||||
|
||||
await page.getByLabel("Search collection").fill("missing");
|
||||
@@ -179,7 +178,9 @@ test("creates a collection and adds supported reference types", async ({
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await row.getByRole("button", { name: "Actions for Design brief" }).click();
|
||||
await row
|
||||
.getByRole("button", { name: "Actions for https://example.com/brief" })
|
||||
.click();
|
||||
await page.getByRole("menuitem", { name: "Remove from Collection" }).click();
|
||||
await expect(
|
||||
page.getByText("No sources in this collection yet."),
|
||||
@@ -188,26 +189,22 @@ test("creates a collection and adds supported reference types", async ({
|
||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await page.getByLabel("Reference type").selectOption("repository");
|
||||
await page.getByLabel("Repository coordinate").fill(repositoryCoordinate);
|
||||
await page.getByLabel("Reference label").fill("Buzz repository");
|
||||
await page.getByRole("button", { name: "Add repository" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await page.getByLabel("Reference type").selectOption("task");
|
||||
await page.getByLabel("Repository coordinate").fill(repositoryCoordinate);
|
||||
await page.getByLabel("Task event ID").fill(taskEventId);
|
||||
await page.getByLabel("Reference label").fill("Prototype task");
|
||||
await page.getByRole("button", { name: "Add repository task" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await page.getByLabel("Reference type").selectOption("note");
|
||||
await page.getByLabel("Note coordinate").fill(noteCoordinate);
|
||||
await page.getByLabel("Reference label").fill("Design note");
|
||||
await page.getByRole("button", { name: "Add note" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await page.getByLabel("Reference type").selectOption("external");
|
||||
await page.getByLabel("External URL").fill(calendarUrl);
|
||||
await page.getByLabel("Reference label").fill("Planning event");
|
||||
await page.getByRole("button", { name: "Add external link" }).click();
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
@@ -249,7 +246,7 @@ test("creates a collection and adds supported reference types", async ({
|
||||
const messageRow = page.getByTestId("message-row").first();
|
||||
await messageRow.hover();
|
||||
await messageRow.locator('[data-testid^="more-actions-"]').click();
|
||||
await page.getByText("Add thread to collection", { exact: true }).click();
|
||||
await page.getByText("Add thread to Collection", { exact: true }).click();
|
||||
await page
|
||||
.getByTestId("add-to-collection-dialog")
|
||||
.getByRole("button", { name: "Bird Voice" })
|
||||
@@ -291,6 +288,9 @@ test("creates a collection and adds supported reference types", async ({
|
||||
sources.getByRole("heading", { name: "External" }),
|
||||
).toBeVisible();
|
||||
await expect(sources.getByLabel("channel source")).toBeVisible();
|
||||
await expect(
|
||||
sources.getByRole("button", { name: "Actions for general" }),
|
||||
).toBeVisible();
|
||||
await expect(sources.getByLabel("repository source")).toBeVisible();
|
||||
const sourceDetails = sources.getByText("Details").first();
|
||||
await sourceDetails.click();
|
||||
@@ -301,9 +301,9 @@ test("creates a collection and adds supported reference types", async ({
|
||||
`collection-derived-${encodeURIComponent(githubPullRequestUrl)}`,
|
||||
);
|
||||
await expect(pullRequest).toHaveCount(1);
|
||||
await expect(page.getByTestId("collection-pull-requests")).toContainText(
|
||||
"Live collections activity feed",
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Pull requests" }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId("collection-activity")).not.toContainText(
|
||||
"Thread status for Bird Voice",
|
||||
);
|
||||
@@ -316,6 +316,10 @@ test("creates a collection and adds supported reference types", async ({
|
||||
await expect(pullRequest.getByLabel(/^Derived from /)).toBeVisible();
|
||||
await expect(pullRequest).toContainText("Live collections activity feed");
|
||||
await expect(pullRequest).toContainText("open by mock-author");
|
||||
await expect(pullRequest).toContainText("1 review · 1 comment");
|
||||
await expect(pullRequest).toContainText(
|
||||
"Latest: mock-reviewer review · approved",
|
||||
);
|
||||
await expect(pullRequest).toContainText("mentioned PR");
|
||||
await expect(pullRequest.getByAltText("mock-author avatar")).toBeVisible();
|
||||
await pullRequest
|
||||
@@ -330,11 +334,11 @@ test("creates a collection and adds supported reference types", async ({
|
||||
`collection-derived-${encodeURIComponent(calendarUrl)}`,
|
||||
);
|
||||
await expect(calendarMeeting.getByLabel(/^Derived from /)).toBeVisible();
|
||||
await expect(calendarMeeting).toContainText("Planning event");
|
||||
await expect(calendarMeeting).toContainText(calendarUrl);
|
||||
await expect(calendarMeeting).toContainText(mockDesignDocumentTitle);
|
||||
await expect(calendarMeeting).toContainText("1 edit · 1 comment");
|
||||
await calendarMeeting
|
||||
.getByRole("button", { name: "Actions for Planning event" })
|
||||
.getByRole("button", { name: `Actions for ${calendarUrl}` })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Remove source from Collection" }),
|
||||
@@ -358,6 +362,21 @@ test("creates a collection and adds supported reference types", async ({
|
||||
.getByRole("button", { name: mockDesignDocumentTitle })
|
||||
.click();
|
||||
await page.getByRole("tab", { name: "Activity" }).click();
|
||||
await expect(page.getByLabel("Filter activity by type")).toHaveValue("all");
|
||||
await page
|
||||
.getByLabel("Filter activity by type")
|
||||
.selectOption("pull-requests");
|
||||
await expect(pullRequest).toHaveCount(1);
|
||||
await expect(page.getByTestId("collection-activity")).not.toContainText(
|
||||
"Unrelated channel update",
|
||||
);
|
||||
await expect(calendarMeeting).toHaveCount(0);
|
||||
await page.getByLabel("Filter activity by type").selectOption("messages");
|
||||
await expect(page.getByTestId("collection-activity")).toContainText(
|
||||
"Unrelated channel update",
|
||||
);
|
||||
await expect(pullRequest).toHaveCount(0);
|
||||
await page.getByLabel("Filter activity by type").selectOption("meetings");
|
||||
await expect(calendarMeeting).toHaveCount(1);
|
||||
await expect(calendarMeeting).toContainText("1 edit · 1 comment");
|
||||
await expect(calendarMeeting).toContainText("Aug 26, 2026");
|
||||
@@ -372,12 +391,12 @@ test("creates a collection and adds supported reference types", async ({
|
||||
await expect(calendarMeeting).toBeVisible();
|
||||
await expect(calendarMeeting).toContainText(mockDesignDocumentTitle);
|
||||
await page.getByRole("tab", { name: "Overview" }).click();
|
||||
await expect(page.getByTestId("collection-pull-requests")).toContainText(
|
||||
"Live collections activity feed",
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Pull requests" }),
|
||||
).toHaveCount(0);
|
||||
await mockEditor.click();
|
||||
await expect(mockEditor).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(calendarMeeting).toContainText("Planning event");
|
||||
await expect(calendarMeeting).toContainText(calendarUrl);
|
||||
const commands = await page.evaluate(
|
||||
() => window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [],
|
||||
);
|
||||
@@ -446,24 +465,24 @@ test("creates a collection and adds supported reference types", async ({
|
||||
const quickNavigation = page.getByTestId("collection-quick-navigation");
|
||||
page.once("dialog", (dialog) => dialog.dismiss());
|
||||
await quickNavigation
|
||||
.getByRole("button", { name: "Actions for Buzz repository" })
|
||||
.getByRole("button", { name: `Actions for ${repositoryCoordinate}` })
|
||||
.click();
|
||||
await page.getByRole("menuitem", { name: "Remove from Collection" }).click();
|
||||
await expect(quickNavigation.getByText("Buzz repository")).toBeVisible();
|
||||
await expect(quickNavigation.getByText(repositoryCoordinate)).toBeVisible();
|
||||
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await quickNavigation
|
||||
.getByRole("button", { name: "Actions for Buzz repository" })
|
||||
.getByRole("button", { name: `Actions for ${repositoryCoordinate}` })
|
||||
.click();
|
||||
await page.getByRole("menuitem", { name: "Remove from Collection" }).click();
|
||||
await expect(quickNavigation.getByText("Buzz repository")).toHaveCount(0);
|
||||
await expect(quickNavigation.getByText(repositoryCoordinate)).toHaveCount(0);
|
||||
|
||||
page.once("dialog", (dialog) => {
|
||||
expect(dialog.message()).toContain("Related discovered activity");
|
||||
return dialog.accept();
|
||||
});
|
||||
await calendarMeeting
|
||||
.getByRole("button", { name: "Actions for Planning event" })
|
||||
.getByRole("button", { name: `Actions for ${calendarUrl}` })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("menuitem", { name: "Remove source from Collection" })
|
||||
@@ -508,18 +527,21 @@ test("surfaces Collection source removal errors", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Create", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await page.getByLabel("External URL").fill("https://example.com/locked");
|
||||
await page.getByLabel("Reference label").fill("Locked source");
|
||||
await page.getByRole("button", { name: "Add external link" }).click();
|
||||
await page.getByRole("tab", { name: "Sources" }).click();
|
||||
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page
|
||||
.getByRole("button", { name: "Actions for Locked source" })
|
||||
.getByRole("button", {
|
||||
name: "Actions for https://example.com/locked",
|
||||
})
|
||||
.last()
|
||||
.click();
|
||||
await page.getByRole("menuitem", { name: "Remove from Collection" }).click();
|
||||
await expect(page.getByText("Source is temporarily locked")).toBeVisible();
|
||||
await expect(page.getByText("Locked source").first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("https://example.com/locked").first(),
|
||||
).toBeVisible();
|
||||
|
||||
const sidebarItem = page.getByTestId("sidebar-collection-collection-1");
|
||||
await sidebarItem.click({ button: "right" });
|
||||
@@ -598,7 +620,10 @@ test("shows exact message and thread Collection memberships", async ({
|
||||
let rootRow = page.locator('[data-message-id="mock-general-welcome"]');
|
||||
await rootRow.hover();
|
||||
await rootRow.getByRole("button", { name: "More actions" }).click();
|
||||
await page.getByText("Add thread to collection", { exact: true }).click();
|
||||
await expect(
|
||||
page.getByText("Add message to Collection", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await page.getByText("Add thread to Collection", { exact: true }).click();
|
||||
await page
|
||||
.getByTestId("add-to-collection-dialog")
|
||||
.getByRole("button", { name: "Alpha work" })
|
||||
@@ -617,7 +642,10 @@ test("shows exact message and thread Collection memberships", async ({
|
||||
rootRow = page.locator('[data-message-id="mock-general-welcome"]');
|
||||
await rootRow.hover();
|
||||
await rootRow.getByRole("button", { name: "More actions" }).click();
|
||||
await page.getByText("Add thread to collection", { exact: true }).click();
|
||||
await expect(
|
||||
page.getByText("Add message to Collection", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await page.getByText("Add thread to Collection", { exact: true }).click();
|
||||
await page
|
||||
.getByTestId("add-to-collection-dialog")
|
||||
.getByRole("button", { name: "Beta work" })
|
||||
@@ -650,7 +678,10 @@ test("shows exact message and thread Collection memberships", async ({
|
||||
await expect(replyRow).toBeVisible();
|
||||
await replyRow.hover();
|
||||
await replyRow.getByRole("button", { name: "More actions" }).click();
|
||||
await page.getByText("Add message to collection", { exact: true }).click();
|
||||
await expect(
|
||||
page.getByText("Add thread to Collection", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await page.getByText("Add message to Collection", { exact: true }).click();
|
||||
await page
|
||||
.getByTestId("add-to-collection-dialog")
|
||||
.getByRole("button", { name: "Alpha work" })
|
||||
@@ -676,7 +707,7 @@ test("shows exact message and thread Collection memberships", async ({
|
||||
.filter({ hasText: "Collection membership reply" });
|
||||
await replyRow.hover();
|
||||
await replyRow.getByRole("button", { name: "More actions" }).click();
|
||||
await page.getByText("Add message to collection", { exact: true }).click();
|
||||
await page.getByText("Add message to Collection", { exact: true }).click();
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page
|
||||
.getByTestId("add-to-collection-dialog")
|
||||
@@ -701,12 +732,11 @@ test("scopes Calendar discovery warnings to their source", async ({ page }) => {
|
||||
|
||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await page.getByLabel("External URL").fill(failingCalendarUrl);
|
||||
await page.getByLabel("Reference label").fill("Planning calendar");
|
||||
await page.getByRole("button", { name: "Add external link" }).click();
|
||||
|
||||
const warning = page.locator('[data-testid^="collection-calendar-warning-"]');
|
||||
await expect(warning).toContainText(
|
||||
"Calendar discovery failed for Planning calendar: Calendar account needs attention",
|
||||
`Calendar discovery failed for ${failingCalendarUrl}: Calendar account needs attention`,
|
||||
);
|
||||
await expect(
|
||||
page.getByText("No recent activity was found across linked sources."),
|
||||
@@ -734,7 +764,7 @@ test("distinguishes explicit source failures from no activity", async ({
|
||||
.filter({ hasText: "Broken activity source" });
|
||||
await row.hover();
|
||||
await row.locator('[data-testid^="more-actions-"]').click();
|
||||
await page.getByText("Add thread to collection", { exact: true }).click();
|
||||
await page.getByText("Add thread to Collection", { exact: true }).click();
|
||||
await page
|
||||
.getByTestId("add-to-collection-dialog")
|
||||
.getByRole("button", { name: "Source health" })
|
||||
|
||||
@@ -27,11 +27,13 @@
|
||||
- Keep Collections separate from Projects and local to one machine, with no relay changes.
|
||||
- Share persisted state between the desktop app and CLI without reading or copying installed production data during ordinary development.
|
||||
- Support adding and removing channels, repositories, repository-linked tasks, threads, messages, notes, and external references.
|
||||
- Resolve source titles from their native objects when available; do not expose editable member aliases yet.
|
||||
- Provide a lightweight home for overview, activity, and source management.
|
||||
- Show Collection membership on associated Buzz objects; access files through their messages rather than as standalone members.
|
||||
|
||||
## Later ideas
|
||||
|
||||
- If collections prove to be the better model, replace the current Projects model and consider presenting collections as Projects.
|
||||
- Allow optional alias and notes metadata on a membership.
|
||||
- Support standalone tasks that do not require a repository.
|
||||
- Make documents and files first-class Buzz objects that can be uploaded directly or linked from external systems and added to collections.
|
||||
|
||||
Reference in New Issue
Block a user