Files
fusion/packages/dashboard/app/hooks/useModalResizePersist.ts
gsxdsm 8a88e23b9c feat(dashboard): UI polish pass — resizable modals, themed scrollbars, settings overhaul
Modal resize + size persistence
- Extract `useModalResizePersist` hook (ResizeObserver + localStorage),
  apply to Files, Git Manager, GitHub Import, Workflow Steps, Automations
  (ScheduledTasks), and Settings modals. Each gets `resize: both`, sane
  min/max constraints, and a unique storage key.
- Bump default heights so the modals feel less cramped (Git: 92vh,
  Workflow/Automation: 80vh, Settings: 80vh / 1100px).

Scrollbar theme
- Add a global `*::-webkit-scrollbar*` + `scrollbar-color` rule in
  styles.css so chat, document, system stats, file browser, usage
  indicator, etc. inherit the theme. Existing per-component overrides
  (.board, .column-body, .settings-sidebar, planning modal) still win.

Document view
- Collapse "Show hidden" toggle and search input onto the same row as
  the Project Files / Task Documents segmented control. Stack again
  below 768px.

Mailbox / Todos
- Match Todos header treatment to the Mailbox header (typography,
  padding, border).
- Add top spacing above Mailbox Inbox/Outbox/Agents tab bar so the
  vertical gaps balance.

Settings
- Wider, resizable, persisted Settings modal.
- Project Models description and Authentication panel get proper
  horizontal padding.
- Reorder project sidebar so "General" is first.
- Plugins page: clean margins, integrate refresh button, exclude
  bundled runtimes from the "Installed Plugins" list (they were
  appearing twice — once erroring, once in their own section).
- New "Updates" panel with auto-check toggle + "Check now" button
  (frequency control noted as needing a backend schema field).

Background sessions
- Fix stale "AI N" / planning-icon badge: `handleDeleted` in
  `useBackgroundSessions` now writes a tombstone, advances the
  timestamp guard, and broadcasts completion so the cross-tab
  sync store stops resurrecting the deleted session on the next
  merge tick. Added regression test.
- Re-fetch list on SSE reconnect so terminal events fired during a
  network blip don't get permanently lost.

System stats
- Refresh button uses correct single class (was getting both `btn`
  and `btn-icon`, which conflicted on padding/border).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:26:07 -07:00

79 lines
2.6 KiB
TypeScript

import { useEffect, type RefObject } from "react";
interface PersistedSize {
width?: number;
height?: number;
}
/**
* Persist a resizable modal's user-chosen dimensions across opens.
*
* Pair this with `resize: both` in CSS on the modal element. When the user
* drags the resize grip, the new pixel size is captured via ResizeObserver
* and stored under `storageKey`. On the next open, the stored size is
* replayed as inline `width` / `height` styles before the modal becomes
* interactive.
*
* The CSS `min-*` / `max-*` constraints still clamp the applied size at
* render time, so a value saved on a 4K display won't break the layout
* when reopened on a laptop.
*
* @param ref ref to the resizable modal element
* @param isOpen the modal's open flag — observation only runs while true
* @param storageKey localStorage key, must be stable + unique per modal
*/
export function useModalResizePersist(
ref: RefObject<HTMLElement | null>,
isOpen: boolean,
storageKey: string,
): void {
useEffect(() => {
if (!isOpen) return;
const node = ref.current;
if (!node) return;
// Apply the persisted size on open.
try {
const raw = localStorage.getItem(storageKey);
if (raw) {
const { width, height } = JSON.parse(raw) as PersistedSize;
if (typeof width === "number" && width > 0) node.style.width = `${width}px`;
if (typeof height === "number" && height > 0) node.style.height = `${height}px`;
}
} catch {
// ignore corrupted entry
}
// jsdom (and very old browsers) lacks ResizeObserver — skip persistence
// gracefully rather than throw. Restoration above still ran.
if (typeof ResizeObserver === "undefined") return;
let lastSavedW = node.offsetWidth;
let lastSavedH = node.offsetHeight;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
const observer = new ResizeObserver(() => {
const w = node.offsetWidth;
const h = node.offsetHeight;
if (w === lastSavedW && h === lastSavedH) return;
lastSavedW = w;
lastSavedH = h;
// Debounce so we don't spam localStorage during the drag.
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
try {
localStorage.setItem(storageKey, JSON.stringify({ width: w, height: h }));
} catch {
// quota / private mode — best-effort
}
}, 200);
});
observer.observe(node);
return () => {
observer.disconnect();
if (saveTimer) clearTimeout(saveTimer);
};
}, [ref, isOpen, storageKey]);
}