diff --git a/apps/web/components/task/changes-panel-file-row.test.tsx b/apps/web/components/task/changes-panel-file-row.test.tsx new file mode 100644 index 0000000000..028087a4ac --- /dev/null +++ b/apps/web/components/task/changes-panel-file-row.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { FileRow } from "./changes-panel-file-row"; + +const noop = () => {}; +const noopSelect = () => false; + +const baseFile = { + status: "modified" as const, + staged: false, + plus: 18, + minus: 3, + oldPath: undefined, +}; + +function renderRow(path: string) { + return render( + + + , + ); +} + +describe("FileRow truncation (regression: path overlaps diff stats in narrow panel)", () => { + it("file name span allows truncation so a long name does not overflow visually", () => { + // Bug: when the panel is narrow, a long file name renders past its container + // and overlaps the diff counts (+/-) on the right. Fix: file name span must be + // truncatable (truncate + min-w-0), not whitespace-nowrap+shrink-0. + const { container } = renderRow("deeply/nested/folder/render_text_to_image_controller_test.go"); + const fileNameSpan = container.querySelector( + "span.font-medium.text-foreground", + ) as HTMLElement | null; + + expect(fileNameSpan).not.toBeNull(); + expect(fileNameSpan!.className).toContain("truncate"); + expect(fileNameSpan!.className).toContain("min-w-0"); + // Must NOT prevent shrinking — that's what causes the overflow. + expect(fileNameSpan!.className).not.toContain("shrink-0"); + }); + + it("folder span keeps truncate so the folder portion is shortened first", () => { + const { container } = renderRow("a/b/c/d/file.go"); + const folderSpan = container.querySelector("span.text-foreground\\/60") as HTMLElement | null; + + expect(folderSpan).not.toBeNull(); + expect(folderSpan!.className).toContain("truncate"); + }); + + it("right-side stats container has shrink-0 so it stays in place when path is long", () => { + // Without shrink-0 on the stats column, flex layout could squeeze the + // diff counts. Anchoring it ensures the file name truncates instead. + const { container } = renderRow("file.go"); + // The stats container is the second direct child of the
  • + const li = container.querySelector("li") as HTMLElement | null; + expect(li).not.toBeNull(); + const statsContainer = li!.children[1] as HTMLElement; + expect(statsContainer.className).toContain("shrink-0"); + }); + + it("renders a file with no folder without crashing and exposes the file name", () => { + const { container } = renderRow("README.md"); + const fileNameSpan = container.querySelector( + "span.font-medium.text-foreground", + ) as HTMLElement | null; + expect(fileNameSpan?.textContent).toBe("README.md"); + // No folder element when there's no slash in the path. + const folderSpan = container.querySelector("span.text-foreground\\/60"); + expect(folderSpan).toBeNull(); + }); +}); diff --git a/apps/web/components/task/changes-panel-file-row.tsx b/apps/web/components/task/changes-panel-file-row.tsx index f50dcdaf48..8a1d79c999 100644 --- a/apps/web/components/task/changes-panel-file-row.tsx +++ b/apps/web/components/task/changes-panel-file-row.tsx @@ -88,12 +88,16 @@ export function FileRow({ /> -
    +
    diff --git a/apps/web/components/task/dockview-group-actions.test.tsx b/apps/web/components/task/dockview-group-actions.test.tsx new file mode 100644 index 0000000000..9189497f2e --- /dev/null +++ b/apps/web/components/task/dockview-group-actions.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { GroupSplitCloseActionsView } from "./dockview-group-actions"; + +afterEach(() => cleanup()); + +const TID = { + maximize: "dockview-maximize-btn", + splitRight: "dockview-split-right-btn", + splitDown: "dockview-split-down-btn", + close: "dockview-close-group-btn", + menu: "dockview-group-actions-menu", +} as const; + +const baseProps = { + isChatGroup: false, + isMaximized: false, + onMaximize: () => {}, + onSplitRight: () => {}, + onSplitDown: () => {}, + onCloseGroup: () => {}, +}; + +function renderView(width: number, override: Partial = {}) { + return render( + + + , + ); +} + +describe("GroupSplitCloseActionsView", () => { + it("wide width: renders 4 inline buttons and no overflow dropdown", () => { + renderView(600); + expect(screen.queryByTestId(TID.maximize)).not.toBeNull(); + expect(screen.queryByTestId(TID.splitRight)).not.toBeNull(); + expect(screen.queryByTestId(TID.splitDown)).not.toBeNull(); + expect(screen.queryByTestId(TID.close)).not.toBeNull(); + expect(screen.queryByTestId(TID.menu)).toBeNull(); + }); + + it("narrow width: keeps Maximize inline, collapses split/close into dropdown", () => { + renderView(200); + expect(screen.queryByTestId(TID.maximize)).not.toBeNull(); + expect(screen.queryByTestId(TID.menu)).not.toBeNull(); + expect(screen.queryByTestId(TID.splitRight)).toBeNull(); + expect(screen.queryByTestId(TID.splitDown)).toBeNull(); + expect(screen.queryByTestId(TID.close)).toBeNull(); + }); + + it("narrow + chat group: no Close anywhere, dropdown still present for splits", () => { + renderView(200, { isChatGroup: true }); + expect(screen.queryByTestId(TID.maximize)).not.toBeNull(); + expect(screen.queryByTestId(TID.menu)).not.toBeNull(); + expect(screen.queryByTestId(TID.close)).toBeNull(); + }); + + it("wide + chat group: 3 inline buttons, no Close button", () => { + renderView(600, { isChatGroup: true }); + expect(screen.queryByTestId(TID.maximize)).not.toBeNull(); + expect(screen.queryByTestId(TID.splitRight)).not.toBeNull(); + expect(screen.queryByTestId(TID.splitDown)).not.toBeNull(); + expect(screen.queryByTestId(TID.close)).toBeNull(); + expect(screen.queryByTestId(TID.menu)).toBeNull(); + }); + + it("hysteresis: stays collapsed between 320 and 340 once collapsed", () => { + const { rerender } = renderView(200); + expect(screen.queryByTestId(TID.menu)).not.toBeNull(); + // Resize up to 330 — still inside hysteresis band, should remain collapsed + rerender( + + + , + ); + expect(screen.queryByTestId(TID.menu)).not.toBeNull(); + expect(screen.queryByTestId(TID.splitRight)).toBeNull(); + // Resize up to 360 — past expand threshold, switches back to inline + rerender( + + + , + ); + expect(screen.queryByTestId(TID.menu)).toBeNull(); + expect(screen.queryByTestId(TID.splitRight)).not.toBeNull(); + }); + + it("Maximize button click fires onMaximize handler in both modes", () => { + const onMaximize = vi.fn(); + const { rerender } = render( + + + , + ); + screen.getByTestId(TID.maximize).click(); + expect(onMaximize).toHaveBeenCalledTimes(1); + rerender( + + + , + ); + screen.getByTestId(TID.maximize).click(); + expect(onMaximize).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/components/task/dockview-group-actions.tsx b/apps/web/components/task/dockview-group-actions.tsx new file mode 100644 index 0000000000..534e557a92 --- /dev/null +++ b/apps/web/components/task/dockview-group-actions.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useCallback, useState, useSyncExternalStore } from "react"; +import { type IDockviewHeaderActionsProps } from "dockview-react"; +import { + IconArrowsMaximize, + IconArrowsMinimize, + IconDots, + IconLayoutColumns, + IconLayoutRows, + IconX, +} from "@tabler/icons-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; + +const ACTION_BTN = + "h-5 w-5 inline-flex items-center justify-center text-muted-foreground/50 hover:text-foreground transition-colors cursor-pointer"; + +/** Width thresholds for collapsing split/close into a dropdown. Hysteresis avoids toggle flicker. */ +const COLLAPSE_WIDTH = 320; +const EXPAND_WIDTH = 340; + +/** Subscribe to a dockview group's width via its panel api. */ +export function useDockviewGroupWidth(group: IDockviewHeaderActionsProps["group"]): number { + // Stable subscribe/getSnapshot tied to the group identity so useSyncExternalStore + // does not resubscribe on every render. + const subscribe = useCallback( + (cb: () => void) => { + const d = group.api.onDidDimensionsChange(cb); + return () => d.dispose(); + }, + [group], + ); + const getSnapshot = useCallback(() => group.api.width, [group]); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +/** + * Sticky collapsed state with hysteresis. Uses the documented React pattern of + * storing previous-derived state and conditionally updating during render — + * https://react.dev/reference/react/useState#storing-information-from-previous-renders + */ +function useCollapsedWithHysteresis(width: number): boolean { + const [collapsed, setCollapsed] = useState(width < COLLAPSE_WIDTH); + const next = collapsed ? width < EXPAND_WIDTH : width < COLLAPSE_WIDTH; + if (next !== collapsed) { + setCollapsed(next); + return next; + } + return collapsed; +} + +type SplitCloseHandlers = { + isChatGroup: boolean; + onSplitRight: () => void; + onSplitDown: () => void; + onCloseGroup: () => void; +}; + +function MaximizeButton({ + isMaximized, + onMaximize, +}: { + isMaximized: boolean; + onMaximize: () => void; +}) { + return ( + + + + + {isMaximized ? "Restore" : "Maximize"} + + ); +} + +function InlineSplitClose({ + isChatGroup, + onSplitRight, + onSplitDown, + onCloseGroup, +}: SplitCloseHandlers) { + return ( + <> + + + + + Split right + + + + + + Split down + + {!isChatGroup && ( + + + + + Close group + + )} + + ); +} + +function SplitCloseDropdown({ + isChatGroup, + onSplitRight, + onSplitDown, + onCloseGroup, +}: SplitCloseHandlers) { + return ( + + + + + + + + Split right + + + + Split down + + {!isChatGroup && ( + + + Close group + + )} + + + ); +} + +type GroupSplitCloseActionsViewProps = SplitCloseHandlers & { + width: number; + isMaximized: boolean; + onMaximize: () => void; +}; + +/** Presentational view: maximize stays inline; split/close collapse into a dropdown when narrow. */ +export function GroupSplitCloseActionsView({ + width, + isChatGroup, + isMaximized, + onMaximize, + onSplitRight, + onSplitDown, + onCloseGroup, +}: GroupSplitCloseActionsViewProps) { + const collapsed = useCollapsedWithHysteresis(width); + const handlers = { isChatGroup, onSplitRight, onSplitDown, onCloseGroup }; + return ( + <> + + {collapsed ? : } + + ); +} diff --git a/apps/web/components/task/dockview-header-actions.tsx b/apps/web/components/task/dockview-header-actions.tsx index 82ccafc1b3..aa16d7a3af 100644 --- a/apps/web/components/task/dockview-header-actions.tsx +++ b/apps/web/components/task/dockview-header-actions.tsx @@ -13,12 +13,7 @@ import { IconGitPullRequest, IconPlayerPlay, IconLayoutSidebarRightCollapse, - IconLayoutColumns, - IconLayoutRows, - IconX, IconBrandVscode, - IconArrowsMaximize, - IconArrowsMinimize, } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; @@ -43,6 +38,7 @@ import { NewSessionDialog } from "./new-session-dialog"; import { NewTaskDropdown } from "./new-task-dropdown"; import { RepositoryScriptsMenuItems, useActiveSessionDevScript } from "./repository-scripts-menu"; import { SessionReopenMenuItems } from "./session-reopen-menu"; +import { GroupSplitCloseActionsView, useDockviewGroupWidth } from "./dockview-group-actions"; /** Map a ProcessInfo response to a ProcessStatusEntry for the store. */ function mapProcessToStatus(process: ProcessInfo): ProcessStatusEntry { @@ -258,9 +254,6 @@ export function LeftHeaderActions(props: IDockviewHeaderActionsProps) { ); } -const ACTION_BTN = - "h-5 w-5 inline-flex items-center justify-center text-muted-foreground/50 hover:text-foreground transition-colors cursor-pointer"; - /** Faded maximize, split, and close buttons for any non-sidebar group. */ function GroupSplitCloseActions({ group, containerApi }: IDockviewHeaderActionsProps) { const centerGroupId = useDockviewStore((s) => s.centerGroupId); @@ -268,6 +261,7 @@ function GroupSplitCloseActions({ group, containerApi }: IDockviewHeaderActionsP const isMaximized = useDockviewStore((s) => s.preMaximizeLayout !== null); const storeMaximize = useDockviewStore((s) => s.maximizeGroup); const storeExitMaximize = useDockviewStore((s) => s.exitMaximizedLayout); + const width = useDockviewGroupWidth(group); const handleMaximize = useCallback(() => { if (isMaximized) { @@ -305,56 +299,15 @@ function GroupSplitCloseActions({ group, containerApi }: IDockviewHeaderActionsP }, [group, containerApi]); return ( - <> - - - - - {isMaximized ? "Restore" : "Maximize"} - - - - - - Split right - - - - - - Split down - - {!isChatGroup && ( - - - - - Close group - - )} - + ); }