Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,10 +471,14 @@ revokes all of them, and a pairing unused for 30 days expires.
replies previews its newest unread reply and opens it in its thread. Muted
conversations are left out. Turn this off with **Show unread first in
search** in Settings → Appearance → Conversations.
- **Activity** threads name their channel above the original message. Select that
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.
- **Find a tab’s source**: thread tabs show **In #channel-name** (or the chat
name) above their messages, including in the current channel. Select that name,
or **Show original message in channel** in the tab’s context menu, to reveal
and highlight the original message in the main timeline. Chat tabs use “chat”
in the action label. The thread tab stays available; Back restores the prior
view. Conversation tabs offer **Open channel/chat**, or **Show message in
channel/chat** when opened at a message. If the original cannot be found,
Buzz shows a failure with Retry.
- **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.
Expand Down
2 changes: 1 addition & 1 deletion docs/inbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ cached/profile-backed identity evidence, never a new inventory or name heuristic
The Mentions activity filter uses explicit mention messages for each conversation's preview, sender, ordering, resume point and unread count. A later ordinary reply does not make a read mention unread again. DMs remain direct activity rather than mentions merely because their recipient is tagged. All activity and Threads retain their conversation-wide projections. A row shows its newest message and sender, matching its time, while opening it still resumes at the oldest unread message. A Solo chat's top-level row shows the newest top-level message from anyone else, and is ordered by it. It skips messages addressed only to agents and roots that have their own thread row.
Rows retain exact IDs and current names while `inboxFeed.incomplete` marks only specific group members awaiting their stored edit/deletion closure. Those rows say “Preview updating…” or “Preview unavailable. Retry activity.”; other rows stay usable. This is a visible completeness warning, not a guarantee that the relay did not change after verification. A failed read exposes Retry above the list. Retry stays focusable while pending; successful recovery returns focus to the filters only if Retry still owned focus when its alert was removed.

The list is the Inbox page's first tab, in the host tab workspace behind `conversation.ui.Tabs`. Opening a row shows its conversation beside the list in the side tab pane, with the same reader and composer as Channels tabs: a thread at its exact message, revealed even outside the newest bounded page, or a whole DM or session conversation where its Channels tab would open. A DM row opens as a thread at its resume message only when that thread holds all of the row's unread messages, or, with none unread, when it resumes at a reply. Otherwise it opens whole, where threads show their reply counts. A DM thread's reads, on the row's click and in its later dwells (including in a tab restored on returning to Activity), cover the DM only through the newest message this device has received, and stop just before its earliest unread message outside that thread (a top-level message or a reply in another thread). So a message outside the thread that was received before the read stays unread, even one received after the row last rendered. A DM's read state is one point in time, so the thread's replies from that message on stay unread too, until the whole DM is read; meanwhile the row opens the whole DM. A message outside the thread received only after a thread read, but dated at or before what that read covered (sender clock skew, a message sent in the same second as a thread reply, or an outbox retry that keeps its original time), is read without being shown. A whole DM opened from its row reads through the click time, as a channel's explicit read does. A plain open replaces the tab the previous one added, so one tab follows the selection; a tab moved to the main pane stays. The tab's right-click menu offers Open in channel, which opens its conversation in Channels; a thread opens there at the same message, beside its root, which the channel timeline reveals and marks even beyond its newest page. A thread also names its conversation above its original message, as the rows do (`#channel` or `DM · names`); that name is a link that does the same. The pane resizes, closes and handles Escape as Channels' does. Returning to Activity in the same session, through Back or the sidebar, restores the list's scroll position once it shows, unless it has already been scrolled, and brings the selected row back into view if it was visible on leaving and has since moved out of view; a relaunch starts at the top. A filter change keeps the list's current position: an empty list that the change fills stays at the top. A late verified root can regroup a conversation without changing its open tab. Inbox tabs do not enter a channel visit, so showing one preserves forced-unread intent. Reading is saved by the shared unread owner, through the reader's dwell and the row's explicit read. A Solo chat's top-level row reads in one save, as its live bottom does: the channel catch-up mark reads its ordinary responses, and mentions are read individually, so its thread rows stay unread. As in a plain channel visit, the visible selected side tab reads while focus stays anywhere on the page, such as on the row that opened it; a tab moved to the main pane reads only by its own focus. Unlike a restored Channels side tab, an Inbox tab restored on returning to Inbox also reads, since it is still the selected conversation. Failed read actions retain their captured Retry unless access, the session or a newer intent retires it. A DM Retry reuses the original channel cutoff and manual-clear keys, not the retry-time clock or later arrivals. Re-clicking the selected row reselects its tab without repeating its read. Closing the tab ends the selection, so reopening the row reads it again. Archiving the selected conversation's channel, or its room becoming a Huddle, closes its tab and returns focus it held to the filters, and restoring the channel does not reopen it; other retained tabs follow Channels' unavailable placeholder, and focus their reader held moves to the tab. Re-clicking the selected row reveals its current message in its tab. A whole DM or session tab stays whole when its row regroups or reads into a thread; a thread tab follows its row, so a DM thread whose row no longer opens as a thread opens the whole DM. Context menus allow device-local Mark unread. Row accessible descriptions reuse the visible safe preview or incomplete placeholder; Show more follows the filtered result count. `NavigationItem` owns selected styling and `aria-current`. No Inbox-owned session, parallel fold, composer or outbox.
The list is the Inbox page's first tab, in the host tab workspace behind `conversation.ui.Tabs`. Opening a row shows its conversation beside the list in the side tab pane, with the same reader and composer as Channels tabs: a thread at its exact message, revealed even outside the newest bounded page, or a whole DM or session conversation where its Channels tab would open. A DM row opens as a thread at its resume message only when that thread holds all of the row's unread messages, or, with none unread, when it resumes at a reply. Otherwise it opens whole, where threads show their reply counts. A DM thread's reads, on the row's click and in its later dwells (including in a tab restored on returning to Activity), cover the DM only through the newest message this device has received, and stop just before its earliest unread message outside that thread (a top-level message or a reply in another thread). So a message outside the thread that was received before the read stays unread, even one received after the row last rendered. A DM's read state is one point in time, so the thread's replies from that message on stay unread too, until the whole DM is read; meanwhile the row opens the whole DM. A message outside the thread received only after a thread read, but dated at or before what that read covered (sender clock skew, a message sent in the same second as a thread reply, or an outbox retry that keeps its original time), is read without being shown. A whole DM opened from its row reads through the click time, as a channel's explicit read does. A plain open replaces the tab the previous one added, so one tab follows the selection; a tab moved to the main pane stays. The tab's right-click menu offers Open channel/chat for whole conversations, or Show original message in channel/chat for threads. The thread action opens Channels and reveals its verified top-level source in the timeline, seeking beyond the newest page when needed; the original Activity tab stays available on return. A thread also names its conversation above its original message, as the rows do (`#channel` or `DM · names`); that name is a link that does the same. The pane resizes, closes and handles Escape as Channels' does. Returning to Activity in the same session, through Back or the sidebar, restores the list's scroll position once it shows, unless it has already been scrolled, and brings the selected row back into view if it was visible on leaving and has since moved out of view; a relaunch starts at the top. A filter change keeps the list's current position: an empty list that the change fills stays at the top. A late verified root can regroup a conversation without changing its open tab. Inbox tabs do not enter a channel visit, so showing one preserves forced-unread intent. Reading is saved by the shared unread owner, through the reader's dwell and the row's explicit read. A Solo chat's top-level row reads in one save, as its live bottom does: the channel catch-up mark reads its ordinary responses, and mentions are read individually, so its thread rows stay unread. As in a plain channel visit, the visible selected side tab reads while focus stays anywhere on the page, such as on the row that opened it; a tab moved to the main pane reads only by its own focus. Unlike a restored Channels side tab, an Inbox tab restored on returning to Inbox also reads, since it is still the selected conversation. Failed read actions retain their captured Retry unless access, the session or a newer intent retires it. A DM Retry reuses the original channel cutoff and manual-clear keys, not the retry-time clock or later arrivals. Re-clicking the selected row reselects its tab without repeating its read. Closing the tab ends the selection, so reopening the row reads it again. Archiving the selected conversation's channel, or its room becoming a Huddle, closes its tab and returns focus it held to the filters, and restoring the channel does not reopen it; other retained tabs follow Channels' unavailable placeholder, and focus their reader held moves to the tab. Re-clicking the selected row reveals its current message in its tab. A whole DM or session tab stays whole when its row regroups or reads into a thread; a thread tab follows its row, so a DM thread whose row no longer opens as a thread opens the whole DM. Context menus allow device-local Mark unread. Row accessible descriptions reuse the visible safe preview or incomplete placeholder; Show more follows the filtered result count. `NavigationItem` owns selected styling and `aria-current`. No Inbox-owned session, parallel fold, composer or outbox.

Solo chats include ordinary incoming top-level messages, excluding agent-only coordination and system events. Their flat rows appear under All, not DMs, matching the sidebar's separate Solo and Direct messages sections; nested threads stay separate. Reading in Channels or Inbox clears the same evidence.

Expand Down
8 changes: 8 additions & 0 deletions docs/navigation-history-behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ reveal left the reader less than 80 px from the end, Back keeps the message
where the reveal placed it, but a plain visit opens at the latest message, as
it does after the reader scrolls that close to the end.

A thread tab's source link and Show original message in channel/chat action
create a timeline visit, including when the source channel is already open.
They resolve the selected message's verified top-level source and seek it within
the existing history limit. The thread tab remains available with its draft;
Back restores the prior selection. Repeating the action reveals the source
again. A missing source produces an explicit failure and Retry instead of
opening another thread. Ordinary message links keep their existing placement.

Profiles and GitHub use the same rule. Their read-only target and optional
thread context are carried as `detail` in the scoped conversation address;
Back/Forward restores selection and reload resolves the panel through the
Expand Down
68 changes: 67 additions & 1 deletion src/bundled/channels/ChannelsPage.context.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render } from "@testing-library/react";
import { act, cleanup, render, screen } from "@testing-library/react";
import type { ComponentProps, ReactElement } from "react";
import { afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
import { composerDOMFixture } from "../../features/messages/composer-testing";
Expand Down Expand Up @@ -531,3 +531,69 @@ it("Back to the visit seeks again once the channel has dropped the pages it read
}),
);
});

it("reveals a reply's verified source in cold timeline history without opening a thread", async () => {
const h = await channelsPage();
const visit = h.visit(
h.target("alpha", {
messageId: fixture.reply.id,
threadRootId: "f".repeat(64),
context: "timeline",
}),
"source",
);
h.go(visit);
await vi.waitFor(() => expect(h.held).toHaveLength(1));
try {
expect(latest("alpha")?.holding).toBe(true);
expect(
screen.queryByRole("tab", { name: "Thread root" }),
).not.toBeInTheDocument();
} finally {
await h.release();
}
await vi.waitFor(() =>
expect(latest("alpha")).toMatchObject({
holding: false,
navigation: {
target: { messageId: fixture.root.id, context: "timeline" },
},
}),
);
expect(
screen.queryByRole("tab", { name: "Thread root" }),
).not.toBeInTheDocument();
});

it("reports an unavailable source instead of opening a replacement thread", async () => {
const h = await channelsPage({ root: false });
h.go(
h.visit(
h.target("alpha", { messageId: fixture.reply.id, context: "timeline" }),
"source",
),
);
await screen.findByText("Couldn't show the original message.");
expect(screen.getByRole("button", { name: "Retry" })).toBeVisible();
expect(latest("alpha")?.navigation).toBeUndefined();
});

it("cancels an obsolete source seek when navigation leaves the channel", async () => {
const h = await channelsPage();
h.go(
h.visit(
h.target("alpha", { messageId: fixture.root.id, context: "timeline" }),
"source",
),
);
await vi.waitFor(() => expect(h.held).toHaveLength(1));
h.go(h.visit(h.target("beta"), "other"));
await h.release();
await vi.waitFor(() => expect(latest("beta")?.holding).toBe(false));
expect(
screen.queryByText("Couldn't show the original message."),
).not.toBeInTheDocument();
expect(latest("beta")?.navigation?.target).toMatchObject({
channelId: "beta",
});
});
25 changes: 15 additions & 10 deletions src/bundled/channels/ChannelsPage.tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1120,7 +1120,7 @@ it("keeps a newer header move recoverable after an earlier one succeeds", async
expect(screen.getByRole("button", { name: "Retry move" })).toBeVisible();
});

it("opens another conversation's tab in its channel, but not the current channel's threads", async () => {
it("opens both current and other conversations from their tab menus", async () => {
const open = vi.fn(async () => ({ status: "opened" as const }));
const h = await page("stream", {
open,
Expand All @@ -1143,14 +1143,20 @@ it("opens another conversation's tab in its channel, but not the current channel
});
return within(await screen.findByRole("menu"));
};
expect(
(await menu("Thread · Alpha")).queryByRole("menuitem", {
name: "Open in channel",
await h.user.click(
(await menu("Thread · Alpha")).getByRole("menuitem", {
name: "Show original message in channel",
}),
).not.toBeInTheDocument();
await h.user.keyboard("{Escape}");
);
expect(open).toHaveBeenLastCalledWith(
expect.objectContaining({
channelId: "alpha",
messageId: root,
context: "timeline",
}),
);
await h.user.click(
(await menu("Beta")).getByRole("menuitem", { name: "Open in channel" }),
(await menu("Beta")).getByRole("menuitem", { name: "Open channel" }),
);
const scope = {
viewer: h.viewer,
Expand Down Expand Up @@ -1179,7 +1185,7 @@ it("opens another conversation's tab in its channel, but not the current channel
);
await h.user.click(
(await menu("Thread · Beta")).getByRole("menuitem", {
name: "Open in channel",
name: "Show original message in channel",
}),
);
await vi.waitFor(() =>
Expand All @@ -1189,8 +1195,7 @@ it("opens another conversation's tab in its channel, but not the current channel
scope,
channelId: "beta",
messageId: other,
threadRootId: other,
context: "channel",
context: "timeline",
}),
);
});
Loading