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:
gsxdsm
2026-04-27 17:26:07 -07:00
parent 0923bffc1b
commit 8a88e23b9c
24 changed files with 489 additions and 154 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -497,6 +497,10 @@
background: var(--surface);
}
.mailbox-view .mailbox-tabs {
padding-top: var(--space-sm);
}
.mailbox-view .mailbox-content {
flex: 1;
overflow: hidden;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 === */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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]);
}

View File

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