+
-
- Label
- setLabel(event.target.value)}
- placeholder="Optional"
- value={label}
- />
-
{type === "external" ? (
diff --git a/desktop/src/features/collections/ui/CollectionDetailRows.tsx b/desktop/src/features/collections/ui/CollectionDetailRows.tsx
index 71d95ef357..91e8acc975 100644
--- a/desktop/src/features/collections/ui/CollectionDetailRows.tsx
+++ b/desktop/src/features/collections/ui/CollectionDetailRows.tsx
@@ -47,10 +47,9 @@ export function DerivedCollectionActivityRow({
activity.activityType === "document-edit" ||
activity.activityType === "document-comment";
const isPullRequest = activity.activityType === "github-pr";
- const isPullRequestActivity = activity.activityType === "github-pr-activity";
const identity = isMessage
? activity.eventId
- : isDocumentActivity || isPullRequestActivity
+ : isDocumentActivity
? `${activity.url}:${activity.activityType}:${activity.createdAt}`
: activity.url;
const timestamp = activity.createdAt
@@ -87,25 +86,8 @@ export function DerivedCollectionActivityRow({
{activity.label}
{timestamp ? ` · ${timestamp}` : null}
- ) : isPullRequestActivity ? (
-
- {activity.actorLabel ?? "Unknown GitHub actor"}{" "}
- {activity.kind.replace(/^PR /u, "")}
- {activity.stateLabel ? ` · ${activity.stateLabel}` : null}
- {timestamp ? ` · ${timestamp}` : null}
-
) : isPullRequest ? (
-
{actorLabel ?? "Unknown Buzz user"}
@@ -172,6 +154,59 @@ export function DerivedCollectionActivityRow({
);
}
+function PullRequestActivitySummary({
+ activity,
+ timestamp,
+}: {
+ activity: DerivedCollectionActivity;
+ timestamp: string | null;
+}) {
+ const recent = activity.pullRequestActivity ?? [];
+ const counts = { comment: 0, merge: 0, review: 0 };
+ for (const item of recent) counts[item.kind] += 1;
+ const countLabels = (["review", "comment", "merge"] as const).flatMap(
+ (kind) =>
+ counts[kind] > 0
+ ? [`${counts[kind]} ${kind}${counts[kind] === 1 ? "" : "s"}`]
+ : [],
+ );
+ const latest = recent.reduce<(typeof recent)[number] | null>(
+ (current, item) =>
+ current === null || item.createdAt > current.createdAt ? item : current,
+ null,
+ );
+ const latestTimestamp = latest
+ ? new Intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(new Date(latest.createdAt * 1_000))
+ : null;
+
+ return (
+
+
+ {activity.stateLabel ? (
+ {activity.stateLabel}
+ ) : (
+ "Mentioned PR"
+ )}
+ {activity.actorLabel ? ` by ${activity.actorLabel}` : null}
+ {timestamp ? ` · ${timestamp}` : null}
+
+ {countLabels.length > 0 ? (
+
{countLabels.join(" · ")}
+ ) : null}
+ {latest ? (
+
+ Latest: {latest.actorLabel ?? "Unknown GitHub actor"} {latest.kind}
+ {latest.stateLabel ? ` · ${latest.stateLabel}` : null}
+ {latestTimestamp ? ` · ${latestTimestamp}` : null}
+
+ ) : null}
+
+ );
+}
+
function ActivityTypeGlyph({
activity,
}: {
@@ -180,8 +215,7 @@ function ActivityTypeGlyph({
const Icon =
activity.activityType === "calendar-meeting"
? CalendarDays
- : activity.activityType === "github-pr" ||
- activity.activityType === "github-pr-activity"
+ : activity.activityType === "github-pr"
? GitPullRequest
: activity.activityType === "calendar-document"
? CalendarDays
@@ -251,23 +285,24 @@ function MeetingActivitySummary({
export function CollectionMemberRow({
canOpen,
+ displayLabel,
isRemoving,
member,
onOpen,
onRemove,
}: {
canOpen: boolean;
+ displayLabel: string;
isRemoving: boolean;
member: CollectionMember;
onOpen: () => void;
onRemove: () => Promise
;
}) {
const identity = collectionReferenceIdentity(member.reference);
- const label = member.label || identity;
const content = (
- {label}
+ {displayLabel}
{member.reference.type === "external" ? (
{new URL(member.reference.url).hostname}
@@ -308,7 +343,7 @@ export function CollectionMemberRow({
) : null}
diff --git a/desktop/src/features/collections/ui/CollectionDetailScreen.tsx b/desktop/src/features/collections/ui/CollectionDetailScreen.tsx
index 41733a0f13..fc22e4b499 100644
--- a/desktop/src/features/collections/ui/CollectionDetailScreen.tsx
+++ b/desktop/src/features/collections/ui/CollectionDetailScreen.tsx
@@ -12,6 +12,7 @@ import * as React from "react";
import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
+import { useChannelsQuery } from "@/features/channels/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { truncatePubkey } from "@/shared/lib/pubkey";
import { useOpenEntityLink } from "@/shared/ui/markdown/entityLinks";
@@ -34,8 +35,8 @@ import {
import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
COLLECTION_REFERENCE_TYPES,
+ collectionMemberDisplayLabel,
collectionMemberMatches,
- collectionReferenceIdentity,
} from "../memberDisplay";
import { collectionMemberNavigationTarget } from "../memberNavigation";
import {
@@ -43,7 +44,11 @@ import {
useCollectionQuery,
useDerivedCollectionLinks,
} from "../hooks";
-import { projectCollectionActivity } from "../derivedLinks";
+import {
+ filterCollectionActivityByType,
+ projectCollectionActivity,
+ type CollectionActivityTypeFilter,
+} from "../derivedLinks";
import type { CollectionReference, CollectionReferenceType } from "../types";
import { useCollectionScope } from "../useCollectionScope";
import { AddCollectionReferenceForm } from "./AddCollectionReferenceForm";
@@ -54,6 +59,16 @@ import {
} from "./CollectionDetailRows";
const PEOPLE_PREVIEW_LIMIT = 8;
+const ACTIVITY_TYPE_FILTERS: ReadonlyArray<{
+ label: string;
+ value: CollectionActivityTypeFilter;
+}> = [
+ { label: "All activity", value: "all" },
+ { label: "Messages & threads", value: "messages" },
+ { label: "Pull requests", value: "pull-requests" },
+ { label: "Meetings", value: "meetings" },
+ { label: "Documents", value: "documents" },
+];
import { CollectionGlyph } from "./CollectionGlyph";
import { CollectionIconDialog } from "./CollectionIconDialog";
@@ -64,6 +79,7 @@ export function CollectionDetailScreen({
}) {
const scope = useCollectionScope();
const collectionQuery = useCollectionQuery(scope, collectionId);
+ const channelsQuery = useChannelsQuery();
const { addMember, remove, removeMember, setIcon } =
useCollectionMutations(scope);
const navigate = useNavigate();
@@ -78,11 +94,23 @@ export function CollectionDetailScreen({
const [activeTab, setActiveTab] = React.useState<
"overview" | "activity" | "sources"
>("overview");
+ const [activityType, setActivityType] =
+ React.useState("all");
const [type, setType] = React.useState(
"all",
);
const data = collectionQuery.data;
- const derived = useDerivedCollectionLinks(data?.members ?? []);
+ const channelNamesById = React.useMemo(
+ () =>
+ new Map(
+ (channelsQuery.data ?? []).map((channel) => [channel.id, channel.name]),
+ ),
+ [channelsQuery.data],
+ );
+ const derived = useDerivedCollectionLinks(
+ data?.members ?? [],
+ channelNamesById,
+ );
const participantIdentities = [
...new Set(
derived.links.flatMap((activity) =>
@@ -107,10 +135,7 @@ export function CollectionDetailScreen({
const hiddenPeopleCount =
peopleIdentities.length - visiblePeopleIdentities.length;
const members = (data?.members ?? []).filter((member) =>
- collectionMemberMatches(member, search, type),
- );
- const pullRequests = derived.links.filter(
- (activity) => activity.activityType === "github-pr",
+ collectionMemberMatches(member, search, type, channelNamesById),
);
const projectedActivity = projectCollectionActivity(
derived.links,
@@ -130,6 +155,10 @@ export function CollectionDetailScreen({
activity.actorIdentities?.includes(selectedPerson),
)
: projectedActivity;
+ const visibleActivity =
+ activeTab === "activity"
+ ? filterCollectionActivityByType(filteredActivity, activityType)
+ : filteredActivity;
const sourceGroups = [
{
label: "Channels",
@@ -192,7 +221,7 @@ export function CollectionDetailScreen({
const member = data?.members.find((candidate) => candidate.id === memberId);
return {
label: member
- ? member.label || collectionReferenceIdentity(member.reference)
+ ? collectionMemberDisplayLabel(member, channelNamesById)
: "source",
memberId,
};
@@ -403,9 +432,10 @@ export function CollectionDetailScreen({
const target = collectionMemberNavigationTarget(
member.reference,
);
- const label =
- member.label ||
- collectionReferenceIdentity(member.reference);
+ const label = collectionMemberDisplayLabel(
+ member,
+ channelNamesById,
+ );
const canOpen = target !== null;
return (
))}
-
- {pullRequests.length > 0 ? (
-
-
-
Pull requests
-
- Pull requests mentioned by explicit thread and message
- sources.
-
-
-
- {pullRequests.map((activity) => (
- {}}
- onRemoveSource={() => {
- const source = sourceDetails(activity.sourceMemberId);
- return removeSource(
- source.memberId,
- source.label,
- true,
- );
- }}
- />
- ))}
-
-
- ) : null}
>
) : null}
{activeTab !== "sources" ? (
-
-
- {activeTab === "overview" ? "Recent activity" : "Activity"}
-
-
- Recent conversations and live context derived from your sources.
-
+
+
+
+ {activeTab === "overview" ? "Recent activity" : "Activity"}
+
+
+ Recent conversations and live context derived from your
+ sources.
+
+
+ {activeTab === "activity" ? (
+
+ setActivityType(
+ event.target.value as CollectionActivityTypeFilter,
+ )
+ }
+ value={activityType}
+ >
+ {ACTIVITY_TYPE_FILTERS.map((filter) => (
+
+ {filter.label}
+
+ ))}
+
+ ) : null}
{(activeTab === "overview"
- ? filteredActivity.slice(0, 5)
- : filteredActivity
+ ? visibleActivity.slice(0, 5)
+ : visibleActivity
).map((activity) => (
))}
- {filteredActivity.length === 0 ? (
+ {visibleActivity.length === 0 ? (
{selectedPerson
? "No activity from this person was found."
- : derived.isFetching
- ? "Loading activity…"
- : data.members.length === 0
- ? "Add a source to discover collection activity."
- : "No recent activity was found across linked sources."}
+ : activeTab === "activity" && activityType !== "all"
+ ? `No ${ACTIVITY_TYPE_FILTERS.find((filter) => filter.value === activityType)?.label.toLocaleLowerCase()} were found.`
+ : derived.isFetching
+ ? "Loading activity…"
+ : data.members.length === 0
+ ? "Add a source to discover collection activity."
+ : "No recent activity was found across linked sources."}
) : null}
- {activeTab === "overview" && filteredActivity.length > 5 ? (
+ {activeTab === "overview" && visibleActivity.length > 5 ? (
setActiveTab("activity")}
@@ -673,6 +695,10 @@ export function CollectionDetailScreen({
return (
removeSource(
member.id,
- member.label ||
- collectionReferenceIdentity(member.reference),
+ collectionMemberDisplayLabel(
+ member,
+ channelNamesById,
+ ),
false,
)
}
diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx
index 38a2bf3656..53e8c0f945 100644
--- a/desktop/src/features/messages/ui/MessageActionBar.tsx
+++ b/desktop/src/features/messages/ui/MessageActionBar.tsx
@@ -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({
setCollectionTarget("message")}
+ data-testid={`add-${collectionAction?.type}-to-collection-${message.id}`}
+ onSelect={() => setIsCollectionDialogOpen(true)}
>
- Add message to collection
+ {collectionAction?.menuLabel}
- {threadRootId ? (
- setCollectionTarget("thread")}
- >
-
- Add thread to collection
-
- ) : null}
) : null}
@@ -356,36 +345,16 @@ function MoreActionsMenu({
- {canAddToCollection && channelId && collectionTarget === "message" ? (
+ {canAddToCollection && collectionAction && isCollectionDialogOpen ? (
{
- 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" ? (
- {
- if (!nextOpen) setCollectionTarget(null);
- }}
- open
- reference={{
- type: "thread",
- channel_id: channelId,
- root_event_id: threadRootId,
- }}
+ reference={collectionAction.reference}
trigger={false}
/>
) : null}
diff --git a/desktop/tests/e2e/collections.spec.ts b/desktop/tests/e2e/collections.spec.ts
index 07a34b03b6..e3e123ebb0 100644
--- a/desktop/tests/e2e/collections.spec.ts
+++ b/desktop/tests/e2e/collections.spec.ts
@@ -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" })
diff --git a/docs/collections.md b/docs/collections.md
index 5472f3ac48..ac83ea74e2 100644
--- a/docs/collections.md
+++ b/docs/collections.md
@@ -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.