diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index f0c2340a0f..8befb11afa 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -1078,7 +1078,9 @@ in the bottom drawer by default or the existing companion column when the contri sets `channelPlacement: "side"`. The bottom drawer closes when the viewer leaves the channel. A side-column panel stays open in the channel's workspace: leaving the channel or page unmounts it, and returning shows it again until the viewer closes it, another -channel panel or Details replaces it, or the plugin is disabled. While the channel is read-only or cached it stays hidden. With `channelPlacement: "tab"` the launcher instead +channel panel or Details replaces it, or the plugin is disabled. While the channel is read-only or cached it stays hidden. For a side panel, `pressed` means the panel is shown in +the open pane, not merely selected: while the pane is hidden the panel stays selected but +reads unpressed, and `toggle` shows it again. With `channelPlacement: "tab"` the launcher instead opens one tab per channel in the side pane, grouped with thread and other panel tabs; a new target replaces that tab, and a link tab already open for the target becomes it. The tab receives `PanelProps.context` too, so it can open or push further tabs. A launcher diff --git a/docs/shell-design.md b/docs/shell-design.md index 3514b7d47c..ed84742a91 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -461,8 +461,10 @@ closing that profile or losing authorization also removes its log tab. Right-click any channel tab and choose **Close all tabs** to close this channel's thread, plugin and settings tabs across both panes, including hidden side-pane tabs. The permanent channel conversation stays; focus returns there if its previous -target closed, while typing in surviving content remains uninterrupted. Other -channels and saved message drafts are untouched. **Close other tabs** in the +target closed, while typing in surviving content remains uninterrupted. A +launcher's side panel, such as Todos, is not a tab, so it stays as it was: +shown in an open pane, or hidden in a closed one. Other channels and saved +message drafts are untouched. **Close other tabs** in the same menu does the same, except that the right-clicked tab stays open, selected and focused. On the channel conversation it is the same as Close all tabs. On a detail tab, such as an agent log, the tab that owns it stays instead, without @@ -476,7 +478,9 @@ A thread that offers it also names its conversation above its original message, as Inbox rows do (`#channel` or `DM · names`); that name is a link that does the same. Shift+F10 on a focused tab opens the same -menu. Hiding the pane remains separate from closing its tabs. +menu. Hiding the pane remains separate from closing its tabs. It also hides a +launcher's side panel without closing it: the launcher reads pressed only while +the pane shows the panel, and one press brings back the pane with it. The main-header split control toggles existing pane content without closing its tabs or resetting their contents. With no content it is disabled but remains focusable, so closing the last tab has a stable focus-return target. There is no diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index a769857efc..d2a831f8eb 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1143,6 +1143,9 @@ function ChannelWorkspace({ target: string; id: string; pane: "main" | "side"; + /** The side pane's visibility to restore: a kept tab shows, and a + * launcher's side panel keeps the visibility it had before the close. */ + paneOpen: boolean; }>(undefined); const dismissContent = ( visits: readonly string[], @@ -1174,6 +1177,8 @@ function ChannelWorkspace({ target: targetKey(fallback), id: keep, pane, + paneOpen: + !!(keptTab || keptEntry || keep === "settings") || tabState.paneOpen, }; } navigator.dismiss(visits, fallback); @@ -1234,11 +1239,16 @@ function ChannelWorkspace({ ); const keptTab = tabState.tabs.find((tab) => tab.id === keep); const keptEntry = remaining[0]; + // A launcher's side panel outlives the tabs, as when its last tab + // closes. The return keeps the pane as it was, shown or hidden, even + // when the channel visit it lands on had saved an open pane. const pane = keptTab ? tabPane(keptTab) : keptEntry ? entryPane(tabState.mainEntries, keptEntry) - : "main"; + : tabState.sidePanel + ? "side" + : "main"; const fallback = closeTarget(pane, remaining); if (!navigator || !fallback) return; const visits = [ @@ -1478,7 +1488,7 @@ function ChannelWorkspace({ if (kept.pane === "main") tabState.selectMain(kept.id); else { tabState.select(kept.id); - tabState.setPaneOpen(true); + tabState.setPaneOpen(kept.paneOpen); } focusKept.current = kept.id; } diff --git a/src/bundled/channels/useChannelPanels.tsx b/src/bundled/channels/useChannelPanels.tsx index 92aafb5813..191cf55532 100644 --- a/src/bundled/channels/useChannelPanels.tsx +++ b/src/bundled/channels/useChannelPanels.tsx @@ -30,6 +30,8 @@ type Opening = ChannelDrawerOpening & { }; /** The page's tab workspace, which owns `channelPlacement: "tab"` panels. */ export type ChannelPanelTabs = { + /** Side panels show only while the pane is open; closing it retains them. */ + paneOpen: boolean; pressed(panel: RegisteredPanel, context: ChannelPanelContext): boolean; toggle( panel: RegisteredPanel, @@ -103,6 +105,10 @@ export function useChannelPanels( if (retained && !available.includes(retained.panel)) forgetRetained?.(undefined); }, [local, selected, retained, forgetRetained, available]); + const shown = + selected?.panel.channelPlacement !== "side" || tabs?.paneOpen !== false + ? selected + : undefined; const activeOpening = useRef(undefined); useLayoutEffect(() => { activeOpening.current = selected; @@ -147,7 +153,7 @@ export function useChannelPanels( pressed={ tabbed ? !!tabs?.pressed(panel, context) - : selected?.panel === panel + : shown?.panel === panel } openMembers={ openMembers @@ -167,7 +173,7 @@ export function useChannelPanels( toggle={(target) => { if (!bound(context, panel)) return; if (tabbed) tabs?.toggle(panel, context, target); - else if (selected?.panel === panel) hide(selected); + else if (shown?.panel === panel) hide(shown); else { onOpen?.(); setOpened({ diff --git a/src/bundled/channels/useChannelTabState.ts b/src/bundled/channels/useChannelTabState.ts index a31970e4f8..c37ce24501 100644 --- a/src/bundled/channels/useChannelTabState.ts +++ b/src/bundled/channels/useChannelTabState.ts @@ -835,7 +835,8 @@ export function useChannelTabState( tabs: tab ? [tab] : [], selected: (side && keep) || "", mainSelected: main ? keep : undefined, - paneOpen: side, + // A launcher's side panel is not a tab, so a pane showing one stays. + paneOpen: side || (s.paneOpen && !!s.sidePanel), thread: undefined, settings, }; diff --git a/src/bundled/channels/useConversationTabs.test.tsx b/src/bundled/channels/useConversationTabs.test.tsx index 59ef4a61ee..298c43cd82 100644 --- a/src/bundled/channels/useConversationTabs.test.tsx +++ b/src/bundled/channels/useConversationTabs.test.tsx @@ -893,7 +893,10 @@ it.each(["member", "guest"] as const)( }, ); -function launcherHarness(mainPane = false) { +function launcherHarness( + mainPane = false, + placement: RegisteredPanel["channelPlacement"] = "tab", +) { const channels = [{ id: "alpha", name: "Alpha" }]; const session = { viewer: "viewer", @@ -906,7 +909,7 @@ function launcherHarness(mainPane = false) { pluginId: "fixture", revision: "1", title: "Work", - channelPlacement: "tab", + channelPlacement: placement, matches: (target) => target.startsWith("work:"), channelLauncher: (props) => { launcher = props; @@ -951,9 +954,77 @@ function launcherHarness(mainPane = false) { pressed: () => launcher.pressed, entries: () => state.entries, workspace: () => workspace, + // As the page shows it: the side panel is visible only in the open pane. + sideShown: () => state.paneOpen && !!workspace.drawer.side, + tabs: () => state.tabs, }; } +it.each([ + ["open", true], + ["closed", false], +])( + "Close all tabs leaves a launcher's side panel as the %s pane showed it, since it isn't a tab", + (_, open) => { + const h = launcherHarness(false, "side"); + act(() => { + h.workspace().openConversationThread("alpha", "root", "root"); + }); + h.toggle("alpha"); + expect(h.tabs()).toHaveLength(1); + expect(h.sideShown()).toBe(true); + if (!open) act(() => h.workspace().closePane()); + act(() => { + expect(h.workspace().closeAllTabs()).toBe(true); + }); + expect(h.tabs()).toHaveLength(0); + expect(h.sideShown()).toBe(open); + expect(h.pressed()).toBe(open); + }, +); + +it("a side panel's launcher reads unpressed while the closed pane hides it, and one press shows it", () => { + const h = launcherHarness(false, "side"); + act(() => { + h.workspace().openConversationThread("alpha", "root", "root"); + }); + h.toggle("alpha"); + expect(h.pressed()).toBe(true); + act(() => h.workspace().closePane()); + expect(h.sideShown()).toBe(false); + expect(h.pressed()).toBe(false); + h.toggle("alpha"); + expect(h.sideShown()).toBe(true); + expect(h.pressed()).toBe(true); + // The pane returns with its tab, and the next press hides the panel. + expect(h.tabs()).toHaveLength(1); + h.toggle("alpha"); + expect(h.workspace().drawer.side).toBeFalsy(); + expect(h.pressed()).toBe(false); +}); + +it("Close other tabs on a main-pane tab keeps a launcher's side panel open beside it", () => { + setThreadPlacementPreference("main"); + onTestFinished(() => setThreadPlacementPreference("side")); + const h = launcherHarness(true, "side"); + h.toggle("alpha"); + act(() => { + h.workspace().openConversationThread("alpha", "first", "first"); + }); + act(() => { + h.workspace().openConversationThread("alpha", "second", "second"); + }); + expect(h.workspace().selectedMain).toBe("thread:alpha:second"); + expect(h.sideShown()).toBe(true); + act(() => { + expect(h.workspace().closeOtherTabs("thread:alpha:first")).toBe(true); + }); + expect(h.tabs().map((tab) => tab.id)).toEqual(["thread:alpha:first"]); + expect(h.workspace().selectedMain).toBe("thread:alpha:first"); + expect(h.sideShown()).toBe(true); + expect(h.pressed()).toBe(true); +}); + it("a launcher takes over a tab a link already opened for its target", () => { const h = launcherHarness(); act(() => { diff --git a/src/bundled/channels/useConversationTabs.tsx b/src/bundled/channels/useConversationTabs.tsx index 72f70f4741..cf2131de6a 100644 --- a/src/bundled/channels/useConversationTabs.tsx +++ b/src/bundled/channels/useConversationTabs.tsx @@ -440,6 +440,7 @@ export function useConversationTabs({ tabState.setPaneOpen(true); }, { + paneOpen: tabState.paneOpen, pressed: (panel, context) => { const entry = channelTab(panel, context.channelId); return !!entry && tabState.paneOpen && selectedTab === tabId(entry); diff --git a/tests/browser/todos.spec.mjs b/tests/browser/todos.spec.mjs index db37ef0b51..0170e264ab 100644 --- a/tests/browser/todos.spec.mjs +++ b/tests/browser/todos.spec.mjs @@ -1,10 +1,12 @@ import { pluginAction } from "./plugin-settings.mjs"; import { + activate, closeSettings, openPage, openSettings, selectSettingsSection, } from "./navigation.mjs"; +import { frames } from "./timeline.mjs"; import { npubEncode } from "nostr-tools/nip19"; import { verifyEvent } from "nostr-tools"; import { test, expect } from "./fixture.mjs"; @@ -347,3 +349,204 @@ test("opt-in Todos saves ordinary Canvas and disabling leaves it editable", asyn await expect(canvas).toHaveValue(head.content); await expect(canvas).toBeEditable(); }); + +// Browser-only boundary: Back and Close all tabs move through native history +// visits. Back restores the channel visit's own closed pane, and Close all +// tabs' return to the channel visit must keep the launcher's side panel, or +// keep it hidden in a closed pane even when that visit saved an open one. +// jsdom page tests don't route visits through navigation. +test("Todos isn't a tab: Close all tabs keeps it, and its launcher tracks the pane", async ({ + page, + app, +}) => { + // Runs after the file's Bestie default, so Todos is the opted-in plugin. + await page.addInitScript(() => + localStorage.setItem( + "buzzodz.plugins.v1", + JSON.stringify({ version: 2, enabled: { "buzz.todos": true } }), + ), + ); + const history = app.histories.get("primary/alpha"); + const at = history.at(-1).created_at + 60; + const root = app.sign({ + kind: 9, + created_at: at, + tags: [["h", "alpha"]], + content: "Release checklist", + }); + history.push( + root, + app.sign({ + kind: 9, + created_at: at + 60, + tags: [ + ["h", "alpha"], + ["e", root.id, "", "reply"], + ], + content: "Looks good", + }), + ); + const second = app.sign({ + kind: 9, + created_at: at + 120, + tags: [["h", "alpha"]], + content: "Retro notes", + }); + history.push( + second, + app.sign({ + kind: 9, + created_at: at + 180, + tags: [ + ["h", "alpha"], + ["e", second.id, "", "reply"], + ], + content: "Added", + }), + ); + const head = app.sign({ + kind: 40100, + content: "## Todos\n\n- [ ] First\n", + tags: [["h", "alpha"]], + created_at: 1700000000, + }); + await page.route("**/api/relay/primary/query", async (route) => { + const filters = route.request().postDataJSON(); + if ( + filters.every((f) => f.kinds?.includes(40100) || f.ids?.includes(head.id)) + ) + return route.fulfill({ json: [head] }); + return route.continue(); + }); + await page.goto(app.origin); + await openPage(page, "Messages"); + await expect( + page.getByRole("textbox", { name: "Message #Alpha", exact: true }), + ).toBeVisible(); + // The reply chip comes only from the relay's live thread summary. + app.threadSummary(root.id, [app.viewer], 1); + app.threadSummary(second.id, [app.viewer], 1); + const launcher = page.getByRole("button", { + name: "Toggle channel todos", + exact: true, + }); + const todos = page.getByRole("region", { + name: "Todos panel", + exact: true, + }); + await launcher.click(); + await expect( + todos.getByRole("checkbox", { name: "First", exact: true }), + ).toBeVisible(); + const viewThreadOf = (id) => + page + .locator(`[data-channel-timeline="alpha"] [data-message-id="${id}"]`) + .getByRole("button", { name: /^View thread:/ }); + const viewThread = viewThreadOf(root.id); + await activate(viewThread); + const thread = page.locator( + '[data-panel-workspace] [role="tab"][data-tab-value^="thread:"]', + ); + await expect(thread).toHaveAttribute("aria-selected", "true"); + await expect(todos).toBeVisible(); + // Closing the pane keeps Todos for its return; the launcher says it's hidden. + await page + .getByRole("button", { name: "Toggle tab pane", expanded: true }) + .click(); + await expect(todos).toBeHidden(); + await expect(launcher).toHaveAttribute("aria-expanded", "false"); + await launcher.click(); + await expect(todos).toBeVisible(); + await expect(thread).toBeVisible(); + await thread.click({ button: "right" }); + await page + .getByRole("menuitem", { name: "Close all tabs", exact: true }) + .click(); + await expect(thread).toHaveCount(0); + await expect(todos).toBeVisible(); + await expect(launcher).toHaveAttribute("aria-expanded", "true"); + // Back restores the channel visit's saved pane: open, so Todos shows with + // the retained thread tab. Hiding the pane there saves it closed, so the + // next Back hides Todos with the launcher unpressed; one press shows it + // with the thread tab, and Close all tabs from there keeps it. + await activate(viewThread); + await expect(thread).toHaveAttribute("aria-selected", "true"); + await page.goBack(); + await expect(todos).toBeVisible(); + await expect(thread).toBeVisible(); + await page + .getByRole("button", { name: "Toggle tab pane", expanded: true }) + .click(); + await expect(todos).toBeHidden(); + await activate(viewThread); + await expect(thread).toHaveAttribute("aria-selected", "true"); + await expect(todos).toBeVisible(); + await page.goBack(); + await expect(todos).toBeHidden(); + await expect(launcher).toHaveAttribute("aria-expanded", "false"); + await launcher.click(); + await expect(todos).toBeVisible(); + await expect(thread).toBeVisible(); + await thread.click({ button: "right" }); + await page + .getByRole("menuitem", { name: "Close all tabs", exact: true }) + .click(); + await expect(thread).toHaveCount(0); + await expect(todos).toBeVisible(); + await expect(launcher).toHaveAttribute("aria-expanded", "true"); + // From a thread visit in a closed pane, ⌘⇧W returns to the channel without + // revealing Todos; one press shows it. + await activate(viewThread); + await expect(thread).toHaveAttribute("aria-selected", "true"); + const threadVisit = page.url(); + await page + .getByRole("button", { name: "Toggle tab pane", expanded: true }) + .click(); + await expect(todos).toBeHidden(); + expect( + await page.evaluate(() => { + const event = new Event("buzz:close-all-tabs", { cancelable: true }); + window.dispatchEvent(event); + return event.defaultPrevented; + }), + ).toBe(true); + await expect(thread).toHaveCount(0); + // The return lands after the address changes. + await expect.poll(() => page.url()).not.toBe(threadVisit); + await frames(page); + await expect(todos).toBeHidden(); + await expect(launcher).toHaveAttribute("aria-expanded", "false"); + await launcher.click(); + await expect(todos).toBeVisible(); + await expect(launcher).toHaveAttribute("aria-expanded", "true"); + // A channel visit showing Todos beside a thread retained from Back saves + // an open pane when a second thread opens. Hiding the pane and closing all + // tabs still returns there with Todos hidden, over that saved open pane. + await activate(viewThread); + await expect(thread).toHaveAttribute("aria-selected", "true"); + await page.goBack(); + await expect(todos).toBeVisible(); + await expect(thread).toBeVisible(); + await activate(viewThreadOf(second.id)); + await expect(thread).toHaveCount(2); + const secondVisit = page.url(); + await page + .getByRole("button", { name: "Toggle tab pane", expanded: true }) + .click(); + await expect(todos).toBeHidden(); + expect( + await page.evaluate(() => { + const event = new Event("buzz:close-all-tabs", { cancelable: true }); + window.dispatchEvent(event); + return event.defaultPrevented; + }), + ).toBe(true); + await expect(thread).toHaveCount(0); + await expect.poll(() => page.url()).not.toBe(secondVisit); + await frames(page); + await expect(todos).toBeHidden(); + await expect(launcher).toHaveAttribute("aria-expanded", "false"); + await launcher.click(); + await expect(todos).toBeVisible(); + await expect(launcher).toHaveAttribute("aria-expanded", "true"); +});