Files
buzz/desktop/tests/e2e/reminders.spec.ts
2bd4c24b71 Inbox refactor (#2045)
## Why

The Inbox mixed overlapping feed categories with personal work queues,
so **All** was not actually comprehensive and several filters did not
make it clear why an item appeared. Threads and DMs could produce one
row per event instead of one row per conversation, drafts were hidden
until selected, and reminders appeared through multiple competing
presentations.

This refactor makes the **Inbox** a focused, conversation-oriented place
to catch up on work relevant to you. It is intentionally not a mirror of
every unread event in every channel.

## What changed

- Keep the destination named **Inbox** and use the standard Lucide bell
icon.
- Refocus **All** on DMs, mentions, thread replies, needs-action items,
replies from agents the user owns or controls, due reminders, and active
drafts.
- Exclude generic top-level channel traffic and updates from agents the
user does not own or control.
- Group each thread or DM into one row, sorted by latest activity.
- Resume an unread conversation at its oldest unread message while
opening the full thread or DM in the detail pane.
- Reuse the existing **New** divider at the unread boundary.
- Make the detail title a direct link to the canonical conversation.
- Give Reminders and Drafts the same list/detail interaction and
location metadata as conversation rows.
- Separate Reminders and Drafts from message filters with a subtle
divider, without adding another labeled section.
- Put reminder and draft counts beside their corresponding filter labels
instead of on the generic filter button.
- Preserve the selected conversation when switching filters if it
remains valid; otherwise select a valid replacement without flashing
stale detail.
- Use filter-specific empty states and rename the options toggle to
**Show unread only**.
- Ship the focused behavior directly. The earlier experiment gate,
Custom view, and default-view controls have been removed from this PR to
keep the first pass focused.

## Filter model

| Filter | What appears |
| --- | --- |
| **All** | One row per personally relevant conversation, plus due
reminders and active drafts. Includes DMs, mentions, thread replies,
explicit needs-action items, and replies from agents the current user
owns or controls. Excludes generic top-level channel traffic, other
agents' updates, and reminders that are not due yet. |
| **Mentions** | Conversations containing a direct mention. Each
conversation appears once and opens with full context. |
| **Threads** | Conventional threaded replies, grouped to one row per
thread. Broadcast replies are not treated as conventional thread
replies. |
| **Needs action** | Feed items explicitly classified as requiring
action. |
| **Agents** | Conversations whose representative response was authored
by an agent the current user owns or controls, including top-level DM
responses. If a human replies afterward, the conversation leaves this
filter until an owned agent responds again. |
| **Reminders** | All pending reminders, including upcoming reminders
that stay out of **All** until they are due. |
| **Drafts** | Active drafts, ordered by their last real edit time. |

## Grouping, ordering, and state

- A thread or DM creates one Inbox row rather than one row per event.
- An unread conversation resumes at its oldest unread message so
intervening context is not skipped.
- Conversation rows still sort by their latest activity.
- The detail pane opens the full available conversation and shows the
shared **New** divider before the first unread message.
- Upcoming reminders appear only in **Reminders**.
- When a reminder becomes due, it enters **All** at its trigger time. If
its source conversation is already represented, the reminder state
merges into that row instead of creating a duplicate; otherwise it
appears as a standalone reminder row.
- A due reminder can enrich a row in another relative filter when that
conversation already qualifies for the filter. Reminder lifecycle
remains separate from message read state.
- Drafts appear in **All** by their last real edit time. Opening an
unchanged draft does not move it to the top.
- Reminder and draft rows show their location as `In #channel` or `In DM
with <name>`.
- **Show unread only** hides reminder and draft work queues because they
do not share message unread semantics.

## Removed or narrowed

- **Remove the old Activity filter.** It overlapped with All while still
omitting items All now includes.
- **Narrow Agents.** It no longer gathers every agent participating in a
shared thread or subsequent human follow-ups.
- **Remove duplicate reminder presentations.** The aggregate
pending-reminders jump and duplicate generic feed rows are replaced by
one list/detail model.
- **Remove Custom and default-view settings from this pass.** They added
considerable state and UI before the core model had been validated.
- **Do not add section labels for Reminders and Drafts.** A divider
communicates the distinction without creating another hierarchy in the
menu.

## Risk assessment

Medium implementation risk because this changes composition, grouping,
ordering, read behavior, and personal queues in a primary desktop view.
The implementation is scoped to the desktop UI and its local feed
projection; it does not change relay schemas or public APIs.

## Testing

- Desktop formatting, lint, file-size, text-size, and TypeScript checks
passed.
- Desktop unit suite: **3,663 passed, 0 failed**.
- Desktop E2E production build passed.
- Playwright smoke coverage across every spec touching this surface
(`channels`, `smoke`, `profile`, `project-inbox`, `community-rail`,
`integration`, `drafts-screenshots`): **118 passed, 0 failed**.
- Full Playwright smoke project: **732 passed, 1 skipped**. Three local
failures were investigated and cleared — `community-rail` keyboard
reorder passed on re-run (flaky), while `relay-reconnect:97` and
`video-attachment:223` are untouched by this commit (the only change to
shared `tests/helpers/bridge.ts` is a comment) and pass in CI.
- Unit coverage includes focused All matching, owned-agent filtering,
conversation grouping, oldest-unread selection, selection stability,
chronological reminder/draft composition, trigger-time reminder
ordering, and duplicate reminder suppression.

## Update: July 27, 2026

The naming decision is settled: the surface stays **Inbox**. An earlier
pass in this branch had renamed it to **Activity**; that rename has been
reverted in `9c00d2d6e`, which is naming-only and changes no behavior.

The revert covers file names, component/hook/type/constant identifiers,
the sidebar label and tooltip, the `Inbox options` and `Filter inbox:`
aria-labels, and the corresponding test names, test ids, and fixture
ids.

Three things were deliberately left as `activity`:

- **The feed API contract** — the `activity` / `agent_activity`
categories, the `feed.activity` and `feed.agentActivity` keys, and the
`types=` query parameter. These are the server's names, not the
surface's.
- **Plain-noun usage** — empty states such as "No activity yet", plus
`latestActivityAt` and `PROJECT_ACTIVITY_KINDS`.
- **Pre-existing agent, project, and profile activity code**, which
refers to a different concept entirely.

The earlier experiment-gate approach has also been dropped, so
`tests/helpers/bridge.ts` no longer claims that an Activity preview
feature exists — `preview-features.json` has no such entry and the seed
helper enables every desktop feature.

Generated with Codex

---------

Signed-off-by: Clay Delk <clay.delk@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 16:45:34 -04:00

363 lines
12 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const MOCK_PUBKEY = "deadbeef".repeat(8);
// The inbox filter dropdown lives in the home pane, not the chat view. Land on
// home and wait for the inbox before reaching for the filter trigger.
async function gotoInboxHome(page: import("@playwright/test").Page) {
await page.goto("/");
await expect(page.getByTestId("home-inbox")).toBeVisible();
}
// Reminders is reached by opening the inbox filter dropdown and selecting the
// "Reminders" option — there is no standalone nav entry or view-mode slider.
async function openRemindersFilter(page: import("@playwright/test").Page) {
await page.getByTestId("inbox-filter-trigger").click();
await page.getByRole("menuitemradio", { name: "Reminders" }).click();
}
// The reminders query mounts (for the badge) before tests seed events, so a
// bare seed lands behind its cached empty result. Invalidate after seeding to
// force the refetch that picks up the mock events.
async function seedReminders(
page: import("@playwright/test").Page,
events: unknown[],
) {
await page.evaluate((seeded) => {
window.__BUZZ_E2E_SEED_MOCK_REMINDERS__?.(
seeded as Parameters<
NonNullable<typeof window.__BUZZ_E2E_SEED_MOCK_REMINDERS__>
>[0],
);
window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
queryKey: ["reminders"],
});
}, events);
}
function mockReminderEvent(opts: {
id: string;
dTag: string;
content: string;
notBefore: number;
createdAt?: number;
}) {
return {
id: opts.id,
pubkey: MOCK_PUBKEY,
created_at: opts.createdAt ?? Math.floor(Date.now() / 1000) - 300,
kind: 30300,
tags: [
["d", opts.dTag],
["not_before", String(opts.notBefore)],
],
content: opts.content,
sig: "mocksig".repeat(20).slice(0, 128),
};
}
test.describe("reminders", () => {
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
test("01 — inbox filter dropdown shows Reminders option", async ({
page,
}) => {
await gotoInboxHome(page);
await page.getByTestId("inbox-filter-trigger").click();
const remindersOption = page.getByRole("menuitemradio", {
name: "Reminders",
});
await expect(remindersOption).toBeVisible();
await waitForAnimations(page);
});
test("02 — message action menu shows Remind me later", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const messageRow = page.getByTestId("message-row").first();
await messageRow.hover();
const moreActionsButton = messageRow.getByRole("button", {
name: "More actions",
});
await expect(moreActionsButton).toBeVisible();
await moreActionsButton.click();
const remindItem = page.getByRole("menuitem", {
name: "Remind me later",
});
await expect(remindItem).toBeVisible();
await waitForAnimations(page);
});
test("03 — Remind me later dialog with time presets", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const messageRow = page.getByTestId("message-row").first();
await messageRow.hover();
const moreActionsButton = messageRow.getByRole("button", {
name: "More actions",
});
await moreActionsButton.click();
const remindItem = page.getByRole("menuitem", {
name: "Remind me later",
});
await expect(remindItem).toBeVisible();
await waitForAnimations(page);
await remindItem.click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog.getByText("Remind me later")).toBeVisible();
await expect(dialog.getByText("In 30 minutes")).toBeVisible();
await expect(dialog.getByText("Custom date & time")).toBeVisible();
await waitForAnimations(page);
});
test("04 — Reminders panel empty state", async ({ page }) => {
await gotoInboxHome(page);
await openRemindersFilter(page);
await expect(page.getByText("No reminders")).toBeVisible();
await waitForAnimations(page);
});
test("05 — Reminders panel with active pending reminder", async ({
page,
}) => {
await gotoInboxHome(page);
// Seed a pending reminder due in the future
const futureTimestamp = Math.floor(Date.now() / 1000) + 3600;
const reminderContent = JSON.stringify({
target: {
eventId: "mock-general-welcome",
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
preview: "Welcome to #general",
authorPubkey: MOCK_PUBKEY,
},
note: "Follow up on this message",
status: "pending",
});
await seedReminders(page, [
mockReminderEvent({
id: "reminder-active-01",
dTag: "rem-active-01",
content: reminderContent,
notBefore: futureTimestamp,
}),
]);
await openRemindersFilter(page);
await expect(
page
.getByTestId("home-reminder-item-rem-active-01")
.getByText("Follow up on this message"),
).toBeVisible();
await waitForAnimations(page);
});
test("06 — Reminders panel with fired/overdue reminder", async ({ page }) => {
await gotoInboxHome(page);
// Seed a reminder that has already fired (notBefore in the past)
const pastTimestamp = Math.floor(Date.now() / 1000) - 7200;
const overdueContent = JSON.stringify({
target: {
eventId: "mock-general-alice",
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
preview: "Hey team — checking in.",
authorPubkey:
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
},
note: "Reply to Alice",
status: "pending",
});
// Also seed a future reminder so both states are visible
const futureTimestamp = Math.floor(Date.now() / 1000) + 7200;
const activeContent = JSON.stringify({
target: {
eventId: "mock-general-welcome",
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
preview: "Welcome to #general",
authorPubkey: MOCK_PUBKEY,
},
status: "pending",
});
await seedReminders(page, [
mockReminderEvent({
id: "reminder-overdue-01",
dTag: "rem-overdue-01",
content: overdueContent,
notBefore: pastTimestamp,
}),
mockReminderEvent({
id: "reminder-upcoming-01",
dTag: "rem-upcoming-01",
content: activeContent,
notBefore: futureTimestamp,
}),
]);
await openRemindersFilter(page);
await expect(
page
.getByTestId("home-reminder-item-rem-overdue-01")
.getByText("Reply to Alice"),
).toBeVisible();
await expect(
page
.getByTestId("home-reminder-item-rem-overdue-01")
.getByText("2h overdue"),
).toBeVisible();
await waitForAnimations(page);
});
});
// Phase 2 — author + source at a glance, and click-to-navigate. Both cases
// seed a reminder targeting Alice's seeded message in #general (event
// `mock-general-alice`, channel `9a1657ac-…`), so the author resolves to
// "alice" and the channel label to "general" from the live profile/channel
// queries — no "Unknown channel" fallback.
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const ALICE_PUBKEY =
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
function aliceReminderContent() {
return JSON.stringify({
target: {
eventId: "mock-general-alice",
channelId: GENERAL_CHANNEL_ID,
preview: "Hey team — checking in.",
authorPubkey: ALICE_PUBKEY,
},
note: "Reply to Alice",
status: "pending",
});
}
// Nav badge — the surface unit tests can't prove: a due reminder driving the
// Inbox nav item's `(1)` count. The count is gated behind `homeBadgeEnabled`,
// so seed that setting on before installMockBridge (addInitScript runs at
// document start, ahead of the app reading localStorage).
const NOTIFICATION_SETTINGS_KEY = `buzz-notification-settings.v2:${MOCK_PUBKEY}`;
test.describe("reminders nav badge", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(
({ key }) => {
localStorage.setItem(key, JSON.stringify({ homeBadgeEnabled: true }));
},
{ key: NOTIFICATION_SETTINGS_KEY },
);
await installMockBridge(page);
});
test("09 — Inbox nav badge counts a due reminder", async ({ page }) => {
await gotoInboxHome(page);
// One overdue pending reminder (notBefore in the past) is the sole badge
// contributor; the feed baseline is 0, so an exact "1" proves the reminder
// — not feed noise — drives the nav count.
const pastTimestamp = Math.floor(Date.now() / 1000) - 7200;
await seedReminders(page, [
mockReminderEvent({
id: "reminder-navbadge-01",
dTag: "rem-navbadge-01",
content: aliceReminderContent(),
notBefore: pastTimestamp,
}),
]);
await expect(page.getByTestId("sidebar-home-count")).toHaveText("1");
await waitForAnimations(page);
});
});
test.describe("reminders phase 2 — author, source, navigation", () => {
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
test("07 — reminder row shows author and source channel", async ({
page,
}) => {
await gotoInboxHome(page);
const futureTimestamp = Math.floor(Date.now() / 1000) + 3600;
await seedReminders(page, [
mockReminderEvent({
id: "reminder-phase2-source-01",
dTag: "rem-phase2-source-01",
content: aliceReminderContent(),
notBefore: futureTimestamp,
}),
]);
await openRemindersFilter(page);
// Author + source line resolves from the live profile/channel queries.
// Scope to the reminders panel to avoid matching the "general" entry in
// the sidebar channel list.
const remindersPanel = page.getByTestId("home-inbox-reminders");
await expect(
remindersPanel.getByText("alice", { exact: true }),
).toBeVisible();
await expect(
remindersPanel.getByText("#general", { exact: true }),
).toBeVisible();
await expect(remindersPanel.getByText("Reply to Alice")).toBeVisible();
await waitForAnimations(page);
});
test("08 — clicking a reminder navigates to the message in context", async ({
page,
}) => {
await gotoInboxHome(page);
const futureTimestamp = Math.floor(Date.now() / 1000) + 3600;
await seedReminders(page, [
mockReminderEvent({
id: "reminder-phase2-nav-01",
dTag: "rem-phase2-nav-01",
content: aliceReminderContent(),
notBefore: futureTimestamp,
}),
]);
await openRemindersFilter(page);
// Selecting a reminder keeps the list visible and opens its detail pane.
// Navigation is an explicit action from that detail.
await page
.getByTestId("home-reminder-item-rem-phase2-nav-01")
.getByRole("button")
.first()
.click();
await page
.getByTestId("home-reminder-detail")
.getByRole("button", { name: "Open message" })
.click();
// Lands in the #general chat view with the target message in context.
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(
page.getByTestId("message-timeline").getByText("Hey team — checking in."),
).toBeVisible();
await waitForAnimations(page);
});
});