Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions t3code/apps/web/src/components/.provenance.json
Original file line number Diff line number Diff line change
@@ -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"
}
14 changes: 11 additions & 3 deletions t3code/apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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();
Expand Down Expand Up @@ -54,12 +56,18 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
}, [navigate]);

return (
<SidebarProvider className="h-dvh! min-h-0!" defaultOpen>
<SidebarProvider
className="h-dvh! min-h-0!"
defaultOpen
style={{ "--sidebar-width": `${THREAD_SIDEBAR_DEFAULT_WIDTH}px` } as CSSProperties}
>
<Sidebar
side="left"
collapsible="offcanvas"
className="border-r border-border bg-card text-foreground"
resizable={{
defaultWidth: THREAD_SIDEBAR_DEFAULT_WIDTH,
maxWidth: THREAD_SIDEBAR_MAX_WIDTH,
minWidth: THREAD_SIDEBAR_MIN_WIDTH,
shouldAcceptWidth: ({ nextWidth, wrapper }) =>
wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,
Expand Down
60 changes: 60 additions & 0 deletions t3code/apps/web/src/components/ui/sidebar.browser.tsx
Original file line number Diff line number Diff line change
@@ -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(
<SidebarProvider defaultOpen>
<Sidebar
collapsible="offcanvas"
resizable={{
defaultWidth: 280,
maxWidth: 500,
minWidth: 200,
onResize,
storageKey: STORAGE_KEY,
}}
>
<div>Navigation</div>
<SidebarRail />
</Sidebar>
<main>Content</main>
</SidebarProvider>,
);

try {
const wrapper = document.querySelector<HTMLElement>("[data-slot='sidebar-wrapper']");
expect(wrapper).not.toBeNull();

await vi.waitFor(() => {
expect(wrapper?.style.getPropertyValue("--sidebar-width")).toBe("420px");
});

const rail = document.querySelector<HTMLButtonElement>("[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();
}
});
});
19 changes: 19 additions & 0 deletions t3code/apps/web/src/components/ui/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
<SidebarProvider>
<Sidebar resizable={{ defaultWidth: 280 }}>
<SidebarRail />
</Sidebar>
</SidebarProvider>,
);

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");
});
});
62 changes: 59 additions & 3 deletions t3code/apps/web/src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type SidebarContextProps = {
};

type SidebarResizableOptions = {
defaultWidth?: number;
maxWidth?: number;
minWidth?: number;
onResize?: (width: number) => void;
Expand All @@ -54,6 +55,7 @@ type SidebarResizableOptions = {
};

type SidebarResolvedResizableOptions = {
defaultWidth: number | null;
maxWidth: number;
minWidth: number;
onResize?: (width: number) => void;
Expand Down Expand Up @@ -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 } : {}),
Expand Down Expand Up @@ -338,6 +348,7 @@ function clampSidebarWidth(width: number, options: SidebarResolvedResizableOptio
function SidebarRail({
className,
onClick,
onDoubleClick,
onPointerCancel,
onPointerDown,
onPointerMove,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -543,6 +558,46 @@ function SidebarRail({
[onClick, open, resolvedResizable, toggleSidebar],
);

const handleDoubleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onDoubleClick?.(event);
if (event.defaultPrevented) return;
if (!resolvedResizable || !open || resolvedResizable.defaultWidth === null) return;

const wrapper = event.currentTarget.closest<HTMLElement>("[data-slot='sidebar-wrapper']");
const sidebarRoot = event.currentTarget.closest<HTMLElement>("[data-slot='sidebar']");
const sidebarContainer = sidebarRoot?.querySelector<HTMLElement>(
"[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;
Expand Down Expand Up @@ -587,6 +642,7 @@ function SidebarRail({
data-sidebar="rail"
data-slot="sidebar-rail"
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onPointerCancel={handlePointerCancel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
Expand Down
Loading