From 6aa43a422d3313e31cfdb9b38375afa0814a469d Mon Sep 17 00:00:00 2001 From: mtoohey Date: Fri, 6 Nov 2026 13:10:01 +0000 Subject: [PATCH 1/3] fix(channels): keep a launcher's side panel open on Close all tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close all tabs (the tab menu, or ⌘⇧W) closed the whole right pane, so a launcher's side panel such as Todos vanished even though it isn't a tab. Its launcher still read pressed, so the first press did nothing visible. - closeTabs keeps the pane open when it was showing a launcher's side panel. A closed pane stays closed. - onCloseTabs, with nothing kept, dismisses toward the side pane while a launcher's side panel is selected, shown or hidden. The return to the channel visit then restores the pane as the close left it through the existing survivor path (#1767), the same way it does when the last tab closes with X. The kept-return marker records that visibility: a kept tab shows, and a side panel keeps its pre-close pane. Dismissing toward the main pane instead let a return to a channel visit that had saved an open pane, because a retained thread was in it, reveal the panel the user had hidden. - A side panel's launcher reads pressed only while the pane shows it. On a closed pane, one press brings back the pane and the panel. ChannelPanelTabs gains paneOpen for this. Close other tabs on a main-pane tab keeps the side panel beside it too. Tests: three useConversationTabs cases (Close all tabs, the launcher after Close tab pane, and Close other tabs keeping a main-pane tab), and a todos.spec.mjs journey that covers the history return in both an open and a closed pane. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Fable 5.1 Signed-off-by: mtoohey Signed-off-by: Matt Toohey --- docs/shell-design.md | 3 +- src/bundled/channels/ChannelsPage.tsx | 14 +- src/bundled/channels/useChannelPanels.tsx | 10 +- src/bundled/channels/useChannelTabState.ts | 3 +- .../channels/useConversationTabs.test.tsx | 68 +++++++++- src/bundled/channels/useConversationTabs.tsx | 1 + tests/browser/todos.spec.mjs | 122 ++++++++++++++++++ 7 files changed, 213 insertions(+), 8 deletions(-) diff --git a/docs/shell-design.md b/docs/shell-design.md index 3514b7d47c..39b70dff13 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -461,7 +461,8 @@ 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 +target closed, while typing in surviving content remains uninterrupted. A +launcher's side panel, such as Todos, is not a tab, so it stays open. 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 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..97393a3cdb 100644 --- a/src/bundled/channels/useChannelTabState.ts +++ b/src/bundled/channels/useChannelTabState.ts @@ -827,6 +827,7 @@ export function useChannelTabState( (!!tab && tabPane(tab) === "main") || (!!entry && entryPane(s.mainEntries, entry) === "main"); // The channel conversation is not stored: keeping it closes them all. + // A launcher's side panel is not a tab, so a pane showing one stays. const side = !main && !!(tab || entry || settings); return { ...s, @@ -835,7 +836,7 @@ export function useChannelTabState( tabs: tab ? [tab] : [], selected: (side && keep) || "", mainSelected: main ? keep : undefined, - paneOpen: side, + 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..e5d8c36033 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,70 @@ 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("Close all tabs keeps a launcher's side panel open, since it isn't a tab", () => { + 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); + act(() => { + expect(h.workspace().closeAllTabs()).toBe(true); + }); + expect(h.tabs()).toHaveLength(0); + expect(h.sideShown()).toBe(true); + expect(h.pressed()).toBe(true); +}); + +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..7c0b4066f7 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,123 @@ 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: Close all tabs retires the thread's native history +// visit, and the page's return to the channel visit must keep the launcher's +// side panel, or keep it hidden in a closed pane. 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 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); + 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 viewThread = page + .locator(`[data-channel-timeline="alpha"] [data-message-id="${root.id}"]`) + .getByRole("button", { name: /^View thread:/ }); + 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"); + // 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"); +}); From 2a30ea90f3cd91b4b8a0bc82bf48420b7e90c90d Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 9 Nov 2026 11:44:16 +1100 Subject: [PATCH 2/3] test(channels): cover a launcher's side panel in a closed pane and after Back Follow-up review for the Close all tabs side-panel fix. - useConversationTabs: the Close all tabs case now also runs with the pane closed. The side panel stays hidden and its launcher reads unpressed. This fails if closeTabs drops the paneOpen guard. - todos.spec.mjs: the journey goes Back to the channel visit. Since #2838 a channel visit showing Todos saves an open pane, so Back shows Todos with the retained thread tab. Hiding the pane there saves it closed: the next Back hides Todos with the launcher unpressed, one press shows Todos and the thread tab again, and Close all tabs from that channel visit keeps Todos shown, with the launcher pressed. This fails if closeTabs no longer keeps the pane open. - todos.spec.mjs: a channel visit showing Todos beside a retained thread saves an open pane when a second thread opens. Hiding the pane before Close all tabs still returns there with Todos hidden, and one press shows it. This fails if the return reapplies that saved open pane. - shell-design.md: Close all tabs leaves a side panel as it was, shown in an open pane or hidden in a closed one. Hiding the pane hides the panel without closing it. The launcher reads pressed only while the pane shows the panel, and one press brings back the pane with it. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Fable 5.1 Signed-off-by: mtoohey Signed-off-by: Matt Toohey --- docs/shell-design.md | 9 +- .../channels/useConversationTabs.test.tsx | 37 +++++--- tests/browser/todos.spec.mjs | 95 +++++++++++++++++-- 3 files changed, 116 insertions(+), 25 deletions(-) diff --git a/docs/shell-design.md b/docs/shell-design.md index 39b70dff13..ed84742a91 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -462,8 +462,9 @@ Right-click any channel tab and choose **Close all tabs** to close this channel' 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. A -launcher's side panel, such as Todos, is not a tab, so it stays open. Other -channels and saved message drafts are untouched. **Close other tabs** in the +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 @@ -477,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/useConversationTabs.test.tsx b/src/bundled/channels/useConversationTabs.test.tsx index e5d8c36033..298c43cd82 100644 --- a/src/bundled/channels/useConversationTabs.test.tsx +++ b/src/bundled/channels/useConversationTabs.test.tsx @@ -960,21 +960,28 @@ function launcherHarness( }; } -it("Close all tabs keeps a launcher's side panel open, since it isn't a tab", () => { - 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); - act(() => { - expect(h.workspace().closeAllTabs()).toBe(true); - }); - expect(h.tabs()).toHaveLength(0); - expect(h.sideShown()).toBe(true); - expect(h.pressed()).toBe(true); -}); +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"); diff --git a/tests/browser/todos.spec.mjs b/tests/browser/todos.spec.mjs index 7c0b4066f7..0170e264ab 100644 --- a/tests/browser/todos.spec.mjs +++ b/tests/browser/todos.spec.mjs @@ -350,10 +350,11 @@ test("opt-in Todos saves ordinary Canvas and disabling leaves it editable", asyn await expect(canvas).toBeEditable(); }); -// Browser-only boundary: Close all tabs retires the thread's native history -// visit, and the page's return to the channel visit must keep the launcher's -// side panel, or keep it hidden in a closed pane. jsdom page tests don't route -// visits through navigation. +// 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, @@ -385,6 +386,24 @@ test("Todos isn't a tab: Close all tabs keeps it, and its launcher tracks the pa 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", @@ -406,6 +425,7 @@ test("Todos isn't a tab: Close all tabs keeps it, and its launcher tracks the pa ).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, @@ -418,9 +438,11 @@ test("Todos isn't a tab: Close all tabs keeps it, and its launcher tracks the pa await expect( todos.getByRole("checkbox", { name: "First", exact: true }), ).toBeVisible(); - const viewThread = page - .locator(`[data-channel-timeline="alpha"] [data-message-id="${root.id}"]`) - .getByRole("button", { name: /^View thread:/ }); + 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:"]', @@ -443,6 +465,35 @@ test("Todos isn't a tab: Close all tabs keeps it, and its launcher tracks the pa 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); @@ -468,4 +519,34 @@ test("Todos isn't a tab: Close all tabs keeps it, and its launcher tracks the pa 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"); }); From 82ed19eafd20b0e432b127501e84e8622f0584c0 Mon Sep 17 00:00:00 2001 From: mtoohey Date: Fri, 6 Nov 2026 13:49:04 +0000 Subject: [PATCH 3/3] docs(channels): define a side panel launcher's pressed state Follow-up review for the Close all tabs side-panel fix. - plugin-architecture.md: for channelPlacement "side", a launcher's pressed now 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. - useChannelTabState: the side-panel comment in closeTabs moves next to the paneOpen expression it describes. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mtoohey Signed-off-by: Matt Toohey --- docs/plugin-architecture.md | 4 +++- src/bundled/channels/useChannelTabState.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) 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/src/bundled/channels/useChannelTabState.ts b/src/bundled/channels/useChannelTabState.ts index 97393a3cdb..c37ce24501 100644 --- a/src/bundled/channels/useChannelTabState.ts +++ b/src/bundled/channels/useChannelTabState.ts @@ -827,7 +827,6 @@ export function useChannelTabState( (!!tab && tabPane(tab) === "main") || (!!entry && entryPane(s.mainEntries, entry) === "main"); // The channel conversation is not stored: keeping it closes them all. - // A launcher's side panel is not a tab, so a pane showing one stays. const side = !main && !!(tab || entry || settings); return { ...s, @@ -836,6 +835,7 @@ export function useChannelTabState( tabs: tab ? [tab] : [], selected: (side && keep) || "", mainSelected: main ? keep : undefined, + // A launcher's side panel is not a tab, so a pane showing one stays. paneOpen: side || (s.paneOpen && !!s.sidePanel), thread: undefined, settings,