mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 08:02:04 +00:00
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:
@@ -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. |
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user