web(unibox): let the scope rail be resized like the conversation list

A drag handle between the scope rail and the list reuses useResizablePane:
drag, arrow keys (Shift for a bigger step), Home/End, Enter or a double-click
to restore the default 220px. The width is kept in the persisted store and
clamped on rehydration; the list re-measures its cap when the rail moves.
This commit is contained in:
Skylinerffm97
2026-09-30 17:18:40 +02:00
parent 41a388e80a
commit 3ec2ff7d95
6 changed files with 259 additions and 6 deletions
+1
View File
@@ -13,6 +13,7 @@ The columns are yours to size. Every choice here is remembered in the browser yo
| Control | What it does |
| --- | --- |
| The divider between the scope rail and the list | Drag it to resize the scope rail. With focus, arrow keys nudge it (hold `Shift` for a bigger step), `Home` and `End` set the minimum and maximum, and `Enter` (or a double-click) restores the default 220 px. The width is remembered in this browser. |
| The divider between the list and the thread | Drag it to widen the conversation list so long subjects fit, or to give the space back to the thread. It also takes the keyboard once it has focus: arrow keys nudge it (hold `Shift` for a bigger step), `Home` and `End` go to the narrowest and widest the window allows, `Enter` (or a double-click) puts it back to the default. |
| **Collapse** at the bottom of the left navigation, or `b` | Shrinks Warmbly's own navigation to an icon rail and gives the width to the page. Row labels become tooltips; unread mail keeps its count badge and an open [Advisor](/guides/advisor/) finding shows as a coloured dot on the icon. The same control expands it again. |
| The section headers in the left navigation (Email, CRM, Resources) | Click one to fold its section away, and again to bring it back. Like the other layout choices, it is remembered in this browser. A folded section still shows the page you are on, and a coloured dot beside its header flags an open [Advisor](/guides/advisor/) finding on one of the pages it hides. The icon rail follows the same choice. |
+35 -2
View File
@@ -28,6 +28,9 @@ import {
UNIBOX_LIST_DEFAULT_WIDTH,
UNIBOX_LIST_MAX_WIDTH,
UNIBOX_LIST_MIN_WIDTH,
UNIBOX_RAIL_DEFAULT_WIDTH,
UNIBOX_RAIL_MAX_WIDTH,
UNIBOX_RAIL_MIN_WIDTH,
} from "@/stores";
import { uniboxListMaxWidth, uniboxThreadReserve } from "@/lib/uniboxLayout";
import { useResizablePane } from "@/hooks/useResizablePane";
@@ -66,9 +69,12 @@ export default function UniboxPage() {
// width the column does not have.
const listWidth = useAppStore((s) => s.uniboxListWidth);
const setListWidth = useAppStore((s) => s.setUniboxListWidth);
const railWidth = useAppStore((s) => s.uniboxRailWidth);
const setRailWidth = useAppStore((s) => s.setUniboxRailWidth);
const contactRailOpen = useAppStore((s) => s.uniboxContactRailOpen);
const isWide = useMediaQuery(LG_QUERY);
const rowRef = React.useRef<HTMLDivElement>(null);
const railRef = React.useRef<HTMLElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
const [maxWidth, setMaxWidth] = React.useState(UNIBOX_LIST_MAX_WIDTH);
@@ -109,8 +115,23 @@ export default function UniboxPage() {
sync();
const ro = new ResizeObserver(sync);
ro.observe(row);
// A rail drag moves the list's left edge without resizing the row.
if (railRef.current) ro.observe(railRef.current);
return () => ro.disconnect();
}, [measureMax]);
}, [measureMax, railWidth]);
const { width: renderedRailWidth, separatorProps: railSeparatorProps } = useResizablePane({
value: railWidth,
onChange: setRailWidth,
min: UNIBOX_RAIL_MIN_WIDTH,
max: UNIBOX_RAIL_MAX_WIDTH,
defaultValue: UNIBOX_RAIL_DEFAULT_WIDTH,
paneRef: railRef,
cssVar: "--unibox-rail-w",
label: "Resize the scope rail",
controls: "unibox-scope-rail",
valueText: (w) => `Scope rail ${w} pixels`,
});
// The splitter itself: pointer capture, the body lock, the window-splitter
// keys and the ARIA bundle all live in the shared hook, which the assistant
@@ -447,10 +468,22 @@ export default function UniboxPage() {
/>
<div ref={rowRef} className="flex-1 min-h-0 flex">
<aside className="hidden lg:flex w-[220px] shrink-0 h-full">
<aside
ref={railRef}
id="unibox-scope-rail"
style={{ "--unibox-rail-w": `${renderedRailWidth}px` } as React.CSSProperties}
className="hidden lg:flex w-[var(--unibox-rail-w)] shrink-0 h-full [&>nav]:border-r-0"
>
<ScopeRail scope={scope} onChange={setScope} />
</aside>
<div
{...railSeparatorProps}
className="group hidden lg:flex w-1.5 shrink-0 cursor-col-resize items-stretch justify-center outline-none"
>
<span className="w-px bg-slate-200 transition-[background-color,width] group-hover:bg-sky-400 group-active:bg-sky-500 group-focus-visible:w-0.5 group-focus-visible:bg-sky-500" />
</div>
{scope.kind === "scheduled" ? (
// Scheduled scope takes the full right side — a
// queued send has no thread context to load.
@@ -0,0 +1,198 @@
import React from "react";
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { screen, act, fireEvent, cleanup } from "@testing-library/react";
import { useAppStore } from "@/stores";
import { installLayoutShims, mount, setViewportWidth, settle, SUITE } from "./uniboxHarness";
beforeAll(() => {
installLayoutShims();
setViewportWidth(1512);
});
vi.mock("@/lib/api/client/Request", () => ({
default: async (cfg: { url?: string }) => {
const { route } = await import("./uniboxHarness");
return route(String(cfg?.url ?? ""));
},
}));
vi.mock("@/lib/helper/getToken", () => ({
default: () => ({
access_token: "a",
refresh_token: "r",
access_token_expires_at: new Date(Date.now() + 3600e3).toISOString(),
refresh_token_expires_at: new Date(Date.now() + 3600e3).toISOString(),
}),
}));
vi.mock("@/hooks/SocketProvider", () => ({
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@/hooks/context/socket", async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return {
...actual,
useSocket: () => ({
isConnected: false,
subscribeToChannel: () => () => {},
pushToChannel: () => {},
socket: null,
status: "closed",
}),
useChannel: () => ({ state: "closed", push: () => {}, channel: null }),
useChannelEvent: () => {},
useChannelSubscription: () => {},
};
});
const separator = () => screen.getByRole("separator", { name: "Resize the scope rail" });
const rail = () => document.getElementById("unibox-scope-rail")!;
const railVar = () => rail().style.getPropertyValue("--unibox-rail-w");
const originalStorage = useAppStore.persist.getOptions().storage;
describe("resizable unibox scope rail", SUITE, () => {
beforeEach(() => {
useAppStore.setState({ navCollapsed: false, uniboxRailWidth: 220, uniboxListWidth: 360 });
});
afterEach(() => {
cleanup();
useAppStore.persist.setOptions({ storage: originalStorage });
vi.mocked(localStorage.getItem).mockReset();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("renders an accessible 220px separator beside the rail, hidden below lg", async () => {
expect(useAppStore.getInitialState().uniboxRailWidth).toBe(220);
await mount("/app/unibox");
await settle();
expect(separator()).toHaveAttribute("aria-valuenow", "220");
expect(separator()).toHaveAttribute("aria-valuemin", "180");
expect(separator()).toHaveAttribute("aria-valuemax", "360");
expect(separator()).toHaveAttribute("aria-orientation", "vertical");
expect(separator()).toHaveAttribute("aria-controls", "unibox-scope-rail");
expect(separator().previousElementSibling).toBe(rail());
expect(separator().nextElementSibling).toHaveAttribute("id", "unibox-conversation-list");
expect(separator().className).toContain("hidden lg:flex");
expect(rail().className).toContain("hidden lg:flex");
expect(railVar()).toBe("220px");
});
it("changes the pane and store with arrow keys and resets with Enter and double-click", async () => {
await mount();
await settle();
act(() => fireEvent.keyDown(separator(), { key: "ArrowRight" }));
expect(useAppStore.getState().uniboxRailWidth).toBe(236);
expect(railVar()).toBe("236px");
expect(separator()).toHaveAttribute("aria-valuenow", "236");
act(() => fireEvent.keyDown(separator(), { key: "ArrowLeft", shiftKey: true }));
expect(useAppStore.getState().uniboxRailWidth).toBe(188);
expect(railVar()).toBe("188px");
act(() => fireEvent.keyDown(separator(), { key: "Enter" }));
expect(useAppStore.getState().uniboxRailWidth).toBe(220);
expect(railVar()).toBe("220px");
act(() => fireEvent.keyDown(separator(), { key: "End" }));
expect(useAppStore.getState().uniboxRailWidth).toBe(360);
act(() => fireEvent.doubleClick(separator()));
expect(useAppStore.getState().uniboxRailWidth).toBe(220);
expect(railVar()).toBe("220px");
});
it("drags the pane, commits on release and stops at both bounds", async () => {
await mount();
await settle();
act(() => {
fireEvent.pointerDown(separator(), { clientX: 220, button: 0, pointerId: 1 });
fireEvent.pointerMove(separator(), { clientX: 300, pointerId: 1 });
});
expect(railVar()).toBe("300px");
expect(useAppStore.getState().uniboxRailWidth).toBe(220);
act(() => fireEvent.pointerUp(separator(), { pointerId: 1 }));
expect(useAppStore.getState().uniboxRailWidth).toBe(300);
act(() => {
fireEvent.pointerDown(separator(), { clientX: 300, button: 0, pointerId: 2 });
fireEvent.pointerMove(separator(), { clientX: 4000, pointerId: 2 });
fireEvent.pointerUp(separator(), { pointerId: 2 });
});
expect(useAppStore.getState().uniboxRailWidth).toBe(360);
expect(railVar()).toBe("360px");
act(() => {
fireEvent.pointerDown(separator(), { clientX: 360, button: 0, pointerId: 3 });
fireEvent.pointerMove(separator(), { clientX: 0, pointerId: 3 });
fireEvent.pointerUp(separator(), { pointerId: 3 });
});
expect(useAppStore.getState().uniboxRailWidth).toBe(180);
expect(railVar()).toBe("180px");
});
it.each([
[4, 180],
[99999, 360],
[null, 220],
["300", 220],
[undefined, 220],
])("rehydrates stored width %s as %s", async (stored, expected) => {
vi.mocked(localStorage.getItem).mockImplementation((key) => key === "warmbly-storage"
? JSON.stringify({ state: { uniboxRailWidth: stored } })
: null);
await useAppStore.persist.rehydrate();
expect(useAppStore.getState().uniboxRailWidth).toBe(expected);
});
it("persists a chosen width and restores it on rehydration", async () => {
await mount();
await settle();
vi.mocked(localStorage.setItem).mockClear();
act(() => fireEvent.keyDown(separator(), { key: "ArrowRight" }));
const saved = vi.mocked(localStorage.setItem).mock.calls
.filter(([key]) => key === "warmbly-storage").at(-1)![1];
expect(JSON.parse(saved).state.uniboxRailWidth).toBe(236);
act(() => useAppStore.getState().setUniboxRailWidth(220));
vi.mocked(localStorage.getItem).mockImplementation((key) => key === "warmbly-storage" ? saved : null);
await act(async () => { await useAppStore.persist.rehydrate(); });
expect(useAppStore.getState().uniboxRailWidth).toBe(236);
expect(railVar()).toBe("236px");
});
it("re-measures the list cap after a rail change and during a drag", async () => {
let railResize: (() => void) | undefined;
vi.stubGlobal("ResizeObserver", class {
constructor(private sync: () => void) {}
observe(el: Element) {
if (el.id === "unibox-scope-rail") railResize = this.sync;
}
unobserve() {}
disconnect() {}
});
const originalRect = Element.prototype.getBoundingClientRect;
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
const list = document.getElementById("unibox-conversation-list");
if (this === list?.parentElement) return new DOMRect(0, 0, 1100, 800);
if (this === list) {
const left = parseFloat(railVar()) + 6;
return new DOMRect(left, 0, 620, 800);
}
return originalRect.call(this);
});
useAppStore.setState({ uniboxListWidth: 620 });
await mount();
await settle();
const listSeparator = () => screen.getByRole("separator", { name: "Resize the conversation list" });
// 1100 - (220 + 6) - 6px list handle - 360px thread reserve.
expect(listSeparator()).toHaveAttribute("aria-valuemax", "508");
act(() => fireEvent.keyDown(separator(), { key: "End" }));
expect(listSeparator()).toHaveAttribute("aria-valuemax", "368");
expect(listSeparator()).toHaveAttribute("aria-valuenow", "368");
act(() => fireEvent.keyDown(separator(), { key: "Enter" }));
expect(listSeparator()).toHaveAttribute("aria-valuemax", "508");
expect(railResize).toBeTypeOf("function");
act(() => {
fireEvent.pointerDown(separator(), { clientX: 220, button: 0, pointerId: 1 });
fireEvent.pointerMove(separator(), { clientX: 300, pointerId: 1 });
railResize!();
});
expect(listSeparator()).toHaveAttribute("aria-valuemax", "428");
expect(useAppStore.getState().uniboxRailWidth).toBe(220);
expect(useAppStore.getState().uniboxListWidth).toBe(620);
act(() => fireEvent.pointerUp(separator(), { pointerId: 1 }));
});
});
+3
View File
@@ -19,6 +19,9 @@ export {
UNIBOX_LIST_MIN_WIDTH,
UNIBOX_LIST_MAX_WIDTH,
UNIBOX_LIST_DEFAULT_WIDTH,
UNIBOX_RAIL_MIN_WIDTH,
UNIBOX_RAIL_MAX_WIDTH,
UNIBOX_RAIL_DEFAULT_WIDTH,
} from './slices/uiSlice'
export type { ShortcutSlice } from './slices/shortcutSlice'
export type { DataSlice } from './slices/dataSlice'
+16
View File
@@ -19,6 +19,15 @@ export interface UniboxRailFavorite {
name?: string
}
export const UNIBOX_RAIL_MIN_WIDTH = 180
export const UNIBOX_RAIL_MAX_WIDTH = 360
export const UNIBOX_RAIL_DEFAULT_WIDTH = 220
export const clampUniboxRailWidth = (w: unknown): number => {
if (typeof w !== 'number' || !Number.isFinite(w)) return UNIBOX_RAIL_DEFAULT_WIDTH
return Math.round(Math.min(UNIBOX_RAIL_MAX_WIDTH, Math.max(UNIBOX_RAIL_MIN_WIDTH, w)))
}
// Exported because rehydration bypasses the setter: zustand's default merge
// writes localStorage straight into state, so the clamp has to run there too or
// a hand-edited (or newly out-of-range) value reaches the DOM unchecked.
@@ -70,6 +79,7 @@ export interface UISlice {
// against the thread pane; the CRM rail remembers the last explicit toggle
// so closing it survives opening the next thread.
uniboxListWidth: number
uniboxRailWidth: number
uniboxContactRailOpen: boolean
// Actions - Sidebar
@@ -103,6 +113,7 @@ export interface UISlice {
// Actions - Unibox layout
setUniboxListWidth: (width: number) => void
setUniboxRailWidth: (width: number) => void
setUniboxContactRailOpen: (open: boolean) => void
}
@@ -204,6 +215,7 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get)
// Unibox layout
uniboxListWidth: UNIBOX_LIST_DEFAULT_WIDTH,
uniboxRailWidth: UNIBOX_RAIL_DEFAULT_WIDTH,
uniboxContactRailOpen: false,
// Actions - Sidebar
@@ -279,6 +291,10 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get)
toggleAIAssistant: () => set((state) => ({ aiAssistantOpen: !state.aiAssistantOpen })),
// Actions - Unibox layout
setUniboxRailWidth: (width) => {
const uniboxRailWidth = clampUniboxRailWidth(width)
set((state) => (state.uniboxRailWidth === uniboxRailWidth ? state : { uniboxRailWidth }))
},
setUniboxListWidth: (width) => {
const uniboxListWidth = clampUniboxListWidth(width)
set((state) => (state.uniboxListWidth === uniboxListWidth ? state : { uniboxListWidth }))
+6 -4
View File
@@ -3,7 +3,7 @@ import { devtools, persist } from 'zustand/middleware'
import { useShallow } from 'zustand/react/shallow'
import { createUserSlice, type UserSlice } from './slices/userSlice'
import { createOrganizationSlice, type OrganizationSlice } from './slices/organizationSlice'
import { createUISlice, clampUniboxListWidth, sanitizeNavCollapsedSections, sanitizeUniboxRailFavorites, sanitizeUniboxRailHidden, sanitizeUniboxRailOrder, type UISlice } from './slices/uiSlice'
import { createUISlice, clampUniboxListWidth, clampUniboxRailWidth, sanitizeNavCollapsedSections, sanitizeUniboxRailFavorites, sanitizeUniboxRailHidden, sanitizeUniboxRailOrder, type UISlice } from './slices/uiSlice'
import { createShortcutSlice, type ShortcutSlice } from './slices/shortcutSlice'
import { createDataSlice, type DataSlice } from './slices/dataSlice'
import { createRealtimeSlice, type RealtimeSlice } from './slices/realtimeSlice'
@@ -38,8 +38,8 @@ export const useAppStore = create<AppStore>()(
}),
{
name: 'warmbly-storage',
// Rehydration does not go through the slice setters, so re-clamp the one
// stored value that has bounds. Without this a value from an older build
// Rehydration does not go through the slice setters, so re-clamp the
// stored values that have bounds. Without this a value from an older build
// (or a hand-edited one) renders as `width: NaNpx`.
merge: (persisted, current) => {
const p = (persisted ?? {}) as Partial<AppStore>
@@ -47,6 +47,7 @@ export const useAppStore = create<AppStore>()(
...current,
...p,
uniboxListWidth: clampUniboxListWidth(p.uniboxListWidth),
uniboxRailWidth: clampUniboxRailWidth(p.uniboxRailWidth),
navCollapsedSections: sanitizeNavCollapsedSections(p.navCollapsedSections),
uniboxRailFolded: sanitizeNavCollapsedSections(p.uniboxRailFolded),
uniboxRailHidden: sanitizeUniboxRailHidden(p.uniboxRailHidden),
@@ -65,8 +66,9 @@ export const useAppStore = create<AppStore>()(
agentWidth: state.agentWidth,
agentFloating: state.agentFloating,
agentFloatRect: state.agentFloatRect,
// Unibox layout (list column width + CRM rail default)
// Unibox layout (pane widths + CRM rail default)
uniboxListWidth: state.uniboxListWidth,
uniboxRailWidth: state.uniboxRailWidth,
uniboxContactRailOpen: state.uniboxContactRailOpen,
// Unibox scope rail (folds, hidden rows, row and section order, favorites)
uniboxRailFolded: state.uniboxRailFolded,