From 704cec375e5f01364e9f5cc017ea17025b53029a Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 12 Nov 2026 09:07:48 -0800 Subject: [PATCH 01/18] feat(conversation): add a reusable workspace Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/buzz-help.md | 5 + docs/plugin-authoring-guide.md | 16 +++ src/bundled/channels/PageTabs.tsx | 129 ++++++++++++++++++- src/bundled/channels/index.tsx | 5 +- src/bundled/channels/useConversationTabs.tsx | 4 +- src/features/conversation/contracts.ts | 12 ++ src/features/conversation/service.tsx | 25 ++++ src/features/panels/service.ts | 4 + src/plugins/author.ts | 4 + 9 files changed, 197 insertions(+), 7 deletions(-) diff --git a/docs/buzz-help.md b/docs/buzz-help.md index acbcb31c4d..6bdf56a084 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -769,6 +769,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 diff --git a/docs/plugin-authoring-guide.md b/docs/plugin-authoring-guide.md index e4c2f2da2c..2061db20c5 100644 --- a/docs/plugin-authoring-guide.md +++ b/docs/plugin-authoring-guide.md @@ -128,6 +128,22 @@ 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, toolbar?, actions?, detail? }`. + `target` is `{ channelId, threadId?, messageId? }`: omit `threadId` for the + channel timeline, or supply it for a thread. `messageId` selects a message. + `toolbar` supplies header icon controls. `actions` supplies menu entries 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. +- 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. diff --git a/src/bundled/channels/PageTabs.tsx b/src/bundled/channels/PageTabs.tsx index 8aac111b1e..865fe075a0 100644 --- a/src/bundled/channels/PageTabs.tsx +++ b/src/bundled/channels/PageTabs.tsx @@ -3,6 +3,7 @@ import { flushSync } from "react-dom"; import type { RelaySession } from "../../features/relay/session"; import type { ConversationExtensions, + ConversationWorkspaceProps, PageTabOptions, PageTabsProps, PageTabTarget, @@ -29,6 +30,8 @@ import { type ConversationTab, } from "./useChannelTabState"; import { useConversationTabs } from "./useConversationTabs"; +import { ConversationTab as ConversationReader } from "./ConversationTab"; +import { panelOpening } from "../../features/panels/service"; import { useCoveredByDock } from "./useCoveredByDock"; import styles from "./Channels.module.css"; @@ -37,6 +40,21 @@ export type { PageTabOptions, PageTabsProps, PageTabTarget }; // The tab each page's next plain open replaces, for as long as its tabs last. const previews = new WeakMap>(); +type HostProps = { + host: ConversationHostServices; + extensions?: ConversationExtensions | undefined; +}; + +export function ConversationWorkspace( + props: ConversationWorkspaceProps & HostProps, +) { + return ( + + {() => null} + + ); +} + /** A page's content as the first tab, with conversations it opens beside it. */ export function PageTabs({ id, @@ -48,10 +66,11 @@ export function PageTabs({ host, extensions, visits, -}: PageTabsProps & { - host: ConversationHostServices; - extensions?: ConversationExtensions | undefined; -}) { + workspace, +}: PageTabsProps & + HostProps & { + workspace?: ConversationWorkspaceProps; + }) { const tabState = useChannelTabState(session, undefined, `page:${id}`); const list = useChannelList(session.channels); const { channels, profiles } = useChannelLabels( @@ -109,6 +128,44 @@ export function PageTabs({ return ; }, }); + const enteredDetail = useRef< + | { + session: RelaySession; + key: string; + } + | undefined + >(undefined); + const detailKey = + workspace?.detail && + JSON.stringify([ + scope, + id, + workspace.target.channelId, + workspace.target.threadId, + workspace.target.messageId, + workspace.detail.target, + workspace.detail.rootId, + ]); + useLayoutEffect(() => { + if (!workspace?.detail || !detailKey) { + enteredDetail.current = undefined; + return; + } + if ( + enteredDetail.current?.session === session && + enteredDetail.current.key === detailKey + ) + return; + const entry = panelOpening( + host.panels, + workspace.target.channelId, + workspace.detail.target, + workspace.detail.rootId, + ); + if (!entry) return; + enteredDetail.current = { session, key: detailKey }; + secondary.open(entry, true); + }); // One stable opener, so a page's effects can depend on it; it reads the // live workspace when called. const opening = useRef< @@ -278,6 +335,27 @@ export function PageTabs({ const hasTabs = secondary.items.length > 0; const showingPanel = tabState.paneOpen && hasTabs; const covered = useCoveredByDock(split.ref, fallbackFocus, showingPanel); + const primaryTarget = workspace?.target; + const primaryChannel = + primaryTarget && + channels.find((channel) => channel.id === primaryTarget.channelId); + const primaryTab: ConversationTab | undefined = primaryTarget && { + id: "conversation", + channelId: primaryTarget.channelId, + ...(primaryTarget.threadId + ? { + kind: "thread" as const, + messageId: primaryTarget.messageId ?? primaryTarget.threadId, + rootId: primaryTarget.threadId, + } + : { + kind: "conversation" as const, + ...(primaryTarget.messageId && { + messageId: primaryTarget.messageId, + }), + }), + ...(primaryTarget.messageId ? { revealRequest: 1 } : { focusRequest: 1 }), + }; // As in a plain channel visit, the page reads its visible selected side tab // while focus stays anywhere on the page, such as the row that opened it. // A conversation moved to the main pane still reads only by its own focus. @@ -362,7 +440,48 @@ export function PageTabs({ id: "conversation", label, icon, - content: children(open, close, isOpen), + toolbar: workspace?.toolbar, + actions: workspace?.actions, + content: workspace ? ( + primaryTab && + primaryChannel && + secondary.canOpenConversation(primaryChannel.id) ? ( + + secondary.openConversationLink( + primaryChannel.id, + url, + origin, + ) + } + canOpenLink={(target) => !!host.panels.resolve(target)} + openThread={(messageId, rootId, intent) => + secondary.openConversationThread( + primaryChannel.id, + messageId, + rootId, + intent, + ) + } + close={() => {}} + /> + ) : ( +

+ This conversation is no longer available. +

+ ) + ) : ( + children(open, close, isOpen) + ), }, ...secondary.mainItems, ]} diff --git a/src/bundled/channels/index.tsx b/src/bundled/channels/index.tsx index 423c2796b3..462bd6df6d 100644 --- a/src/bundled/channels/index.tsx +++ b/src/bundled/channels/index.tsx @@ -4,7 +4,7 @@ import { channelQuery } from "./channel-query"; import { isChannelRoute } from "../../features/channel-navigation/routes"; import type { PluginModule } from "../../plugins/api"; import { ChannelsPage } from "./ChannelsPage"; -import { PageTabs } from "./PageTabs"; +import { ConversationWorkspace, PageTabs } from "./PageTabs"; import { registerCanvas } from "./canvas-panel"; import { ChannelTemplateSettings } from "./ChannelTemplateSettings"; import { ChatCircleIcon, CopyIcon } from "../../shared/design-system/icons"; @@ -39,6 +39,9 @@ export const apply: PluginModule["apply"] = (ctx) => { extensions.registerTabs((props) => ( )); + extensions.registerWorkspace((props) => ( + + )); const relay = ctx.relay; const navigator = ctx.navigation; ctx.conversation.registerMessageAction({ diff --git a/src/bundled/channels/useConversationTabs.tsx b/src/bundled/channels/useConversationTabs.tsx index 46344d4ba9..e57589467e 100644 --- a/src/bundled/channels/useConversationTabs.tsx +++ b/src/bundled/channels/useConversationTabs.tsx @@ -54,7 +54,9 @@ import tabStyles from "./ChannelTabs.module.css"; /** The content-tab families whose visits follow explicit dismissal. */ export const historyPanel = (entry: PanelOpening) => !entry.channelContext && - (profileContentPanel(entry) || entry.panel.key === "buzz.github/object"); + (entry.panel.addressable === true || + profileContentPanel(entry) || + entry.panel.key === "buzz.github/object"); /** Shared secondary workspace. Pages retain their main reader and route semantics. */ export function useConversationTabs({ diff --git a/src/features/conversation/contracts.ts b/src/features/conversation/contracts.ts index 407dcf4a23..f75e65394f 100644 --- a/src/features/conversation/contracts.ts +++ b/src/features/conversation/contracts.ts @@ -459,6 +459,18 @@ export type PageTabTarget = { threadId?: string; messageId?: string; }; +/** A page-owned conversation with the host's reader, composer, and side pane. */ +export type ConversationWorkspaceProps = { + id: string; + session: RelaySession; + scope: string; + target: PageTabTarget; + label: string; + toolbar?: ReactNode; + actions?: readonly { label: string; run(): void }[]; + /** Select this panel on entry. Closing it keeps the main conversation. */ + detail?: { target: string; rootId?: string }; +}; /** `keep` opens a tab beside the others instead of in place of the last. * `markRead: false` keeps a conversation tab from marking what it shows read, * so the page decides; threads read as in Channels. */ diff --git a/src/features/conversation/service.tsx b/src/features/conversation/service.tsx index eeae4eeb57..8fdf8bfbc7 100644 --- a/src/features/conversation/service.tsx +++ b/src/features/conversation/service.tsx @@ -36,6 +36,7 @@ import type { MessageAction, ContributionReader, PageTabsProps, + ConversationWorkspaceProps, } from "./contracts"; import type { RelaySession } from "../relay/session"; @@ -103,9 +104,15 @@ export type Conversation = { /** The page's content as the first tab, beside conversations it opens; * absent until a plugin provides the tab workspace, as Channels does. */ readonly Tabs?: ((props: PageTabsProps) => ReactNode) | undefined; + /** A permanent conversation with a side pane; supplied by Channels. */ + readonly Workspace?: + | ((props: ConversationWorkspaceProps) => ReactNode) + | undefined; }; /** Provides `ui.Tabs` while the providing plugin runs; the first one wins. */ registerTabs(component: ComponentType): void; + /** Provides `ui.Workspace` while the plugin runs; the first one wins. */ + registerWorkspace(component: ComponentType): void; /** Solo chat icons; the sidebar shows the first one. */ chatIcons: ContributionReader; /** Shows `component` as each Solo chat's sidebar icon while the providing @@ -182,6 +189,7 @@ export class ConversationService extends Service implements Conversation { readonly identityMarks; private readonly identityMarkEntries; private readonly tabEntries; + private readonly workspaceEntries; readonly chatMenus; private readonly chatMenuEntries; readonly chatIcons; @@ -254,6 +262,11 @@ export class ConversationService extends Service implements Conversation { component: ComponentType; }>(ctx); this.tabEntries = tabs; + const workspaces = createContributions<{ + id: string; + component: ComponentType; + }>(ctx); + this.workspaceEntries = workspaces; const chatMenus = createContributions(ctx); this.chatMenuEntries = chatMenus; this.chatMenus = { @@ -270,6 +283,10 @@ export class ConversationService extends Service implements Conversation { const Provided = tabs.snapshot()[0]?.component; return Provided ? : null; }; + const Workspace = (props: ConversationWorkspaceProps) => { + const Provided = workspaces.snapshot()[0]?.component; + return Provided ? : null; + }; this.ui = { Thread: (props: EmbeddedThreadProps) => ( @@ -288,6 +305,9 @@ export class ConversationService extends Service implements Conversation { get Tabs() { return tabs.snapshot().length ? Tabs : undefined; }, + get Workspace() { + return workspaces.snapshot().length ? Workspace : undefined; + }, }; } registerTabs(component: ComponentType) { @@ -295,6 +315,11 @@ export class ConversationService extends Service implements Conversation { throw new Error("Page tabs need a component"); this.tabEntries.register(this.ctx, { id: "tabs", component }); } + registerWorkspace(component: ComponentType) { + if (typeof component !== "function") + throw new Error("A conversation workspace needs a component"); + this.workspaceEntries.register(this.ctx, { id: "workspace", component }); + } registerChatMenu(component: ComponentType) { if (typeof component !== "function") throw new Error("A chat menu needs a component"); diff --git a/src/features/panels/service.ts b/src/features/panels/service.ts index c72a556bee..9f94963c43 100644 --- a/src/features/panels/service.ts +++ b/src/features/panels/service.ts @@ -80,6 +80,8 @@ export type Panel = Readonly<{ /** Tabs opened from links stay until closed, like a channel tool's, instead * of giving way to the next link or thread. */ keepOpen?: boolean; + /** Opt into conversation detail addresses and Back/Forward/Close behavior. */ + addressable?: true; /** Mount tab content on first selection, then retain it for the workspace visit. * Component-provided titles and local detail tabs are also deferred. */ deferUntilShown?: boolean; @@ -206,6 +208,8 @@ export class PanelsService extends Service implements Panels { throw new Error("A channel placement must be bottom, side or tab"); if (panel.keepOpen !== undefined && typeof panel.keepOpen !== "boolean") throw new Error("A panel's keepOpen must be a boolean"); + if (panel.addressable !== undefined && panel.addressable !== true) + throw new Error("A panel's addressable must be true when set"); if ( panel.deferUntilShown !== undefined && typeof panel.deferUntilShown !== "boolean" diff --git a/src/plugins/author.ts b/src/plugins/author.ts index 9f2e48425c..65bdb0487a 100644 --- a/src/plugins/author.ts +++ b/src/plugins/author.ts @@ -60,6 +60,10 @@ export type { } from "../features/panels/service"; export type { Conversation } from "../features/conversation/service"; export type { + ConversationWorkspaceProps, + PageTabTarget, + PageTabOptions, + PageTabsProps, ComposerObservation, CompletionContext, CompletionQuery, From f1d94e54e3e73ad014463292839b8c1598f089d3 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 12 Nov 2026 09:16:32 -0800 Subject: [PATCH 02/18] fix(conversation): place workspace actions Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/plugin-authoring-guide.md | 3 +- src/bundled/channels/Channels.module.css | 5 + src/bundled/channels/PageTabs.tsx | 119 +++++++++++++++-------- src/features/conversation/contracts.ts | 2 + 4 files changed, 90 insertions(+), 39 deletions(-) diff --git a/docs/plugin-authoring-guide.md b/docs/plugin-authoring-guide.md index 2061db20c5..4cc334f0ac 100644 --- a/docs/plugin-authoring-guide.md +++ b/docs/plugin-authoring-guide.md @@ -135,7 +135,8 @@ Add `"host"` to `inject`, then: `{ id, session, scope, target, label, toolbar?, actions?, detail? }`. `target` is `{ channelId, threadId?, messageId? }`: omit `threadId` for the channel timeline, or supply it for a thread. `messageId` selects a message. - `toolbar` supplies header icon controls. `actions` supplies menu entries with + `toolbar` supplies content controls below the header. `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. diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css index d1732fe7eb..941dd5f6f6 100644 --- a/src/bundled/channels/Channels.module.css +++ b/src/bundled/channels/Channels.module.css @@ -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; diff --git a/src/bundled/channels/PageTabs.tsx b/src/bundled/channels/PageTabs.tsx index 865fe075a0..0718e5c818 100644 --- a/src/bundled/channels/PageTabs.tsx +++ b/src/bundled/channels/PageTabs.tsx @@ -20,7 +20,16 @@ import { PanelWorkspace } from "../../features/panels/PanelWorkspace"; import { PanelDock } from "../../features/panels/PanelDock"; import { usePanelSplit } from "../../features/panels/usePanelSplit"; import { IconButton } from "../../shared/design-system/ui/IconButton"; -import { SidebarRightIcon } from "../../shared/design-system/icons"; +import { + DotsThreeIcon, + SidebarRightIcon, +} from "../../shared/design-system/icons"; +import { + MenuRoot, + MenuTrigger, + MenuPopup, + MenuItem, +} from "../../shared/design-system/ui/Menu"; import panelStyles from "../../features/panels/Panels.module.css"; import { useChannelLabels } from "./useChannelLabels"; import { @@ -440,45 +449,79 @@ export function PageTabs({ id: "conversation", label, icon, - toolbar: workspace?.toolbar, - actions: workspace?.actions, - content: workspace ? ( - primaryTab && - primaryChannel && - secondary.canOpenConversation(primaryChannel.id) ? ( - - secondary.openConversationLink( - primaryChannel.id, - url, - origin, - ) + toolbar: !!workspace?.actions?.length && ( + + + ), + actions: workspace?.actions, + content: workspace ? ( +
+ {workspace.toolbar} +
+ {primaryTab && + primaryChannel && + secondary.canOpenConversation(primaryChannel.id) ? ( + + secondary.openConversationLink( + primaryChannel.id, + url, + origin, + ) + } + canOpenLink={(target) => + !!host.panels.resolve(target) + } + openThread={(messageId, rootId, intent) => + secondary.openConversationThread( + primaryChannel.id, + messageId, + rootId, + intent, + ) + } + close={() => {}} + /> + ) : ( +

+ This conversation is no longer available. +

+ )} +
+
) : ( children(open, close, isOpen) ), diff --git a/src/features/conversation/contracts.ts b/src/features/conversation/contracts.ts index f75e65394f..e538e70a29 100644 --- a/src/features/conversation/contracts.ts +++ b/src/features/conversation/contracts.ts @@ -466,7 +466,9 @@ export type ConversationWorkspaceProps = { scope: string; target: PageTabTarget; label: string; + /** Content controls below the pane header and above the reader. */ toolbar?: ReactNode; + /** Actions in the pane's menu and the main tab's context menu. */ actions?: readonly { label: string; run(): void }[]; /** Select this panel on entry. Closing it keeps the main conversation. */ detail?: { target: string; rootId?: string }; From 671014c9a12c85c547405c23e8fddc613094bd03 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 12 Nov 2026 09:26:20 -0800 Subject: [PATCH 03/18] fix(conversation): handle workspace panel requests Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- src/bundled/channels/PageTabs.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/bundled/channels/PageTabs.tsx b/src/bundled/channels/PageTabs.tsx index 0718e5c818..0ed4beb0af 100644 --- a/src/bundled/channels/PageTabs.tsx +++ b/src/bundled/channels/PageTabs.tsx @@ -175,6 +175,24 @@ export function PageTabs({ enteredDetail.current = { session, key: detailKey }; secondary.open(entry, true); }); + const primaryChannelId = workspace?.target.channelId; + useLayoutEffect(() => { + if (!primaryChannelId) return; + return host.panels.handleOpenRequests(({ target, channelId, rootId }) => { + const connection = host.relay.snapshot(); + if ( + !mounted.current || + channelId !== primaryChannelId || + connection.status !== "ready" || + connection.session !== session + ) + return false; + const entry = panelOpening(host.panels, channelId, target, rootId); + if (!entry) return false; + secondary.open(entry, true); + return true; + }); + }, [host.panels, host.relay, primaryChannelId, secondary.open, session]); // One stable opener, so a page's effects can depend on it; it reads the // live workspace when called. const opening = useRef< From c97927a49c5c4083e91e9bb50d480a07617342a8 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 12 Nov 2026 09:50:06 -0800 Subject: [PATCH 04/18] feat: keep conversation navigation for plugin pages Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/plugin-architecture.md | 3 +++ src/app/shell/presentation.ts | 2 +- src/features/pages/service.ts | 4 ++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index fce2699a0f..f12f09aa60 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -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 diff --git a/src/app/shell/presentation.ts b/src/app/shell/presentation.ts index e0505dd48f..6c4d84085d 100644 --- a/src/app/shell/presentation.ts +++ b/src/app/shell/presentation.ts @@ -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; diff --git a/src/features/pages/service.ts b/src/features/pages/service.ts index f3b29a10a1..c7881fac0b 100644 --- a/src/features/pages/service.ts +++ b/src/features/pages/service.ts @@ -32,6 +32,8 @@ export type Page = Readonly<{ component: ComponentType; /** Replace the conversation sidebar while this page is selected. */ sidebar?: ComponentType; + /** Keep the Messages sidebar and dock selection while this page is open. */ + conversationNavigation?: boolean; layout?: "document" | "workspace"; // Opt in only when every page state places the supplied companion card. companion?: boolean; @@ -101,6 +103,8 @@ export class PagesService extends Service implements Pages { !page.title.trim() || typeof page.component !== "function" || (page.sidebar !== undefined && typeof page.sidebar !== "function") || + (page.conversationNavigation !== undefined && + typeof page.conversationNavigation !== "boolean") || (page.layout !== undefined && page.layout !== "document" && page.layout !== "workspace") || From 797180baf44eabceee9278be9632606cf47c5d22 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 12 Nov 2026 10:30:17 -0800 Subject: [PATCH 05/18] feat: place workspace intro in conversation history Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/plugin-authoring-guide.md | 4 ++-- src/bundled/channels/ConversationTab.tsx | 6 ++++++ src/bundled/channels/PageTabs.tsx | 2 +- src/features/conversation/contracts.ts | 4 ++-- src/features/messages/ThreadPanel.tsx | 15 ++++++--------- 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/docs/plugin-authoring-guide.md b/docs/plugin-authoring-guide.md index 4cc334f0ac..0522add961 100644 --- a/docs/plugin-authoring-guide.md +++ b/docs/plugin-authoring-guide.md @@ -132,10 +132,10 @@ Add `"host"` to `inject`, then: 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, toolbar?, actions?, detail? }`. + `{ 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. - `toolbar` supplies content controls below the header. `actions` supplies + `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. diff --git a/src/bundled/channels/ConversationTab.tsx b/src/bundled/channels/ConversationTab.tsx index b3502a1968..b93f6b42cf 100644 --- a/src/bundled/channels/ConversationTab.tsx +++ b/src/bundled/channels/ConversationTab.tsx @@ -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"; @@ -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; @@ -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, @@ -155,6 +158,7 @@ export function ConversationTab({ {tab.kind === "thread" ? ( ) : target && inTimeline === false ? ( - {workspace.toolbar}
{primaryTab && primaryChannel && @@ -505,6 +504,7 @@ export function PageTabs({ value + 1); }, [navigation, onRetryNavigation]); // A new request never clears a previously opened editor while evidence loads. - // biome-ignore lint/correctness/useExhaustiveDependencies: attempt retries the selected request; outer identity owns session/channel lifetime. useEffect(() => { if (navigation?.signal.aborted) return; const request = new AbortController(); @@ -406,6 +406,7 @@ function RetainedThreadPanel(props: ThreadPanelProps) { ); } function OwnedThreadPanel({ + intro, session, extensions, scope, @@ -432,7 +433,6 @@ function OwnedThreadPanel({ const [error, setError] = useState(); const [attempt, setAttempt] = useState(0); // Allocate in the effect, not render/useMemo: StrictMode must not leak owned views. - // biome-ignore lint/correctness/useExhaustiveDependencies: attempt is explicit recovery after view allocation fails. useEffect(() => { try { if (navigation?.signal.aborted) return; @@ -482,6 +482,7 @@ function OwnedThreadPanel({
) : view ? (
, ); return { + host, + session, openers, navigation: host.navigation, open(target: PageTabTarget, options?: PageTabOptions) { @@ -408,3 +419,34 @@ 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 f = fixture(); + cleanup(); + const source = vi.fn(); + render( + Source channel: #alpha} + 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 userEvent.click( + screen.getByRole("button", { name: "Conversation actions" }), + ); + await userEvent.click( + screen.getByRole("menuitem", { name: "View in source channel" }), + ); + expect(source).toHaveBeenCalledOnce(); +}); From 658f123dcff05346ef81e7b0a59e143c87b187e7 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 12 Nov 2026 12:28:22 -0800 Subject: [PATCH 07/18] docs: describe conversation page navigation Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/plugin-authoring-guide.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/plugin-authoring-guide.md b/docs/plugin-authoring-guide.md index 0522add961..ec296aaa33 100644 --- a/docs/plugin-authoring-guide.md +++ b/docs/plugin-authoring-guide.md @@ -135,11 +135,14 @@ Add `"host"` to `inject`, then: `{ 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 + `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 From d2d4acb407395a63b1bbc581b50de411890ea1d8 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 12 Nov 2026 12:34:20 -0800 Subject: [PATCH 08/18] test: align workspace fixtures with registration lifetime Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- src/bundled/channels/PageTabs.test.tsx | 7 ++++--- src/bundled/channels/index.test.tsx | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/bundled/channels/PageTabs.test.tsx b/src/bundled/channels/PageTabs.test.tsx index 8e60195058..4567ffc7fb 100644 --- a/src/bundled/channels/PageTabs.test.tsx +++ b/src/bundled/channels/PageTabs.test.tsx @@ -421,6 +421,7 @@ 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(); @@ -442,11 +443,11 @@ it("a workspace owns its main reader, intro, menu, and no initial detail", async screen.getByRole("link", { name: "Source channel: #alpha" }), ).toBeVisible(); expect(screen.queryAllByRole("tablist")).toHaveLength(1); - await userEvent.click( + await user.click( screen.getByRole("button", { name: "Conversation actions" }), ); - await userEvent.click( - screen.getByRole("menuitem", { name: "View in source channel" }), + await user.click( + await screen.findByRole("menuitem", { name: "View in source channel" }), ); expect(source).toHaveBeenCalledOnce(); }); diff --git a/src/bundled/channels/index.test.tsx b/src/bundled/channels/index.test.tsx index 051314835a..8f43ebcdd2 100644 --- a/src/bundled/channels/index.test.tsx +++ b/src/bundled/channels/index.test.tsx @@ -15,6 +15,7 @@ function registered() { conversation: { registerCompletion() {}, registerTabs() {}, + registerWorkspace() {}, registerMessageAction() {}, }, settingsCards: { register() {} }, From 9aad4aa724ffd0bc5ab7f002faf60645f176776a Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 12 Nov 2026 12:50:14 -0800 Subject: [PATCH 09/18] fix: check thread effect annotations in file hooks --- biome.json | 1 + src/features/messages/ThreadPanel.tsx | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/biome.json b/biome.json index b2016ed675..04350cfea3 100644 --- a/biome.json +++ b/biome.json @@ -32,6 +32,7 @@ "enabled": true, "rules": { "preset": "recommended", + "correctness": { "useExhaustiveDependencies": "error" }, "suspicious": { "noUnknownAtRules": "off" }, diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx index 84ff46cf98..d4f04f052d 100644 --- a/src/features/messages/ThreadPanel.tsx +++ b/src/features/messages/ThreadPanel.tsx @@ -266,6 +266,7 @@ function RetainedThreadPanel(props: ThreadPanelProps) { else retry((value) => value + 1); }, [navigation, onRetryNavigation]); // A new request never clears a previously opened editor while evidence loads. + // biome-ignore lint/correctness/useExhaustiveDependencies: attempt retries the selected request; outer identity owns session/channel lifetime. useEffect(() => { if (navigation?.signal.aborted) return; const request = new AbortController(); @@ -433,6 +434,7 @@ function OwnedThreadPanel({ const [error, setError] = useState(); const [attempt, setAttempt] = useState(0); // Allocate in the effect, not render/useMemo: StrictMode must not leak owned views. + // biome-ignore lint/correctness/useExhaustiveDependencies: attempt is explicit recovery after view allocation fails. useEffect(() => { try { if (navigation?.signal.aborted) return; @@ -788,6 +790,7 @@ function ThreadMessages({ >(undefined); const olderDemand = useRef(false); const targetAnchor = useRef(undefined); + // biome-ignore lint/correctness/useExhaustiveDependencies: each new target request retires the previous reveal anchor, not the reader's state. useLayoutEffect(() => { targetAnchor.current = undefined; }, [messageId, navigation?.signal, revealRequest]); @@ -904,6 +907,7 @@ function ThreadMessages({ // The signal identifies one request; the navigation object is re-bound per // relay snapshot, so a re-render of the same request must not refocus. const handledRequest = useRef(navigation?.signal); + // biome-ignore lint/correctness/useExhaustiveDependencies: only a new request moves focus; the others merely qualify it. useLayoutEffect(() => { if (handledRequest.current === navigation?.signal) return; handledRequest.current = navigation?.signal; @@ -933,6 +937,7 @@ function ThreadMessages({ (!navigation || positionedVisit) && snapshot.status !== "error"; const [inlineReveal, setInlineReveal] = useState(); + // biome-ignore lint/correctness/useExhaustiveDependencies: each reveal request needs its own signal. useEffect(() => { if (!revealSelected) return; const request = new AbortController(); @@ -973,6 +978,7 @@ function ThreadMessages({ const leftTree = useRef(tree); leftTree.current = tree; const withdrawn = useRef<() => void>(undefined); + // biome-ignore lint/correctness/useExhaustiveDependencies: hiding or showing the reader again records its place too. useLayoutEffect(() => { if (!navigation) return withdrawn.current; withdrawn.current = undefined; @@ -1175,6 +1181,7 @@ function ThreadMessages({ if (arrivals > 0 && !follow.current) setNewMessageCount((count) => count + arrivals); }, [snapshot.status, snapshot.canLoadMore, snapshot.replies]); + // biome-ignore lint/correctness/useExhaustiveDependencies: Rendered rows/profiles change scroll height; sending is explicit navigation intent. useLayoutEffect(() => { const element = scroller.current; if (!element || navigation?.signal.aborted) return; @@ -1363,6 +1370,7 @@ function ThreadMessages({ // Main's retained presentation can be usable before the initial history walk // finishes. That is not yet automatic reading intent. Exact revealed targets // remain individually readable; later background refreshes keep earned readiness. + // biome-ignore lint/correctness/useExhaustiveDependencies: active can complete an exact background visit in the preceding layout effect without changing the snapshot. useLayoutEffect(() => { if ( !positioned.current || @@ -1380,6 +1388,7 @@ function ThreadMessages({ } }, [active, navigation, routePending, rootTarget, revealed, snapshot]); // An own send can land in the middle of a branch, not at the list bottom. + // biome-ignore lint/correctness/useExhaustiveDependencies: Retry DOM lookup after history or branch visibility changes. useLayoutEffect(() => { if (!sent) return; const row = [ From 3bf45b93a13ae6906307f6c6bf887b8a0fc67b11 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 12 Nov 2026 14:06:50 -0800 Subject: [PATCH 10/18] feat: retain native Solo controls for custom destinations --- .../channel-navigation/SoloDestination.tsx | 6 +++-- .../channel-navigation/SoloRegions.test.tsx | 22 +++++++++++++++++++ .../solo-navigation-contract.ts | 4 ++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/features/channel-navigation/SoloDestination.tsx b/src/features/channel-navigation/SoloDestination.tsx index 6413e64807..3894d84623 100644 --- a/src/features/channel-navigation/SoloDestination.tsx +++ b/src/features/channel-navigation/SoloDestination.tsx @@ -187,10 +187,12 @@ export function RegionConversation(props: SoloConversationProps) { ); const state: ConversationState = { channel, - selected: current === channel.id, + selected: props.selected ?? current === channel.id, menu, open() { - if (active()) void navigator.open(conversationTarget(scope, channel.id)); + if (!active()) return; + if (props.onOpen) props.onOpen(); + else void navigator.open(conversationTarget(scope, channel.id)); }, canRename: session.channelDetails.available && !channel.readOnly && !channel.cached, diff --git a/src/features/channel-navigation/SoloRegions.test.tsx b/src/features/channel-navigation/SoloRegions.test.tsx index 88e6ea7779..4c2d338883 100644 --- a/src/features/channel-navigation/SoloRegions.test.tsx +++ b/src/features/channel-navigation/SoloRegions.test.tsx @@ -2219,3 +2219,25 @@ it("keeps a visible priority cue on a muted Solo recovery destination", () => { const row = screen.getByRole("button", { name: /^Owned/ }); expect(row.querySelector("[data-channel-priority]")).toBeInTheDocument(); }); + +it("keeps native rename and selection with a source-owned destination", async () => { + const h = fixture(); + const open = vi.fn(); + await h.register({ + component: ({ ui }) => ( + + {(c) => } + + ), + }); + const view = mount(h); + const row = screen.getByRole("button", { name: "Owned", exact: true }); + expect(row).toHaveAttribute("aria-current", "page"); + fireEvent.click(row); + expect(open).toHaveBeenCalledOnce(); + expect(view.open).not.toHaveBeenCalled(); + fireEvent.doubleClick(row); + expect( + await screen.findByRole("textbox", { name: "Chat name", exact: true }), + ).toBeInTheDocument(); +}); diff --git a/src/features/channel-navigation/solo-navigation-contract.ts b/src/features/channel-navigation/solo-navigation-contract.ts index 3e4f8eda05..6e16447ed0 100644 --- a/src/features/channel-navigation/solo-navigation-contract.ts +++ b/src/features/channel-navigation/solo-navigation-contract.ts @@ -124,6 +124,10 @@ export type SoloWorkspaceProps = Readonly<{ /** Host conversation behavior. The source composes the presentation from its parts. */ export type SoloConversationProps = Readonly<{ channelId: string; + /** Use a source-owned destination with the native row controls. */ + onOpen?(): void; + /** Selection from the source-owned destination. */ + selected?: boolean; menuActions?: readonly SoloRegionAction[]; /** Not called for a conversation outside this region. Renaming replaces it with the host editor. */ children(conversation: SoloConversation): ReactNode; From 49767072a1c214a22dfde34dc07931c784bac854 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 12 Nov 2026 14:11:26 -0800 Subject: [PATCH 11/18] test: use the Testing Library role options --- src/features/channel-navigation/SoloRegions.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/channel-navigation/SoloRegions.test.tsx b/src/features/channel-navigation/SoloRegions.test.tsx index 4c2d338883..e54c1d28e5 100644 --- a/src/features/channel-navigation/SoloRegions.test.tsx +++ b/src/features/channel-navigation/SoloRegions.test.tsx @@ -2231,13 +2231,13 @@ it("keeps native rename and selection with a source-owned destination", async () ), }); const view = mount(h); - const row = screen.getByRole("button", { name: "Owned", exact: true }); + const row = screen.getByRole("button", { name: "Owned" }); expect(row).toHaveAttribute("aria-current", "page"); fireEvent.click(row); expect(open).toHaveBeenCalledOnce(); expect(view.open).not.toHaveBeenCalled(); fireEvent.doubleClick(row); expect( - await screen.findByRole("textbox", { name: "Chat name", exact: true }), + await screen.findByRole("textbox", { name: "Chat name" }), ).toBeInTheDocument(); }); From ab42da74f0b637fe1d9cec78012706e34082c4e5 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 12 Nov 2026 14:17:57 -0800 Subject: [PATCH 12/18] test: enable native rename in the row fixture --- src/features/channel-navigation/SoloRegions.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/features/channel-navigation/SoloRegions.test.tsx b/src/features/channel-navigation/SoloRegions.test.tsx index e54c1d28e5..6fa207854f 100644 --- a/src/features/channel-navigation/SoloRegions.test.tsx +++ b/src/features/channel-navigation/SoloRegions.test.tsx @@ -2222,6 +2222,15 @@ it("keeps a visible priority cue on a muted Solo recovery destination", () => { it("keeps native rename and selection with a source-owned destination", async () => { const h = fixture(); + Object.assign(h.session, { + channelDetails: { + ...h.session.channelDetails, + available: true, + load: async () => { + throw new Error("Offline"); + }, + }, + }); const open = vi.fn(); await h.register({ component: ({ ui }) => ( From 3a9de1b51dcdfbfcfd858645eaab11eeab1ac715 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 12 Nov 2026 14:33:10 -0800 Subject: [PATCH 13/18] fix: keep focus in the sidebar when a workspace mounts --- src/bundled/channels/PageTabs.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bundled/channels/PageTabs.tsx b/src/bundled/channels/PageTabs.tsx index eb65e76bcd..12269abe34 100644 --- a/src/bundled/channels/PageTabs.tsx +++ b/src/bundled/channels/PageTabs.tsx @@ -504,6 +504,7 @@ export function PageTabs({ Date: Thu, 12 Nov 2026 14:33:39 -0800 Subject: [PATCH 14/18] fix: retain explicit workspace message reveals --- src/bundled/channels/PageTabs.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bundled/channels/PageTabs.tsx b/src/bundled/channels/PageTabs.tsx index 12269abe34..b752923be8 100644 --- a/src/bundled/channels/PageTabs.tsx +++ b/src/bundled/channels/PageTabs.tsx @@ -381,7 +381,8 @@ export function PageTabs({ messageId: primaryTarget.messageId, }), }), - ...(primaryTarget.messageId ? { revealRequest: 1 } : { focusRequest: 1 }), + // A delayed workspace mount must not take focus from a sidebar name edit. + ...(primaryTarget.messageId ? { revealRequest: 1 } : {}), }; // As in a plain channel visit, the page reads its visible selected side tab // while focus stays anywhere on the page, such as the row that opened it. @@ -504,7 +505,6 @@ export function PageTabs({ Date: Thu, 12 Nov 2026 15:14:21 -0800 Subject: [PATCH 15/18] fix: preserve exact source reveals beside task details --- src/bundled/channels/ChannelsPage.tsx | 54 ++++++++++++++----- src/bundled/channels/PageTabs.test.tsx | 29 ++++++++++ src/bundled/channels/PageTabs.tsx | 9 +++- .../channels/useChannelTabState.test.tsx | 31 +++++++++++ src/bundled/channels/useChannelTabState.ts | 7 ++- .../useConversationTabs.panels.test.tsx | 6 +++ src/bundled/channels/useConversationTabs.tsx | 11 ++-- 7 files changed, 126 insertions(+), 21 deletions(-) diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index d172dd057c..ec8f0daa79 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -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 && @@ -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, @@ -1024,6 +1040,7 @@ function ChannelWorkspace({ requestedThread, exact, threadPane, + detailReply, ], ); // Open in channel also shows the thread's root in the timeline, reading @@ -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 } @@ -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); @@ -1608,6 +1627,8 @@ function ChannelWorkspace({ exact?.request, exact?.inTimeline, routedThread, + detailReply, + detailSource, tabState.enterVisit, tabState.select, tabState.selectMain, @@ -2073,7 +2094,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} diff --git a/src/bundled/channels/PageTabs.test.tsx b/src/bundled/channels/PageTabs.test.tsx index 4567ffc7fb..3454cb76a9 100644 --- a/src/bundled/channels/PageTabs.test.tsx +++ b/src/bundled/channels/PageTabs.test.tsx @@ -451,3 +451,32 @@ it("a workspace owns its main reader, intro, menu, and no initial detail", async ); 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 ? ( + + ) : ( + + ), + ); + screen.getByRole(editing ? "textbox" : "button").focus(); + render( + , + ); + const reader = screen.getByText("conversation in alpha", { exact: false }); + if (editing) expect(reader).not.toHaveAttribute("data-focus"); + else expect(reader).toHaveAttribute("data-focus", "1"); + }, +); diff --git a/src/bundled/channels/PageTabs.tsx b/src/bundled/channels/PageTabs.tsx index b752923be8..dd6d552ec4 100644 --- a/src/bundled/channels/PageTabs.tsx +++ b/src/bundled/channels/PageTabs.tsx @@ -362,6 +362,11 @@ export function PageTabs({ const hasTabs = secondary.items.length > 0; const showingPanel = tabState.paneOpen && hasTabs; const covered = useCoveredByDock(split.ref, fallbackFocus, showingPanel); + const [primaryFocus] = useState(() => + document.activeElement?.closest('input, textarea, [contenteditable="true"]') + ? undefined + : 1, + ); const primaryTarget = workspace?.target; const primaryChannel = primaryTarget && @@ -382,7 +387,9 @@ export function PageTabs({ }), }), // A delayed workspace mount must not take focus from a sidebar name edit. - ...(primaryTarget.messageId ? { revealRequest: 1 } : {}), + ...(primaryTarget.messageId + ? { revealRequest: 1 } + : { focusRequest: primaryFocus }), }; // As in a plain channel visit, the page reads its visible selected side tab // while focus stays anywhere on the page, such as the row that opened it. diff --git a/src/bundled/channels/useChannelTabState.test.tsx b/src/bundled/channels/useChannelTabState.test.tsx index e4ddb215b0..2c782bb34f 100644 --- a/src/bundled/channels/useChannelTabState.test.tsx +++ b/src/bundled/channels/useChannelTabState.test.tsx @@ -34,6 +34,37 @@ afterEach(() => { localStorage.clear(); }); +test("a detail visit retains the exact source thread in the main pane", () => { + const session = {} as RelaySession; + const h = renderHook(() => useChannelTabState(session, "alpha")); + act(() => { + h.result.current.openThreadTab("alpha", "reply", "root", undefined, "side"); + }); + const source = h.result.current.tabs.find((tab) => tab.kind === "thread"); + if (!source) throw new Error("The source thread is missing."); + const id = source.id; + act(() => h.result.current.resolveThreadRoot(id, "root")); + act(() => { + h.result.current.enterVisit( + "source:detail", + { channelId: "alpha", messageId: "reply", rootId: "root", pane: "main" }, + true, + { channelId: "alpha", target: "example:task" }, + ); + }); + expect(h.result.current.tabs).toHaveLength(1); + expect(h.result.current.tabs[0]).toMatchObject({ + id, + pane: "main", + messageId: "reply", + }); + expect(h.result.current.mainSelected).toBe(id); + expect(h.result.current.detailVisits?.source).toEqual({ + channelId: "alpha", + target: "example:task", + }); +}); + test("selection readers stay stable during local updates and change with the channel", () => { const session = {} as RelaySession; const observations: unknown[] = []; diff --git a/src/bundled/channels/useChannelTabState.ts b/src/bundled/channels/useChannelTabState.ts index 1b316e025f..2b9d50fd08 100644 --- a/src/bundled/channels/useChannelTabState.ts +++ b/src/bundled/channels/useChannelTabState.ts @@ -187,6 +187,7 @@ function withThread( intent: ThreadIntent | undefined, pane: TabPane, attention = false, + relocate = false, ): State { const existing = threadTab(state.tabs, channelId, rootId); const baseId = `thread:${channelId}:${rootId}`; @@ -221,8 +222,8 @@ function withThread( intent === "reveal" ? (prior?.revealRequest ?? 0) + 1 : prior?.revealRequest, - // An already-open thread stays wherever it was placed. - pane: prior ? tabPane(prior) : pane, + // Detail visits need the source thread in the other pane. + pane: prior && !relocate ? tabPane(prior) : pane, rootId: prior?.rootId, name: prior?.name, }; @@ -620,6 +621,8 @@ export function useChannelTabState( thread.rootId, "select", thread.pane ?? threadPlacementPreference(), + false, + !!detail && thread.pane === "main", ); const saved = visits?.[visit]; return saved diff --git a/src/bundled/channels/useConversationTabs.panels.test.tsx b/src/bundled/channels/useConversationTabs.panels.test.tsx index ef462693cb..bdb26541ef 100644 --- a/src/bundled/channels/useConversationTabs.panels.test.tsx +++ b/src/bundled/channels/useConversationTabs.panels.test.tsx @@ -188,6 +188,12 @@ it.each([ draft, ); expect(draft).toHaveValue("Unfinished reply"); + act(() => state.moveEntry("app:target-49", "main")); + act(() => workspace.open(entries[49], true, "side")); + expect( + document.querySelector("[data-panel-workspace]")?.contains(draft), + ).toBe(true); + expect(draft).toHaveValue("Unfinished reply"); view.rerender( diff --git a/src/bundled/channels/useConversationTabs.tsx b/src/bundled/channels/useConversationTabs.tsx index e57589467e..33a817f242 100644 --- a/src/bundled/channels/useConversationTabs.tsx +++ b/src/bundled/channels/useConversationTabs.tsx @@ -233,8 +233,8 @@ export function useConversationTabs({ opening.current = opened; } const selectOpening = useCallback( - (next: Opening | undefined) => { - if (next && inMain(next)) { + (next: Opening | undefined, pane?: TabPane) => { + if (next && (pane ? pane === "main" : inMain(next))) { tabState.selectMain(panelTabId(next)); return; } @@ -244,7 +244,7 @@ export function useConversationTabs({ [inMain, tabState.select, tabState.selectMain], ); const open = useCallback( - (next: Opening | undefined, append = false) => { + (next: Opening | undefined, append = false, pane?: TabPane) => { const found = next ? entryList.current.find( (entry) => @@ -294,9 +294,10 @@ export function useConversationTabs({ if (selected && historyPanel(selected)) panelHistory.current.onVisitPanel?.(selected); setEntries(updated); - selectOpening(selected); + if (selected && pane) tabState.moveEntry(panelTabId(selected), pane); + selectOpening(selected, pane); }, - [selectOpening, setEntries], + [selectOpening, setEntries, tabState.moveEntry], ); const panelTabs = entries.filter( (entry) => From 686041f7f006d55a2c7d509e974fd15e3ac64752 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 12 Nov 2026 15:24:43 -0800 Subject: [PATCH 16/18] test: read detail visits through the public state API --- src/bundled/channels/useChannelTabState.test.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/bundled/channels/useChannelTabState.test.tsx b/src/bundled/channels/useChannelTabState.test.tsx index 2c782bb34f..b0da84dc5f 100644 --- a/src/bundled/channels/useChannelTabState.test.tsx +++ b/src/bundled/channels/useChannelTabState.test.tsx @@ -59,10 +59,9 @@ test("a detail visit retains the exact source thread in the main pane", () => { messageId: "reply", }); expect(h.result.current.mainSelected).toBe(id); - expect(h.result.current.detailVisits?.source).toEqual({ - channelId: "alpha", - target: "example:task", - }); + expect(h.result.current.detailVisits("alpha", "example:task")).toEqual([ + "source", + ]); }); test("selection readers stay stable during local updates and change with the channel", () => { From 8098409091fdbec51637b9d53b006b78b24cd65f Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 12 Nov 2026 15:39:57 -0800 Subject: [PATCH 17/18] test: isolate pane reopen and shell lookup fixtures --- .../channels/useConversationTabs.panels.test.tsx | 16 ++++++++++------ src/bundled/pi/launch.test.ts | 6 +++++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/bundled/channels/useConversationTabs.panels.test.tsx b/src/bundled/channels/useConversationTabs.panels.test.tsx index bdb26541ef..e79735c867 100644 --- a/src/bundled/channels/useConversationTabs.panels.test.tsx +++ b/src/bundled/channels/useConversationTabs.panels.test.tsx @@ -188,12 +188,16 @@ it.each([ draft, ); expect(draft).toHaveValue("Unfinished reply"); - act(() => state.moveEntry("app:target-49", "main")); - act(() => workspace.open(entries[49], true, "side")); - expect( - document.querySelector("[data-panel-workspace]")?.contains(draft), - ).toBe(true); - expect(draft).toHaveValue("Unfinished reply"); + if (pane === "side") { + act(() => { + state.moveEntry("app:target-49", "main"); + workspace.open(entries[49], true, "side"); + }); + expect( + document.querySelector("[data-panel-workspace]")?.contains(draft), + ).toBe(true); + expect(draft).toHaveValue("Unfinished reply"); + } view.rerender( diff --git a/src/bundled/pi/launch.test.ts b/src/bundled/pi/launch.test.ts index 5ff0922a11..33bc519fe5 100644 --- a/src/bundled/pi/launch.test.ts +++ b/src/bundled/pi/launch.test.ts @@ -182,6 +182,10 @@ it.skipIf(!existsSync("/bin/zsh"))( async () => { const pi = await disk(); await rm(join(pi.bin, "node")); + // A system Node must not bypass the shell lookup this test exercises. + await writeFile(join(pi.bin, "node"), "#!/bin/sh\nexit 1\n", { + mode: 0o755, + }); // Only .zshrc, which a plain login shell skips, has this Node, which // notes that it ran. const managed = join(pi.home, "nvm"); @@ -195,7 +199,7 @@ it.skipIf(!existsSync("/bin/zsh"))( join(pi.home, ".zshrc"), `echo "Welcome back"\nexport PATH=${JSON.stringify(managed)}:$PATH\n`, ); - const out = await pi.step({ PATH: "/usr/bin:/bin", SHELL: "/bin/zsh" }); + const out = await pi.step({ SHELL: "/bin/zsh" }); expect(JSON.parse(out)).toMatchObject({ line: "hello" }); expect(existsSync(join(pi.home, "ran"))).toBe(true); }, From 7be2ed36c29b64be415a527d872ec19b705b25c3 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 12 Nov 2026 15:45:56 -0800 Subject: [PATCH 18/18] test: create retained panels through their opener --- src/bundled/channels/useConversationTabs.panels.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bundled/channels/useConversationTabs.panels.test.tsx b/src/bundled/channels/useConversationTabs.panels.test.tsx index e79735c867..265f1e66df 100644 --- a/src/bundled/channels/useConversationTabs.panels.test.tsx +++ b/src/bundled/channels/useConversationTabs.panels.test.tsx @@ -143,7 +143,7 @@ it.each([ target: `target-${index}`, })); act(() => { - state.setEntries(entries); + for (const entry of entries) workspace.open(entry, true); if (pane === "main") for (const entry of entries) state.moveEntry(`app:${entry.target}`, "main");