Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
704cec3
feat(conversation): add a reusable workspace
dr0uleau Nov 12, 2026
f1d94e5
fix(conversation): place workspace actions
dr0uleau Nov 12, 2026
671014c
fix(conversation): handle workspace panel requests
dr0uleau Nov 12, 2026
c97927a
feat: keep conversation navigation for plugin pages
dr0uleau Nov 12, 2026
797180b
feat: place workspace intro in conversation history
dr0uleau Nov 12, 2026
a3889c0
test: cover conversation workspace ownership
dr0uleau Nov 12, 2026
658f123
docs: describe conversation page navigation
dr0uleau Nov 12, 2026
d2d4acb
test: align workspace fixtures with registration lifetime
dr0uleau Nov 12, 2026
9aad4aa
fix: check thread effect annotations in file hooks
dr0uleau Nov 12, 2026
3bf45b9
feat: retain native Solo controls for custom destinations
dr0uleau Nov 12, 2026
4976707
test: use the Testing Library role options
dr0uleau Nov 12, 2026
ab42da7
test: enable native rename in the row fixture
dr0uleau Nov 12, 2026
3a9de1b
fix: keep focus in the sidebar when a workspace mounts
dr0uleau Nov 12, 2026
f8d6543
fix: retain explicit workspace message reveals
dr0uleau Nov 12, 2026
e6f11f6
fix: preserve exact source reveals beside task details
dr0uleau Nov 12, 2026
c94b37f
Merge remote-tracking branch 'origin/main' into drouleau/conversation…
dr0uleau Nov 12, 2026
686041f
test: read detail visits through the public state API
dr0uleau Nov 12, 2026
8098409
test: isolate pane reopen and shell lookup fixtures
dr0uleau Nov 12, 2026
7be2ed3
test: create retained panels through their opener
dr0uleau Nov 12, 2026
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
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"enabled": true,
"rules": {
"preset": "recommended",
"correctness": { "useExhaustiveDependencies": "error" },
"suspicious": {
"noUnknownAtRules": "off"
},
Expand Down
5 changes: 5 additions & 0 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,11 @@ for 30 days expires.
and Linux) an HTTP(S) link in a message to open it in your default browser
instead of a side panel. You can also right-click the link and choose
**Open in browser**.
- **Conversations in plugin pages**: a plugin can show a conversation as its
main view, with the normal message controls and a panel on the right. Its
header actions come from the plugin. Closing a side tab keeps the main view.
Plugins can also make their panels available as conversation detail links.
Back and Forward restore these visits. Close retires the closed panel's visits.
- **Close tabs or the window (macOS)**: ⌘W closes the active tab. With no tab
to close, it closes the window while Buzz and running agents stay open.
Reopen Buzz from the Dock. ⌘⇧W clears the current channel's tabs without
Expand Down
3 changes: 3 additions & 0 deletions docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ bundled/inbox/ builtin Inbox page for unread conversations and mentions
The host composes the conversation sidebar (Solo and Team inventories) beside
independently mounted pages. Settings supplies its own modal navigation; a selected
page can replace the conversation sidebar through its optional `sidebar` contribution.
A page can set `conversationNavigation: true` to keep the Messages sidebar and
dock selection. This also applies to pages with `primary: false`; it does not add
a navigation row. Without this option, the page keeps its own navigation owner.
The conversation sidebar reuses session-owned roster, unread, creation and
preferences capabilities; it does not
retain a hidden Channels page or message reader. Sidebar and page render errors
Expand Down
20 changes: 20 additions & 0 deletions docs/plugin-authoring-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,26 @@ Add `"host"` to `inject`, then:

## UI

- `ctx.conversation.ui.Workspace` shows a permanent conversation in a plugin page.
Check that it exists before use. Channels supplies it through
`registerWorkspace`, with the same provider lifetime as `ui.Tabs`.
Set the page's `layout` to `"workspace"`. Pass
`{ id, session, scope, target, label, intro?, actions?, detail? }`.
`target` is `{ channelId, threadId?, messageId? }`: omit `threadId` for the
channel timeline, or supply it for a thread. `messageId` selects a message.
`intro` supplies content at the start of history. It scrolls with the messages.
`actions` supplies
entries in the header's three-dot menu and the tab's context menu, with
`{ label, run() }`. `detail: { target, rootId? }` initially selects a panel
in the right pane. The host owns the reader, composer, tabs, and panel frame.
The page owns its route, content lookup, labels, and actions.
Set `conversationNavigation: true` on the page to keep the Messages sidebar
and dock selection without adding a primary navigation row.
- A panel can set `addressable: true` to accept conversation `detail` targets.
It then uses the host's Back/Forward and explicit Close behavior. The host
resolves the target again on each visit. The panel must check current access
and support restoration without replaying writes. This option does not grant
access. Contextual channel tools keep their existing placement and lifetime.
- Prefer host classes and tokens over custom chrome: `className="ui-card"` for a card surface, `var(--space-panel-inset)` and `var(--space-2)` for spacing, `shell-sidebar-default` for a page-sidebar root. Plain `h1`, `h2`, `h3`, `p` and `button` get host default styling. Use inline styles for layout; separate CSS is not supported.
- The bundled `buzz.profiles/profile` contribution's component advertises `supportsPersonInfoContent: true` when it accepts an optional `renderPersonInfo(content)` prop. The hook applies to people; agent profiles, including owned-agent Settings and Activity, are outside this contract. A specializing panel can use that function to compose the existing Info content with its own content in DOM order. Keep the supplied content's native controls intact. The hook affects Info only; the host retains the header, tabs, focus region and Channels view. Check the capability before claiming normal profile targets on older hosts.
- A specializing reader opts in with `component.profileHistory = true`; canonical profile targets then inherit profile Back/Forward and explicit Close behavior in conversation tabs. Match the existing profile address rather than inventing a separate history store; unmarked catch-all panels and contextual channel panels retain their own behavior.
Expand Down
13 changes: 13 additions & 0 deletions src/app/shell/presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,16 @@ describe("navigation owner", () => {
},
);
});

test("unlisted conversation pages keep Messages without adding a navigation row", () => {
const taskPage = {
...page("example.task/main", "Task"),
primary: false,
conversationNavigation: true,
};
expect(pagePlacement(taskPage)).toBeUndefined();
expect(
navigationOwner([{ ...messages, primary: true }, taskPage], taskPage.key),
).toBe(messages.key);
expect(navigationOwner([taskPage], taskPage.key)).toBe(taskPage.key);
});
2 changes: 1 addition & 1 deletion src/app/shell/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export function navigationOwner(
const messages = "buzz.channels/channels";
const page = pages.find((page) => page.key === selected);
return page &&
pagePlacement(page) === "sidebar" &&
(page.conversationNavigation || pagePlacement(page) === "sidebar") &&
pages.some((page) => page.key === messages)
? messages
: selected;
Expand Down
5 changes: 5 additions & 0 deletions src/bundled/channels/Channels.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@
.soloConversation {
container: solo-conversation / inline-size;
}
.workspaceReader {
flex: 1;
min-height: 0;
min-width: 0;
}
.sidebar {
/* Figma specifies true Semibold for unread rows, rather than the legacy 500 alias. */
--type-weight-semibold: 600;
Expand Down
54 changes: 41 additions & 13 deletions src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -990,16 +990,32 @@ function ChannelWorkspace({
]);
// A live connection can still be confirming its restored membership. Keep
// the pending intent; an exact reader cannot use display-only authority.
const exact =
cached || current?.cached
? undefined
: navigation && requestedMessage && requestedThread === requestedMessage
? { request: navigation, inTimeline: false }
: exactOpening?.request === navigation
? exactOpening
: undefined;
const exact = useMemo(
() =>
cached || current?.cached
? undefined
: navigation && requestedMessage && requestedThread === requestedMessage
? { request: navigation, inTimeline: false }
: exactOpening?.request === navigation
? exactOpening
: undefined,
[
cached,
current?.cached,
navigation,
requestedMessage,
requestedThread,
exactOpening,
],
);
// The route addresses a thread; its tab owns presentation, the route only
// selects it and supplies the reveal request.
const detailSource =
!solo &&
navigation?.target.kind === "conversation" &&
!!navigation.target.detail &&
navigation.target.context === "channel";
const detailReply = detailSource && requestedMessage !== requestedThread;
const routedThread = useMemo(
() =>
!cached &&
Expand All @@ -1012,7 +1028,7 @@ function ChannelWorkspace({
channelId: current.id,
messageId: requestedMessage,
rootId: exact.rootId ?? requestedThread ?? requestedMessage,
pane: threadPane,
pane: detailReply ? ("main" as const) : threadPane,
navigation,
}
: undefined,
Expand All @@ -1024,6 +1040,7 @@ function ChannelWorkspace({
requestedThread,
exact,
threadPane,
detailReply,
],
);
// Open in channel also shows the thread's root in the timeline, reading
Expand Down Expand Up @@ -1527,7 +1544,7 @@ function ChannelWorkspace({
pendingReturn.current = undefined;
tabState.enterVisit(
`${navigation.entryId}:${detail ? "detail" : routedThread ? "thread" : "channel"}`,
detail ? undefined : routedThread,
detail && !detailReply ? undefined : routedThread,
!!detail,
detail && currentId
? { channelId: currentId, target: detail.target }
Expand All @@ -1542,8 +1559,10 @@ function ChannelWorkspace({
detail.target,
detail.rootId,
);
if (entry && historyPanel(entry)) open(entry);
else navigation.complete({ status: "failed", reason: "unavailable" });
if (entry && historyPanel(entry)) {
open(entry, false, detailSource ? "side" : undefined);
if (detailSource && !detailReply) tabState.selectMain(undefined);
} else navigation.complete({ status: "failed", reason: "unavailable" });
}
// Reselect it over the restored selection and give it its own visit.
if (reopen) open(reopen);
Expand Down Expand Up @@ -1608,6 +1627,8 @@ function ChannelWorkspace({
exact?.request,
exact?.inTimeline,
routedThread,
detailReply,
detailSource,
tabState.enterVisit,
tabState.select,
tabState.selectMain,
Expand Down Expand Up @@ -2075,7 +2096,14 @@ function ChannelWorkspace({
cached={cached}
// The composer below mounts TypingIndicator for members of a live channel.
navigation={
!requestedMessage || exact?.inTimeline ? navigation : undefined
!requestedMessage ||
exact?.inTimeline ||
(contextTarget &&
!detailReply &&
navigation?.target.kind === "conversation" &&
navigation.target.detail)
? navigation
: undefined
}
holding={contextHolding}
inlineTarget={contextTarget}
Expand Down
6 changes: 6 additions & 0 deletions src/bundled/channels/ConversationTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import type { ChannelSummary } from "../../features/relay/contracts";
import type { RelaySession } from "../../features/relay/session";
Expand All @@ -27,6 +28,7 @@ import styles from "./ChannelTabs.module.css";

type ConversationTabProps = {
tab: Tab;
intro?: ReactNode;
active: boolean;
/** Showing the tab visits its conversation, as Channels does. */
visit?: boolean | undefined;
Expand All @@ -52,6 +54,7 @@ type ConversationTabProps = {
/** Each secondary conversation retains its own editing and media-review scope. */
export function ConversationTab({
tab,
intro,
active,
visit = true,
focusOnMount = true,
Expand Down Expand Up @@ -155,6 +158,7 @@ export function ConversationTab({
<MessageManagementStatus />
{tab.kind === "thread" ? (
<ConversationThread
intro={intro}
active={active}
messageId={tab.messageId}
navigation={navigation}
Expand All @@ -172,6 +176,7 @@ export function ConversationTab({
/>
) : target && inTimeline === false ? (
<ConversationThread
intro={intro}
active={active}
messageId={target}
retainThread
Expand All @@ -184,6 +189,7 @@ export function ConversationTab({
) : (
<SoloColumn enabled={soloConversation}>
<ChannelBody
intro={intro}
viewer={session.viewer}
cached={!!channel.cached}
// The composer below mounts TypingIndicator for a live channel.
Expand Down
76 changes: 74 additions & 2 deletions src/bundled/channels/PageTabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ import type { ConversationExtensions } from "../../features/conversation/contrac
import { setThreadPlacementPreference } from "../../features/messages/thread-preferences";
import type { RelaySession } from "../../features/relay/session";
import type { ConversationTab as Tab } from "./useChannelTabState";
import { PageTabs, type PageTabOptions, type PageTabTarget } from "./PageTabs";
import {
ConversationWorkspace,
PageTabs,
type PageTabOptions,
type PageTabTarget,
} from "./PageTabs";

// The tab's own conversation has its mounted suite; this owner places it.
vi.mock("./ConversationTab", () => ({
Expand All @@ -26,7 +31,9 @@ vi.mock("./ConversationTab", () => ({
visit,
onResolvedRoot,
parent,
intro,
}: {
intro?: import("react").ReactNode;
tab: Tab;
channel: { name: string };
visit: boolean;
Expand All @@ -41,6 +48,7 @@ vi.mock("./ConversationTab", () => ({
data-focus={tab.focusRequest}
data-reply={tab.kind === "thread" ? tab.replyRequest : undefined}
>
{intro}
{tab.kind} in {channel.name}
{!visit && " unvisited"}
{onResolvedRoot && (
Expand Down Expand Up @@ -107,13 +115,14 @@ function fixture(visits?: boolean) {
ensure: () => Promise.resolve(),
},
} as unknown as RelaySession;
const connection = { status: "ready", session };
const connection = { status: "ready", session, scope: "origin:viewer" };
const host = {
relay: { snapshot: () => connection },
panels: {
snapshot: () => available,
subscribe: () => () => {},
resolve: () => undefined,
handleOpenRequests: () => () => {},
},
navigation: { open: vi.fn() },
} as unknown as ConversationHostServices;
Expand Down Expand Up @@ -142,6 +151,8 @@ function fixture(visits?: boolean) {
</PageTabs>,
);
return {
host,
session,
openers,
navigation: host.navigation,
open(target: PageTabTarget, options?: PageTabOptions) {
Expand Down Expand Up @@ -408,3 +419,64 @@ it("opens a tab's conversation in its channel from the tab's menu", async () =>
}),
);
});

it("a workspace owns its main reader, intro, menu, and no initial detail", async () => {
const user = userEvent.setup();
const f = fixture();
cleanup();
const source = vi.fn();
render(
<ConversationWorkspace
id="task"
session={f.session}
scope="origin:viewer"
target={{ channelId: "alpha", threadId: "root" }}
label="Task"
host={f.host}
intro={<a href="buzz://channel/alpha">Source channel: #alpha</a>}
actions={[{ label: "View in source channel", run: source }]}
/>,
);
expect(screen.queryByText("Chat list")).toBeNull();
expect(screen.getByText("thread in alpha", { exact: false })).toBeVisible();
expect(
screen.getByRole("link", { name: "Source channel: #alpha" }),
).toBeVisible();
expect(screen.queryAllByRole("tablist")).toHaveLength(1);
await user.click(
screen.getByRole("button", { name: "Conversation actions" }),
);
await user.click(
await screen.findByRole("menuitem", { name: "View in source channel" }),
);
expect(source).toHaveBeenCalledOnce();
});

it.each([false, true])(
"a workspace requests composer focus unless another editor owns it (editing=%s)",
(editing) => {
const f = fixture();
cleanup();
render(
editing ? (
<input aria-label="Chat name" />
) : (
<button type="button">Open task</button>
),
);
screen.getByRole(editing ? "textbox" : "button").focus();
render(
<ConversationWorkspace
id="task"
session={f.session}
scope="origin:viewer"
target={{ channelId: "alpha" }}
label="Task"
host={f.host}
/>,
);
const reader = screen.getByText("conversation in alpha", { exact: false });
if (editing) expect(reader).not.toHaveAttribute("data-focus");
else expect(reader).toHaveAttribute("data-focus", "1");
},
);
Loading
Loading