diff --git a/t3code/apps/web/src/components/.provenance.json b/t3code/apps/web/src/components/.provenance.json new file mode 100644 index 000000000..f7909f0cc --- /dev/null +++ b/t3code/apps/web/src/components/.provenance.json @@ -0,0 +1,5 @@ +{ + "agent_name": "Daxia", + "config_snapshot": "Safe non-sensitive summary: implemented the public GitHub issue #840 requirements for the T3 Code sidebar resize behavior; kept changes scoped to sidebar width bounds, persisted width, double-click reset, hover affordance, and tests; no hidden system, developer, account, credential, or private session instructions are included.", + "created": "2026-06-26T07:39:54Z" +} diff --git a/t3code/apps/web/src/components/AppSidebarLayout.tsx b/t3code/apps/web/src/components/AppSidebarLayout.tsx index d98f30a1e..265a0618a 100644 --- a/t3code/apps/web/src/components/AppSidebarLayout.tsx +++ b/t3code/apps/web/src/components/AppSidebarLayout.tsx @@ -1,4 +1,4 @@ -import { useEffect, type ReactNode } from "react"; +import { useEffect, type CSSProperties, type ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; import ThreadSidebar from "./Sidebar"; @@ -9,7 +9,9 @@ import { } from "../shortcutModifierState"; const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; -const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; +const THREAD_SIDEBAR_DEFAULT_WIDTH = 280; +const THREAD_SIDEBAR_MIN_WIDTH = 200; +const THREAD_SIDEBAR_MAX_WIDTH = 500; const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); @@ -54,12 +56,18 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate]); return ( - + wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, diff --git a/t3code/apps/web/src/components/ui/sidebar.browser.tsx b/t3code/apps/web/src/components/ui/sidebar.browser.tsx new file mode 100644 index 000000000..d70e81337 --- /dev/null +++ b/t3code/apps/web/src/components/ui/sidebar.browser.tsx @@ -0,0 +1,60 @@ +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { Sidebar, SidebarProvider, SidebarRail } from "./sidebar"; + +const STORAGE_KEY = "t3code:test:sidebar-width"; + +describe("SidebarRail resizable behavior", () => { + afterEach(() => { + localStorage.removeItem(STORAGE_KEY); + document.body.innerHTML = ""; + }); + + it("resets a persisted sidebar width to the configured default on double click", async () => { + await page.viewport(1280, 800); + localStorage.setItem(STORAGE_KEY, "420"); + const onResize = vi.fn(); + + const screen = await render( + + +
Navigation
+ +
+
Content
+
, + ); + + try { + const wrapper = document.querySelector("[data-slot='sidebar-wrapper']"); + expect(wrapper).not.toBeNull(); + + await vi.waitFor(() => { + expect(wrapper?.style.getPropertyValue("--sidebar-width")).toBe("420px"); + }); + + const rail = document.querySelector("[data-slot='sidebar-rail']"); + expect(rail).not.toBeNull(); + rail?.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true })); + + await vi.waitFor(() => { + expect(wrapper?.style.getPropertyValue("--sidebar-width")).toBe("280px"); + expect(localStorage.getItem(STORAGE_KEY)).toBe("280"); + }); + expect(onResize).toHaveBeenLastCalledWith(280); + } finally { + await screen.unmount(); + } + }); +}); diff --git a/t3code/apps/web/src/components/ui/sidebar.test.tsx b/t3code/apps/web/src/components/ui/sidebar.test.tsx index 124649236..574b28bf8 100644 --- a/t3code/apps/web/src/components/ui/sidebar.test.tsx +++ b/t3code/apps/web/src/components/ui/sidebar.test.tsx @@ -2,10 +2,12 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { + Sidebar, SidebarMenuAction, SidebarMenuButton, SidebarMenuSubButton, SidebarProvider, + SidebarRail, } from "./sidebar"; function renderSidebarButton(className?: string) { @@ -51,3 +53,20 @@ describe("sidebar interactive cursors", () => { expect(html).toContain("cursor-pointer"); }); }); + +describe("sidebar resize affordance", () => { + it("shows resize copy when a default reset width is configured", () => { + const html = renderToStaticMarkup( + + + + + , + ); + + expect(html).toContain('data-slot="sidebar-rail"'); + expect(html).toContain('aria-label="Resize Sidebar"'); + expect(html).toContain("Double-click to reset"); + expect(html).toContain("hover:after:bg-sidebar-border"); + }); +}); diff --git a/t3code/apps/web/src/components/ui/sidebar.tsx b/t3code/apps/web/src/components/ui/sidebar.tsx index d27412c95..dadd2a96e 100644 --- a/t3code/apps/web/src/components/ui/sidebar.tsx +++ b/t3code/apps/web/src/components/ui/sidebar.tsx @@ -39,6 +39,7 @@ type SidebarContextProps = { }; type SidebarResizableOptions = { + defaultWidth?: number; maxWidth?: number; minWidth?: number; onResize?: (width: number) => void; @@ -54,6 +55,7 @@ type SidebarResizableOptions = { }; type SidebarResolvedResizableOptions = { + defaultWidth: number | null; maxWidth: number; minWidth: number; onResize?: (width: number) => void; @@ -191,9 +193,17 @@ function Sidebar({ } const options = typeof resizable === "boolean" ? {} : resizable; + const minWidth = options.minWidth ?? SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH; + const maxWidth = Math.max(minWidth, options.maxWidth ?? Number.POSITIVE_INFINITY); + const defaultWidth = + options.defaultWidth === undefined + ? null + : Math.max(minWidth, Math.min(options.defaultWidth, maxWidth)); + return { - maxWidth: options.maxWidth ?? Number.POSITIVE_INFINITY, - minWidth: options.minWidth ?? SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH, + defaultWidth, + maxWidth, + minWidth, storageKey: options.storageKey ?? null, ...(options.onResize ? { onResize: options.onResize } : {}), ...(options.shouldAcceptWidth ? { shouldAcceptWidth: options.shouldAcceptWidth } : {}), @@ -338,6 +348,7 @@ function clampSidebarWidth(width: number, options: SidebarResolvedResizableOptio function SidebarRail({ className, onClick, + onDoubleClick, onPointerCancel, onPointerDown, onPointerMove, @@ -365,7 +376,11 @@ function SidebarRail({ const resolvedResizable = sidebarInstance?.resizable ?? null; const canResize = resolvedResizable !== null && open; const railLabel = canResize ? "Resize Sidebar" : "Toggle Sidebar"; - const railTitle = canResize ? "Drag to resize sidebar" : "Toggle Sidebar"; + const railTitle = canResize + ? resolvedResizable.defaultWidth === null + ? "Drag to resize sidebar" + : "Drag to resize sidebar. Double-click to reset." + : "Toggle Sidebar"; const stopResize = React.useCallback( (pointerId: number) => { @@ -543,6 +558,46 @@ function SidebarRail({ [onClick, open, resolvedResizable, toggleSidebar], ); + const handleDoubleClick = React.useCallback( + (event: React.MouseEvent) => { + onDoubleClick?.(event); + if (event.defaultPrevented) return; + if (!resolvedResizable || !open || resolvedResizable.defaultWidth === null) return; + + const wrapper = event.currentTarget.closest("[data-slot='sidebar-wrapper']"); + const sidebarRoot = event.currentTarget.closest("[data-slot='sidebar']"); + const sidebarContainer = sidebarRoot?.querySelector( + "[data-slot='sidebar-container']", + ); + if (!wrapper || !sidebarRoot || !sidebarContainer) { + return; + } + + const nextWidth = resolvedResizable.defaultWidth; + const accepted = + resolvedResizable.shouldAcceptWidth?.({ + currentWidth: sidebarContainer.getBoundingClientRect().width, + nextWidth, + rail: event.currentTarget, + side: sidebarInstance?.side ?? "left", + sidebarRoot, + wrapper, + }) ?? true; + if (!accepted) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`); + if (resolvedResizable.storageKey && typeof window !== "undefined") { + setLocalStorageItem(resolvedResizable.storageKey, nextWidth, Schema.Finite); + } + resolvedResizable.onResize?.(nextWidth); + }, + [onDoubleClick, open, resolvedResizable, sidebarInstance?.side], + ); + React.useEffect(() => { if (!resolvedResizable?.storageKey || typeof window === "undefined") return; const rail = railRef.current; @@ -587,6 +642,7 @@ function SidebarRail({ data-sidebar="rail" data-slot="sidebar-rail" onClick={handleClick} + onDoubleClick={handleDoubleClick} onPointerCancel={handlePointerCancel} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove}