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/docs/buzz-help.md b/docs/buzz-help.md index 5d7f61a8e0..b6cf866cd0 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -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 diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index aa19435a3c..2342bc52f5 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/docs/plugin-authoring-guide.md b/docs/plugin-authoring-guide.md index e4c2f2da2c..ec296aaa33 100644 --- a/docs/plugin-authoring-guide.md +++ b/docs/plugin-authoring-guide.md @@ -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. diff --git a/src/app/shell/presentation.test.ts b/src/app/shell/presentation.test.ts index fe00e9dcff..6eddb3f68c 100644 --- a/src/app/shell/presentation.test.ts +++ b/src/app/shell/presentation.test.ts @@ -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); +}); 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/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/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index a86b63956f..a1be4dbdf6 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, @@ -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} 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 ? ( ({ @@ -26,7 +31,9 @@ vi.mock("./ConversationTab", () => ({ visit, onResolvedRoot, parent, + intro, }: { + intro?: import("react").ReactNode; tab: Tab; channel: { name: string }; visit: boolean; @@ -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 && ( @@ -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; @@ -142,6 +151,8 @@ function fixture(visits?: boolean) { , ); return { + host, + session, openers, navigation: host.navigation, open(target: PageTabTarget, options?: PageTabOptions) { @@ -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( + 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 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 ? ( + + ) : ( + + ), + ); + 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 8aac111b1e..dd6d552ec4 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, @@ -19,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 { @@ -29,6 +39,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 +49,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 +75,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 +137,62 @@ 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); + }); + 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< @@ -278,6 +362,35 @@ 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 && + 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, + }), + }), + // A delayed workspace mount must not take focus from a sidebar name edit. + ...(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. // A conversation moved to the main pane still reads only by its own focus. @@ -362,7 +475,82 @@ export function PageTabs({ id: "conversation", label, icon, - content: children(open, close, isOpen), + toolbar: !!workspace?.actions?.length && ( + + + ), + 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.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() {} }, 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/useChannelTabState.test.tsx b/src/bundled/channels/useChannelTabState.test.tsx index e4ddb215b0..b0da84dc5f 100644 --- a/src/bundled/channels/useChannelTabState.test.tsx +++ b/src/bundled/channels/useChannelTabState.test.tsx @@ -34,6 +34,36 @@ 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("alpha", "example:task")).toEqual([ + "source", + ]); +}); + 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..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"); @@ -188,6 +188,16 @@ it.each([ draft, ); 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/channels/useConversationTabs.tsx b/src/bundled/channels/useConversationTabs.tsx index 46344d4ba9..33a817f242 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({ @@ -231,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; } @@ -242,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) => @@ -292,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) => 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); }, 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..6fa207854f 100644 --- a/src/features/channel-navigation/SoloRegions.test.tsx +++ b/src/features/channel-navigation/SoloRegions.test.tsx @@ -2219,3 +2219,34 @@ 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(); + 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 }) => ( + + {(c) => } + + ), + }); + const view = mount(h); + 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" }), + ).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; diff --git a/src/features/conversation/contracts.ts b/src/features/conversation/contracts.ts index 407dcf4a23..2a5f55b3f9 100644 --- a/src/features/conversation/contracts.ts +++ b/src/features/conversation/contracts.ts @@ -459,6 +459,20 @@ 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; + /** Content at the start of history. It scrolls with the messages. */ + intro?: 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 }; +}; /** `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/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx index f210954af5..d4f04f052d 100644 --- a/src/features/messages/ThreadPanel.tsx +++ b/src/features/messages/ThreadPanel.tsx @@ -67,6 +67,7 @@ import { JumpToLatestButton } from "./JumpToLatestButton"; import { correctScrollTop } from "./scroll-correction"; export type ThreadPanelProps = { + intro?: ReactNode; extensions?: ConversationExtensions | undefined; session: RelaySession; scope: string; @@ -406,6 +407,7 @@ function RetainedThreadPanel(props: ThreadPanelProps) { ); } function OwnedThreadPanel({ + intro, session, extensions, scope, @@ -482,6 +484,7 @@ function OwnedThreadPanel({ ) : view ? (