From cea3498ea3b55018fa7a89a1a4bb7e5e0be70261 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:46:17 -0700 Subject: [PATCH] Remove blank-tab creation from the side pane Tabs now open only from content: threads, profiles, channel settings, channel menu panels and linked sessions. The plus action, the empty-pane toggle's blank tab and the channel/DM/tool picker are removed in Messages and Me. Todos and Terminal keep their channel launchers. The empty split toggle stays focusable but unavailable so closing the last tab keeps a stable focus target. Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- docs/shell-design.md | 16 +- .../channels/ChannelTabPicker.test.tsx | 249 ------------ src/bundled/channels/ChannelTabPicker.tsx | 229 ----------- src/bundled/channels/ChannelTabs.module.css | 26 -- src/bundled/channels/ChannelsPage.tsx | 13 +- src/bundled/channels/ConversationTab.test.tsx | 94 +---- src/bundled/channels/ConversationTab.tsx | 40 +- .../channels/useChannelPanels.test.tsx | 53 --- src/bundled/channels/useChannelPanels.tsx | 2 - .../channels/useChannelTabState.test.tsx | 11 +- src/bundled/channels/useChannelTabState.ts | 1 - .../channels/useConversationTabs.test.tsx | 12 +- src/bundled/channels/useConversationTabs.tsx | 176 ++------ src/bundled/sessions/SessionsPage.tsx | 9 +- src/features/panels/PanelWorkspace.tsx | 17 - src/features/panels/Panels.module.css | 19 - tests/browser/channel-tabs.spec.mjs | 380 +++++++----------- tests/browser/channel-usage-geometry.spec.mjs | 11 + tests/browser/conversation-tabs.mjs | 23 -- tests/browser/me-workspace.spec.mjs | 87 ++-- tests/browser/message-navigation.spec.mjs | 4 +- tests/browser/scrollbar-polish.spec.mjs | 29 +- tests/browser/todos.spec.mjs | 73 +--- 23 files changed, 252 insertions(+), 1322 deletions(-) delete mode 100644 src/bundled/channels/ChannelTabPicker.test.tsx delete mode 100644 src/bundled/channels/ChannelTabPicker.tsx delete mode 100644 tests/browser/conversation-tabs.mjs diff --git a/docs/shell-design.md b/docs/shell-design.md index 782e42a46..231db1cd3 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -322,15 +322,13 @@ terminal drawer keeps its existing Cmd/Ctrl+J/Hide behavior. Browser-build shortcuts are unchanged. Feature-local details such as harness logs remain tied to their owning profile; closing that profile or losing authorization also removes its log tab. -The main-header split control toggles the tab pane without closing its tabs or -resetting their contents. Opening an empty pane creates one new tab. -The plus control appears on header hover or keyboard focus (always on touch) and -opens a picker with Channels, Direct messages, and Channel tools categories, -each with searchable choices. Enabled Todos and native-desktop Terminal reuse -their registered components and channel context. Opening a tool in a tab replaces -its drawer; its launcher then toggles that tab's pane without a second mount. -Terminal sessions remain plugin-owned when their tabs close, and Escape in the -terminal input remains a shell key. +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 +blank-tab creation action or channel/DM/tool picker. Tabs open from content such +as threads, profiles, channel settings and linked sessions. Todos and native +desktop Terminal remain available through their existing channel launchers; +closing a terminal presentation does not stop its plugin-owned shell. Conversation tabs reuse the timeline, composer, draft and message-management owners; selecting an already-open conversation selects its existing tab. Each main channel keeps its own tab descriptors, selected tab, and pane visibility in memory for the diff --git a/src/bundled/channels/ChannelTabPicker.test.tsx b/src/bundled/channels/ChannelTabPicker.test.tsx deleted file mode 100644 index 5817a0bf9..000000000 --- a/src/bundled/channels/ChannelTabPicker.test.tsx +++ /dev/null @@ -1,249 +0,0 @@ -// @vitest-environment jsdom -import "@testing-library/jest-dom/vitest"; -import { - cleanup, - fireEvent, - render, - screen, - within, -} from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, expect, it, vi } from "vitest"; -import { ChannelTabPicker, isChannelTabTool } from "./ChannelTabPicker"; -import type { RegisteredPanel } from "../../features/panels/service"; -import type { ChannelSummary } from "../../features/relay/contracts"; -import { - pickerText, - readSearchUsage, - recordChoice, - recordVisit, -} from "../../features/search/usage"; -afterEach(() => { - cleanup(); - localStorage.clear(); -}); -const scope = `https://relay.example:${"f".repeat(64)}`; -const tool: RegisteredPanel = { - id: "todos", - pluginId: "buzz.todos", - key: "buzz.todos/todos", - revision: "1", - title: "Todos", - matches: () => false, - channelLauncher: () => null, - component: () => null, -}; -const channels = [ - { id: "alpha", name: "Alpha", channelType: "stream" }, - { id: "dm", name: "Ada", channelType: "dm" }, -] as ChannelSummary[]; -it("filters by category and query and opens only the chosen registered tool", async () => { - const user = userEvent.setup(), - choose = vi.fn(), - chooseTool = vi.fn(); - const view = render( - null} - choose={choose} - chooseTool={chooseTool} - />, - ); - expect(screen.getByRole("searchbox")).toHaveFocus(); - expect(screen.queryByRole("button", { name: "Ada" })).toBeNull(); - await user.click(screen.getByRole("tab", { name: "DMs" })); - expect(screen.queryByRole("button", { name: "Alpha" })).toBeNull(); - await user.type(screen.getByRole("searchbox"), "no match"); - expect(screen.getByRole("status")).toHaveTextContent("No matches"); - await user.clear(screen.getByRole("searchbox")); - await user.click(screen.getByRole("button", { name: "Ada" })); - expect(choose).toHaveBeenCalledExactlyOnceWith("dm"); - await user.click(screen.getByRole("tab", { name: "Tools" })); - expect(screen.getByRole("searchbox")).toHaveValue(""); - expect( - within(screen.getByRole("tablist")).getByRole("tab", { - name: "Tools", - }), - ).toHaveAttribute("aria-selected", "true"); - await user.click(screen.getByRole("button", { name: "Todos" })); - expect(chooseTool).toHaveBeenCalledExactlyOnceWith(tool); - view.rerender( - null} - choose={choose} - chooseTool={chooseTool} - />, - ); - expect(screen.queryByRole("button", { name: "Todos" })).toBeNull(); - expect(screen.getByRole("status")).toHaveTextContent("No channel tools"); -}); -it("switches categories with the keyboard and labels the active panel", async () => { - const user = userEvent.setup(); - render( - null} - choose={vi.fn()} - chooseTool={vi.fn()} - />, - ); - await user.tab({ shift: true }); - expect(screen.getByRole("tab", { name: "Channels" })).toHaveFocus(); - await user.keyboard("{ArrowRight}"); - expect(screen.getByRole("tab", { name: "DMs" })).toHaveFocus(); - await user.keyboard("{Enter}"); - expect(screen.getByRole("tabpanel", { name: "DMs" })).toContainElement( - screen.getByRole("button", { name: "Ada" }), - ); - await user.keyboard("{ArrowRight}"); - await user.keyboard("{Enter}"); - expect(screen.getByRole("tabpanel", { name: "Tools" })).toContainElement( - screen.getByRole("button", { name: "Todos" }), - ); -}); -it("accepts only the current supported channel tool contributions", () => { - expect(isChannelTabTool(tool)).toBe(true); - expect(isChannelTabTool({ ...tool, pluginId: "other" })).toBe(false); - expect( - isChannelTabTool({ ...tool, id: "terminal", pluginId: "buzz.terminal" }), - ).toBe(true); - const { channelLauncher: _, ...withoutLauncher } = tool; - expect(isChannelTabTool(withoutLauncher)).toBe(false); -}); -it("orders matches like Command-K, underlines them and opens the highlighted one with Enter", async () => { - Element.prototype.scrollIntoView = vi.fn(); - const user = userEvent.setup(), - choose = vi.fn(); - const named = (id: string, name: string) => - ({ id, name, channelType: "stream" }) as ChannelSummary; - const list = [ - named("deploys", "deploys"), - named("prs", "buzz-github-prs"), - named("bugs", "bugs"), - named("bug", "bug"), - named("debug", "debug-log"), - named("mine", "my-bugs"), - ]; - for (let visit = 0; visit < 20; visit++) recordVisit(scope, "channel:debug"); - // Command-K's choice for the same text belongs to Command-K: it would put - // debug-log right after the exact name here. - recordChoice(scope, "bug", "channel:debug"); - render( - null} - choose={choose} - chooseTool={vi.fn()} - usageScope={scope} - />, - ); - const names = () => - screen - .getAllByRole("button") - .filter((row) => !row.hasAttribute("aria-label")) - .map((row) => row.textContent); - // Before typing, the list keeps its own order. - expect(names()).toEqual(list.map(({ name }) => name)); - const search = screen.getByRole("searchbox"); - // An exact name, then a prefix. Usage lifts a substring past a slightly - // better word start, never past a much better prefix. Fuzzy comes last. - await user.type(search, "bug"); - expect(names()).toEqual([ - "bug", - "bugs", - "debug-log", - "my-bugs", - "buzz-github-prs", - ]); - expect(screen.getByRole("status")).toHaveTextContent( - "bug. Press Enter to open.", - ); - await user.clear(search); - // Fuzzy: letters that start words ("bg" in buzz-github-prs) rank above - // letters anywhere; the much-visited debug-log is lifted past both. - await user.type(search, "bg"); - expect(names()).toEqual([ - "debug-log", - "buzz-github-prs", - "bugs", - "bug", - "my-bugs", - ]); - expect( - screen - .getByRole("button", { name: "buzz-github-prs" }) - .querySelectorAll("mark"), - ).toHaveLength(2); - await user.keyboard("{ArrowDown}"); - expect(screen.getByRole("status")).toHaveTextContent( - "buzz-github-prs. Press Enter to open.", - ); - expect(search).toHaveFocus(); - await user.keyboard("{Enter}"); - expect(choose).toHaveBeenCalledExactlyOnceWith("prs"); - // The choice is this picker's own. - const candidates = new Set(["channel:debug", "channel:prs"]); - expect(readSearchUsage(scope).pick(pickerText("tab", "bg"), candidates)).toBe( - "channel:prs", - ); - expect(readSearchUsage(scope).pick("bg", candidates)).toBeUndefined(); -}); -it("puts the earlier choice for this text after an exact name", async () => { - Element.prototype.scrollIntoView = vi.fn(); - const user = userEvent.setup(); - const named = (id: string, name: string) => - ({ id, name, channelType: "stream" }) as ChannelSummary; - recordChoice(scope, pickerText("tab", "de"), "channel:debug"); - render( - null} - choose={vi.fn()} - chooseTool={vi.fn()} - usageScope={scope} - />, - ); - await user.type(screen.getByRole("searchbox"), "de"); - expect(screen.getByRole("status")).toHaveTextContent( - "debug-log. Press Enter to open.", - ); -}); -it("keeps focus on the highlighted row after Tab, for arrows and pointer", async () => { - Element.prototype.scrollIntoView = vi.fn(); - const user = userEvent.setup(), - choose = vi.fn(); - render( - null} - choose={choose} - chooseTool={vi.fn()} - />, - ); - const first = screen.getByRole("button", { name: "Alpha" }); - const last = screen.getByRole("button", { name: "Beta" }); - for (let i = 0; i < 10 && document.activeElement !== first; i += 1) - await user.tab(); - expect(first).toHaveFocus(); - // Down on a focused row moves the highlight and focus together. - await user.keyboard("{ArrowDown}"); - expect(last).toHaveFocus(); - // A pointer highlight moves focus too, so Enter opens the highlighted row. - fireEvent.pointerMove(first, { clientX: 1, clientY: 1 }); - fireEvent.pointerMove(first, { clientX: 2, clientY: 2 }); - expect(first).toHaveFocus(); - await user.keyboard("{Enter}"); - expect(choose).toHaveBeenCalledExactlyOnceWith("alpha"); -}); diff --git a/src/bundled/channels/ChannelTabPicker.tsx b/src/bundled/channels/ChannelTabPicker.tsx deleted file mode 100644 index 19f11de5a..000000000 --- a/src/bundled/channels/ChannelTabPicker.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import { - useEffect, - useId, - useMemo, - useRef, - useState, - type ReactNode, -} from "react"; -import type { ChannelSummary } from "../../features/relay/contracts"; -import type { RegisteredPanel } from "../../features/panels/service"; -import { matchName } from "../../features/search/match"; -import { MatchedLabel } from "../../features/search/MatchedLabel"; -import { - noSearchUsage, - pickerText, - readSearchUsage, - recordChoice, - searchOrder, -} from "../../features/search/usage"; -import { useSearchHighlight } from "../../features/search/use-search-highlight"; -import { - TerminalWindowIcon, - InfoIcon, - ListChecksIcon, -} from "../../shared/design-system/icons"; -import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; -import { Tabs } from "../../shared/design-system/ui/Tabs"; -import { SearchField } from "../../shared/design-system/ui/SearchField"; -import styles from "./ChannelTabs.module.css"; - -// These existing channel tools accept their context from the host; arbitrary -// target-based contributions still open through their own links. -export function channelToolIcon(panel: RegisteredPanel) { - return panel.channelMenu ? ( - - ) : panel.pluginId === "buzz.terminal" ? ( - - ) : ( - - ); -} -export function isChannelTabTool(panel: RegisteredPanel) { - return ( - !!panel.channelLauncher && - ((panel.pluginId === "buzz.terminal" && panel.id === "terminal") || - (panel.pluginId === "buzz.todos" && panel.id === "todos")) - ); -} -const categories = [ - { id: "channels", label: "Channels" }, - { id: "dm", label: "DMs" }, - { id: "tools", label: "Tools" }, -] as const; - -type Choice = { - key: string; - label: string; - icon: ReactNode; - run(): void; -}; - -export function ChannelTabPicker({ - channels, - tools, - icon, - choose, - chooseTool, - usageScope, -}: { - channels: readonly ChannelSummary[]; - tools: readonly RegisteredPanel[]; - icon(channel: ChannelSummary): ReactNode; - choose(channelId: string): void; - chooseTool(panel: RegisteredPanel): void; - /** View-state partition whose visits and earlier choices rank matches. */ - usageScope?: string | undefined; -}) { - const panelId = useId(); - const input = useRef(null); - useEffect(() => { - input.current?.focus(); - }, []); - const [category, setCategory] = useState<"channels" | "dm" | "tools">( - "channels", - ); - const [query, setQuery] = useState(""); - const needle = query.trim().toLowerCase().replace(/^#/, ""); - // Read once, so a choice made here cannot reorder the list. - const usage = useMemo( - () => (usageScope ? readSearchUsage(usageScope) : noSearchUsage), - [usageScope], - ); - const typed = pickerText("tab", needle); - const all: Choice[] = - category === "tools" - ? tools.map((tool) => ({ - key: `tool:${tool.key}`, - label: tool.title, - icon: channelToolIcon(tool), - run: () => chooseTool(tool), - })) - : channels - .filter( - (channel) => (channel.channelType === "dm") === (category === "dm"), - ) - .map((channel) => ({ - key: `channel:${channel.id}`, - label: channel.name, - icon: icon(channel), - run: () => choose(channel.id), - })); - // Everything here is on this device, so fuzzy matches are stable: "bgp" - // finds buzz-github-prs. Order with the shared search order, as Command-K - // does. - const matched = all.flatMap((choice) => { - const match = needle ? matchName(choice.label, needle) : undefined; - return !needle || match ? [{ choice, match }] : []; - }); - const picked = usage.pick( - typed, - new Set(matched.map(({ choice }) => choice.key)), - ); - const rankOf = ({ choice, match }: (typeof matched)[number]) => - match - ? searchOrder(usage, picked, { key: choice.key, rank: match.rank }) - : 0; - const results = matched - .map((row) => ({ ...row, rank: rankOf(row) })) - .sort((a, b) => a.rank - b.rank); - const run = (choice: Choice) => { - if (usageScope && typed) recordChoice(usageScope, typed, choice.key); - choice.run(); - }; - const highlight = useSearchHighlight({ - query, - keys: results.map(({ choice }) => choice.key), - onChoose: (key) => { - const row = results.find(({ choice }) => choice.key === key); - if (row) run(row.choice); - }, - }); - const highlighted = results.find( - ({ choice }) => choice.key === highlight.active, - )?.choice; - const empty = !results.length; - return ( -
-
- { - setCategory(value); - setQuery(""); - }} - items={categories.map(({ id, label }) => ({ - value: id, - label, - panelId: `${panelId}-${id}`, - }))} - /> -
- { - highlight.keyDown(event); - }} - /> -
- {results.map(({ choice, match }) => ( - - } - icon={choice.icon} - onClick={() => run(choice)} - /> - ))} - {/* One status line: the empty state, or for screen readers the - highlighted row that Enter opens. */} -

- {!empty - ? highlighted - ? `${highlighted.label}. Press Enter to open.` - : "" - : query.trim() - ? "No matches. Try another name." - : category === "tools" - ? "No channel tools are available. Enable tools in Settings → Plugins. Terminal requires Buzz Desktop." - : category === "dm" - ? "No direct messages available yet." - : "No other channels available yet."} -

-
-
-
-
- ); -} diff --git a/src/bundled/channels/ChannelTabs.module.css b/src/bundled/channels/ChannelTabs.module.css index 399b070d5..00ae3fb8f 100644 --- a/src/bundled/channels/ChannelTabs.module.css +++ b/src/bundled/channels/ChannelTabs.module.css @@ -1,31 +1,5 @@ @reference "../../shared/styles/globals.css"; -.picker { - width: 100%; - padding: var(--space-4) var(--space-panel-inset); - display: flex; - min-height: 0; - overflow: hidden; -} -.pickerLayout { - display: grid; - width: 100%; - grid-template-columns: minmax(0, 1fr); - grid-template-rows: auto minmax(0, 1fr); - gap: var(--space-4); - min-height: 0; - overflow: hidden; -} -.group { - margin-block-start: var(--space-4); -} -.choices { - min-width: 0; - overflow: auto; -} -.group > p { - padding: var(--space-2); -} .conversation { display: flex; flex-direction: column; diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index bb3dba36d..16a376cf4 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -706,11 +706,9 @@ function ChannelWorkspace({ panelTabs, drawer, drawerContext, - rootTabIds, selectedTab, selectPanelTab, afterClose, - addTab, panelTrigger, splitTrigger, } = secondary; @@ -1415,11 +1413,11 @@ function ChannelWorkspace({ : "Open tab pane" } aria-expanded={!!showingChannelPanel} - onClick={() => { - tabState.setPaneOpen(!showingChannelPanel); - if (!showingChannelPanel && !rootTabIds.length) - addTab(); - }} + disabled={!hasChannelPanel} + focusableWhenDisabled + onClick={() => + tabState.setPaneOpen(!showingChannelPanel) + } icon={