diff --git a/apps/web/app/dockview-theme.css b/apps/web/app/dockview-theme.css index 76efca585c..ff8562643c 100644 --- a/apps/web/app/dockview-theme.css +++ b/apps/web/app/dockview-theme.css @@ -67,12 +67,44 @@ display: none !important; } +/* Give the tab strip back native horizontal scrolling. + * + * Dockview hides the strip's overflow and drives scrolling entirely from its + * own `wheel` handler, which reads `deltaY` only: a two-finger horizontal + * swipe on a touchpad produces `deltaX` and moved nothing. Handing the axis + * back to the browser makes the gesture work. Dockview's overlay scrollbar + * stays the visible affordance and follows along (it syncs from the element's + * `scroll` event), so the native bar is hidden rather than squeezed into the + * 40px strip. */ +.dockview-theme-kandev .dv-scrollable > .dv-tabs-container { + overflow-x: auto; + scrollbar-width: none; +} + +.dockview-theme-kandev .dv-scrollable > .dv-tabs-container::-webkit-scrollbar { + display: none; +} + +/* Tabs shrink before the strip clips them. + * + * Dockview pins `.dv-tab` at `flex-shrink: 0`, so a group narrower than its tab + * row kept every tab at full width and the strip clipped the row from the + * right. The close button sits at a tab's right edge, so it was the first thing + * to disappear and a tab could become impossible to close without widening the + * group again. Shrinking down to a floor truncates the label instead and keeps + * the close button in view; past the floor Dockview's own overflow dropdown + * takes over. + * + * The floor is 4rem because that leaves a few characters of label beside the + * close button, and because it sits just under the natural width of the + * shortest panel titles ("Plan", "Files") so the common case is not widened. */ .dockview-theme-kandev .dv-tab { padding: 0; margin: 0 1px; font-size: 12px; font-weight: 500; - min-width: auto; + flex-shrink: 1; + min-width: 4rem; border-radius: 5px; border-bottom: none !important; outline: none; @@ -107,7 +139,26 @@ line-height: 1; } +/* Every wrapper a tab component puts between `.dv-tab` and `.dv-default-tab` + * (context-menu trigger, terminal ordinal row, session badge row, plugin icon + * row) is a flex container, and a flex item's default `min-width: auto` would + * stop the shrink above before it ever reaches the label. */ +.dockview-theme-kandev .dv-tab *:has(.dv-default-tab), +.dockview-theme-kandev .dv-default-tab { + min-width: 0; +} + +.dockview-theme-kandev .dv-default-tab .dv-default-tab-content { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* `flex-shrink: 0` — the close control is the one part of a tab that must + * never be shrunk away. */ .dockview-theme-kandev .dv-default-tab .dv-default-tab-action { + flex-shrink: 0; opacity: 0; transition: opacity 0.15s ease, @@ -144,6 +195,7 @@ /* Session tabs own their delete control so the X can transition to an * in-place spinner while the delete request is in flight. */ .dockview-theme-kandev .session-tab-close-action { + flex-shrink: 0; opacity: 0; transition: opacity 0.15s ease, diff --git a/apps/web/components/task/preview-tab.tsx b/apps/web/components/task/preview-tab.tsx index 51fc07f3fb..fc3adcd6e6 100644 --- a/apps/web/components/task/preview-tab.tsx +++ b/apps/web/components/task/preview-tab.tsx @@ -13,6 +13,7 @@ import { cn } from "@/lib/utils"; import { useDockviewStore } from "@/lib/state/dockview-store"; import type { PreviewType } from "@/lib/state/dockview-panel-actions"; import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; +import { useTabContextActions } from "./use-tab-context-actions"; import { useTranslation } from "react-i18next"; /** @@ -35,25 +36,6 @@ export function useMiddleClickClose( ); } -function useTabContextActions( - api: IDockviewPanelHeaderProps["api"], - containerApi: IDockviewPanelHeaderProps["containerApi"], -) { - const handleClose = useCallback(() => { - const panel = containerApi.getPanel(api.id); - if (panel) containerApi.removePanel(panel); - }, [api, containerApi]); - - const handleCloseOthers = useCallback(() => { - const toClose = api.group.panels.filter( - (p) => p.id !== api.id && p.id !== "chat" && !p.id.startsWith("session:"), - ); - for (const panel of toClose) containerApi.removePanel(panel); - }, [api, containerApi]); - - return { handleClose, handleCloseOthers }; -} - /** * Preview tab: italic title + double-click to pin + middle-click to close. * One per preview type (file-editor / file-diff / commit-detail). diff --git a/apps/web/components/task/tab-context-menu.test.tsx b/apps/web/components/task/tab-context-menu.test.tsx new file mode 100644 index 0000000000..170074c582 --- /dev/null +++ b/apps/web/components/task/tab-context-menu.test.tsx @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; + +vi.mock("dockview-react", () => ({ + DockviewDefaultTab: () => null, +})); + +vi.mock("./use-tab-maximize", () => ({ + useTabMaximizeOnDoubleClick: () => () => {}, +})); + +// Render the menu inline so its items are clickable without driving Radix's +// pointer choreography. +vi.mock("@kandev/ui/context-menu", () => ({ + ContextMenu: ({ children }: { children: React.ReactNode }) => <>{children}, + ContextMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + ContextMenuContent: ({ children }: { children: React.ReactNode }) => <>{children}, + ContextMenuItem: ({ + children, + onSelect, + disabled, + }: { + children: React.ReactNode; + onSelect: () => void; + disabled?: boolean; + }) => ( + + ), +})); + +import { ContextMenuTab } from "./tab-context-menu"; + +type Panel = { id: string }; + +function makeProps(panelId: string, groupPanels: string[], params?: Record) { + const removePanel = vi.fn(); + const panels: Panel[] = groupPanels.map((id) => ({ id })); + const api = { id: panelId, group: { panels } }; + const containerApi = { + getPanel: (id: string) => panels.find((panel) => panel.id === id), + removePanel, + }; + return { + removePanel, + props: { api, containerApi, params } as unknown as React.ComponentProps, + }; +} + +describe("ContextMenuTab", () => { + afterEach(() => cleanup()); + + it("closes its own panel from the menu", () => { + const { props, removePanel } = makeProps("browser", ["chat", "browser", "terminal"]); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + expect(removePanel).toHaveBeenCalledTimes(1); + expect(removePanel).toHaveBeenCalledWith({ id: "browser" }); + }); + + it("closes siblings but spares the chat and session panels", () => { + const { props, removePanel } = makeProps("browser", [ + "chat", + "session:abc", + "browser", + "terminal", + "files", + ]); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Close Others" })); + + expect(removePanel.mock.calls.map(([panel]) => (panel as Panel).id)).toEqual([ + "terminal", + "files", + ]); + }); + + it("renders panel-injected items alongside the close actions", () => { + const onSelect = vi.fn(); + const { props } = makeProps("browser", ["browser"], { + contextMenuItems: [{ label: "Reload", onSelect }], + }); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Reload" })); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(screen.getByRole("button", { name: "Close" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Close Others" })).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task/tab-context-menu.tsx b/apps/web/components/task/tab-context-menu.tsx index 627879fa5a..3e4246b685 100644 --- a/apps/web/components/task/tab-context-menu.tsx +++ b/apps/web/components/task/tab-context-menu.tsx @@ -1,6 +1,5 @@ "use client"; -import { useCallback } from "react"; import { DockviewDefaultTab, type IDockviewPanelHeaderProps } from "dockview-react"; import { ContextMenu, @@ -9,6 +8,7 @@ import { ContextMenuTrigger, } from "@kandev/ui/context-menu"; import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; +import { useTabContextActions } from "./use-tab-context-actions"; import { useTranslation } from "react-i18next"; /** An item in the tab right-click context menu. @@ -25,19 +25,13 @@ export type TabContextMenuParams = { }; /** Default tab component — wraps DockviewDefaultTab with a right-click menu. - * Always provides "Close Others". Panels may inject additional items via - * props.params.contextMenuItems. */ + * Always provides "Close" and "Close Others". Panels may inject additional + * items via props.params.contextMenuItems. */ export function ContextMenuTab(props: IDockviewPanelHeaderProps) { const { t } = useTranslation(); const { api, containerApi } = props; const onDoubleClick = useTabMaximizeOnDoubleClick(api); - - const handleCloseOthers = useCallback(() => { - const toClose = api.group.panels.filter( - (p) => p.id !== api.id && p.id !== "chat" && !p.id.startsWith("session:"), - ); - for (const panel of toClose) containerApi.removePanel(panel); - }, [api, containerApi]); + const { handleClose, handleCloseOthers } = useTabContextActions(api, containerApi); const extraItems: TabContextMenuItem[] = (props.params as TabContextMenuParams | undefined)?.contextMenuItems ?? []; @@ -52,11 +46,21 @@ export function ContextMenuTab(props: IDockviewPanelHeaderProps) { {extraItems.map((item) => ( - + {item.label} ))} - {t("task:closeOthers")} + + {t("task:close")} + + + {t("task:closeOthers")} + ); diff --git a/apps/web/components/task/use-tab-context-actions.ts b/apps/web/components/task/use-tab-context-actions.ts new file mode 100644 index 0000000000..960fbba93d --- /dev/null +++ b/apps/web/components/task/use-tab-context-actions.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useCallback } from "react"; +import type { IDockviewPanelHeaderProps } from "dockview-react"; + +/** + * The close actions every dockview tab context menu offers. + * + * `Close` matters beyond convenience: a group narrow enough to push a tab's + * close button toward the edge of the strip leaves the context menu as the + * reachable way to close it. + * + * `Close Others` deliberately spares the chat and session panels — they are + * the task's permanent tabs and closing them is a session action, not a panel + * one. + */ +export function useTabContextActions( + api: IDockviewPanelHeaderProps["api"], + containerApi: IDockviewPanelHeaderProps["containerApi"], +) { + const handleClose = useCallback(() => { + const panel = containerApi.getPanel(api.id); + if (panel) containerApi.removePanel(panel); + }, [api, containerApi]); + + const handleCloseOthers = useCallback(() => { + const toClose = api.group.panels.filter( + (p) => p.id !== api.id && p.id !== "chat" && !p.id.startsWith("session:"), + ); + for (const panel of toClose) containerApi.removePanel(panel); + }, [api, containerApi]); + + return { handleClose, handleCloseOthers }; +} diff --git a/apps/web/e2e/tests/layout/narrow-tab-strip.spec.ts b/apps/web/e2e/tests/layout/narrow-tab-strip.spec.ts new file mode 100644 index 0000000000..baab68de10 --- /dev/null +++ b/apps/web/e2e/tests/layout/narrow-tab-strip.spec.ts @@ -0,0 +1,129 @@ +import { test, expect } from "../../fixtures/test-base"; +import type { Locator, Page } from "@playwright/test"; +import { openWideTask, resizeColumnViaSplitview } from "../../helpers/dockview-resize"; + +/** + * A group narrow enough to squeeze its tab row has to take the space out of + * the titles, not out of the close buttons. + * + * Dockview pins `.dv-tab` at `flex-shrink: 0`, so before the theme override in + * `app/dockview-theme.css` the titles stayed at full width and the strip + * clipped the row from the right instead. The close button sits at a tab's + * right edge, so it was the first thing to go and a tab could become + * impossible to close without widening the group again. + */ + +type TabProbe = { + title: string; + tabWidth: number; + closeWidth: number; + truncated: boolean; +}; + +/** + * Locate the Dockview group that owns the `Files` tab and hand back the two + * elements every reading here comes from. + * + * Dockview can mount more than one group, so the lookup refuses to guess: + * measuring one tab row while scrolling another would still look like a pass. + * Returned via `data-*` markers because `page.evaluate` cannot return DOM + * handles to the test process. + */ +async function markFilesGroup(page: Page): Promise<{ strip: Locator; row: Locator }> { + await page.evaluate(() => { + const strips = Array.from(document.querySelectorAll(".dv-tabs-and-actions-container")); + const matches = strips.filter((strip) => + Array.from(strip.querySelectorAll(".dv-default-tab-content")).some( + (label) => label.textContent?.trim() === "Files", + ), + ); + if (matches.length !== 1) { + throw new Error( + `expected exactly one tab strip holding a Files tab, found ${matches.length}`, + ); + } + for (const marked of Array.from(document.querySelectorAll("[data-probe-strip]"))) { + marked.removeAttribute("data-probe-strip"); + } + matches[0].setAttribute("data-probe-strip", "files"); + }); + const strip = page.locator("[data-probe-strip='files']"); + return { strip, row: strip.locator(".dv-tabs-container") }; +} + +/** Measure every tab of the marked group. */ +async function probeTabStrip(strip: Locator): Promise { + return strip.evaluate((element) => + Array.from(element.querySelectorAll(".dv-tab")).map((tab) => { + const label = tab.querySelector(".dv-default-tab-content") as HTMLElement | null; + const close = tab.querySelector(".dv-default-tab-action") as HTMLElement | null; + if (!label || !close) { + throw new Error( + `tab "${label?.textContent?.trim() ?? tab.className}" has no label or close button`, + ); + } + return { + title: label.textContent?.trim() ?? "", + tabWidth: tab.getBoundingClientRect().width, + closeWidth: close.getBoundingClientRect().width, + truncated: label.scrollWidth > label.clientWidth, + }; + }), + ); +} + +const rowWidth = (tabs: TabProbe[]): number => tabs.reduce((total, tab) => total + tab.tabWidth, 0); + +const rowOverflow = (row: Locator): Promise => + row.evaluate((element) => element.scrollWidth - element.clientWidth); + +const rowScrollLeft = (row: Locator): Promise => + row.evaluate((element) => element.scrollLeft); + +test.describe("narrow tab strip", () => { + test("squeezes tab titles rather than the close buttons", async ({ + testPage, + apiClient, + seedData, + }) => { + await openWideTask(testPage, apiClient, seedData, "Narrow right column tabs"); + const { strip } = await markFilesGroup(testPage); + + const wide = await probeTabStrip(strip); + expect(wide.length).toBeGreaterThan(1); + expect(wide.map((tab) => tab.truncated)).not.toContain(true); + expect(wide.every((tab) => tab.closeWidth > 0)).toBe(true); + + await resizeColumnViaSplitview(testPage, "right", 240); + const narrow = await probeTabStrip(strip); + + expect(narrow.map((tab) => tab.title)).toEqual(wide.map((tab) => tab.title)); + // The tabs give up width... + expect(rowWidth(narrow)).toBeLessThan(rowWidth(wide)); + // ...their titles ellipsize... + expect(narrow.map((tab) => tab.truncated)).toContain(true); + // ...and the close buttons keep every pixel they had. + expect(narrow.map((tab) => tab.closeWidth)).toEqual(wide.map((tab) => tab.closeWidth)); + }); + + test("scrolls the tab row with a horizontal wheel gesture", async ({ + testPage, + apiClient, + seedData, + }) => { + await openWideTask(testPage, apiClient, seedData, "Horizontal tab scroll"); + await resizeColumnViaSplitview(testPage, "right", 240); + const { row } = await markFilesGroup(testPage); + + const filesTab = row.locator(".dv-default-tab").filter({ hasText: /^Files$/ }); + await expect(filesTab).toBeVisible(); + expect(await rowOverflow(row)).toBeGreaterThan(0); + + // A two-finger horizontal touchpad swipe reaches the page as `deltaX`, + // which Dockview's own wheel handler ignores. + await filesTab.hover(); + await testPage.mouse.wheel(60, 0); + + await expect.poll(() => rowScrollLeft(row)).toBeGreaterThan(0); + }); +});