feat(collections): refine activity and member actions

Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
John Tennant
2026-08-27 18:52:37 -04:00
parent b3bfe98656
commit da197f53f3
14 changed files with 553 additions and 265 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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",
};
}

View File

@@ -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" ? (

View File

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

View File

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

View File

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

View File

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

View File

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