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:
Fusion
2026-05-09 14:25:35 -07:00
committed by gsxdsm
parent c995ab3d90
commit f182aa30ad
6 changed files with 234 additions and 14 deletions

View 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.

View File

@@ -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;

View File

@@ -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>

View File

@@ -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 () => {

View File

@@ -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", () => {

View File

@@ -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",