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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user