feat(FN-3885): add resizable split pane to mailbox view
Adds a resizable split-pane to the MailboxView component (FN-3885) and introduces a report archive feature to fusion-plugin-reports with a full store, schema, and types layer (FN-3784). Also adds tests for the notification dispatcher pipeline, updates the MemoryView test allowlist, and clarifies the Fusion-Task-Id: FN-3885
This commit is contained in:
5
.changeset/fn-3885-mailbox-resizable-pane.md
Normal file
5
.changeset/fn-3885-mailbox-resizable-pane.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Mailbox view now has a draggable resize handle between the list and detail panes (desktop only), with keyboard support and per-project persisted width.
|
||||
@@ -649,12 +649,30 @@
|
||||
|
||||
.mailbox-view .mailbox-split-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(17.5rem, 38%) minmax(0, 1fr);
|
||||
gap: var(--space-lg);
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
gap: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-split-resize-handle {
|
||||
width: var(--space-sm);
|
||||
cursor: col-resize;
|
||||
background: color-mix(in srgb, var(--border) 70%, transparent);
|
||||
transition: background var(--transition-fast);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-split-resize-handle:hover,
|
||||
.mailbox-view .mailbox-split-resize-handle:focus-visible {
|
||||
background: color-mix(in srgb, var(--todo) 35%, transparent);
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-split-resize-handle:focus-visible {
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-split-list-pane,
|
||||
.mailbox-view .mailbox-split-detail-pane {
|
||||
min-height: 0;
|
||||
@@ -857,6 +875,10 @@
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-split-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-split-list-pane,
|
||||
.mailbox-view .mailbox-split-detail-pane {
|
||||
border: none;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./MailboxModal.css";
|
||||
import { useState, useEffect, useCallback, useMemo, type CSSProperties } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties } from "react";
|
||||
import {
|
||||
Mail,
|
||||
Send,
|
||||
@@ -38,6 +38,7 @@ import { MessageComposer } from "./MessageComposer";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -50,6 +51,34 @@ interface MailboxViewProps {
|
||||
onUnreadCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
const MAILBOX_SIDEBAR_MIN_WIDTH = 280;
|
||||
const MAILBOX_SIDEBAR_MAX_RATIO = 0.65;
|
||||
const MAILBOX_SIDEBAR_KEYBOARD_STEP = 16;
|
||||
const MAILBOX_SIDEBAR_DEFAULT_WIDTH = 320;
|
||||
|
||||
function getMailboxSidebarMaxWidth(containerWidth: number): number {
|
||||
return Math.max(MAILBOX_SIDEBAR_MIN_WIDTH, containerWidth * MAILBOX_SIDEBAR_MAX_RATIO);
|
||||
}
|
||||
|
||||
function clampMailboxSidebarWidth(width: number, containerWidth: number): number {
|
||||
const maxWidth = getMailboxSidebarMaxWidth(containerWidth);
|
||||
return Math.min(Math.max(width, MAILBOX_SIDEBAR_MIN_WIDTH), maxWidth);
|
||||
}
|
||||
|
||||
function readMailboxSidebarWidth(projectId?: string): number {
|
||||
try {
|
||||
const saved = getScopedItem("kb-dashboard-mailbox-sidebar-width", projectId);
|
||||
if (!saved) return MAILBOX_SIDEBAR_DEFAULT_WIDTH;
|
||||
const parsed = Number(saved);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Invalid localStorage data - fall through to default
|
||||
}
|
||||
|
||||
return MAILBOX_SIDEBAR_DEFAULT_WIDTH;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -191,6 +220,8 @@ export function MailboxView({
|
||||
const viewportMode = useViewportMode();
|
||||
const isMobile = viewportMode === "mobile";
|
||||
const isSplitPane = !isMobile;
|
||||
const [sidebarWidth, setSidebarWidth] = useState<number>(() => readMailboxSidebarWidth(projectId));
|
||||
const splitLayoutRef = useRef<HTMLDivElement>(null);
|
||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
|
||||
const containerKeyboardStyle = useMemo<CSSProperties | undefined>(() => {
|
||||
if (!keyboardOpen) {
|
||||
@@ -204,6 +235,75 @@ export function MailboxView({
|
||||
} as CSSProperties;
|
||||
}, [keyboardOpen, keyboardOverlap, viewportHeight, viewportOffsetTop]);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarWidth(readMailboxSidebarWidth(projectId));
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSplitPane) return;
|
||||
const containerWidth = splitLayoutRef.current?.clientWidth;
|
||||
if (!containerWidth) return;
|
||||
|
||||
setSidebarWidth((current) => clampMailboxSidebarWidth(current, containerWidth));
|
||||
}, [isSplitPane]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSplitPane) return;
|
||||
try {
|
||||
setScopedItem("kb-dashboard-mailbox-sidebar-width", String(sidebarWidth), projectId);
|
||||
} catch {
|
||||
// localStorage persistence is best-effort.
|
||||
}
|
||||
}, [isSplitPane, projectId, sidebarWidth]);
|
||||
|
||||
const handleSplitResizeStart = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isSplitPane) return;
|
||||
event.preventDefault();
|
||||
const container = splitLayoutRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||
const proposedWidth = moveEvent.clientX - rect.left;
|
||||
setSidebarWidth(clampMailboxSidebarWidth(proposedWidth, rect.width));
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
}, [isSplitPane]);
|
||||
|
||||
const handleSplitResizeKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!isSplitPane) return;
|
||||
const measuredWidth = splitLayoutRef.current?.clientWidth ?? 0;
|
||||
const fallbackWidth = sidebarWidth / MAILBOX_SIDEBAR_MAX_RATIO + MAILBOX_SIDEBAR_KEYBOARD_STEP;
|
||||
const containerWidth = Math.max(measuredWidth, fallbackWidth);
|
||||
|
||||
const maxWidth = getMailboxSidebarMaxWidth(containerWidth);
|
||||
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
const delta = event.key === "ArrowLeft" ? -MAILBOX_SIDEBAR_KEYBOARD_STEP : MAILBOX_SIDEBAR_KEYBOARD_STEP;
|
||||
setSidebarWidth((current) => clampMailboxSidebarWidth(current + delta, containerWidth));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
setSidebarWidth(MAILBOX_SIDEBAR_MIN_WIDTH);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
setSidebarWidth(maxWidth);
|
||||
}
|
||||
}, [isSplitPane, sidebarWidth]);
|
||||
|
||||
// ── Data fetching ─────────────────────────────────────────────────────
|
||||
|
||||
const loadInbox = useCallback(async () => {
|
||||
@@ -1062,10 +1162,27 @@ export function MailboxView({
|
||||
|
||||
<div className="mailbox-content" data-testid="mailbox-content">
|
||||
{isSplitPane ? (
|
||||
<div className="mailbox-split-layout" data-testid="mailbox-split-layout">
|
||||
<div className="mailbox-split-list-pane" data-testid="mailbox-split-list-pane">
|
||||
<div className="mailbox-split-layout" data-testid="mailbox-split-layout" ref={splitLayoutRef}>
|
||||
<div
|
||||
className="mailbox-split-list-pane"
|
||||
data-testid="mailbox-split-list-pane"
|
||||
style={{ width: `${sidebarWidth}px` }}
|
||||
>
|
||||
{renderListPane()}
|
||||
</div>
|
||||
<div
|
||||
className="mailbox-split-resize-handle"
|
||||
data-testid="mailbox-split-resize-handle"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize message list pane"
|
||||
tabIndex={0}
|
||||
aria-valuemin={MAILBOX_SIDEBAR_MIN_WIDTH}
|
||||
aria-valuemax={Math.round(getMailboxSidebarMaxWidth(splitLayoutRef.current?.clientWidth ?? sidebarWidth / MAILBOX_SIDEBAR_MAX_RATIO))}
|
||||
aria-valuenow={Math.round(sidebarWidth)}
|
||||
onMouseDown={handleSplitResizeStart}
|
||||
onKeyDown={handleSplitResizeKeyDown}
|
||||
/>
|
||||
<div className="mailbox-split-detail-pane" data-testid="mailbox-split-detail-pane">
|
||||
{renderDetailPane()}
|
||||
</div>
|
||||
|
||||
@@ -169,6 +169,7 @@ function makeOutboxResponse(messages: Message[]) {
|
||||
describe("MailboxView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
sseSubscriptions.length = 0;
|
||||
mockUseViewportMode.mockReturnValue("desktop");
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
@@ -1538,6 +1539,80 @@ describe("MailboxView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resizable split pane", () => {
|
||||
it("renders a desktop resize handle with separator aria semantics", async () => {
|
||||
mockUseViewportMode.mockReturnValue("desktop");
|
||||
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
|
||||
|
||||
render(<MailboxView {...defaultProps} projectId="proj-resize" />);
|
||||
|
||||
const handle = await screen.findByTestId("mailbox-split-resize-handle");
|
||||
expect(handle.getAttribute("role")).toBe("separator");
|
||||
expect(handle.getAttribute("aria-orientation")).toBe("vertical");
|
||||
expect(Number(handle.getAttribute("aria-valuenow"))).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not render the resize handle on mobile", async () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
|
||||
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
|
||||
await screen.findByTestId("mailbox-view");
|
||||
expect(screen.queryByTestId("mailbox-split-resize-handle")).toBeNull();
|
||||
});
|
||||
|
||||
it("supports keyboard resize and Home/End clamping", async () => {
|
||||
mockUseViewportMode.mockReturnValue("desktop");
|
||||
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
|
||||
|
||||
render(<MailboxView {...defaultProps} projectId="proj-keys" />);
|
||||
|
||||
const handle = await screen.findByTestId("mailbox-split-resize-handle");
|
||||
const initial = Number(handle.getAttribute("aria-valuenow"));
|
||||
|
||||
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
||||
const afterLeft = Number(handle.getAttribute("aria-valuenow"));
|
||||
expect(afterLeft).toBeLessThan(initial);
|
||||
|
||||
fireEvent.keyDown(handle, { key: "ArrowRight" });
|
||||
const afterRight = Number(handle.getAttribute("aria-valuenow"));
|
||||
expect(afterRight).toBeGreaterThanOrEqual(afterLeft);
|
||||
|
||||
fireEvent.keyDown(handle, { key: "Home" });
|
||||
expect(Number(handle.getAttribute("aria-valuenow"))).toBe(280);
|
||||
|
||||
fireEvent.keyDown(handle, { key: "End" });
|
||||
expect(Number(handle.getAttribute("aria-valuenow"))).toBeGreaterThanOrEqual(280);
|
||||
});
|
||||
|
||||
it("persists and restores scoped mailbox sidebar width", async () => {
|
||||
mockUseViewportMode.mockReturnValue("desktop");
|
||||
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
|
||||
|
||||
const projectId = "proj-persist";
|
||||
const storageKey = `kb:${projectId}:kb-dashboard-mailbox-sidebar-width`;
|
||||
window.localStorage.setItem(storageKey, "360");
|
||||
|
||||
const { unmount } = render(<MailboxView {...defaultProps} projectId={projectId} />);
|
||||
|
||||
const handle = await screen.findByTestId("mailbox-split-resize-handle");
|
||||
expect(Number(handle.getAttribute("aria-valuenow"))).toBe(360);
|
||||
|
||||
fireEvent.keyDown(handle, { key: "ArrowRight" });
|
||||
await waitFor(() => {
|
||||
const savedWidth = Number(window.localStorage.getItem(storageKey));
|
||||
expect(savedWidth).toBeGreaterThan(360);
|
||||
});
|
||||
|
||||
unmount();
|
||||
render(<MailboxView {...defaultProps} projectId={projectId} />);
|
||||
const remountedHandle = await screen.findByTestId("mailbox-split-resize-handle");
|
||||
const persistedWidth = Number(window.localStorage.getItem(storageKey));
|
||||
expect(Number(remountedHandle.getAttribute("aria-valuenow"))).toBe(Math.round(persistedWidth));
|
||||
});
|
||||
});
|
||||
|
||||
describe("mobile layout CSS regressions", () => {
|
||||
it("defines .mailbox-view base flex layout with min-height: 0", async () => {
|
||||
const fs = await import("fs");
|
||||
@@ -1557,12 +1632,7 @@ describe("MailboxView", () => {
|
||||
it("defines desktop/tablet split-pane selectors under .mailbox-view scope", async () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
const splitLayoutBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-layout\s*\{([^}]*)\}/);
|
||||
expect(splitLayoutBlockMatch).toBeTruthy();
|
||||
const splitLayoutBlock = splitLayoutBlockMatch![1];
|
||||
expect(splitLayoutBlock).toContain("display: grid;");
|
||||
expect(splitLayoutBlock).toContain("min-height: 0;");
|
||||
expect(splitLayoutBlock).toContain("grid-template-columns");
|
||||
expect(css).toMatch(/\.mailbox-view\s+\.mailbox-split-layout\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*auto\s+auto\s+minmax\(0,\s*1fr\);[^}]*gap:\s*0;[^}]*min-height:\s*0;[^}]*\}/);
|
||||
|
||||
const splitPaneBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-list-pane,\s*\n\.mailbox-view\s+\.mailbox-split-detail-pane\s*\{([^}]*)\}/);
|
||||
expect(splitPaneBlockMatch).toBeTruthy();
|
||||
@@ -1571,6 +1641,8 @@ describe("MailboxView", () => {
|
||||
expect(splitPaneBlock).toContain("border: var(--btn-border-width) solid var(--border);");
|
||||
expect(splitPaneBlock).toContain("background: var(--surface);");
|
||||
|
||||
expect(css).toMatch(/\.mailbox-view\s+\.mailbox-split-resize-handle\s*\{[^}]*cursor:\s*col-resize;[^}]*background:\s*color-mix\(in srgb,\s*var\(--border\)\s*70%,\s*transparent\);[^}]*\}/);
|
||||
|
||||
const splitEmptyBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-empty\s*\{([^}]*)\}/);
|
||||
expect(splitEmptyBlockMatch).toBeTruthy();
|
||||
expect(splitEmptyBlockMatch![1]).toContain("color: var(--text-muted);");
|
||||
@@ -1594,14 +1666,16 @@ describe("MailboxView", () => {
|
||||
// Verify .mailbox-view selectors are in mobile section
|
||||
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-header");
|
||||
expect(mailboxMobileSection).toMatch(/\.mailbox-modal \.mailbox-header-actions,\s*\.mailbox-view \.mailbox-header-actions\s*\{[^}]*gap:\s*var\(--space-sm\);[^}]*\}/);
|
||||
expect(mailboxMobileSection).toMatch(/\.mailbox-modal \.mailbox-header-actions \.btn,[^}]*\.mailbox-view \.mailbox-header-actions \.btn-icon\s*\{[^}]*min-height:\s*36px;[^}]*\}/);
|
||||
expect(mailboxMobileSection).toMatch(/\.mailbox-modal \.mailbox-header-actions \.btn-icon,[^}]*\.mailbox-view \.mailbox-header-actions \.btn-icon\s*\{[^}]*min-width:\s*36px;[^}]*display:\s*inline-flex;[^}]*\}/);
|
||||
expect(mailboxMobileSection).toMatch(/\.mailbox-modal \.mailbox-header-actions \.btn,[^}]*\.mailbox-view \.mailbox-header-actions \.btn-icon\s*\{[^}]*min-height:\s*2\.25rem;[^}]*\}/);
|
||||
expect(mailboxMobileSection).toMatch(/\.mailbox-modal \.mailbox-header-actions \.btn-icon,[^}]*\.mailbox-view \.mailbox-header-actions \.btn-icon\s*\{[^}]*min-width:\s*2\.25rem;[^}]*display:\s*inline-flex;[^}]*\}/);
|
||||
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-tabs");
|
||||
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-content");
|
||||
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-split-layout");
|
||||
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-split-list-pane");
|
||||
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-split-detail-pane");
|
||||
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-split-resize-handle");
|
||||
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-empty");
|
||||
expect(mailboxMobileSection).toMatch(/\.mailbox-view\s+\.mailbox-split-resize-handle\s*\{[^}]*display:\s*none;[^}]*\}/);
|
||||
});
|
||||
|
||||
it("uses mobile-specific values for .mailbox-view content and FAB", async () => {
|
||||
|
||||
@@ -83,6 +83,7 @@ describe("projectStorage", () => {
|
||||
"kb-dashboard-selected-tasks",
|
||||
"kb-dashboard-list-selected-task",
|
||||
"kb-dashboard-list-sidebar-width",
|
||||
"kb-dashboard-mailbox-sidebar-width",
|
||||
"kb-quick-entry-text",
|
||||
"kb-inline-create-text",
|
||||
"fn-agent-view",
|
||||
@@ -101,7 +102,7 @@ describe("projectStorage", () => {
|
||||
"fusion-plugin-dependency-graph:positions",
|
||||
]),
|
||||
);
|
||||
expect(PROJECT_STORAGE_KEYS).toHaveLength(23);
|
||||
expect(PROJECT_STORAGE_KEYS).toHaveLength(24);
|
||||
});
|
||||
|
||||
it("stores branch filter values as scoped strings per project", () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ export const PROJECT_STORAGE_KEYS: string[] = [
|
||||
"kb-dashboard-selected-tasks",
|
||||
"kb-dashboard-list-selected-task",
|
||||
"kb-dashboard-list-sidebar-width",
|
||||
"kb-dashboard-mailbox-sidebar-width",
|
||||
"kb-quick-entry-text",
|
||||
"kb-inline-create-text",
|
||||
"fn-agent-view",
|
||||
|
||||
Reference in New Issue
Block a user