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>
This commit is contained in:
@@ -38,11 +38,18 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.documents-controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.documents-tab-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.documents-tab {
|
||||
@@ -84,8 +91,8 @@
|
||||
}
|
||||
|
||||
.documents-hidden-toggle {
|
||||
align-self: flex-start;
|
||||
margin-bottom: var(--space-sm);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.documents-hidden-toggle[aria-pressed="true"] {
|
||||
@@ -98,6 +105,8 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.documents-search-icon {
|
||||
@@ -109,7 +118,7 @@
|
||||
|
||||
.documents-search-input {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
min-width: 120px;
|
||||
padding: var(--space-sm) var(--space-sm) var(--space-sm) calc(var(--space-sm) + 24px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
@@ -850,6 +859,12 @@
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.documents-controls-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.documents-tab-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -388,61 +388,63 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="documents-tab-bar" role="tablist" aria-label="Documents sections">
|
||||
<button
|
||||
className={`btn documents-tab${activeTab === "project" ? " active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={activeTab === "project"}
|
||||
aria-label="Show project markdown files"
|
||||
onClick={() => handleTabChange("project")}
|
||||
>
|
||||
Project Files
|
||||
<span className="documents-tab-count">{projectFiles.length}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`btn documents-tab${activeTab === "tasks" ? " active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={activeTab === "tasks"}
|
||||
aria-label="Show task documents"
|
||||
onClick={() => handleTabChange("tasks")}
|
||||
>
|
||||
Task Documents
|
||||
<span className="documents-tab-count">{groupedDocuments.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "project" && (
|
||||
<button
|
||||
className="btn btn-sm documents-hidden-toggle"
|
||||
onClick={() => setShowHiddenProjectFiles((prev) => !prev)}
|
||||
aria-pressed={showHiddenProjectFiles}
|
||||
aria-label={showHiddenProjectFiles ? "Hide hidden project files" : "Show hidden project files"}
|
||||
title={showHiddenProjectFiles ? "Hide hidden files" : "Show hidden files"}
|
||||
>
|
||||
{showHiddenProjectFiles ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
{showHiddenProjectFiles ? "Hide Hidden" : "Show Hidden"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="documents-search">
|
||||
<Search size={16} className="documents-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="documents-search-input"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
aria-label={searchPlaceholder}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<div className="documents-controls-row">
|
||||
<div className="documents-tab-bar" role="tablist" aria-label="Documents sections">
|
||||
<button
|
||||
className="documents-search-clear"
|
||||
onClick={clearSearch}
|
||||
aria-label="Clear search"
|
||||
className={`btn documents-tab${activeTab === "project" ? " active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={activeTab === "project"}
|
||||
aria-label="Show project markdown files"
|
||||
onClick={() => handleTabChange("project")}
|
||||
>
|
||||
<X size={16} />
|
||||
Project Files
|
||||
<span className="documents-tab-count">{projectFiles.length}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`btn documents-tab${activeTab === "tasks" ? " active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={activeTab === "tasks"}
|
||||
aria-label="Show task documents"
|
||||
onClick={() => handleTabChange("tasks")}
|
||||
>
|
||||
Task Documents
|
||||
<span className="documents-tab-count">{groupedDocuments.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "project" && (
|
||||
<button
|
||||
className="btn btn-sm documents-hidden-toggle"
|
||||
onClick={() => setShowHiddenProjectFiles((prev) => !prev)}
|
||||
aria-pressed={showHiddenProjectFiles}
|
||||
aria-label={showHiddenProjectFiles ? "Hide hidden project files" : "Show hidden project files"}
|
||||
title={showHiddenProjectFiles ? "Hide hidden files" : "Show hidden files"}
|
||||
>
|
||||
{showHiddenProjectFiles ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
{showHiddenProjectFiles ? "Hide Hidden" : "Show Hidden"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="documents-search">
|
||||
<Search size={16} className="documents-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="documents-search-input"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
aria-label={searchPlaceholder}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="documents-search-clear"
|
||||
onClick={clearSearch}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/* === File Browser === */
|
||||
.modal.file-browser-modal {
|
||||
width: 90vw;
|
||||
max-width: 1600px;
|
||||
max-width: 95vw;
|
||||
min-width: 360px;
|
||||
height: 80vh;
|
||||
max-height: calc(100vh - 2 * var(--overlay-padding-top, 10vh));
|
||||
min-height: 320px;
|
||||
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -584,9 +588,13 @@
|
||||
/* === File Browser === */
|
||||
.modal.file-browser-modal {
|
||||
width: 90vw;
|
||||
max-width: 1600px;
|
||||
max-width: 95vw;
|
||||
min-width: 360px;
|
||||
height: 80vh;
|
||||
max-height: calc(100vh - 2 * var(--overlay-padding-top, 10vh));
|
||||
min-height: 320px;
|
||||
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
.file-browser-modal-header {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import "./FileBrowser.css";
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { X, Save, RotateCcw, Folder, FileType, ArrowLeft } from "lucide-react";
|
||||
import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser";
|
||||
import { useWorkspaceFileEditor } from "../hooks/useWorkspaceFileEditor";
|
||||
import { useWorkspaces } from "../hooks/useWorkspaces";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { downloadFileUrl } from "../api";
|
||||
import { FileBrowser } from "./FileBrowser";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
@@ -60,6 +61,8 @@ export function FileBrowserModal({
|
||||
projectId,
|
||||
}: FileBrowserModalProps) {
|
||||
const { projectName, workspaces } = useWorkspaces(projectId);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, true, "fusion:files-modal-size");
|
||||
const [currentWorkspace, setCurrentWorkspace] = useState(initialWorkspace);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
@@ -169,7 +172,7 @@ export function FileBrowserModal({
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={onClose} role="dialog" aria-modal="true">
|
||||
<div className="modal file-browser-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal file-browser-modal" ref={modalRef} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header file-browser-modal-header">
|
||||
<div className="file-browser-header-title">
|
||||
<Folder size={18} />
|
||||
|
||||
@@ -174,9 +174,14 @@
|
||||
|
||||
/* Wider modal for two-pane layout */
|
||||
.modal.github-import-modal {
|
||||
width: 90vw;
|
||||
max-width: 1600px;
|
||||
max-height: min(900px, calc(100vh - 64px));
|
||||
width: min(90vw, 1200px);
|
||||
max-width: 95vw;
|
||||
min-width: 480px;
|
||||
height: 80vh;
|
||||
min-height: 480px;
|
||||
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
.github-import-modal__header {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type GitRemote,
|
||||
} from "../api";
|
||||
import { Loader2, RefreshCw, ArrowLeft, GitPullRequest, CircleDot } from "lucide-react";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
|
||||
interface GitHubImportModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -52,6 +53,8 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
const [loadingRemotes, setLoadingRemotes] = useState(false);
|
||||
const [selectedRemoteName, setSelectedRemoteName] = useState<string>("");
|
||||
const mountedRef = useRef(false);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, isOpen, "fusion:github-modal-size");
|
||||
|
||||
// Mobile view state
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
@@ -350,7 +353,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && onClose()} role="dialog" aria-modal="true">
|
||||
<div className="modal modal-lg github-import-modal">
|
||||
<div className="modal modal-lg github-import-modal" ref={modalRef}>
|
||||
<div className="modal-header github-import-modal__header">
|
||||
<div>
|
||||
<h3>Import from GitHub</h3>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Task } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import type {
|
||||
GitStatus,
|
||||
GitCommit,
|
||||
@@ -177,6 +178,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sectionError, setSectionError] = useState<string | null>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, isOpen, "fusion:git-modal-size");
|
||||
const copyToClipboard = useCopyToClipboard(addToast);
|
||||
|
||||
// ── Status state
|
||||
|
||||
@@ -497,6 +497,10 @@
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-tabs {
|
||||
padding-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type AiSessionSummary,
|
||||
} from "../api";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import {
|
||||
savePlanningDescription,
|
||||
getPlanningDescription,
|
||||
@@ -148,62 +149,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const overlayMouseDownOnSelfRef = useRef(false);
|
||||
const thinkingOutputRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Persist user-chosen modal size across opens. The CSS `resize: both` lets
|
||||
// the user drag the bottom-right corner; we capture the resulting inline
|
||||
// width/height via ResizeObserver and replay them on the next mount. Stored
|
||||
// in localStorage as raw pixels — the CSS max-* / min-* still clamp at
|
||||
// render time, so saving an out-of-range value on one viewport won't break
|
||||
// the modal on a smaller one.
|
||||
const SIZE_STORAGE_KEY = "fusion:planning-modal-size";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const node = modalRef.current;
|
||||
if (!node) return;
|
||||
|
||||
// Apply the persisted size on open.
|
||||
try {
|
||||
const raw = localStorage.getItem(SIZE_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const { width, height } = JSON.parse(raw) as { width?: number; height?: number };
|
||||
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 — gracefully skip
|
||||
// persistence rather than throw. Restoration above still runs.
|
||||
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(SIZE_STORAGE_KEY, JSON.stringify({ width: w, height: h }));
|
||||
} catch {
|
||||
// quota / private mode — best-effort
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
|
||||
observer.observe(node);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
};
|
||||
}, [isOpen]);
|
||||
useModalResizePersist(modalRef, isOpen, "fusion:planning-modal-size");
|
||||
|
||||
// Keep the streaming AI thinking pane pinned to the bottom as new tokens
|
||||
// arrive. If the user has scrolled up to read earlier output, we leave the
|
||||
@@ -586,10 +532,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
if (resumeSessionId && resumeSessionId === selectedSessionId) return; // resume effect handles this case
|
||||
if (streamConnectionRef.current?.isConnected()) return;
|
||||
void loadSession(selectedSessionId);
|
||||
// We intentionally do not depend on selectedSessionId here — handleSelectSession
|
||||
// already drives loadSession when the user picks a different row. This effect
|
||||
// only needs to fire on the open transition.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// We intentionally do not depend on selectedSessionId or loadSession here:
|
||||
// handleSelectSession already drives loadSession when the user picks a
|
||||
// different row, and this effect only needs to fire on the open
|
||||
// transition. Listing them here would cause it to re-run mid-session.
|
||||
}, [isOpen]);
|
||||
|
||||
// Load + maintain the planning sessions list (sidebar).
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
padding-inline: var(--space-xl);
|
||||
padding-block: var(--space-md);
|
||||
}
|
||||
|
||||
.plugin-manager-header {
|
||||
@@ -13,6 +15,14 @@
|
||||
justify-content: space-between;
|
||||
padding-bottom: var(--space-sm);
|
||||
border-bottom: var(--btn-border-width) solid var(--border);
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.plugin-manager-header-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.plugin-manager-actions {
|
||||
|
||||
@@ -498,6 +498,11 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
|
||||
const installedPluginIds = new Set(plugins.map((plugin) => plugin.id));
|
||||
|
||||
// Bundled runtime plugin IDs — these are shown in the dedicated section below,
|
||||
// so we exclude them from the main plugin list to avoid duplication.
|
||||
const bundledPluginIds = new Set(BUNDLED_RUNTIME_PLUGINS.map((p) => p.id));
|
||||
const userInstalledPlugins = plugins.filter((p) => !bundledPluginIds.has(p.id));
|
||||
|
||||
const renderBundledRuntimeSection = () => (
|
||||
<section className="plugin-bundled-runtime-section" aria-label="Bundled Runtime Plugins">
|
||||
<div className="plugin-bundled-runtime-header">
|
||||
@@ -544,11 +549,13 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
return (
|
||||
<div className="plugin-manager" data-testid="plugin-manager">
|
||||
<div className="plugin-manager-header">
|
||||
<span className="plugin-manager-header-title">Installed Plugins</span>
|
||||
<div className="plugin-manager-actions">
|
||||
<button className="btn-icon" onClick={loadPlugins} title="Refresh">
|
||||
<RefreshCw size={16} className={loading ? "spin" : ""} />
|
||||
<button className="btn btn-sm btn-ghost" onClick={loadPlugins} title="Refresh" aria-label="Refresh plugin list">
|
||||
<RefreshCw size={14} className={loading ? "spin" : ""} />
|
||||
Refresh
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => setShowInstall(true)}>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => setShowInstall(true)}>
|
||||
<Plus size={14} /> Install
|
||||
</button>
|
||||
</div>
|
||||
@@ -585,7 +592,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
<div className="settings-empty-state">Loading plugins...</div>
|
||||
) : (
|
||||
<>
|
||||
{plugins.length === 0 ? (
|
||||
{userInstalledPlugins.length === 0 ? (
|
||||
<div className="settings-empty-state">
|
||||
<Package size={32} className="text-muted" />
|
||||
<p>No plugins installed.</p>
|
||||
@@ -593,7 +600,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="plugin-list">
|
||||
{plugins.map((plugin) => (
|
||||
{userInstalledPlugins.map((plugin) => (
|
||||
<div key={plugin.id} className="plugin-item">
|
||||
<div className="plugin-info">
|
||||
<span className="plugin-name">{plugin.name}</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ScheduledTasksModal renders schedule/routine cards using .scheduling-*, .routine-*,
|
||||
// .schedule-form classes that live in ScriptsModal.css. Both modals share that file.
|
||||
import "./ScriptsModal.css";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { Plus, Zap, Globe, Folder, X } from "lucide-react";
|
||||
import type { Routine, RoutineCreateInput } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { RoutineCard } from "./RoutineCard";
|
||||
import { RoutineEditor } from "./RoutineEditor";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
|
||||
/** Polling interval for auto-refreshing the schedule/routine list (30 seconds). */
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
@@ -40,6 +41,9 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
|
||||
const [lastRunOutput, setLastRunOutput] = useState<Record<string, { output: string; error?: string; success: boolean }>>({});
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, true, "fusion:automation-modal-size");
|
||||
|
||||
// Build scope options for API calls
|
||||
const scopeOptions = useMemo(() => ({
|
||||
scope: activeScope,
|
||||
@@ -286,7 +290,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
routineView === "list" && routines.length > 0;
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick}>
|
||||
<div className="modal modal-lg" role="dialog" aria-modal="true" aria-labelledby="schedules-modal-title">
|
||||
<div ref={modalRef} className="modal modal-lg automation-modal" role="dialog" aria-modal="true" aria-labelledby="schedules-modal-title">
|
||||
<div className="modal-header">
|
||||
<div className="detail-title-row">
|
||||
<Zap size={20} className="icon-triage" />
|
||||
|
||||
@@ -90,10 +90,23 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* === Automation (ScheduledTasksModal) === */
|
||||
.modal.automation-modal {
|
||||
width: min(95vw, 720px);
|
||||
max-width: 95vw;
|
||||
min-width: 480px;
|
||||
height: 80vh;
|
||||
min-height: 480px;
|
||||
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
.schedule-modal-content {
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
overflow-y: auto;
|
||||
max-height: 70vh;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Scheduling scope selector */
|
||||
@@ -1708,12 +1721,16 @@
|
||||
|
||||
/* Modal size override - flex layout for sidebar + content */
|
||||
.modal.gm-modal {
|
||||
width: 1400px;
|
||||
width: min(95vw, 1400px);
|
||||
max-width: 95vw;
|
||||
max-height: 85vh;
|
||||
min-width: 480px;
|
||||
height: 92vh;
|
||||
min-height: 480px;
|
||||
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
/* Main layout: sidebar + content */
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
Extracted from styles.css as part of the Sweep-3 CSS extraction effort.
|
||||
Imported by SettingsModal.tsx (and MemoryView.tsx for shared classes). */
|
||||
|
||||
/* === Settings Modal: sizing + resizability === */
|
||||
.settings-modal {
|
||||
width: min(95vw, 1100px);
|
||||
max-width: 95vw;
|
||||
min-width: 520px;
|
||||
height: 80vh;
|
||||
min-height: 480px;
|
||||
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
/* === Settings Layout === */
|
||||
.settings-modal-heading {
|
||||
display: flex;
|
||||
@@ -582,6 +594,12 @@
|
||||
}
|
||||
|
||||
/* === Auth Provider Cards === */
|
||||
|
||||
/* Horizontal gutter wrapper for the auth section body, matching form-group padding */
|
||||
.auth-panel-body {
|
||||
padding-inline: var(--space-xl);
|
||||
}
|
||||
|
||||
.auth-section-hint {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 12px;
|
||||
@@ -733,7 +751,8 @@
|
||||
.settings-description {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-md);
|
||||
padding-inline: var(--space-xl);
|
||||
margin-block: 0 var(--space-md);
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* === Settings: Model Presets === */
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
import { FileBrowser } from "./FileBrowser";
|
||||
import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
const PluginManager = lazy(() => import("./PluginManager").then((m) => ({ default: m.PluginManager })));
|
||||
const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager })));
|
||||
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
||||
@@ -66,11 +67,12 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
{ id: "notifications", label: "Notifications", scope: "global" },
|
||||
{ id: "node-sync", label: "Node Sync", scope: "global" },
|
||||
{ id: "global-models", label: "Models", scope: "global" },
|
||||
{ id: "updates", label: "Updates", scope: "global" },
|
||||
|
||||
// Project group (specific to this project)
|
||||
{ id: "__project_header", label: "Project", scope: undefined, isGroupHeader: true },
|
||||
{ id: "project-models", label: "Project Models", scope: "project" },
|
||||
{ id: "general", label: "General", scope: "project" },
|
||||
{ id: "project-models", label: "Project Models", scope: "project" },
|
||||
{ id: "scheduling", label: "Scheduling", scope: "project" },
|
||||
{ id: "worktrees", label: "Worktrees", scope: "project" },
|
||||
{ id: "commands", label: "Commands", scope: "project" },
|
||||
@@ -185,6 +187,8 @@ export function SettingsModal({
|
||||
onReopenOnboarding,
|
||||
}: SettingsModalProps) {
|
||||
const { confirm } = useConfirm();
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, true, "fusion:settings-modal-size");
|
||||
const [form, setForm] = useState<SettingsFormState>({
|
||||
maxConcurrent: 2,
|
||||
maxTriageConcurrent: 2,
|
||||
@@ -1587,6 +1591,72 @@ export function SettingsModal({
|
||||
);
|
||||
}
|
||||
|
||||
case "updates": {
|
||||
return (
|
||||
<>
|
||||
{renderScopeBanner()}
|
||||
<h4 className="settings-section-heading">Updates</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="updateCheckEnabled" className="checkbox-label">
|
||||
<input
|
||||
id="updateCheckEnabled"
|
||||
type="checkbox"
|
||||
checked={form.updateCheckEnabled !== false}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, updateCheckEnabled: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Check for updates automatically
|
||||
</label>
|
||||
<small>
|
||||
When enabled, Fusion checks npm daily for new versions of{" "}
|
||||
<code>@runfusion/fusion</code> and shows update notices in the CLI and dashboard.
|
||||
</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Check Now</label>
|
||||
<div className="settings-update-check">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm settings-update-btn"
|
||||
onClick={() => {
|
||||
void handleCheckForUpdates();
|
||||
}}
|
||||
disabled={updateCheckLoading}
|
||||
>
|
||||
<RefreshCw className={updateCheckLoading ? "spinning" : undefined} size={14} />
|
||||
{updateCheckLoading ? "Checking…" : "Check for updates"}
|
||||
</button>
|
||||
{updateCheckResult && (
|
||||
<span
|
||||
aria-live="polite"
|
||||
className={`settings-update-result ${
|
||||
updateCheckResult.error
|
||||
? "settings-update-result--error"
|
||||
: updateCheckResult.updateAvailable
|
||||
? "settings-update-result--available"
|
||||
: "settings-update-result--up-to-date"
|
||||
}`}
|
||||
>
|
||||
{updateCheckResult.error
|
||||
? updateCheckResult.error
|
||||
: updateCheckResult.updateAvailable && updateCheckResult.latestVersion
|
||||
? `v${updateCheckResult.latestVersion} available`
|
||||
: "You're up to date ✓"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<small>Manually check for the latest version right now.</small>
|
||||
</div>
|
||||
<p className="settings-note">
|
||||
Update frequency control (on-startup / daily / weekly) is not yet configurable here
|
||||
— it requires a backend schema addition to <code>GlobalSettings</code>. The toggle
|
||||
above enables or disables the daily automatic check entirely.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
case "project-models": {
|
||||
const presets = form.modelPresets || [];
|
||||
const presetOptions = presets.map((preset) => ({ id: preset.id, name: preset.name }));
|
||||
@@ -3867,7 +3937,7 @@ export function SettingsModal({
|
||||
No providers available
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="auth-panel-body">
|
||||
{cliAuthProviders.some((p) => p.id === "claude-cli") && (
|
||||
<ClaudeCliProviderCard
|
||||
compact
|
||||
@@ -4044,7 +4114,7 @@ export function SettingsModal({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
<small className="auth-hint">
|
||||
Authentication changes take effect immediately — no need to save.
|
||||
@@ -4072,7 +4142,7 @@ export function SettingsModal({
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick} role="dialog" aria-modal="true">
|
||||
<div className="modal modal-lg">
|
||||
<div className="modal modal-lg settings-modal" ref={modalRef}>
|
||||
<div className="modal-header">
|
||||
<div className="settings-modal-heading">
|
||||
<h3>Settings</h3>
|
||||
|
||||
@@ -244,7 +244,7 @@ export function SystemStatsModal({ isOpen, onClose, projectId }: SystemStatsModa
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon"
|
||||
className="btn-icon"
|
||||
onClick={() => void loadStats()}
|
||||
title="Refresh"
|
||||
aria-label="Refresh system stats"
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: var(--space-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -11,21 +10,28 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-lg);
|
||||
align-items: center;
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
gap: var(--space-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.todo-view-header h2 {
|
||||
font-size: calc(var(--space-lg) + (var(--space-xs) / 2));
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.todo-view-description {
|
||||
color: var(--text-muted);
|
||||
font-size: calc(var(--space-md) + (var(--space-xs) / 4));
|
||||
margin: var(--space-xs) 0 0 0;
|
||||
font-size: 0.8125rem;
|
||||
margin: 2px 0 0 0;
|
||||
}
|
||||
|
||||
.todo-view-layout {
|
||||
@@ -34,6 +40,7 @@
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.todo-view-sidebar {
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
/* === Workflow Step Manager Modal === */
|
||||
.modal.workflow-step-manager-modal {
|
||||
width: min(900px, calc(100vw - 32px));
|
||||
width: min(900px, 95vw);
|
||||
max-width: 95vw;
|
||||
min-width: 480px;
|
||||
height: 80vh;
|
||||
min-height: 480px;
|
||||
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
.wfm-body {
|
||||
padding: var(--space-md);
|
||||
max-height: 70vh;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./WorkflowStepManager.css";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { WorkflowStep, WorkflowStepInput, WorkflowStepMode, WorkflowStepPhase } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type ModelInfo,
|
||||
} from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import {
|
||||
X,
|
||||
Plus,
|
||||
@@ -135,6 +136,9 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
const [availableScripts, setAvailableScripts] = useState<Record<string, string>>({});
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, isOpen, "fusion:workflow-steps-modal-size");
|
||||
|
||||
const loadSteps = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -373,6 +377,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={onClose} data-testid="workflow-step-manager">
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="modal workflow-step-manager-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
|
||||
@@ -542,10 +542,12 @@ describe("FileBrowserModal", () => {
|
||||
const maxHeightValue = blockMatch![1].trim();
|
||||
|
||||
// The max-height must use calc() with the overlay-padding-top variable
|
||||
// so the modal fits within the visible viewport (100vh minus top+bottom padding)
|
||||
// so the modal fits within the visible viewport. We accept either
|
||||
// 100vh or 100dvh (the latter accounts for mobile dynamic viewport
|
||||
// chrome and is preferred for the resize-aware modals).
|
||||
expect(maxHeightValue).toContain("calc(");
|
||||
expect(maxHeightValue).toContain("--overlay-padding-top");
|
||||
expect(maxHeightValue).toContain("100vh");
|
||||
expect(maxHeightValue).toMatch(/100d?vh/);
|
||||
});
|
||||
|
||||
it("height and max-height together do not exceed viewport on desktop", async () => {
|
||||
|
||||
@@ -480,6 +480,74 @@ describe("useBackgroundSessions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not resurrect a session after ai_session:deleted when a stale sync update arrives", async () => {
|
||||
// Regression: handleDeleted cleared sessionTimestampsRef without setting a
|
||||
// dismissed tombstone or calling broadcastCompleted. The merge effect then
|
||||
// saw knownTimestamp=0 for the deleted session, accepted the still-live
|
||||
// syncedSessions entry (non-terminal status), and re-added it — causing the
|
||||
// stale badge count even when the DB was empty.
|
||||
mockFetchAiSessions.mockResolvedValueOnce([
|
||||
makeSession({ id: "delete-resurface", status: "generating", type: "planning" }),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useBackgroundSessions());
|
||||
const { result: syncResult } = renderHook(() => useAiSessionSync());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions.map((session) => session.id)).toEqual(["delete-resurface"]);
|
||||
});
|
||||
|
||||
// Simulate a sibling tab broadcasting the session as active in the sync store.
|
||||
act(() => {
|
||||
syncResult.current.broadcastUpdate({
|
||||
sessionId: "delete-resurface",
|
||||
status: "generating",
|
||||
needsInput: false,
|
||||
type: "planning",
|
||||
title: "Deleted Resurface",
|
||||
updatedAt: "2026-04-08T00:00:01.000Z",
|
||||
timestamp: Date.parse("2026-04-08T00:00:01.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
// Session is in sync store with non-terminal status at this point.
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions.map((session) => session.id)).toEqual(["delete-resurface"]);
|
||||
});
|
||||
|
||||
// Server fires ai_session:deleted — the session is gone from the DB.
|
||||
const eventSource = MockEventSource.instances[0]!;
|
||||
act(() => {
|
||||
eventSource._emit("ai_session:deleted", "delete-resurface");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toEqual([]);
|
||||
expect(result.current.planningSessions).toEqual([]);
|
||||
});
|
||||
|
||||
// Now a stale sync update arrives (same timestamp as before, non-terminal).
|
||||
// Without the fix this re-materialized the session in the UI.
|
||||
act(() => {
|
||||
syncResult.current.broadcastUpdate({
|
||||
sessionId: "delete-resurface",
|
||||
status: "generating",
|
||||
needsInput: false,
|
||||
type: "planning",
|
||||
title: "Deleted Resurface",
|
||||
updatedAt: "2026-04-08T00:00:01.000Z",
|
||||
timestamp: Date.parse("2026-04-08T00:00:01.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toEqual([]);
|
||||
expect(result.current.planningSessions).toEqual([]);
|
||||
expect(result.current.generating).toBe(0);
|
||||
expect(result.current.needsInput).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("dismissSession calls cancelSubtaskBreakdown for subtask sessions", async () => {
|
||||
mockFetchAiSessions.mockResolvedValueOnce([
|
||||
makeSession({ id: "subtask-session", status: "generating", type: "subtask" }),
|
||||
|
||||
@@ -250,9 +250,24 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions
|
||||
const handleDeleted = (e: MessageEvent) => {
|
||||
try {
|
||||
const id = JSON.parse(e.data) as string;
|
||||
const deletionTimestamp = Date.now();
|
||||
|
||||
// Record a tombstone so the merge effect cannot re-materialize this
|
||||
// session from syncedSessions after deletion. Without this, the sync
|
||||
// store still holds the session with its last non-terminal status and
|
||||
// the merge effect (which reads knownTimestamp=0 after the delete below)
|
||||
// would re-add the session on the next render, causing the stale badge.
|
||||
dismissedSessionTimestampsRef.current.set(id, deletionTimestamp);
|
||||
sessionTimestampsRef.current.set(
|
||||
id,
|
||||
Math.max(sessionTimestampsRef.current.get(id) ?? 0, deletionTimestamp),
|
||||
);
|
||||
|
||||
// Mark the session as terminal in the cross-tab sync store so that
|
||||
// other consumers and sibling tabs also see it as completed.
|
||||
broadcastCompleted({ sessionId: id, status: "complete", timestamp: deletionTimestamp });
|
||||
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
sessionTimestampsRef.current.delete(id);
|
||||
dismissedSessionTimestampsRef.current.delete(id);
|
||||
} catch {
|
||||
// ignore malformed payload
|
||||
}
|
||||
|
||||
78
packages/dashboard/app/hooks/useModalResizePersist.ts
Normal file
78
packages/dashboard/app/hooks/useModalResizePersist.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
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]);
|
||||
}
|
||||
@@ -7,6 +7,44 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* === Global Scrollbar Theme ===
|
||||
Low-specificity wildcard rules so all scrollable surfaces pick up the
|
||||
dark theme by default. Per-component overrides (e.g. .board, .column-body,
|
||||
.settings-sidebar, .settings-content, .planning-modal *) remain unaffected
|
||||
because their class-level selectors have higher specificity than * . */
|
||||
* {
|
||||
scrollbar-color: var(--border) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Firefox app-wide scrollbar via html element */
|
||||
html {
|
||||
scrollbar-color: var(--border) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* === Utility Classes === */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user