From 5b4b31107a8d48c30b7fd2c7ee4dca783d710673 Mon Sep 17 00:00:00 2001 From: mtoohey Date: Thu, 5 Nov 2026 14:30:28 +0000 Subject: [PATCH 1/6] fix(inbox): count unread mentions on the Inbox card and lead with the total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar Inbox card's header counted any conversation that had ever mentioned you as an unread mention, so a read mention followed by a plain reply read "1 mention unread" while Mentions with Unread only was empty. It also gave a Solo chat's flat row no kind, so two unread rows could read "1 mention unread". The header now leads with the number of unread conversations, then counts each once by its most specific kind ("2 unread · 1 mention"). A mention counts only while its explicit-mention projection is unread, matching the page's Mentions filter with Unread only; a Solo row alone reads "1 unread". The summary stays non-interactive. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mtoohey Signed-off-by: Matt Toohey --- docs/inbox.md | 7 ++-- src/bundled/inbox/InboxPage.test.tsx | 51 ++++++++++++++++++++++++++-- src/bundled/inbox/InboxPreview.tsx | 8 +++-- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/docs/inbox.md b/docs/inbox.md index ada8e55a90..da06ed9a09 100644 --- a/docs/inbox.md +++ b/docs/inbox.md @@ -50,8 +50,11 @@ Resting a pointer on the sidebar's Activity row opens a card of recent conversat through the page contract's `preview`. It reads the same `session.unread.inbox()` and `session.inboxFeed` projection as the page, with All activity and Everyone, newest first and bounded to 50 rows after mixing conversations and reactions, as on the -page. The header counts unread -conversations once each, as a DM, mention or thread. Unread only is the card's own +page. The header leads with the number of +unread conversations, then counts each once by its most specific kind: DM, then +mention, then thread. A mention counts only while it is unread, as Mentions with +Unread only would list it; a Solo chat's flat row has no kind, so only the total +counts it. Unread only is the card's own choice, saved on this device per account and community, and never changes the page's filters. A row opens its conversation directly, through the same conversation target as the page's fallback, and closes the card without opening diff --git a/src/bundled/inbox/InboxPage.test.tsx b/src/bundled/inbox/InboxPage.test.tsx index 490eafa041..331f899fd7 100644 --- a/src/bundled/inbox/InboxPage.test.tsx +++ b/src/bundled/inbox/InboxPage.test.tsx @@ -568,6 +568,8 @@ async function markRead(h: ReturnType, channelId: string) { for (const item of items) if (item.target.kind === "channel") await h.session.unread.prepareChannelRead(channelId)(); + else if (item.timeline) + await h.session.unread.markTimelineRead(channelId, item.messageIds); else for (const step of item.readThrough) await h.session.unread.markThrough(step.target, step.messageId); @@ -580,7 +582,7 @@ it("previews recent conversations and opens a row directly", async () => { render(previewAt(h, close)); await waitFor(() => expect(previewRows()).toHaveLength(3)); expect( - screen.getByText("1 mention · 1 thread · 1 DM unread"), + screen.getByText("3 unread · 1 mention · 1 thread · 1 DM"), ).toBeInTheDocument(); // The newest #Design row opens at its exact message, in its thread when it has one. @@ -619,7 +621,9 @@ it("keeps Unread only to the card and offers recent activity once caught up", as expect(screen.getByRole("switch", { name: "Unread only" })).not.toBeChecked(); await markRead(h, "dm-room"); await waitFor(() => - expect(screen.getByText("1 mention · 1 thread unread")).toBeInTheDocument(), + expect( + screen.getByText("2 unread · 1 mention · 1 thread"), + ).toBeInTheDocument(), ); const unreadOnly = () => screen.getByRole("switch", { name: "Unread only" }); fireEvent.click(unreadOnly()); @@ -641,6 +645,37 @@ it("keeps Unread only to the card and offers recent activity once caught up", as expect(unreadOnly()).not.toBeChecked(); }); +it("leads the card's header with the total and counts a mention only while it is unread", async () => { + const h = fixture({ withSolo: true }); + render(previewAt(h)); + await waitFor(() => expect(previewRows()).toHaveLength(3)); + expect( + screen.getByText("3 unread · 1 mention · 1 thread"), + ).toBeInTheDocument(); + // A Solo chat's flat row has no kind, so only the total counts it. + await markRead(h, "room"); + expect(await screen.findByText("1 unread")).toBeInTheDocument(); + + const reply = (content: string, at: number, tags: string[][] = []) => { + const event = message(h.alice, "room", content, at, [ + ["e", h.root?.id ?? "", "", "reply"], + ...tags, + ]); + h.addEvent(event); + act(() => h.emit([event])); + }; + reply("Can you check this?", 30, [["p", h.viewer.pubkey]]); + expect(await screen.findByText("2 unread · 1 mention")).toBeInTheDocument(); + + // A plain reply after the mention was read is thread activity, as Mentions + // with Unread only would list nothing. + await markRead(h, "room"); + await markRead(h, "solo-room"); + expect(await screen.findByText("Nothing unread")).toBeInTheDocument(); + reply("Following up", 31); + expect(await screen.findByText("1 unread · 1 thread")).toBeInTheDocument(); +}); + it("shows the card's connection and read failures with their recovery", async () => { const offline = fixture(); const view = render(previewAt(offline)); @@ -798,6 +833,7 @@ function fixture( failRoster?: boolean; archivedChannel?: boolean; withDm?: boolean; + withSolo?: boolean; withSenders?: boolean; holdProfiles?: boolean; withWriter?: boolean; @@ -931,6 +967,17 @@ function fixture( message(alice, "dm-room", "A direct reply", 23), ] : []), + ...(options.withSolo + ? [ + roster(relayKey, "solo-room", [viewer.pubkey, alice.pubkey], 10), + metadata(relayKey, "solo-room", "Notes", 10, [ + ["t", "stream"], + ["private"], + ["t", "buzz.solo"], + ]), + message(alice, "solo-room", "A Solo update", 23), + ] + : []), ...(options.withSenders ? [ roster(relayKey, "agent-dm", [viewer.pubkey, agent.pubkey], 10), diff --git a/src/bundled/inbox/InboxPreview.tsx b/src/bundled/inbox/InboxPreview.tsx index 9509dc2c9a..2bf495d809 100644 --- a/src/bundled/inbox/InboxPreview.tsx +++ b/src/bundled/inbox/InboxPreview.tsx @@ -258,13 +258,15 @@ function InboxPreviewCard({ if (failure || document.activeElement !== document.body) return; unreadOnlyControl.current?.focus({ preventScroll: true }); }, [failure]); - // Each conversation counts once, under its most specific kind. + // Each conversation counts once, under its most specific kind, as the + // page's filters with Unread only would list it. A Solo chat's own row has + // no kind, so the total leads. let dms = 0; let mentions = 0; let threads = 0; for (const item of unread) { if (channelOf(item.channelId)?.channelType === "dm") dms++; - else if (item.mentioned) mentions++; + else if (item.mention && hasUnread(item.mention)) mentions++; else if (item.thread) threads++; } const kinds = [ @@ -275,7 +277,7 @@ function InboxPreviewCard({ const summary = loading ? "Checking recent activity…" : unread.length - ? `${kinds.length ? kinds.join(" · ") : unread.length} unread` + ? [`${unread.length} unread`, ...kinds].join(" · ") : checked ? "Nothing unread" : undefined; From b8afb4758eef585676e44365f8cbe41bb2cb1bde Mon Sep 17 00:00:00 2001 From: mtoohey Date: Thu, 5 Nov 2026 15:00:07 +0000 Subject: [PATCH 2/6] feat(channels): list unread top-level mentions in sidebar Activity Sidebar Activity listed only unread thread replies, so a top-level @mention never appeared in it. A row whose only unread item was a mention opened no popover, and next to an unread thread a mention left no trace: the dot still read "1 unread thread". The unread engine now adds each channel's unread top-level mentions of the viewer to its Activity snapshot, newest first, under a new optional `mentions` field. A reply's mention stays under its thread; DMs and broadcasts add none. The popover lists a Mentions section above Unread threads. A mention opens at its message with no thread root, through the existing ChannelInventory and Solo open paths, and Mark read writes a `message` mark ("Could not mark this mention as read. Try again." on failure). Revealing the popover still reads nothing. There is still one dot. Its label and title name mentions too ("1 unread mention, 1 unread thread"), and a mention-only row keeps its priority dot. Channel rows get mentions through a new optional `onOpenMessage` prop on ChannelSidebarItem, wired to openActivityMessage, so this rebases mechanically onto #2555. docs/unread.md and docs/buzz-help.md describe the Mentions section. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mtoohey Signed-off-by: Matt Toohey --- docs/buzz-help.md | 3 + docs/unread.md | 13 +- .../channels/ChannelActivityPopover.test.tsx | 124 +++++++++++++++++- .../channels/ChannelActivityPopover.tsx | 90 ++++++++++--- src/bundled/channels/ChannelSidebarItem.tsx | 5 + .../channels/ChannelSidebarMenuItem.test.tsx | 21 ++- src/bundled/channels/UnreadBadge.tsx | 10 +- .../ChannelInventory.test.tsx | 64 +++++++++ .../channel-navigation/ChannelInventory.tsx | 1 + .../channel-navigation/SoloInventory.test.tsx | 37 ++++++ .../channel-navigation/SoloInventory.tsx | 10 +- src/features/relay/unread.test.ts | 47 +++++++ src/features/relay/unread.ts | 63 ++++++++- tests/browser/thread-unread.spec.mjs | 76 +++++++++++ 14 files changed, 525 insertions(+), 39 deletions(-) diff --git a/docs/buzz-help.md b/docs/buzz-help.md index e03f930fdb..37de6039b3 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -349,6 +349,9 @@ revokes all of them, and a pairing unused for 30 days expires. name, or **Open in channel** in the thread tab's menu, to open the thread in Channels beside its original message, which the channel history shows and highlights. +- **Sidebar activity**: rest the pointer on a sidebar row to list its unread + mentions of you and its unread threads, mentions first. Open one or mark it + read from the list; just looking marks nothing read. - **Mute** is available from a channel or DM's sidebar context menu. Once confirmed, a crossed-out bell appears immediately after its name. New messages do not make muted channel or DM names bold. diff --git a/docs/unread.md b/docs/unread.md index de4a092b96..97165e636d 100644 --- a/docs/unread.md +++ b/docs/unread.md @@ -222,8 +222,8 @@ do not contribute to “N unread” offscreen counts or avatar previews, even fo mentions, manual unread or thread replies. Muted channel and DM sidebar rows show a crossed-out bell immediately after their names, even with no unread activity. They hide all unread dots, including -thread activity dots. Thread activity remains available through the row's activity popover, -and its unread count remains accessible to assistive technology. Unmute restores +thread activity dots. Mentions and thread activity remain available through the row's activity +popover, and the thread dot's label remains accessible to assistive technology. Unmute restores the dots from retained unread evidence. The promoted Bestie DM also hides muted unread dots; its label follows the same mute rule. Solo chat labels strengthen for any observed unread message, including mentions, @@ -232,8 +232,13 @@ Ordinary unread renders no row marker. Unmuted DMs, mentions, broadcasts, and relevant thread replies add one accent dot; non-DM row numerals are omitted and DM avatars are reserved for promoted offscreen cues. Thread activity reuses that dot: its hover/focus/click popover groups unread replies by canonical thread root and -opens the existing thread panel, so overlapping priority and thread activity never -produce duplicate dots. Merely revealing the popover does not acknowledge a reply. +opens the existing thread panel. Above the threads it lists unread top-level messages that +mention the viewer, newest first; a mention opens at its message and is read like any +visit. A reply's mention stays under its thread; DMs and broadcasts list no +mentions. A row with only mentions keeps its priority dot and still opens the +popover; with threads, the dot's label counts both ("1 unread mention, 1 unread +thread"). Overlapping priority, mention and thread activity never produce +duplicate dots. Merely revealing the popover does not acknowledge a mention or reply. A local manual-unread mark strengthens the label without fabricating priority; the underlying observed count remains available. Channel Settings → Diagnostics exposes explicit actions and Unread status/retry. Unknown and diff --git a/src/bundled/channels/ChannelActivityPopover.test.tsx b/src/bundled/channels/ChannelActivityPopover.test.tsx index 583dbef025..7c99fa115a 100644 --- a/src/bundled/channels/ChannelActivityPopover.test.tsx +++ b/src/bundled/channels/ChannelActivityPopover.test.tsx @@ -1,6 +1,13 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; -import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { + act, + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, test, vi } from "vitest"; import { ChannelActivityPopover } from "./ChannelActivityPopover"; @@ -8,6 +15,7 @@ import { Button } from "../../shared/design-system/ui/Button"; import { ChannelSidebarRows } from "../../features/channel-navigation/ChannelSidebarRows"; import type { RelaySession } from "../../features/relay/session"; import type { + MentionActivityItem, ThreadActivityItem, ThreadActivitySnapshot, } from "../../features/relay/unread"; @@ -42,6 +50,7 @@ test("closes activity while its row exits and does not reopen on re-entry", asyn trigger={} agents={["a".repeat(64)]} onOpenThread={vi.fn()} + onOpenMention={vi.fn()} />, ] : []} @@ -98,6 +107,7 @@ test("mouse presses do not pin the hover preview open, and only hover marks it a channelName="Studio" trigger={} onOpenThread={vi.fn()} + onOpenMention={vi.fn()} />, ); const trigger = screen.getByRole("button", { name: "Activity" }); @@ -161,6 +171,7 @@ test("activity keeps profile loading lazy, explains stale results and opens the channelName="Studio" trigger={} onOpenThread={open} + onOpenMention={vi.fn()} />, ); expect(ensure).not.toHaveBeenCalled(); @@ -212,6 +223,114 @@ test("activity keeps profile loading lazy, explains stale results and opens the ); }); +test("unread mentions open activity alone, list above threads, open by message and read by message", async () => { + const user = userEvent.setup(); + const mention: MentionActivityItem = { + channelId: "studio", + messageId: "mention", + authorId: "sam", + createdAt: 2, + preview: "@you can you sign off?", + }; + const thread: ThreadActivityItem = { + channelId: "studio", + rootId: "thread", + latestMessageId: "reply", + authorId: "alex", + createdAt: 1, + preview: "Please review the designs", + unreadCount: 1, + }; + let snapshot: ThreadActivitySnapshot = { + channelId: "studio", + items: [], + mentions: [mention], + coverage: "observed", + freshness: "observed", + }; + const listeners = new Set<() => void>(); + const profiles = new Map([ + ["sam", { name: "Sam" }], + ["alex", { name: "Alex" }], + ]); + const ensure = vi.fn(async () => {}); + const openMention = vi.fn(); + const openThread = vi.fn(); + const markThrough = vi + .fn() + .mockRejectedValueOnce(new Error("Storage unavailable")) + .mockResolvedValueOnce({ durability: "saved" }); + const session = { + agentChoices: { snapshot: () => ({ identities: [] }) }, + unread: { + activity: () => snapshot, + subscribeActivity: (_: string, listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + markThrough, + }, + profiles: { snapshot: () => profiles, subscribe: () => () => {}, ensure }, + } as unknown as RelaySession; + render( + Activity} + onOpenThread={openThread} + onOpenMention={openMention} + />, + ); + const trigger = screen.getByRole("button", { name: "Activity" }); + await user.hover(trigger); + const popup = await screen.findByRole("dialog", { + name: "Activity in Studio", + }); + expect( + screen.getByRole("heading", { level: 2, name: "Mentions" }), + ).toBeVisible(); + expect(screen.queryByRole("region", { name: "Unread threads" })).toBeNull(); + // Revealing the popover reads nothing. + expect(markThrough).not.toHaveBeenCalled(); + const markRead = screen.getByRole("button", { + name: "Mark mention from Sam as read", + }); + await user.click(markRead); + expect(markThrough).toHaveBeenCalledExactlyOnceWith( + { kind: "message", channelId: "studio", messageId: "mention" }, + "mention", + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not mark this mention as read. Try again.", + ); + await user.click(markRead); + await waitFor(() => expect(screen.queryByRole("alert")).toBeNull()); + snapshot = { ...snapshot, items: [thread] }; + act(() => { + for (const listener of listeners) listener(); + }); + expect( + within(popup) + .getAllByRole("region") + .map((section) => section.getAttribute("aria-label")), + ).toEqual(["Mentions", "Unread threads"]); + await user.click( + screen.getByRole("button", { + name: "Open unread mention from Sam: @you can you sign off?", + }), + ); + expect(openMention).toHaveBeenCalledExactlyOnceWith(mention); + expect(openThread).not.toHaveBeenCalled(); + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); + trigger.focus(); + await user.keyboard("{Enter}"); + await screen.findByRole("dialog", { name: "Activity in Studio" }); + expect(ensure).toHaveBeenLastCalledWith(["sam", "alex"], "background"); +}); + test("working agents open their thread when linked, otherwise the conversation", async () => { const user = userEvent.setup(); const first = "a".repeat(64); @@ -281,6 +400,7 @@ test("working agents open their thread when linked, otherwise the conversation", onOpenWorkingAgent={openWorkingAgent} onOpenAgentActivity={openAgentActivity} onOpenThread={vi.fn()} + onOpenMention={vi.fn()} />, ); screen.getByRole("button", { name: "Activity" }).focus(); @@ -374,6 +494,7 @@ test("a typing agent still opens its profile activity while Agent Activity is of onOpenWorkingAgent={openWorkingAgent} onOpenAgentActivity={openAgentActivity} onOpenThread={vi.fn()} + onOpenMention={vi.fn()} />, ); screen.getByRole("button", { name: "Activity" }).focus(); @@ -429,6 +550,7 @@ test("a press while there is no activity does not block the hover preview once a channelName="Studio" trigger={} onOpenThread={vi.fn()} + onOpenMention={vi.fn()} />, ); const trigger = screen.getByRole("button", { name: "Activity" }); diff --git a/src/bundled/channels/ChannelActivityPopover.tsx b/src/bundled/channels/ChannelActivityPopover.tsx index 3bcd3f93a1..d699a8917c 100644 --- a/src/bundled/channels/ChannelActivityPopover.tsx +++ b/src/bundled/channels/ChannelActivityPopover.tsx @@ -23,7 +23,10 @@ import { import { selectProfiles } from "../../features/relay/profile-selection"; import type { Profile } from "../../features/relay/contracts"; import type { RelaySession } from "../../features/relay/session"; -import type { ThreadActivityItem } from "../../features/relay/unread"; +import type { + MentionActivityItem, + ThreadActivityItem, +} from "../../features/relay/unread"; import { Avatar } from "../../shared/Avatar"; import { Avatar as WorkflowAvatar } from "../../shared/design-system/ui/Avatar"; import { publicKeyLabels } from "../../shared/identity/public-key"; @@ -51,14 +54,18 @@ const noSubscribe = () => () => {}; function ActivityRow({ item, + noun, session, agents, onOpen, + read, }: { - item: ThreadActivityItem; + item: ThreadActivityItem | MentionActivityItem; + noun: "thread" | "mention"; session: RelaySession; agents: readonly string[]; - onOpen(item: ThreadActivityItem): void; + onOpen(): void; + read(): Promise; }) { const selection = useMemo( () => @@ -91,10 +98,7 @@ function ActivityRow({ setPending(true); setError(false); try { - await session.unread.markThrough( - { kind: "thread", channelId: item.channelId, rootId: item.rootId }, - item.latestMessageId, - ); + await read(); } catch { setError(true); } finally { @@ -107,8 +111,8 @@ function ActivityRow({ > onOpen(item)} + aria-label={`Open unread ${noun} from ${name}: ${item.preview}`} + onClick={onOpen} icon={ item.workflowOwnerId ? ( } - aria-label={`Mark thread from ${name} as read`} + aria-label={`Mark ${noun} from ${name} as read`} title="Mark as read" loading={pending} onClick={() => void markRead()} @@ -150,7 +154,7 @@ function ActivityRow({ {error && (

- Could not mark this thread as read. Try again. + Could not mark this {noun} as read. Try again.

)} @@ -167,6 +171,7 @@ export function ChannelActivityPopover({ onOpenWorkingAgent, onOpenAgentActivity, onOpenThread, + onOpenMention, }: { session: RelaySession; channelId: string; @@ -185,6 +190,7 @@ export function ChannelActivityPopover({ messageId: string | undefined, ) => void; onOpenThread(item: ThreadActivityItem): void; + onOpenMention(item: MentionActivityItem): void; }) { const present = useIsPresent(); const subscribe = useCallback( @@ -198,6 +204,7 @@ export function ChannelActivityPopover({ ); const snapshot = useSyncExternalStore(subscribe, get, get); const items = snapshot.items ?? []; + const mentions = snapshot.mentions ?? []; const activeAgents = agents ?? []; const agentIds = activeAgents.join(","); const [open, setOpen] = useState(false); @@ -209,9 +216,9 @@ export function ChannelActivityPopover({ NonNullable["actionsRef"]>["current"] >(null); const pressedOpen = useRef(false); - // An opened thread takes focus itself. Base UI returns focus to the trigger - // in a microtask after closing, which can land after, and steal it from, the - // thread. + // An opened thread or mention takes focus itself. Base UI returns focus to + // the trigger in a microtask after closing, which can land after, and steal + // it from, what opened. const handoff = useRef(false); // Hover opens a transient preview. Selecting its row may still take focus. const [preview, setPreview] = useState(false); @@ -246,11 +253,12 @@ export function ChannelActivityPopover({ label?: string; } | null)[]; const keyLabels = publicKeyLabels(activeAgents); - const hasActivity = items.length > 0 || activeAgents.length > 0; + const unread = items.length + mentions.length; + const hasActivity = unread > 0 || activeAgents.length > 0; useEffect(() => { if (!hasActivity && open) actions.current?.close(); }, [hasActivity, open]); - const stale = items.length > 0 && snapshot.freshness === "stale"; + const stale = unread > 0 && snapshot.freshness === "stale"; // Row actions close through Base UI so its hover/press state resets; the // sidebar trigger stays mounted after the navigation they start. const close = () => { @@ -291,12 +299,12 @@ export function ChannelActivityPopover({ handoff.current = false; setNow(Date.now()); } - if (next && items.length) + if (next && unread) void session.profiles .ensure( [ ...new Set( - items.map( + [...mentions, ...items].map( ({ authorId, workflowOwnerId }) => workflowOwnerId ?? authorId, ), @@ -368,6 +376,37 @@ export function ChannelActivityPopover({ })} )} + {mentions.length > 0 && ( +
+

+ Mentions +

+ {mentions.map((mention) => ( + { + handoff.current = true; + close(); + onOpenMention(mention); + }} + read={() => + session.unread.markThrough( + { + kind: "message", + channelId: mention.channelId, + messageId: mention.messageId, + }, + mention.messageId, + ) + } + /> + ))} +
+ )} {items.length > 0 && (

@@ -377,13 +416,24 @@ export function ChannelActivityPopover({ { + onOpen={() => { handoff.current = true; close(); - onOpenThread(selected); + onOpenThread(item); }} + read={() => + session.unread.markThrough( + { + kind: "thread", + channelId: item.channelId, + rootId: item.rootId, + }, + item.latestMessageId, + ) + } /> ))}

diff --git a/src/bundled/channels/ChannelSidebarItem.tsx b/src/bundled/channels/ChannelSidebarItem.tsx index 6b2bd546f4..9d65ea1924 100644 --- a/src/bundled/channels/ChannelSidebarItem.tsx +++ b/src/bundled/channels/ChannelSidebarItem.tsx @@ -48,6 +48,7 @@ type ItemProps = { selected: string | undefined; onSelect: (id: string) => void; onOpenThread: (channelId: string, rootId: string) => void; + onOpenMessage?: (channelId: string, messageId: string) => void; onOpenWorkingAgent?: ( channelId: string, agent: string, @@ -143,6 +144,7 @@ function ChannelSidebarItemCore({ selected, onSelect, onOpenThread, + onOpenMessage, onOpenWorkingAgent, onOpenAgentActivity, onHideDm, @@ -267,6 +269,9 @@ function ChannelSidebarItemCore({ {...(onOpenWorkingAgent ? { onOpenWorkingAgent } : {})} {...(onOpenAgentActivity ? { onOpenAgentActivity } : {})} onOpenThread={(item) => onOpenThread(item.channelId, item.rootId)} + onOpenMention={(item) => + onOpenMessage?.(item.channelId, item.messageId) + } trigger={trigger} /> ); diff --git a/src/bundled/channels/ChannelSidebarMenuItem.test.tsx b/src/bundled/channels/ChannelSidebarMenuItem.test.tsx index c398e0ad57..5601b3ec32 100644 --- a/src/bundled/channels/ChannelSidebarMenuItem.test.tsx +++ b/src/bundled/channels/ChannelSidebarMenuItem.test.tsx @@ -93,10 +93,24 @@ it("keeps menu choices in sync with ordinary, attention, thread and working side expect(item.querySelector("[data-channel-priority]")).toBeNull(); act(() => { unread = { ...unread, attentionCount: 1 }; + activity = { + ...activity, + mentions: [ + { + channelId: "alpha", + messageId: "mention", + authorId: "peer", + createdAt: 2, + preview: "Ask", + }, + ], + }; publish(); }); expect(label).not.toHaveAttribute("data-ordinary"); + // A mention alone keeps the priority dot. expect(item.querySelector("[data-channel-priority]")).toBeInTheDocument(); + expect(item.querySelector("[data-channel-activity]")).toBeNull(); act(() => { activity = { ...activity, @@ -114,9 +128,10 @@ it("keeps menu choices in sync with ordinary, attention, thread and working side }; publish(); }); + // One dot names both. expect( - screen.getByRole("img", { name: "1 unread thread" }), - ).toBeInTheDocument(); + screen.getByRole("img", { name: "1 unread mention, 1 unread thread" }), + ).toHaveAttribute("title", "1 unread mention, 1 unread thread"); expect(item.querySelector("[data-channel-priority]")).toBeNull(); act(() => { agents = { @@ -139,7 +154,7 @@ it("keeps menu choices in sync with ordinary, attention, thread and working side ).toBeInTheDocument(); act(() => { unread = { ...unread, observedCount: 0, attentionCount: 0 }; - activity = { ...activity, items: [] }; + activity = { ...activity, items: [], mentions: [] }; publish(); }); expect(label).not.toHaveAttribute("data-ordinary"); diff --git a/src/bundled/channels/UnreadBadge.tsx b/src/bundled/channels/UnreadBadge.tsx index 2a9639845c..b604ba5142 100644 --- a/src/bundled/channels/UnreadBadge.tsx +++ b/src/bundled/channels/UnreadBadge.tsx @@ -134,7 +134,9 @@ export function UnreadBadge({ const unreadLabel = manual ? `Marked unread${snapshot.manual === "local-only" ? " on this device only" : ""}` : `${count} observed unread messages${snapshot.freshness === "stale" ? "; may be out of date" : ""}. Not an exact total.`; - const threadLabel = `${threadCount} unread ${threadCount === 1 ? "thread" : "threads"}${activity.freshness === "stale" ? "; may be out of date" : ""}`; + // Mentions share the thread dot; a mention alone keeps the priority dot. + const mentionCount = activity.mentions?.length ?? 0; + const activityLabel = `${mentionCount ? `${mentionCount} unread ${mentionCount === 1 ? "mention" : "mentions"}, ` : ""}${threadCount} unread ${threadCount === 1 ? "thread" : "threads"}${activity.freshness === "stale" ? "; may be out of date" : ""}`; return ( <> {label !== undefined && ( @@ -166,15 +168,15 @@ export function UnreadBadge({ )} {threadCount > 0 && (hideUnreadDots ? ( - {threadLabel} + {activityLabel} ) : ( ))} diff --git a/src/features/channel-navigation/ChannelInventory.test.tsx b/src/features/channel-navigation/ChannelInventory.test.tsx index 8cb9ecccdb..ba542c45d8 100644 --- a/src/features/channel-navigation/ChannelInventory.test.tsx +++ b/src/features/channel-navigation/ChannelInventory.test.tsx @@ -812,6 +812,70 @@ it("closes a removed DM's menu and focuses its neighbor before the exit ends", a expect(bob).toHaveFocus(); }); +it("opens an Activity mention at its message, with no thread root", async () => { + const message = "e".repeat(64); + const h = fixture(); + const mention = { + channelId: "beta", + messageId: message, + authorId: "b".repeat(64), + preview: "Can you sign off?", + createdAt: 1, + }; + // Stable per channel, as external-store snapshots must be. + const snapshots = new Map( + ["alpha", "beta", "gamma"].map((id) => [ + id, + { + channelId: id, + items: [], + mentions: id === "beta" ? [mention] : [], + coverage: "observed" as const, + freshness: "observed" as const, + }, + ]), + ); + const unread = h.session.unread; + h.session.unread = { + ...unread, + activity: (id) => snapshots.get(id) ?? unread.activity(id), + subscribeActivity: () => () => {}, + }; + // The sidebar reads back the attempt it started to hand off focus return. + let opened: unknown; + vi.mocked(h.navigator.open).mockImplementation(async (next) => { + opened = next; + return { status: "opened" }; + }); + Object.assign(h.navigator, { + snapshot: () => ({ + attempt: { + id: "visit", + entry: { id: "entry", target: opened }, + signal: new AbortController().signal, + }, + }), + }); + render(h.view("alpha")); + const beta = await screen.findByRole("button", { name: /^beta/ }); + beta.focus(); + await userEvent.keyboard("{Enter}"); + const popup = await screen.findByRole("dialog", { name: "Activity in beta" }); + await userEvent.click( + within(popup).getByRole("button", { name: /^Open unread mention from/ }), + ); + expect(h.navigator.open).toHaveBeenLastCalledWith( + expect.objectContaining({ + kind: "conversation", + channelId: "beta", + messageId: message, + }), + ); + expect(vi.mocked(h.navigator.open).mock.lastCall?.[0]).not.toHaveProperty( + "threadRootId", + ); +}); + it("copies a channel link that opens the same channel", async () => { const writeText = vi .fn() diff --git a/src/features/channel-navigation/ChannelInventory.tsx b/src/features/channel-navigation/ChannelInventory.tsx index 14302dd469..114fb111ca 100644 --- a/src/features/channel-navigation/ChannelInventory.tsx +++ b/src/features/channel-navigation/ChannelInventory.tsx @@ -1544,6 +1544,7 @@ export function ChannelInventory({ selected={composingMessage ? undefined : selected} onSelect={select} onOpenThread={openActivityThread} + onOpenMessage={openActivityMessage} onOpenWorkingAgent={openWorkingAgent} onOpenAgentActivity={openAgentActivity} menuEnabled diff --git a/src/features/channel-navigation/SoloInventory.test.tsx b/src/features/channel-navigation/SoloInventory.test.tsx index 21431962b3..4a1bcf87b3 100644 --- a/src/features/channel-navigation/SoloInventory.test.tsx +++ b/src/features/channel-navigation/SoloInventory.test.tsx @@ -162,6 +162,43 @@ it("offers unread thread navigation and mark-read through the shared activity po }), ); }); +it("opens an unread mention from the shared activity popover at its message, with no thread root", async () => { + const f = fixture(); + const user = userEvent.setup(); + const message = "e".repeat(64); + f.threads({ + channelId: id, + items: [], + mentions: [ + { + channelId: id, + messageId: message, + authorId: agent, + preview: "Can you sign off?", + createdAt: 1, + }, + ], + coverage: "observed", + freshness: "observed", + }); + screen.getByRole("button", { name: /^Planning/ }).focus(); + await user.keyboard("{Enter}"); + await user.click( + within( + screen.getByRole("dialog", { name: "Activity in Planning" }), + ).getByRole("button", { + name: "Open unread mention from Owned Agent: Can you sign off?", + }), + ); + expect(f.open).toHaveBeenLastCalledWith( + expect.objectContaining({ + kind: "conversation", + channelId: id, + messageId: message, + }), + ); + expect(f.open.mock.lastCall?.[0]).not.toHaveProperty("threadRootId"); +}); it("opens a typing-only agent's profile activity in its Solo chat", async () => { const f = fixture(); f.typing([{ channelId: id, pubkey: agent }]); diff --git a/src/features/channel-navigation/SoloInventory.tsx b/src/features/channel-navigation/SoloInventory.tsx index 33fcb20d67..b679a8c4eb 100644 --- a/src/features/channel-navigation/SoloInventory.tsx +++ b/src/features/channel-navigation/SoloInventory.tsx @@ -187,6 +187,9 @@ export function SoloInventory({ onSelect={() => void open(item.id)} onRename={rename} onOpenThread={(thread) => openMessage(thread.channelId, thread.rootId)} + onOpenMention={(mention) => + openMessage(mention.channelId, mention.messageId) + } onOpenWorkingAgent={(id, _agent, messageId) => { if (messageId) openMessage(id, messageId); else void open(id); @@ -393,6 +396,7 @@ function SoloConversationRow({ onSelect, onRename, onOpenThread, + onOpenMention, onOpenWorkingAgent, onOpenAgentActivity, }: { @@ -409,7 +413,10 @@ function SoloConversationRow({ onRename: (() => boolean) | undefined; } & Pick< ComponentProps, - "onOpenThread" | "onOpenWorkingAgent" | "onOpenAgentActivity" + | "onOpenThread" + | "onOpenMention" + | "onOpenWorkingAgent" + | "onOpenAgentActivity" >) { const activation = useSoloRowActivation( navigation, @@ -433,6 +440,7 @@ function SoloConversationRow({ agents={agents} agentProfiles={agentProfiles} onOpenThread={onOpenThread} + onOpenMention={onOpenMention} {...(onOpenWorkingAgent ? { onOpenWorkingAgent } : {})} {...(onOpenAgentActivity ? { onOpenAgentActivity } : {})} trigger={ diff --git a/src/features/relay/unread.test.ts b/src/features/relay/unread.test.ts index 2fbb6f54d6..46145664bc 100644 --- a/src/features/relay/unread.test.ts +++ b/src/features/relay/unread.test.ts @@ -840,6 +840,53 @@ it("resolves every activity item through its own channel hierarchy", () => { expect(h.session.unread.activity("other").items).toEqual([]); }); +it("lists unread top-level mentions apart from thread activity, never for a DM or a broadcast", async () => { + const h = setup(); + h.grant("room"); + h.grant("dm"); + h.emit([metadata(h.relay, "dm", "DM", 11, [["t", "dm"]])]); + const tag = [["p", h.viewer.pubkey]]; + const root = message(h.viewer, "room", "My thread", 20); + const older = message(h.alice, "room", "First ask", 21, tag); + const reply = message(h.alice, "room", "In your thread", 22, [ + ["e", root.id, "", "reply"], + ...tag, + ]); + const broadcast = message(h.alice, "room", "Everyone", 23, [ + ["broadcast", "1"], + ]); + h.emit([root, older, reply, broadcast]); + const changed = vi.fn(); + h.session.unread.subscribeActivity("room", changed); + const newer = message(h.alice, "room", "Second ask", 24, tag); + h.emit([newer, message(h.alice, "dm", "Direct ask", 25, tag)]); + + expect(changed).toHaveBeenCalled(); + // The reply's mention stays with its thread. + expect(h.session.unread.activity("room")).toMatchObject({ + items: [{ rootId: root.id, latestMessageId: reply.id }], + mentions: [ + { + channelId: "room", + messageId: newer.id, + authorId: h.alice.pubkey, + createdAt: 24, + preview: "Second ask", + }, + { messageId: older.id, createdAt: 21, preview: "First ask" }, + ], + }); + expect(h.session.unread.activity("dm").mentions).toEqual([]); + + await h.session.unread.markThrough( + { kind: "message", channelId: "room", messageId: newer.id }, + newer.id, + ); + expect(h.session.unread.activity("room").mentions).toEqual([ + expect.objectContaining({ messageId: older.id }), + ]); +}); + it("distinguishes unknown thread-activity evidence from observed evidence", () => { const h = setup(); h.grant("room"); diff --git a/src/features/relay/unread.ts b/src/features/relay/unread.ts index f20ab55d7c..633b0e3877 100644 --- a/src/features/relay/unread.ts +++ b/src/features/relay/unread.ts @@ -76,10 +76,21 @@ export type ThreadActivityItem = Readonly<{ preview: string; unreadCount: number; }>; +export type MentionActivityItem = Readonly<{ + channelId: string; + messageId: string; + authorId: string; + workflowOwnerId?: string | undefined; + createdAt: number; + preview: string; +}>; export type ThreadActivitySnapshot = Readonly<{ channelId: string; /** null means activity evidence is unknown or access is denied. */ items: readonly ThreadActivityItem[] | null; + /** Unread top-level messages that mention the viewer, newest first. A + * reply's mention is listed under its thread instead. */ + mentions?: readonly MentionActivityItem[]; coverage: "unknown" | "observed"; freshness: "unknown" | "observed" | "stale"; error?: string | undefined; @@ -1074,11 +1085,37 @@ export function createUnread({ const state = reads.state(); const dm = isDm(channelId); const grouped = new Map(); - // Only unread thread activity is presented; most publishes have none. + const mentions: MentionActivityItem[] = []; + // Only unread activity is presented; most publishes have none. let presented: Map | undefined; + const previewOf = (event: RelayEvent) => { + presented ??= new Map( + folded(channelId).map((message) => [message.id, message.content]), + ); + return presented.get(event.id) ?? event.content; + }; for (const evidence of byChannel.get(channelId) ?? []) { const { event, rootId } = evidence; want(evidence, dm); + // A top-level mention is listed by message. A DM never has one, and a + // broadcast alone is not one. + if (!evidence.parentId) { + if ( + category(evidence, dm) === "mention" && + isUnread(evidence, state, dm) + ) + mentions.push( + Object.freeze({ + channelId, + messageId: event.id, + authorId: event.pubkey, + workflowOwnerId: workflowOwner(event, workflowAuthority), + createdAt: event.created_at, + preview: previewOf(event), + }), + ); + continue; + } if ( !rootId || !activityEvidence(evidence) || @@ -1086,10 +1123,7 @@ export function createUnread({ ) continue; const current = grouped.get(rootId); - presented ??= new Map( - folded(channelId).map((message) => [message.id, message.content]), - ); - const preview = presented.get(event.id) ?? event.content; + const preview = previewOf(event); if (!current) { grouped.set( rootId, @@ -1135,6 +1169,12 @@ export function createUnread({ a.latestMessageId.localeCompare(b.latestMessageId), ), ), + mentions: Object.freeze( + mentions.sort( + (a, b) => + b.createdAt - a.createdAt || a.messageId.localeCompare(b.messageId), + ), + ), coverage: "observed", freshness, ...(error ? { error } : {}), @@ -1162,7 +1202,18 @@ export function createUnread({ item.preview === other.preview && item.unreadCount === other.unreadCount ); - }))); + }))) && + (a.mentions?.length ?? 0) === (b.mentions?.length ?? 0) && + (a.mentions ?? []).every((mention, index) => { + const other = b.mentions?.[index]; + return ( + mention.messageId === other?.messageId && + mention.authorId === other.authorId && + mention.workflowOwnerId === other.workflowOwnerId && + mention.createdAt === other.createdAt && + mention.preview === other.preview + ); + }); function activity(channelId: string) { const previous = activitySnapshots.get(channelId); if (previous && !activityDirty.delete(channelId)) return previous; diff --git a/tests/browser/thread-unread.spec.mjs b/tests/browser/thread-unread.spec.mjs index 513c91fc37..f60189f50e 100644 --- a/tests/browser/thread-unread.spec.mjs +++ b/tests/browser/thread-unread.spec.mjs @@ -7,6 +7,7 @@ import { readJournal, releaseReadingFocus, } from "./reading.mjs"; +import { openPage } from "./navigation.mjs"; import { channelUnread } from "./unread-state.mjs"; test.use({ @@ -1160,3 +1161,78 @@ test("a live reply in a main-pane thread stays unread while focus is in the side await page.clock.runFor(1000); expect(await unread(x.id)).toBeGreaterThan(0); }); + +test.describe("top-level mention activity", () => { + test.use({ historyCounts: { alpha: 20, beta: 0 } }); + + // Browser-only: hover opening, the exact opening in the timeline and the + // visibility read depend on real layout and focus. + test("a mention-only row opens Activity, and opening its mention reads it", async ({ + page, + app, + }) => { + await open(page, app); + // Load Beta's window first: an exact opening that arrives while the window + // is still settling opens the message as its own tab instead. + const beta = page.locator('button[data-channel-id="beta"]'); + await beta.click(); + await expect( + page.getByRole("textbox", { name: "Message #Beta", exact: true }), + ).toBeVisible(); + await page.locator('button[data-channel-id="alpha"]').click(); + await expect( + page.getByRole("textbox", { name: "Message #Alpha", exact: true }), + ).toBeVisible(); + const mention = app.append( + "primary", + "beta", + "Can you sign off on Beta?", + true, + false, + undefined, + undefined, + [["p", app.viewer]], + ); + await openPage(page, "Inbox"); + const inbox = page.getByRole("region", { name: "Inbox", exact: true }); + const row = inbox + .getByRole("list", { name: "Inbox conversations" }) + .getByRole("listitem") + .filter({ has: page.getByRole("button", { name: / in #Beta$/ }) }); + await expect(row.getByRole("img", { name: "Unread" })).toBeVisible(); + await openPage(page, "Messages"); + // A mention alone keeps the priority dot. + await expect(beta.locator("[data-channel-priority]")).toBeVisible(); + await expect(beta.locator("[data-channel-activity]")).toHaveCount(0); + await beta.hover(); + const popover = page.getByRole("dialog", { name: "Activity in Beta" }); + const item = popover + .getByRole("region", { name: "Mentions" }) + .getByRole("button", { + name: /^Open unread mention from .+: Can you sign off on Beta\?$/, + }); + await expect(item).toBeVisible(); + await expect( + popover.getByRole("region", { name: "Unread threads" }), + ).toHaveCount(0); + await item.click(); + const message = page.locator( + `[data-channel-timeline] [data-message-id="${mention.id}"]`, + ); + await expect(message).toBeInViewport(); + await expect(message).toBeFocused(); + await expect + .poll(() => + page.evaluate( + (id) => + window.fixtureRelay.snapshot().session.unread.attention("beta", id) + .unread, + mention.id, + ), + ) + .toBe(false); + await openPage(page, "Inbox"); + await expect(row).toBeVisible(); + await expect(row.getByRole("img", { name: "Unread" })).toHaveCount(0); + }); +}); From 4810588486bfa6cf1eae52f174bb92606e596c97 Mon Sep 17 00:00:00 2001 From: mtoohey Date: Thu, 5 Nov 2026 15:41:28 +0000 Subject: [PATCH 3/6] fix(messages): read a revealed row that lands a sub-pixel past the edge Opening an off-screen top-level message exactly, such as a mention from sidebar Activity, revealed and focused it but never read it. Scroll offsets snap to whole pixels while rows sit at fractional offsets, so scrollIntoView({ block: "start" }) left the row 0.047px above the scroller (row top 96.953 vs 97 in Chromium and WebKit). use-reading's shown() required the row to be strictly inside, so the row stayed unread, and stayed in Activity, until the reader scrolled. shown() now allows one pixel at each edge, like the 1px slack the bottom check already uses. It covers any fractional scroll position, not just this reveal. It can't count a visibly clipped row: the timeline has no sticky header or scroll padding over rows, and shown() measures against the scroller box, so overlays were never counted anyway. A cached-window blip that opens the message as a lone tab is a separate gap and is unchanged. A new use-reading case pins the tolerance at the top and bottom edges. A new browser journey opens a mention above the bottom from Activity: it is revealed, focused and read, leaves Activity, and its Inbox row loses Unread. Both fail without the fix. docs/unread.md notes the tolerance. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mtoohey Signed-off-by: Matt Toohey --- docs/unread.md | 8 ++- src/features/messages/use-reading.test.ts | 18 ++++++ src/features/messages/use-reading.ts | 10 +-- tests/browser/thread-unread.spec.mjs | 78 +++++++++++++++++++++++ 4 files changed, 107 insertions(+), 7 deletions(-) diff --git a/docs/unread.md b/docs/unread.md index 97165e636d..81ab838b04 100644 --- a/docs/unread.md +++ b/docs/unread.md @@ -51,9 +51,11 @@ content does not qualify. Pane motion completion restarts observation after unchanged rows enter the viewport. Another pane, a dialog, background window, preload, and mounted virtualizer overscan do not qualify. -Away from the bottom, observations mark individual fully visible messages. At the -physical bottom, 300 ms visibility of the newest message's bottom edge also earns -catch-up (including a message taller than the viewport): +Away from the bottom, observations mark individual fully visible messages, to +within a pixel: a reveal's whole-pixel scroll can leave a row a fraction of a +pixel past the edge. At the physical bottom, 300 ms visibility of the newest +message's bottom edge also earns catch-up (including a message taller than the +viewport): - `activity:` uses the newest retained verified event timestamp at dwell completion, including replies newer than the visible top-level head. It reads diff --git a/src/features/messages/use-reading.test.ts b/src/features/messages/use-reading.test.ts index 157b46528b..9e69e8df78 100644 --- a/src/features/messages/use-reading.test.ts +++ b/src/features/messages/use-reading.test.ts @@ -199,6 +199,24 @@ it("reports only fully visible settled evidence after dwell, not mounted oversca vi.advanceTimersByTime(1); expect(h.leases[0]?.observe).toHaveBeenCalledExactlyOnceWith(["visible"]); }); +it("a row within a pixel of the viewport's edge is fully visible; one clipped further is not", () => { + const h = setup(); + // Scroll offsets snap to whole pixels but rows sit at fractional offsets, so + // an exact reveal can leave its row a sliver above the scroller's top. + h.setRows([ + row("revealed", -0.047, 40), + row("above", -1.5, 40), + row("last", 460, 500.5), + row("below", 460, 501.5), + ]); + h.mutation(); + vi.advanceTimersToNextFrame(); // Row changes are measured in the next frame. + vi.advanceTimersByTime(300); + expect(h.dwelled()?.observe).toHaveBeenCalledExactlyOnceWith([ + "revealed", + "last", + ]); +}); it("a burst of scrolls and row changes cancels dwell at once but measures rows once, in the next frame", () => { const h = setup(); const measured = vi.spyOn(h.element, "getBoundingClientRect"); diff --git a/src/features/messages/use-reading.ts b/src/features/messages/use-reading.ts index b39ed12262..4dd7d8643f 100644 --- a/src/features/messages/use-reading.ts +++ b/src/features/messages/use-reading.ts @@ -118,6 +118,8 @@ export function useReading({ for (const observed of observing) observed.dispose(); observing.clear(); } + // Within a pixel: scroll offsets snap to whole pixels but rows sit at + // fractional ones, so an exact reveal can leave its row a sliver past an edge. function shown(row: HTMLElement, viewport: DOMRect) { const bounds = row.getBoundingClientRect(); return ( @@ -125,10 +127,10 @@ export function useReading({ row.dataset.huddleRow === undefined && bounds.height > 0 && bounds.width > 0 && - bounds.top >= Math.max(viewport.top, 0) && - bounds.bottom <= Math.min(viewport.bottom, window.innerHeight) && - bounds.left >= Math.max(viewport.left, 0) && - bounds.right <= Math.min(viewport.right, window.innerWidth) + bounds.top >= Math.max(viewport.top, 0) - 1 && + bounds.bottom <= Math.min(viewport.bottom, window.innerHeight) + 1 && + bounds.left >= Math.max(viewport.left, 0) - 1 && + bounds.right <= Math.min(viewport.right, window.innerWidth) + 1 ); } function visibleIds() { diff --git a/tests/browser/thread-unread.spec.mjs b/tests/browser/thread-unread.spec.mjs index f60189f50e..b865b94dab 100644 --- a/tests/browser/thread-unread.spec.mjs +++ b/tests/browser/thread-unread.spec.mjs @@ -1235,4 +1235,82 @@ test.describe("top-level mention activity", () => { await expect(row).toBeVisible(); await expect(row.getByRole("img", { name: "Unread" })).toHaveCount(0); }); + + // Browser-only: the reveal's fractional scroll position and the visibility + // read depend on real layout. + test("opening a mention above the bottom from Activity reads it there", async ({ + page, + app, + }) => { + await open(page, app); + // Leave Alpha, its window loaded, so its new messages arrive unseen. + await page.locator('button[data-channel-id="beta"]').click(); + await expect( + page.getByRole("textbox", { name: "Message #Beta", exact: true }), + ).toBeVisible(); + const mention = app.append( + "primary", + "alpha", + "Can you sign off on Alpha?", + true, + false, + undefined, + undefined, + [["p", app.viewer]], + ); + // Enough later messages that revealing the mention cannot reach the + // bottom, whose catch-up would read it anyway. + for (let index = 0; index < 40; index++) + app.append("primary", "alpha", `Later update ${index}`, true, false); + await openPage(page, "Inbox"); + const row = page + .getByRole("region", { name: "Inbox", exact: true }) + .getByRole("list", { name: "Inbox conversations" }) + .getByRole("listitem") + .filter({ hasText: "Can you sign off on Alpha?" }); + await expect(row.getByRole("img", { name: "Unread" })).toBeVisible(); + await openPage(page, "Messages"); + const alpha = page.locator('button[data-channel-id="alpha"]'); + await alpha.hover(); + const popover = page.getByRole("dialog", { name: "Activity in Alpha" }); + const mentions = popover.getByRole("region", { name: "Mentions" }); + await mentions + .getByRole("button", { + name: /^Open unread mention from .+: Can you sign off on Alpha\?$/, + }) + .click(); + const message = page.locator( + `[data-channel-timeline] [data-message-id="${mention.id}"]`, + ); + await expect(message).toBeInViewport(); + await expect(message).toBeFocused(); + expect( + await page + .getByRole("region", { name: "Channel message history" }) + .evaluate( + (element) => + element.scrollHeight - element.clientHeight - element.scrollTop, + ), + "the reveal stops above the bottom", + ).toBeGreaterThan(1); + await expect + .poll(() => + page.evaluate( + (id) => + window.fixtureRelay.snapshot().session.unread.attention("alpha", id) + .unread, + mention.id, + ), + ) + .toBe(false); + // Alpha's unread threads keep Activity open; the mention has left it. + await alpha.hover(); + await expect( + popover.getByRole("region", { name: "Unread threads" }), + ).toBeVisible(); + await expect(mentions).toHaveCount(0); + await openPage(page, "Inbox"); + await expect(row).toBeVisible(); + await expect(row.getByRole("img", { name: "Unread" })).toHaveCount(0); + }); }); From fc103d39e008db5ead57dbe5edcee352f355bb13 Mon Sep 17 00:00:00 2001 From: mtoohey Date: Thu, 5 Nov 2026 15:53:53 +0000 Subject: [PATCH 4/6] fix(channels): announce a mention-only row's mentions to assistive tech On a row whose only Activity is top-level mentions, nothing named them to a screen reader. The priority dot is aria-hidden, and the activity label only rendered with a thread, so the row read "Beta 1 observed unread messages. Not an exact total." A muted row hides the priority dot too, so the label was the only place to name them. The badge now adds a screen-reader-only "N unread mention(s)", with the same "; may be out of date" suffix, whenever there are mentions and no thread, muted or not. With a thread, the dot's label already names both, so nothing is announced twice. The visible dot, its CSS and [data-channel-priority] are unchanged. In Chromium's accessibility tree and Playwright's name in Chromium and WebKit, the row now reads "Beta 1 observed unread messages. Not an exact total. 1 unread mention", so name selectors like /^Beta\b/ still match. ChannelSidebarItem's onOpenMessage is now required, so a row can't list a mention that opens nothing. ChannelInventory, its only consumer, already passes it. Test renders and two browser fixtures pass it too. The ThreadActivitySnapshot.mentions doc now says a reply whose thread root is unresolved is in neither section. Behaviour is unchanged. New ChannelSidebarMenuItem cases cover the mention-only label, muted and unmuted, and that it goes away once a thread arrives. They fail without the badge change. unread.test.ts now pins three cases with no production change: a quiet audience=agents mention is never listed, a top-level mention with an unmarked or quote e tag still is, and activity("room") keeps its identity through an unrelated publish. Each fails against a mutation that breaks it. docs/unread.md now covers the mention-only label and the muted-row sentence. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mtoohey Signed-off-by: Matt Toohey --- docs/unread.md | 11 +-- .../channels/ChannelSidebarItem.test.tsx | 4 + src/bundled/channels/ChannelSidebarItem.tsx | 4 +- .../channels/ChannelSidebarMenuItem.test.tsx | 80 ++++++++++++++++++- src/bundled/channels/DmTypingBadge.test.tsx | 1 + src/bundled/channels/UnreadBadge.tsx | 10 ++- src/features/relay/unread.test.ts | 31 +++++++ src/features/relay/unread.ts | 3 +- tests/fixtures/nav-thinking.tsx | 2 + tests/fixtures/user-status.tsx | 1 + 10 files changed, 134 insertions(+), 13 deletions(-) diff --git a/docs/unread.md b/docs/unread.md index 81ab838b04..3021fcbf13 100644 --- a/docs/unread.md +++ b/docs/unread.md @@ -225,7 +225,7 @@ mentions, manual unread or thread replies. Muted channel and DM sidebar rows show a crossed-out bell immediately after their names, even with no unread activity. They hide all unread dots, including thread activity dots. Mentions and thread activity remain available through the row's activity -popover, and the thread dot's label remains accessible to assistive technology. Unmute restores +popover, and their counts remain accessible to assistive technology. Unmute restores the dots from retained unread evidence. The promoted Bestie DM also hides muted unread dots; its label follows the same mute rule. Solo chat labels strengthen for any observed unread message, including mentions, @@ -237,10 +237,11 @@ its hover/focus/click popover groups unread replies by canonical thread root and opens the existing thread panel. Above the threads it lists unread top-level messages that mention the viewer, newest first; a mention opens at its message and is read like any visit. A reply's mention stays under its thread; DMs and broadcasts list no -mentions. A row with only mentions keeps its priority dot and still opens the -popover; with threads, the dot's label counts both ("1 unread mention, 1 unread -thread"). Overlapping priority, mention and thread activity never produce -duplicate dots. Merely revealing the popover does not acknowledge a mention or reply. +mentions. A row with only mentions keeps its priority dot, names them to +assistive technology ("1 unread mention") and still opens the popover; with +threads, the dot's label counts both ("1 unread mention, 1 unread thread"). +Overlapping priority, mention and thread activity never produce duplicate dots. +Merely revealing the popover does not acknowledge a mention or reply. A local manual-unread mark strengthens the label without fabricating priority; the underlying observed count remains available. Channel Settings → Diagnostics exposes explicit actions and Unread status/retry. Unknown and diff --git a/src/bundled/channels/ChannelSidebarItem.test.tsx b/src/bundled/channels/ChannelSidebarItem.test.tsx index 0e9c323554..b80ebdac91 100644 --- a/src/bundled/channels/ChannelSidebarItem.test.tsx +++ b/src/bundled/channels/ChannelSidebarItem.test.tsx @@ -127,6 +127,7 @@ function itemProps( selected: undefined, onSelect: vi.fn(), onOpenThread: vi.fn(), + onOpenMessage: vi.fn(), }; } @@ -275,6 +276,7 @@ it("keeps live unread updates and uses replacement session callbacks across row selected: undefined, onSelect, onOpenThread: vi.fn(), + onOpenMessage: vi.fn(), }; const view = render(); const row = screen.getByRole("button", { name: "Alpha" }); @@ -353,6 +355,7 @@ it("badges one-to-one DM avatars with live presence and sanitizes their media", selected: undefined, onSelect: vi.fn(), onOpenThread: vi.fn(), + onOpenMessage: vi.fn(), }; const view = render(); const row = screen.getByRole("button", { name: /^Alice$/ }); @@ -448,6 +451,7 @@ it.each(["ContextMenu", "F10"])( selected={undefined} onSelect={onSelect} onOpenThread={vi.fn()} + onOpenMessage={vi.fn()} menuEnabled sectionKey="group:work" onOpenMenu={onOpenMenu} diff --git a/src/bundled/channels/ChannelSidebarItem.tsx b/src/bundled/channels/ChannelSidebarItem.tsx index 9d65ea1924..29d3857a45 100644 --- a/src/bundled/channels/ChannelSidebarItem.tsx +++ b/src/bundled/channels/ChannelSidebarItem.tsx @@ -48,7 +48,7 @@ type ItemProps = { selected: string | undefined; onSelect: (id: string) => void; onOpenThread: (channelId: string, rootId: string) => void; - onOpenMessage?: (channelId: string, messageId: string) => void; + onOpenMessage: (channelId: string, messageId: string) => void; onOpenWorkingAgent?: ( channelId: string, agent: string, @@ -270,7 +270,7 @@ function ChannelSidebarItemCore({ {...(onOpenAgentActivity ? { onOpenAgentActivity } : {})} onOpenThread={(item) => onOpenThread(item.channelId, item.rootId)} onOpenMention={(item) => - onOpenMessage?.(item.channelId, item.messageId) + onOpenMessage(item.channelId, item.messageId) } trigger={trigger} /> diff --git a/src/bundled/channels/ChannelSidebarMenuItem.test.tsx b/src/bundled/channels/ChannelSidebarMenuItem.test.tsx index 5601b3ec32..05ae36b258 100644 --- a/src/bundled/channels/ChannelSidebarMenuItem.test.tsx +++ b/src/bundled/channels/ChannelSidebarMenuItem.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; -import { act, cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; import { createRelaySession } from "../../features/relay/session"; @@ -108,9 +108,10 @@ it("keeps menu choices in sync with ordinary, attention, thread and working side publish(); }); expect(label).not.toHaveAttribute("data-ordinary"); - // A mention alone keeps the priority dot. + // A mention alone keeps the priority dot, and names itself to assistive tech. expect(item.querySelector("[data-channel-priority]")).toBeInTheDocument(); expect(item.querySelector("[data-channel-activity]")).toBeNull(); + expect(item).toHaveAccessibleName(/1 unread mention$/); act(() => { activity = { ...activity, @@ -128,10 +129,11 @@ it("keeps menu choices in sync with ordinary, attention, thread and working side }; publish(); }); - // One dot names both. + // One dot names both, so the mention is not announced twice. expect( screen.getByRole("img", { name: "1 unread mention, 1 unread thread" }), ).toHaveAttribute("title", "1 unread mention, 1 unread thread"); + expect(within(item).queryByText(/unread mention/)).toBeNull(); expect(item.querySelector("[data-channel-priority]")).toBeNull(); act(() => { agents = { @@ -178,3 +180,75 @@ it("keeps menu choices in sync with ordinary, attention, thread and working side expect(unreadListeners.size).toBe(0); expect(agentListeners.size).toBe(0); }); + +it.each([ + ["an unmuted", false], + ["a muted", true], +])( + "announces the mentions of %s row with no unread thread", + async (_, muted) => { + const owner = createRelaySession(null); + owners.push(owner); + const unread = { + ...owner.session.unread.snapshot({ kind: "channel", channelId: "alpha" }), + observedCount: 2, + attentionCount: 2, + }; + const mention = { + channelId: "alpha", + messageId: "newer", + authorId: "peer", + createdAt: 2, + preview: "Ask", + }; + const activity = { + ...owner.session.unread.activity("alpha"), + items: [], + mentions: [mention, { ...mention, messageId: "older", createdAt: 1 }], + freshness: "stale" as const, + }; + const session = { + ...owner.session, + unread: { + ...owner.session.unread, + snapshot: () => unread, + activity: () => activity, + }, + sidebarPreferences: { + ...owner.session.sidebarPreferences, + snapshot: () => ({ + status: "ready" as const, + data: { + sections: [], + assignments: {}, + starred: [], + muted: muted ? ["alpha"] : [], + }, + }), + }, + }; + render( + + Choose channel + + {}} + /> + + , + ); + const item = await screen.findByRole("menuitem", { name: /^Alpha/ }); + // The priority dot is aria-hidden, and a muted row hides it. + expect(!!item.querySelector("[data-channel-priority]")).toBe(!muted); + expect(item.querySelector("[data-channel-activity]")).toBeNull(); + expect( + within(item).getByText("2 unread mentions; may be out of date"), + ).toHaveClass("sr-only"); + expect(item).toHaveAccessibleName( + /unread messages\. Not an exact total\.\s*2 unread mentions; may be out of date$/, + ); + }, +); diff --git a/src/bundled/channels/DmTypingBadge.test.tsx b/src/bundled/channels/DmTypingBadge.test.tsx index 88c3961365..25ba568c71 100644 --- a/src/bundled/channels/DmTypingBadge.test.tsx +++ b/src/bundled/channels/DmTypingBadge.test.tsx @@ -93,6 +93,7 @@ function fixture() { selected={selected} onSelect={() => {}} onOpenThread={() => {}} + onOpenMessage={() => {}} /> diff --git a/src/bundled/channels/UnreadBadge.tsx b/src/bundled/channels/UnreadBadge.tsx index b604ba5142..1ed9fb4d5d 100644 --- a/src/bundled/channels/UnreadBadge.tsx +++ b/src/bundled/channels/UnreadBadge.tsx @@ -134,9 +134,12 @@ export function UnreadBadge({ const unreadLabel = manual ? `Marked unread${snapshot.manual === "local-only" ? " on this device only" : ""}` : `${count} observed unread messages${snapshot.freshness === "stale" ? "; may be out of date" : ""}. Not an exact total.`; - // Mentions share the thread dot; a mention alone keeps the priority dot. const mentionCount = activity.mentions?.length ?? 0; - const activityLabel = `${mentionCount ? `${mentionCount} unread ${mentionCount === 1 ? "mention" : "mentions"}, ` : ""}${threadCount} unread ${threadCount === 1 ? "thread" : "threads"}${activity.freshness === "stale" ? "; may be out of date" : ""}`; + const stale = activity.freshness === "stale" ? "; may be out of date" : ""; + const mentionLabel = `${mentionCount} unread ${mentionCount === 1 ? "mention" : "mentions"}`; + // Mentions share the thread dot. Without a thread they have only the + // aria-hidden priority dot, or none when muted, so name them on their own. + const activityLabel = `${mentionCount ? `${mentionLabel}, ` : ""}${threadCount} unread ${threadCount === 1 ? "thread" : "threads"}${stale}`; return ( <> {label !== undefined && ( @@ -166,6 +169,9 @@ export function UnreadBadge({ aria-hidden="true" /> )} + {mentionCount > 0 && threadCount === 0 && ( + {`${mentionLabel}${stale}`} + )} {threadCount > 0 && (hideUnreadDots ? ( {activityLabel} diff --git a/src/features/relay/unread.test.ts b/src/features/relay/unread.test.ts index 46145664bc..cc5012ca00 100644 --- a/src/features/relay/unread.test.ts +++ b/src/features/relay/unread.test.ts @@ -887,6 +887,36 @@ it("lists unread top-level mentions apart from thread activity, never for a DM o ]); }); +it("lists a top-level mention that cites or quotes a message, and keeps the snapshot through an unrelated publish", () => { + const h = setup(); + h.grant("room"); + const tag = [["p", h.viewer.pubkey]]; + const root = message(h.viewer, "room", "My thread", 20); + // Neither tag marks a reply, so both stay top-level. + const cited = message(h.alice, "room", "Cites it", 21, [ + ["e", root.id], + ...tag, + ]); + const quoted = message(h.alice, "room", "Quotes it", 22, [ + ["e", root.id, "", "mention"], + ...tag, + ]); + h.emit([root, cited, quoted]); + const activity = h.session.unread.activity("room"); + expect(activity).toMatchObject({ + items: [], + mentions: [{ messageId: quoted.id }, { messageId: cited.id }], + }); + + const changed = vi.fn(); + h.session.unread.subscribeActivity("room", changed); + h.emit([message(h.alice, "room", "Unrelated", 23)]); + // The publish landed, and left Activity alone. + expect(h.snapshot().observedCount).toBe(3); + expect(changed).not.toHaveBeenCalled(); + expect(h.session.unread.activity("room")).toBe(activity); +}); + it("distinguishes unknown thread-activity evidence from observed evidence", () => { const h = setup(); h.grant("room"); @@ -4393,6 +4423,7 @@ it("agent-audience messages never count, earn attention, list in Inbox or activi attentionCount: 0, }); expect(unread.activity("room").items).toEqual([]); + expect(unread.activity("room").mentions).toEqual([]); expect(unread.inbox().items).toEqual([]); for (const [channelId, row] of [ ["room", progress], diff --git a/src/features/relay/unread.ts b/src/features/relay/unread.ts index 633b0e3877..c2f5e33e57 100644 --- a/src/features/relay/unread.ts +++ b/src/features/relay/unread.ts @@ -89,7 +89,8 @@ export type ThreadActivitySnapshot = Readonly<{ /** null means activity evidence is unknown or access is denied. */ items: readonly ThreadActivityItem[] | null; /** Unread top-level messages that mention the viewer, newest first. A - * reply's mention is listed under its thread instead. */ + * reply's mention is listed under its thread instead, and in neither while + * its thread root is unresolved. */ mentions?: readonly MentionActivityItem[]; coverage: "unknown" | "observed"; freshness: "unknown" | "observed" | "stale"; diff --git a/tests/fixtures/nav-thinking.tsx b/tests/fixtures/nav-thinking.tsx index c8762aa283..9108b45518 100644 --- a/tests/fixtures/nav-thinking.tsx +++ b/tests/fixtures/nav-thinking.tsx @@ -264,6 +264,7 @@ function Preview() { selected={selected} onSelect={selectChannel} onOpenThread={() => {}} + onOpenMessage={() => {}} onOpenWorkingAgent={(channelId, agent) => { selectChannel(channelId); setDetail({ agent, destination: "thread" }); @@ -289,6 +290,7 @@ function Preview() { selected={selected} onSelect={selectChannel} onOpenThread={() => {}} + onOpenMessage={() => {}} onOpenWorkingAgent={(channelId, agent) => { selectChannel(channelId); setDetail({ agent, destination: "thread" }); diff --git a/tests/fixtures/user-status.tsx b/tests/fixtures/user-status.tsx index 24eb01d257..e2056b2810 100644 --- a/tests/fixtures/user-status.tsx +++ b/tests/fixtures/user-status.tsx @@ -180,6 +180,7 @@ createRoot(root).render( selected={undefined} onSelect={() => {}} onOpenThread={() => {}} + onOpenMessage={() => {}} /> From d41fe2154fb459cb695166f67be439f25d5a0f61 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 6 Nov 2026 23:47:50 +1100 Subject: [PATCH 5/6] fix(channels): hold a mention's preview while its edits are unchecked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening Activity can admit an unread top-level mention before its stored edit and deletion reads finish. Until they settle, or after they fail, the message ID stays in the Inbox feed's incomplete list and the retained body may be superseded. The Activity page hides it behind a placeholder, but sidebar Activity's mention rows showed it as current, both visibly and in the row's accessible name. Mention rows now subscribe to the existing feed snapshot and apply the same rule: "Preview updating…" while the reads are pending, "Preview unavailable. Retry activity." after they fail, and the body once they settle. The rule moves out of InboxRowButton into an exported incompletePreview helper that both surfaces use, so the strings live in one place. It adds no fetch or cache. Thread rows are unchanged. A new mounted popover test drives a real Inbox feed through a held edit/deletion read, its failure and a retry. It checks the placeholder in the visible text and the accessible name, that the body appears nowhere while the ID is incomplete, and that the body returns after recovery. It fails without the fix. Popover tests that render rows now give their fake session a settled feed. docs/unread.md describes the placeholder. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- docs/unread.md | 3 + .../channels/ChannelActivityPopover.test.tsx | 133 ++++++++++++++++++ .../channels/ChannelActivityPopover.tsx | 16 ++- src/bundled/inbox/InboxRow.tsx | 21 ++- 4 files changed, 164 insertions(+), 9 deletions(-) diff --git a/docs/unread.md b/docs/unread.md index 3021fcbf13..371663b758 100644 --- a/docs/unread.md +++ b/docs/unread.md @@ -242,6 +242,9 @@ assistive technology ("1 unread mention") and still opens the popover; with threads, the dot's label counts both ("1 unread mention, 1 unread thread"). Overlapping priority, mention and thread activity never produce duplicate dots. Merely revealing the popover does not acknowledge a mention or reply. +While Activity is still checking a mention's stored edits and deletions, its row +shows "Preview updating…" instead of the text, or "Preview unavailable. Retry +activity." if the check failed, as on the Activity page. A local manual-unread mark strengthens the label without fabricating priority; the underlying observed count remains available. Channel Settings → Diagnostics exposes explicit actions and Unread status/retry. Unknown and diff --git a/src/bundled/channels/ChannelActivityPopover.test.tsx b/src/bundled/channels/ChannelActivityPopover.test.tsx index 7c99fa115a..96c79dc7a0 100644 --- a/src/bundled/channels/ChannelActivityPopover.test.tsx +++ b/src/bundled/channels/ChannelActivityPopover.test.tsx @@ -19,9 +19,17 @@ import type { ThreadActivityItem, ThreadActivitySnapshot, } from "../../features/relay/unread"; +import { createInboxFeed } from "../../features/relay/inbox-feed"; +import type { InboxFeedSnapshot } from "../../features/relay/inbox-feed"; +import type { ChannelQueries } from "../../features/relay/contracts"; +import type { RelayEvent } from "../../features/relay/events"; afterEach(cleanup); +const settled: InboxFeedSnapshot = { status: "ready", incomplete: [] }; +// No message's edits or deletions are still being checked. +const settledFeed = { snapshot: () => settled, subscribe: () => () => {} }; + test("closes activity while its row exits and does not reopen on re-entry", async () => { const user = userEvent.setup(); const snapshot: ThreadActivitySnapshot = { @@ -99,6 +107,7 @@ test("mouse presses do not pin the hover preview open, and only hover marks it a ensure: async () => {}, }, agentChoices: { snapshot: () => ({ identities: [] }) }, + inboxFeed: settledFeed, } as unknown as RelaySession; render( profiles, subscribe: () => () => {}, ensure }, + inboxFeed: settledFeed, } as unknown as RelaySession; render( profiles, subscribe: () => () => {}, ensure }, + inboxFeed: settledFeed, } as unknown as RelaySession; render( { + const user = userEvent.setup(); + const body = "@you can you sign off?"; + const mention: MentionActivityItem = { + channelId: "studio", + messageId: "mention", + authorId: "sam", + createdAt: 2, + preview: body, + }; + const snapshot: ThreadActivitySnapshot = { + channelId: "studio", + items: [], + mentions: [mention], + coverage: "observed", + freshness: "observed", + }; + const event = { + id: mention.messageId, + kind: 9, + pubkey: mention.authorId, + created_at: mention.createdAt, + content: body, + tags: [["p", "you"]], + } as unknown as RelayEvent; + // Each attempt's edit/deletion read of the mention is held until released. + const reads: { + resolve(events: RelayEvent[]): void; + reject(error: Error): void; + }[] = []; + const feed = createInboxFeed({ + viewer: "you", + channels: { + list: () => ({ channels: [] }), + subscribeList: () => () => {}, + } as unknown as ChannelQueries, + reader: { + read: ([filter]) => + filter?.["#e"]?.includes(event.id) + ? new Promise((resolve, reject) => reads.push({ resolve, reject })) + : Promise.resolve([]), + }, + async addressedRead(_filter, _signal, prepare) { + prepare([event]); + return [event]; + }, + retainedEvent: (id) => (id === event.id ? event : undefined), + retainedEditIds: () => [], + }); + const profiles = new Map([["sam", { name: "Sam" }]]); + const session = { + agentChoices: { snapshot: () => ({ identities: [] }) }, + unread: { activity: () => snapshot, subscribeActivity: () => () => {} }, + profiles: { + snapshot: () => profiles, + subscribe: () => () => {}, + ensure: async () => {}, + }, + inboxFeed: feed, + } as unknown as RelaySession; + render( + Activity} + onOpenThread={vi.fn()} + onOpenMention={vi.fn()} + />, + ); + const row = (preview: string) => + screen.getByRole("button", { + name: `Open unread mention from Sam: ${preview}`, + }); + try { + screen.getByRole("button", { name: "Activity" }).focus(); + await user.keyboard("{Enter}"); + const popup = await screen.findByRole("dialog", { + name: "Activity in Studio", + }); + expect(row(body)).toHaveTextContent(body); + let work = Promise.resolve(); + await act(async () => { + work = feed.ensure(); + }); + await waitFor(() => expect(reads).toHaveLength(1)); + expect(feed.snapshot()).toMatchObject({ + status: "loading", + incomplete: [event.id], + }); + expect(row("Preview updating…")).toHaveTextContent("Preview updating…"); + expect(popup).not.toHaveTextContent(body); + await act(async () => { + reads[0]?.reject(new Error("Edits unavailable")); + await work; + }); + expect(feed.snapshot()).toMatchObject({ + status: "error", + incomplete: [event.id], + }); + expect(row("Preview unavailable. Retry activity.")).toHaveTextContent( + "Preview unavailable. Retry activity.", + ); + expect(popup).not.toHaveTextContent(body); + await act(async () => { + work = feed.refresh(); + }); + await waitFor(() => expect(reads).toHaveLength(2)); + expect(row("Preview updating…")).toHaveTextContent("Preview updating…"); + await act(async () => { + reads[1]?.resolve([]); + await work; + }); + expect(feed.snapshot()).toMatchObject({ status: "ready", incomplete: [] }); + expect(row(body)).toHaveTextContent(body); + } finally { + for (const read of reads) read.resolve([]); + feed.dispose(); + } +}); + test("working agents open their thread when linked, otherwise the conversation", async () => { const user = userEvent.setup(); const first = "a".repeat(64); @@ -542,6 +674,7 @@ test("a press while there is no activity does not block the hover preview once a ensure: async () => {}, }, agentChoices: { snapshot: () => ({ identities: [] }) }, + inboxFeed: settledFeed, } as unknown as RelaySession; render( { const seconds = Math.max(0, Math.floor(now / 1000) - createdAt); @@ -55,6 +56,7 @@ const noSubscribe = () => () => {}; function ActivityRow({ item, noun, + previewId, session, agents, onOpen, @@ -62,6 +64,9 @@ function ActivityRow({ }: { item: ThreadActivityItem | MentionActivityItem; noun: "thread" | "mention"; + /** A message whose body Activity must not show until its edits and + * deletions are checked. */ + previewId?: string; session: RelaySession; agents: readonly string[]; onOpen(): void; @@ -85,6 +90,12 @@ function ActivityRow({ profile?.name ?? displayId.slice(0, 10), ); const name = item.workflowOwnerId ? workflowLabel(resolved) : resolved; + const feed = useSyncExternalStore( + session.inboxFeed.subscribe, + session.inboxFeed.snapshot, + session.inboxFeed.snapshot, + ); + const preview = incompletePreview(feed, [previewId]) ?? item.preview; const isAgent = agents.includes(item.authorId) || !!profile?.isAgent || @@ -111,7 +122,7 @@ function ActivityRow({ > - {item.preview} + {preview} } /> @@ -386,6 +397,7 @@ export function ChannelActivityPopover({ key={mention.messageId} item={mention} noun="mention" + previewId={mention.messageId} session={session} agents={activeAgents} onOpen={() => { diff --git a/src/bundled/inbox/InboxRow.tsx b/src/bundled/inbox/InboxRow.tsx index 62fd8f7e52..169f464394 100644 --- a/src/bundled/inbox/InboxRow.tsx +++ b/src/bundled/inbox/InboxRow.tsx @@ -30,6 +30,18 @@ type ResolveName = ( export const hasUnread = (item: InboxItem) => item.unreadCount > 0 || item.manual; +/** What a preview shows instead of a body whose stored edits or deletions are + * not settled yet, or undefined when none of `ids` is incomplete. */ +export const incompletePreview = ( + feed: InboxFeedSnapshot, + ids: readonly (string | undefined)[], +) => + ids.some((id) => id !== undefined && feed.incomplete.includes(id)) + ? feed.status === "error" + ? "Preview unavailable. Retry activity." + : "Preview updating…" + : undefined; + /** Who sent a row and where, as the Inbox page and its sidebar preview name it. */ export function rowIdentity( item: InboxItem, @@ -119,13 +131,8 @@ export function InboxRowButton({ unread={unread} previewId={previewId} preview={ - [item.previewMessageId, ...item.messageIds].some( - (id) => id !== undefined && feed.incomplete.includes(id), - ) - ? feed.status === "error" - ? "Preview unavailable. Retry activity." - : "Preview updating…" - : messagePreview(item.preview) + incompletePreview(feed, [item.previewMessageId, ...item.messageIds]) ?? + messagePreview(item.preview) } /> ); From 773f0b1e45a903fcecb86cbd8c22db3ceb4bc7ad Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Sat, 7 Nov 2026 00:42:37 +1100 Subject: [PATCH 6/6] test(browser): open Activity by its new name in the mention journeys #2543 renamed the Inbox page to Activity, so the two top-level mention journeys this branch adds could no longer find it. They opened the page as "Inbox" and looked for the "Inbox" region and the "Inbox conversations" list. They now open "Activity" and use the region and list named "Activity", matching what the page exposes on main and the other Activity journeys. Nothing else changes. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- tests/browser/thread-unread.spec.mjs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/browser/thread-unread.spec.mjs b/tests/browser/thread-unread.spec.mjs index b865b94dab..3bc9816513 100644 --- a/tests/browser/thread-unread.spec.mjs +++ b/tests/browser/thread-unread.spec.mjs @@ -1193,10 +1193,10 @@ test.describe("top-level mention activity", () => { undefined, [["p", app.viewer]], ); - await openPage(page, "Inbox"); - const inbox = page.getByRole("region", { name: "Inbox", exact: true }); + await openPage(page, "Activity"); + const inbox = page.getByRole("region", { name: "Activity", exact: true }); const row = inbox - .getByRole("list", { name: "Inbox conversations" }) + .getByRole("list", { name: "Activity", exact: true }) .getByRole("listitem") .filter({ has: page.getByRole("button", { name: / in #Beta$/ }) }); await expect(row.getByRole("img", { name: "Unread" })).toBeVisible(); @@ -1231,7 +1231,7 @@ test.describe("top-level mention activity", () => { ), ) .toBe(false); - await openPage(page, "Inbox"); + await openPage(page, "Activity"); await expect(row).toBeVisible(); await expect(row.getByRole("img", { name: "Unread" })).toHaveCount(0); }); @@ -1262,10 +1262,10 @@ test.describe("top-level mention activity", () => { // bottom, whose catch-up would read it anyway. for (let index = 0; index < 40; index++) app.append("primary", "alpha", `Later update ${index}`, true, false); - await openPage(page, "Inbox"); + await openPage(page, "Activity"); const row = page - .getByRole("region", { name: "Inbox", exact: true }) - .getByRole("list", { name: "Inbox conversations" }) + .getByRole("region", { name: "Activity", exact: true }) + .getByRole("list", { name: "Activity", exact: true }) .getByRole("listitem") .filter({ hasText: "Can you sign off on Alpha?" }); await expect(row.getByRole("img", { name: "Unread" })).toBeVisible(); @@ -1309,7 +1309,7 @@ test.describe("top-level mention activity", () => { popover.getByRole("region", { name: "Unread threads" }), ).toBeVisible(); await expect(mentions).toHaveCount(0); - await openPage(page, "Inbox"); + await openPage(page, "Activity"); await expect(row).toBeVisible(); await expect(row.getByRole("img", { name: "Unread" })).toHaveCount(0); });