feat(FN-2645): add desktop resize support for Quick Chat panel

- Add panel resize state and pointer-driven drag handling to QuickChatFAB
- Wire resize behavior into component layout so desktop users can adjust panel dimensions
- Style resize handles and interaction affordances in QuickChatFAB.css
- Expand QuickChatFAB tests to cover resize lifecycle, constraints, and persistence behavior
This commit is contained in:
Fusion
2026-04-26 23:39:27 -07:00
committed by gsxdsm
parent 9d220717cb
commit acbe093d32
3 changed files with 428 additions and 9 deletions

View File

@@ -44,9 +44,14 @@
/* Position set via inline style from useDraggable */
.quick-chat-panel {
--quick-chat-min-width: 280px;
--quick-chat-min-height: 260px;
position: fixed;
width: 320px;
height: 400px;
min-width: var(--quick-chat-min-width);
min-height: var(--quick-chat-min-height);
display: flex;
flex-direction: column;
background: var(--surface);
@@ -57,6 +62,36 @@
z-index: 1001;
}
.quick-chat-resize-handle {
position: absolute;
z-index: 2;
background: transparent;
}
.quick-chat-resize-handle[data-resize-direction="n"] {
cursor: ns-resize;
top: 0;
left: 6px;
right: 6px;
height: 6px;
}
.quick-chat-resize-handle[data-resize-direction="w"] {
cursor: ew-resize;
top: 6px;
left: 0;
bottom: 6px;
width: 6px;
}
.quick-chat-resize-handle[data-resize-direction="nw"] {
cursor: nwse-resize;
top: 0;
left: 0;
width: 10px;
height: 10px;
}
.quick-chat-panel-header {
display: flex;
align-items: center;
@@ -338,6 +373,10 @@
z-index: 1100;
}
.quick-chat-resize-handle {
display: none;
}
.quick-chat-panel-messages {
/* Let messages take all remaining space between header and input */
flex: 1 1 auto;

View File

@@ -245,6 +245,26 @@ interface Position {
y: number;
}
interface PanelSize {
width: number;
height: number;
}
type ResizeDirection = "n" | "w" | "nw";
const QUICK_CHAT_DEFAULT_PANEL_SIZE: PanelSize = {
width: 320,
height: 400,
};
const QUICK_CHAT_MIN_PANEL_SIZE: PanelSize = {
width: 280,
height: 260,
};
const QUICK_CHAT_DESKTOP_BREAKPOINT = 768;
const QUICK_CHAT_VIEWPORT_PADDING = 8;
/**
* Custom hook for draggable behavior.
* Positions are stored as right/bottom offsets (matching the current positioning model).
@@ -416,6 +436,153 @@ function useDraggable(projectId?: string, externalDidDragRef?: React.MutableRefO
};
}
function usePanelResize(projectId: string | undefined, panelRight: number, panelBottom: number) {
const storageKey = `fusion-quick-chat-size-${projectId || "default"}`;
const isDesktopViewport = useCallback(
() => typeof window !== "undefined" && window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT,
[],
);
const clampPanelSize = useCallback(
(size: PanelSize): PanelSize => {
if (typeof window === "undefined") {
return size;
}
const maxWidth = Math.max(
QUICK_CHAT_MIN_PANEL_SIZE.width,
window.innerWidth - panelRight - QUICK_CHAT_VIEWPORT_PADDING,
);
const maxHeight = Math.max(
QUICK_CHAT_MIN_PANEL_SIZE.height,
window.innerHeight - panelBottom - QUICK_CHAT_VIEWPORT_PADDING,
);
return {
width: Math.max(QUICK_CHAT_MIN_PANEL_SIZE.width, Math.min(maxWidth, size.width)),
height: Math.max(QUICK_CHAT_MIN_PANEL_SIZE.height, Math.min(maxHeight, size.height)),
};
},
[panelBottom, panelRight],
);
const [panelSize, setPanelSize] = useState<PanelSize>(() => {
if (typeof window === "undefined" || window.innerWidth <= QUICK_CHAT_DESKTOP_BREAKPOINT) {
return QUICK_CHAT_DEFAULT_PANEL_SIZE;
}
try {
const rawSize = localStorage.getItem(storageKey);
if (!rawSize) {
return QUICK_CHAT_DEFAULT_PANEL_SIZE;
}
const parsed = JSON.parse(rawSize) as Partial<PanelSize>;
if (typeof parsed.width !== "number" || typeof parsed.height !== "number") {
return QUICK_CHAT_DEFAULT_PANEL_SIZE;
}
return {
width: parsed.width,
height: parsed.height,
};
} catch {
return QUICK_CHAT_DEFAULT_PANEL_SIZE;
}
});
useEffect(() => {
if (!isDesktopViewport()) {
return;
}
setPanelSize((current) => clampPanelSize(current));
}, [clampPanelSize, isDesktopViewport]);
useEffect(() => {
if (!isDesktopViewport()) {
return;
}
try {
localStorage.setItem(storageKey, JSON.stringify(panelSize));
} catch {
// Ignore storage errors
}
}, [isDesktopViewport, panelSize, storageKey]);
const handleResizeStart = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!isDesktopViewport()) {
return;
}
const direction = event.currentTarget.dataset.resizeDirection as ResizeDirection | undefined;
if (!direction) {
return;
}
event.preventDefault();
event.stopPropagation();
const resizeHandle = event.currentTarget;
if (typeof resizeHandle.setPointerCapture === "function") {
resizeHandle.setPointerCapture(event.pointerId);
}
const resizeStart = {
pointerX: event.clientX,
pointerY: event.clientY,
width: panelSize.width,
height: panelSize.height,
};
document.body.style.userSelect = "none";
const handlePointerMove = (moveEvent: PointerEvent) => {
let nextWidth = resizeStart.width;
let nextHeight = resizeStart.height;
if (direction.includes("w")) {
nextWidth = resizeStart.width + (resizeStart.pointerX - moveEvent.clientX);
}
if (direction.includes("n")) {
nextHeight = resizeStart.height + (resizeStart.pointerY - moveEvent.clientY);
}
setPanelSize(
clampPanelSize({
width: nextWidth,
height: nextHeight,
}),
);
};
const handlePointerUp = (upEvent: PointerEvent) => {
if (typeof resizeHandle.releasePointerCapture === "function") {
resizeHandle.releasePointerCapture(upEvent.pointerId);
}
document.body.style.userSelect = "";
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
};
document.addEventListener("pointermove", handlePointerMove);
document.addEventListener("pointerup", handlePointerUp);
},
[clampPanelSize, isDesktopViewport, panelSize.height, panelSize.width],
);
return {
panelSize,
handleResizeStart,
};
}
export function QuickChatFAB({
projectId,
addToast,
@@ -490,6 +657,11 @@ export function QuickChatFAB({
handlePointerUp,
} = useDraggable(projectId, didDragRef);
// Panel stays 60px above FAB (FAB is 48px tall + 12px gap)
const panelY = position.y + 60;
const { panelSize, handleResizeStart } = usePanelResize(projectId, position.x, panelY);
const shouldApplyDesktopPanelSize = typeof window !== "undefined" && window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT;
// Chat session hook
const {
activeSession,
@@ -1004,9 +1176,6 @@ export function QuickChatFAB({
setIsOpen((prev) => !prev);
}, [setIsOpen]);
// Calculate panel position: 60px above the FAB (FAB is 48px tall + 12px gap)
const panelY = position.y + 60;
return (
<>
{showFAB && (
@@ -1032,8 +1201,35 @@ export function QuickChatFAB({
className="quick-chat-panel"
ref={panelRef}
data-testid="quick-chat-panel"
style={{ right: position.x, bottom: panelY }}
style={{
right: position.x,
bottom: panelY,
...(shouldApplyDesktopPanelSize ? { width: panelSize.width, height: panelSize.height } : {}),
}}
>
{shouldApplyDesktopPanelSize && (
<>
<div
className="quick-chat-resize-handle"
data-resize-direction="n"
onPointerDown={handleResizeStart}
aria-hidden="true"
/>
<div
className="quick-chat-resize-handle"
data-resize-direction="w"
onPointerDown={handleResizeStart}
aria-hidden="true"
/>
<div
className="quick-chat-resize-handle"
data-resize-direction="nw"
onPointerDown={handleResizeStart}
aria-hidden="true"
/>
</>
)}
<div className="quick-chat-panel-header">
<div style={headerTitleWrapStyle}>
<h3>Quick Chat</h3>

View File

@@ -1348,6 +1348,178 @@ describe("QuickChatFAB", () => {
});
});
describe("panel resizing", () => {
const localStorageMock = {
store: {} as Record<string, string>,
getItem: vi.fn((key: string) => localStorageMock.store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { localStorageMock.store[key] = value; }),
removeItem: vi.fn((key: string) => { delete localStorageMock.store[key]; }),
clear: vi.fn(() => { localStorageMock.store = {}; }),
};
let originalInnerWidth: number;
let originalInnerHeight: number;
beforeEach(() => {
originalInnerWidth = window.innerWidth;
originalInnerHeight = window.innerHeight;
Object.defineProperty(window, "innerWidth", { value: 1200, writable: true });
Object.defineProperty(window, "innerHeight", { value: 900, writable: true });
vi.stubGlobal("localStorage", localStorageMock);
localStorageMock.store = {};
localStorageMock.getItem.mockClear();
localStorageMock.setItem.mockClear();
});
afterEach(() => {
Object.defineProperty(window, "innerWidth", { value: originalInnerWidth, writable: true });
Object.defineProperty(window, "innerHeight", { value: originalInnerHeight, writable: true });
vi.unstubAllGlobals();
});
it("renders default desktop panel dimensions", () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" open={true} />);
const panel = screen.getByTestId("quick-chat-panel");
expect(panel.style.width).toBe("320px");
expect(panel.style.height).toBe("400px");
});
it("persists panel size when a resize handle is dragged", () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" open={true} />);
const panel = screen.getByTestId("quick-chat-panel");
const leftHandle = panel.querySelector('[data-resize-direction="w"]');
expect(leftHandle).not.toBeNull();
fireEvent.pointerDown(leftHandle!, {
clientX: 400,
clientY: 250,
pointerId: 7,
button: 0,
});
fireEvent.pointerMove(document, {
clientX: 320,
clientY: 250,
pointerId: 7,
});
fireEvent.pointerUp(document, {
clientX: 320,
clientY: 250,
pointerId: 7,
});
expect(localStorageMock.setItem).toHaveBeenCalledWith(
"fusion-quick-chat-size-proj-123",
expect.stringContaining('"width":'),
);
expect(parseFloat(panel.style.width)).toBeGreaterThan(320);
expect(panel.style.height).toBe("400px");
});
it("restores panel size from localStorage on desktop mount", () => {
localStorageMock.store["fusion-quick-chat-size-proj-123"] = JSON.stringify({ width: 470, height: 520 });
render(<QuickChatFAB addToast={addToast} projectId="proj-123" open={true} />);
const panel = screen.getByTestId("quick-chat-panel");
expect(panel.style.width).toBe("470px");
expect(panel.style.height).toBe("520px");
});
it("does not render resize handles on mobile viewport", () => {
Object.defineProperty(window, "innerWidth", { value: 640, writable: true });
render(<QuickChatFAB addToast={addToast} projectId="proj-123" open={true} />);
const panel = screen.getByTestId("quick-chat-panel");
expect(panel.querySelector('[data-resize-direction="n"]')).toBeNull();
expect(panel.querySelector('[data-resize-direction="w"]')).toBeNull();
expect(panel.querySelector('[data-resize-direction="nw"]')).toBeNull();
expect(panel.style.width).toBe("");
expect(panel.style.height).toBe("");
});
it("clamps resized panel dimensions to min and viewport max bounds", () => {
Object.defineProperty(window, "innerWidth", { value: 900, writable: true });
Object.defineProperty(window, "innerHeight", { value: 700, writable: true });
render(<QuickChatFAB addToast={addToast} projectId="proj-123" open={true} />);
const panel = screen.getByTestId("quick-chat-panel");
const cornerHandle = panel.querySelector('[data-resize-direction="nw"]');
expect(cornerHandle).not.toBeNull();
fireEvent.pointerDown(cornerHandle!, {
clientX: 450,
clientY: 320,
pointerId: 9,
button: 0,
});
// Drag down-right to force min clamp
fireEvent.pointerMove(document, {
clientX: 1200,
clientY: 1200,
pointerId: 9,
});
expect(panel.style.width).toBe("280px");
expect(panel.style.height).toBe("260px");
// Drag up-left to force viewport-max clamp
fireEvent.pointerMove(document, {
clientX: -1200,
clientY: -1200,
pointerId: 9,
});
fireEvent.pointerUp(document, {
clientX: -1200,
clientY: -1200,
pointerId: 9,
});
const maxWidth = 900 - 24 - 8;
const maxHeight = 700 - (24 + 60) - 8;
expect(panel.style.width).toBe(`${maxWidth}px`);
expect(panel.style.height).toBe(`${maxHeight}px`);
});
it("resizing the panel does not change FAB drag position", () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" open={true} />);
const fab = screen.getByTestId("quick-chat-fab");
const panel = screen.getByTestId("quick-chat-panel");
const initialFabRight = fab.style.right;
const initialFabBottom = fab.style.bottom;
const leftHandle = panel.querySelector('[data-resize-direction="w"]');
expect(leftHandle).not.toBeNull();
fireEvent.pointerDown(leftHandle!, {
clientX: 400,
clientY: 280,
pointerId: 11,
button: 0,
});
fireEvent.pointerMove(document, {
clientX: 300,
clientY: 280,
pointerId: 11,
});
fireEvent.pointerUp(document, {
clientX: 300,
clientY: 280,
pointerId: 11,
});
expect(fab.style.right).toBe(initialFabRight);
expect(fab.style.bottom).toBe(initialFabBottom);
expect(parseFloat(panel.style.width)).toBeGreaterThan(320);
});
});
// Drag-related tests
describe("draggable behavior", () => {
const localStorageMock = {
@@ -1447,8 +1619,11 @@ describe("QuickChatFAB", () => {
// Should have toggled panel (treated as click)
expect(onOpenChange).toHaveBeenCalledWith(true);
// Should NOT have saved position to localStorage (was a click, not a drag)
expect(localStorageMock.setItem).not.toHaveBeenCalled();
// Should NOT have saved FAB position to localStorage (was a click, not a drag)
expect(localStorageMock.setItem).not.toHaveBeenCalledWith(
"fusion-quick-chat-position-proj-123",
expect.any(String),
);
});
it("FAB position is loaded from localStorage on mount", async () => {
@@ -1496,7 +1671,10 @@ describe("QuickChatFAB", () => {
});
// Desktop: position should be clamped to at least 8px from edges
const savedPosition = JSON.parse(localStorageMock.setItem.mock.calls[0]?.[1] || "{}");
const positionCall = localStorageMock.setItem.mock.calls.find(
([key]) => key === "fusion-quick-chat-position-proj-123",
);
const savedPosition = JSON.parse(positionCall?.[1] || "{}");
expect(savedPosition.x).toBeGreaterThanOrEqual(8);
expect(savedPosition.y).toBeGreaterThanOrEqual(8);
});
@@ -1533,7 +1711,10 @@ describe("QuickChatFAB", () => {
});
// Mobile (375px <= 768px): position should be clamped to at least 4px from edges
const savedPosition = JSON.parse(localStorageMock.setItem.mock.calls[0]?.[1] || "{}");
const positionCall = localStorageMock.setItem.mock.calls.find(
([key]) => key === "fusion-quick-chat-position-proj-123",
);
const savedPosition = JSON.parse(positionCall?.[1] || "{}");
expect(savedPosition.x).toBeGreaterThanOrEqual(4);
expect(savedPosition.y).toBeGreaterThanOrEqual(4);
});
@@ -1570,7 +1751,10 @@ describe("QuickChatFAB", () => {
});
// Desktop: position should be clamped to at least 8px from edges
const savedPosition = JSON.parse(localStorageMock.setItem.mock.calls[0]?.[1] || "{}");
const positionCall = localStorageMock.setItem.mock.calls.find(
([key]) => key === "fusion-quick-chat-position-proj-123",
);
const savedPosition = JSON.parse(positionCall?.[1] || "{}");
expect(savedPosition.x).toBeGreaterThanOrEqual(8);
expect(savedPosition.y).toBeGreaterThanOrEqual(8);
});