feat(dashboard,cli): update-check frequency, GitHub star button, more resizable modals

Update-check frequency (end-to-end)
- Add `updateCheckFrequency: "manual" | "on-startup" | "daily" | "weekly"`
  to GlobalSettings (default: "daily").
- Backend `performUpdateCheck` honors the frequency: TTL = day/week,
  `manual` returns cache or empty without hitting npm, `on-startup`
  refreshes once per process lifetime then serves cache. `/refresh`
  route forces network regardless.
- Settings → Updates surfaces a working `<select>` for the cadence,
  disabled when auto-checks are off entirely.
- Tests: 4 new cases covering ttlForFrequency mapping, weekly window,
  manual semantics, on-startup once-per-process behavior.

TUI update notice
- Read cached update result synchronously on TUI startup. When an
  update is available, render a yellow notice line on the splash and
  a colored ● next to the version in the status bar.

Settings modal header polish
- Add "Star on GitHub" pill (icon + Star + cached star count from the
  GitHub API, 1h localStorage TTL) and "Help" button (opens project
  Discussions). Both link to the Runfusion/Fusion repo.
- Star button auto-hides after the user clicks it (intent = star),
  tracked in localStorage `fusion:github-star-clicked`.
- Settings → General gets a "Show Star on GitHub button" checkbox so
  users can hide it preemptively. New global setting
  `showGitHubStarButton: boolean` (default true) gates rendering.

More resizable modals
- Task Detail modal: 85vh default, resize: both, persisted via
  useModalResizePersist (key `fusion:task-detail-modal-size`).
- Quick Chat FAB: full 8-direction resize (4 corners + 4 edges) via
  pointer-event handlers; persisted to
  `fusion:quick-chat-size-<projectId>`. Each handle has the right
  cursor + role="separator" for accessibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-27 17:55:15 -07:00
parent c1e4c8df85
commit 61ea16e31f
16 changed files with 752 additions and 98 deletions

View File

@@ -66,32 +66,97 @@
position: absolute;
z-index: 2;
background: transparent;
/* Faint accent line appears on hover to indicate resizability */
transition: background var(--transition-fast);
}
/* Edge handles */
.quick-chat-resize-handle[data-resize-direction="n"] {
cursor: ns-resize;
cursor: n-resize;
top: 0;
left: 6px;
right: 6px;
left: 10px;
right: 10px;
height: 6px;
}
.quick-chat-resize-handle[data-resize-direction="w"] {
cursor: ew-resize;
top: 6px;
left: 0;
bottom: 6px;
.quick-chat-resize-handle[data-resize-direction="s"] {
cursor: s-resize;
bottom: 0;
left: 10px;
right: 10px;
height: 6px;
}
.quick-chat-resize-handle[data-resize-direction="e"] {
cursor: e-resize;
top: 10px;
right: 0;
bottom: 10px;
width: 6px;
}
.quick-chat-resize-handle[data-resize-direction="w"] {
cursor: w-resize;
top: 10px;
left: 0;
bottom: 10px;
width: 6px;
}
/* Corner handles */
.quick-chat-resize-handle[data-resize-direction="nw"] {
cursor: nwse-resize;
cursor: nw-resize;
top: 0;
left: 0;
width: 10px;
height: 10px;
}
.quick-chat-resize-handle[data-resize-direction="ne"] {
cursor: ne-resize;
top: 0;
right: 0;
width: 10px;
height: 10px;
}
.quick-chat-resize-handle[data-resize-direction="sw"] {
cursor: sw-resize;
bottom: 0;
left: 0;
width: 10px;
height: 10px;
}
.quick-chat-resize-handle[data-resize-direction="se"] {
cursor: se-resize;
bottom: 0;
right: 0;
width: 10px;
height: 10px;
}
/* Subtle hover accent on edge handles */
.quick-chat-resize-handle[data-resize-direction="n"]:hover,
.quick-chat-resize-handle[data-resize-direction="s"]:hover {
background: linear-gradient(
to bottom,
transparent 30%,
color-mix(in srgb, var(--border) 60%, transparent) 50%,
transparent 70%
);
}
.quick-chat-resize-handle[data-resize-direction="e"]:hover,
.quick-chat-resize-handle[data-resize-direction="w"]:hover {
background: linear-gradient(
to right,
transparent 30%,
color-mix(in srgb, var(--border) 60%, transparent) 50%,
transparent 70%
);
}
.quick-chat-panel-header {
display: flex;
align-items: center;

View File

@@ -252,7 +252,13 @@ interface PanelSize {
height: number;
}
type ResizeDirection = "n" | "w" | "nw";
type ResizeDirection = "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se";
/** Offset of the panel anchor relative to the FAB position (right/bottom deltas in px). */
interface PanelAnchorOffset {
right: number;
bottom: number;
}
const QUICK_CHAT_DEFAULT_PANEL_SIZE: PanelSize = {
width: 320,
@@ -438,27 +444,28 @@ function useDraggable(projectId?: string, externalDidDragRef?: React.MutableRefO
};
}
function usePanelResize(projectId: string | undefined, panelRight: number, panelBottom: number) {
const storageKey = `fusion-quick-chat-size-${projectId || "default"}`;
function usePanelResize(projectId: string | undefined, fabRight: number, fabBottom: number) {
const storageKey = `fusion:quick-chat-size-${projectId || "default"}`;
const isDesktopViewport = useCallback(
() => typeof window !== "undefined" && window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT,
[],
);
/** Clamp width/height given the effective anchor point (right/bottom offsets from viewport edges). */
const clampPanelSize = useCallback(
(size: PanelSize): PanelSize => {
(size: PanelSize, anchorRight: number, anchorBottom: number): PanelSize => {
if (typeof window === "undefined") {
return size;
}
const maxWidth = Math.max(
QUICK_CHAT_MIN_PANEL_SIZE.width,
window.innerWidth - panelRight - QUICK_CHAT_VIEWPORT_PADDING,
window.innerWidth - anchorRight - QUICK_CHAT_VIEWPORT_PADDING,
);
const maxHeight = Math.max(
QUICK_CHAT_MIN_PANEL_SIZE.height,
window.innerHeight - panelBottom - QUICK_CHAT_VIEWPORT_PADDING,
window.innerHeight - anchorBottom - QUICK_CHAT_VIEWPORT_PADDING,
);
return {
@@ -466,121 +473,171 @@ function usePanelResize(projectId: string | undefined, panelRight: number, panel
height: Math.max(QUICK_CHAT_MIN_PANEL_SIZE.height, Math.min(maxHeight, size.height)),
};
},
[panelBottom, panelRight],
[],
);
const [panelSize, setPanelSize] = useState<PanelSize>(() => {
const loadPersistedSize = useCallback((): 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>;
const raw = localStorage.getItem(storageKey);
if (!raw) return QUICK_CHAT_DEFAULT_PANEL_SIZE;
const parsed = JSON.parse(raw) 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,
};
return { width: parsed.width, height: parsed.height };
} catch {
return QUICK_CHAT_DEFAULT_PANEL_SIZE;
}
});
}, [storageKey]);
const [panelSize, setPanelSize] = useState<PanelSize>(loadPersistedSize);
/**
* Anchor offset relative to the FAB position.
* When the user drags the south or east handle, we shift the anchor so the
* panel top/left edge moves while the opposite edge stays fixed.
*/
const [anchorOffset, setAnchorOffset] = useState<PanelAnchorOffset>({ right: 0, bottom: 0 });
useEffect(() => {
if (!isDesktopViewport()) {
return;
}
setPanelSize((current) => clampPanelSize(current));
}, [clampPanelSize, isDesktopViewport]);
if (!isDesktopViewport()) return;
const effective = { right: fabRight + anchorOffset.right, bottom: fabBottom + anchorOffset.bottom };
setPanelSize((current) => clampPanelSize(current, effective.right, effective.bottom));
}, [clampPanelSize, isDesktopViewport, fabRight, fabBottom, anchorOffset]);
useEffect(() => {
if (!isDesktopViewport()) {
return;
}
if (!isDesktopViewport()) return;
try {
localStorage.setItem(storageKey, JSON.stringify(panelSize));
} catch {
// Ignore storage errors
// Ignore storage errors (private mode / quota)
}
}, [isDesktopViewport, panelSize, storageKey]);
const handleResizeStart = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!isDesktopViewport()) {
return;
}
if (!isDesktopViewport()) return;
const direction = event.currentTarget.dataset.resizeDirection as ResizeDirection | undefined;
if (!direction) {
return;
}
if (!direction) return;
event.preventDefault();
event.stopPropagation();
const resizeHandle = event.currentTarget;
if (typeof resizeHandle.setPointerCapture === "function") {
resizeHandle.setPointerCapture(event.pointerId);
}
const resizeStart = {
const startState = {
pointerX: event.clientX,
pointerY: event.clientY,
width: panelSize.width,
height: panelSize.height,
anchorRight: anchorOffset.right,
anchorBottom: anchorOffset.bottom,
};
document.body.style.userSelect = "none";
const handlePointerMove = (moveEvent: PointerEvent) => {
let nextWidth = resizeStart.width;
let nextHeight = resizeStart.height;
const onPointerMove = (moveEvent: PointerEvent) => {
const dx = moveEvent.clientX - startState.pointerX;
const dy = moveEvent.clientY - startState.pointerY;
let nextWidth = startState.width;
let nextHeight = startState.height;
let nextAnchorRight = startState.anchorRight;
let nextAnchorBottom = startState.anchorBottom;
// West handle: dragging left grows width (panel expands left).
if (direction.includes("w")) {
nextWidth = resizeStart.width + (resizeStart.pointerX - moveEvent.clientX);
nextWidth = startState.width - dx;
}
// East handle: dragging right grows width (panel expands right).
// The right anchor must shift leftward (decrease) to keep left edge fixed.
if (direction.includes("e")) {
const widthDelta = dx;
nextWidth = startState.width + widthDelta;
nextAnchorRight = startState.anchorRight - widthDelta;
}
// North handle: dragging up grows height (panel expands upward).
if (direction.includes("n")) {
nextHeight = resizeStart.height + (resizeStart.pointerY - moveEvent.clientY);
nextHeight = startState.height - dy;
}
setPanelSize(
clampPanelSize({
width: nextWidth,
height: nextHeight,
}),
// South handle: dragging down grows height (panel expands downward).
// The bottom anchor must shift upward (decrease) to keep the top edge fixed.
if (direction.includes("s")) {
const heightDelta = dy;
nextHeight = startState.height + heightDelta;
nextAnchorBottom = startState.anchorBottom - heightDelta;
}
// Clamp size against effective anchor position.
const effectiveRight = fabRight + nextAnchorRight;
const effectiveBottom = fabBottom + nextAnchorBottom;
const clamped = clampPanelSize({ width: nextWidth, height: nextHeight }, effectiveRight, effectiveBottom);
// Also clamp the anchor offsets so the panel doesn't go off-screen.
const clampedAnchorRight = Math.max(
QUICK_CHAT_VIEWPORT_PADDING - fabRight,
Math.min(
window.innerWidth - fabRight - QUICK_CHAT_MIN_PANEL_SIZE.width - QUICK_CHAT_VIEWPORT_PADDING,
nextAnchorRight,
),
);
const clampedAnchorBottom = Math.max(
QUICK_CHAT_VIEWPORT_PADDING - fabBottom,
Math.min(
window.innerHeight - fabBottom - QUICK_CHAT_MIN_PANEL_SIZE.height - QUICK_CHAT_VIEWPORT_PADDING,
nextAnchorBottom,
),
);
setPanelSize(clamped);
setAnchorOffset({ right: clampedAnchorRight, bottom: clampedAnchorBottom });
};
const handlePointerUp = (upEvent: PointerEvent) => {
const onPointerUp = (upEvent: PointerEvent) => {
if (typeof resizeHandle.releasePointerCapture === "function") {
resizeHandle.releasePointerCapture(upEvent.pointerId);
}
document.body.style.userSelect = "";
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
// Persist final size.
try {
localStorage.setItem(storageKey, JSON.stringify({ width: panelSize.width, height: panelSize.height }));
} catch {
// Best-effort
}
};
document.addEventListener("pointermove", handlePointerMove);
document.addEventListener("pointerup", handlePointerUp);
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
},
[clampPanelSize, isDesktopViewport, panelSize.height, panelSize.width],
[
anchorOffset.bottom,
anchorOffset.right,
clampPanelSize,
fabBottom,
fabRight,
isDesktopViewport,
panelSize.height,
panelSize.width,
storageKey,
],
);
return {
panelSize,
anchorOffset,
handleResizeStart,
};
}
@@ -671,7 +728,7 @@ export function QuickChatFAB({
// 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 { panelSize, anchorOffset, handleResizeStart } = usePanelResize(projectId, position.x, panelY);
const shouldApplyDesktopPanelSize = typeof window !== "undefined" && window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT;
// Chat session hook
@@ -1214,31 +1271,83 @@ export function QuickChatFAB({
ref={panelRef}
data-testid="quick-chat-panel"
style={{
right: position.x,
bottom: panelY,
right: position.x + anchorOffset.right,
bottom: panelY + anchorOffset.bottom,
...(shouldApplyDesktopPanelSize ? { width: panelSize.width, height: panelSize.height } : {}),
...keyboardPanelStyle,
}}
>
{shouldApplyDesktopPanelSize && (
<>
{/* Edge handles */}
<div
className="quick-chat-resize-handle"
data-resize-direction="n"
data-testid="quick-chat-resize-n"
onPointerDown={handleResizeStart}
aria-hidden="true"
role="separator"
aria-orientation="horizontal"
aria-label="Resize panel from top"
/>
<div
className="quick-chat-resize-handle"
data-resize-direction="s"
data-testid="quick-chat-resize-s"
onPointerDown={handleResizeStart}
role="separator"
aria-orientation="horizontal"
aria-label="Resize panel from bottom"
/>
<div
className="quick-chat-resize-handle"
data-resize-direction="e"
data-testid="quick-chat-resize-e"
onPointerDown={handleResizeStart}
role="separator"
aria-orientation="vertical"
aria-label="Resize panel from right"
/>
<div
className="quick-chat-resize-handle"
data-resize-direction="w"
data-testid="quick-chat-resize-w"
onPointerDown={handleResizeStart}
aria-hidden="true"
role="separator"
aria-orientation="vertical"
aria-label="Resize panel from left"
/>
{/* Corner handles */}
<div
className="quick-chat-resize-handle"
data-resize-direction="nw"
data-testid="quick-chat-resize-nw"
onPointerDown={handleResizeStart}
aria-hidden="true"
role="separator"
aria-label="Resize panel from top-left corner"
/>
<div
className="quick-chat-resize-handle"
data-resize-direction="ne"
data-testid="quick-chat-resize-ne"
onPointerDown={handleResizeStart}
role="separator"
aria-label="Resize panel from top-right corner"
/>
<div
className="quick-chat-resize-handle"
data-resize-direction="sw"
data-testid="quick-chat-resize-sw"
onPointerDown={handleResizeStart}
role="separator"
aria-label="Resize panel from bottom-left corner"
/>
<div
className="quick-chat-resize-handle"
data-resize-direction="se"
data-testid="quick-chat-resize-se"
onPointerDown={handleResizeStart}
role="separator"
aria-label="Resize panel from bottom-right corner"
/>
</>
)}

View File

@@ -2,6 +2,60 @@
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 header action buttons (Star on GitHub, Help) === */
.settings-header-actions {
display: flex;
align-items: center;
gap: var(--space-xs);
margin-left: auto;
margin-right: var(--space-sm);
}
/* GitHub star button — split-pill layout: [Star half | Count half] */
.settings-github-star-btn {
display: inline-flex;
align-items: stretch;
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-pill);
background: var(--card);
color: var(--text);
font-size: 12px;
font-weight: 500;
text-decoration: none;
overflow: hidden;
transition:
border-color var(--transition-fast),
background var(--transition-fast);
}
.settings-github-star-btn:hover {
border-color: var(--text-muted);
background: var(--card-hover);
}
.settings-github-star-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
border-color: var(--todo);
}
.settings-github-star-btn__action {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
}
.settings-github-star-btn__count {
display: inline-flex;
align-items: center;
padding: 4px 9px;
border-left: var(--btn-border-width) solid var(--border);
background: color-mix(in srgb, var(--surface) 60%, var(--card));
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
/* === Settings Modal: sizing + resizability === */
.settings-modal {
width: min(95vw, 1100px);

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef, lazy, Suspense, type MouseEvent } from "react";
import { Globe, Folder, RefreshCw } from "lucide-react";
import { Globe, Folder, RefreshCw, Star, HelpCircle } from "lucide-react";
import { THINKING_LEVELS, isGlobalSettingsKey, isProjectSettingsKey, getErrorMessage } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, activateRemoteProvider, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
@@ -24,6 +24,97 @@ import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPr
import { appendTokenQuery } from "../auth";
import { useConfirm } from "../hooks/useConfirm";
// ---------------------------------------------------------------------------
// GitHub star count — fetched once per session, cached in localStorage (1 h).
// ---------------------------------------------------------------------------
const GITHUB_STAR_CACHE_KEY = "fusion_github_star_count";
const GITHUB_STAR_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
const GITHUB_STAR_CLICKED_KEY = "fusion:github-star-clicked";
/**
* Has the user already clicked the "Star on GitHub" button at any point in
* the past? Used to permanently hide the button afterward — clicking opens
* the repo where the actual star happens, so we treat that click as intent
* to star and stop nagging.
*/
function useStarClickedFlag(): [boolean, () => void] {
const [clicked, setClicked] = useState<boolean>(() => {
try {
return localStorage.getItem(GITHUB_STAR_CLICKED_KEY) === "true";
} catch {
return false;
}
});
const markClicked = useCallback(() => {
setClicked(true);
try {
localStorage.setItem(GITHUB_STAR_CLICKED_KEY, "true");
} catch {
// quota / private mode — best-effort
}
}, []);
return [clicked, markClicked];
}
interface StarCache {
count: number;
fetchedAt: number;
}
function useGitHubStarCount(): number | null {
const [count, setCount] = useState<number | null>(() => {
try {
const raw = localStorage.getItem(GITHUB_STAR_CACHE_KEY);
if (raw) {
const parsed: StarCache = JSON.parse(raw) as StarCache;
if (Date.now() - parsed.fetchedAt < GITHUB_STAR_CACHE_TTL_MS) {
return parsed.count;
}
}
} catch {
// ignore malformed cache
}
return null;
});
useEffect(() => {
// If we already have a fresh count from the initial state, skip the fetch.
try {
const raw = localStorage.getItem(GITHUB_STAR_CACHE_KEY);
if (raw) {
const parsed: StarCache = JSON.parse(raw) as StarCache;
if (Date.now() - parsed.fetchedAt < GITHUB_STAR_CACHE_TTL_MS) {
return;
}
}
} catch {
// ignore
}
fetch("https://api.github.com/repos/Runfusion/Fusion")
.then((res) => {
if (!res.ok) return;
return res.json() as Promise<{ stargazers_count?: number }>;
})
.then((data) => {
if (data && typeof data.stargazers_count === "number") {
const cache: StarCache = { count: data.stargazers_count, fetchedAt: Date.now() };
try {
localStorage.setItem(GITHUB_STAR_CACHE_KEY, JSON.stringify(cache));
} catch {
// quota exceeded — just skip
}
setCount(data.stargazers_count);
}
})
.catch(() => {
// Network failure — hide count gracefully, no update
});
}, []);
return count;
}
/**
* Settings sections configuration.
*
@@ -235,6 +326,8 @@ export function SettingsModal({
const [appVersion, setAppVersion] = useState<string | null>(null);
const [updateCheckLoading, setUpdateCheckLoading] = useState(false);
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResponse | null>(null);
const gitHubStarCount = useGitHubStarCount();
const [starClicked, markStarClicked] = useStarClickedFlag();
const [prefixError, setPrefixError] = useState<string | null>(null);
const [overlapPathPickerIndex, setOverlapPathPickerIndex] = useState<number | null>(null);
@@ -1410,6 +1503,23 @@ export function SettingsModal({
</label>
<small>Show the floating chat button in the dashboard. Chat is still accessible from the Chat tab in the mobile navigation.</small>
</div>
<div className="form-group">
<label htmlFor="showGitHubStarButton" className="checkbox-label">
<input
id="showGitHubStarButton"
type="checkbox"
checked={form.showGitHubStarButton !== false}
onChange={(e) =>
setForm((f) => ({ ...f, showGitHubStarButton: e.target.checked }))
}
/>
Show &quot;Star on GitHub&quot; button in Settings header
</label>
<small>
Once you click the Star button it&apos;s hidden automatically. Uncheck this to keep
it hidden even before clicking.
</small>
</div>
</>
);
case "global-models": {
@@ -1609,8 +1719,37 @@ export function SettingsModal({
Check for updates automatically
</label>
<small>
When enabled, Fusion checks npm daily for new versions of{" "}
When enabled, Fusion checks npm for new versions of{" "}
<code>@runfusion/fusion</code> and shows update notices in the CLI and dashboard.
Cadence is governed by the frequency below.
</small>
</div>
<div className="form-group">
<label htmlFor="updateCheckFrequency">Frequency</label>
<select
id="updateCheckFrequency"
value={form.updateCheckFrequency ?? "daily"}
onChange={(e) =>
setForm((f) => ({
...f,
updateCheckFrequency: e.target.value as
| "manual"
| "on-startup"
| "daily"
| "weekly",
}))
}
disabled={form.updateCheckEnabled === false}
>
<option value="manual">Manual only — never auto-check</option>
<option value="on-startup">On startup — once per server launch</option>
<option value="daily">Daily (recommended)</option>
<option value="weekly">Weekly</option>
</select>
<small>
Controls how often the dashboard re-fetches the npm registry. The
&quot;Check Now&quot; button below always triggers an immediate fetch
regardless of this setting.
</small>
</div>
<div className="form-group">
@@ -1648,11 +1787,6 @@ export function SettingsModal({
</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>
</>
);
}
@@ -4180,6 +4314,42 @@ export function SettingsModal({
)}
</div>
</div>
<div className="settings-header-actions">
{form.showGitHubStarButton !== false && !starClicked && (
<a
href="https://github.com/Runfusion/Fusion"
target="_blank"
rel="noopener noreferrer"
className="settings-github-star-btn"
aria-label="Star Fusion on GitHub"
title="Star Fusion on GitHub"
onClick={markStarClicked}
>
<span className="settings-github-star-btn__action">
<Star size={13} aria-hidden="true" />
Star
</span>
{gitHubStarCount !== null && (
<span className="settings-github-star-btn__count" aria-label={`${gitHubStarCount.toLocaleString()} stars`}>
{gitHubStarCount >= 1000
? `${(gitHubStarCount / 1000).toFixed(1)}k`
: gitHubStarCount.toLocaleString()}
</span>
)}
</a>
)}
<a
href="https://github.com/Runfusion/Fusion/discussions"
target="_blank"
rel="noopener noreferrer"
className="btn btn-sm"
aria-label="Help and discussions"
title="Help and discussions"
>
<HelpCircle size={13} aria-hidden="true" />
Help
</a>
</div>
<button className="modal-close" onClick={onClose} aria-label="Close">
&times;
</button>

View File

@@ -1,4 +1,15 @@
/* === Detail Modal === */
.modal.task-detail-modal {
width: min(95vw, 800px);
max-width: 95vw;
min-width: 480px;
height: 85vh;
min-height: 480px;
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
overflow: hidden;
resize: both;
}
.detail-title-row {
display: flex;
align-items: center;

View File

@@ -1,6 +1,7 @@
import "./TaskDetailModal.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Bot, X, ChevronDown } from "lucide-react";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue } from "@fusion/core";
@@ -371,6 +372,8 @@ export function TaskDetailModal({
const [showActionsMenu, setShowActionsMenu] = useState(false);
const moveMenuRef = useRef<HTMLDivElement>(null);
const actionsMenuRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
useModalResizePersist(modalRef, true, "fusion:task-detail-modal-size");
// Plugin UI slots for task-detail-tab
const { getSlotsForId: getPluginSlots } = usePluginUiSlots(projectId);
@@ -1277,7 +1280,7 @@ export function TaskDetailModal({
return (
<div className="modal-overlay open" onClick={handleOverlayClick} role="dialog" aria-modal="true">
<div className="modal modal-lg" onDragOver={handleDragOver} onDrop={handleDrop}>
<div className="modal modal-lg task-detail-modal" ref={modalRef} onDragOver={handleDragOver} onDrop={handleDrop}>
<div className="modal-header">
<div className="detail-title-row">
<span className="detail-id">{task.id}</span>

View File

@@ -1417,7 +1417,7 @@ describe("QuickChatFAB", () => {
});
expect(localStorageMock.setItem).toHaveBeenCalledWith(
"fusion-quick-chat-size-proj-123",
"fusion:quick-chat-size-proj-123",
expect.stringContaining('"width":'),
);
expect(parseFloat(panel.style.width)).toBeGreaterThan(320);
@@ -1425,7 +1425,7 @@ describe("QuickChatFAB", () => {
});
it("restores panel size from localStorage on desktop mount", () => {
localStorageMock.store["fusion-quick-chat-size-proj-123"] = JSON.stringify({ width: 470, height: 520 });
localStorageMock.store["fusion:quick-chat-size-proj-123"] = JSON.stringify({ width: 470, height: 520 });
render(<QuickChatFAB addToast={addToast} projectId="proj-123" open={true} />);

View File

@@ -93,6 +93,8 @@ vi.mock("lucide-react", async (importOriginal) => {
Globe: () => <span data-testid="icon-globe" />,
Folder: () => <span data-testid="icon-folder" />,
RefreshCw: ({ className }: { className?: string }) => <span data-testid="icon-refresh" className={className} />,
Star: ({ size }: { size?: number }) => <span data-testid="icon-star" style={{ width: size, height: size }} />,
HelpCircle: ({ size }: { size?: number }) => <span data-testid="icon-help-circle" style={{ width: size, height: size }} />,
};
});

View File

@@ -7,6 +7,8 @@ import {
clearUpdateCheckCache,
performUpdateCheck,
readCachedUpdateCheck,
ttlForFrequency,
__resetStartupRefreshFlag,
type UpdateCheckResult,
} from "../update-check.js";
@@ -129,6 +131,113 @@ describe("update-check", () => {
expect(readCachedUpdateCheck(fusionDir)).toEqual(value);
});
describe("frequency", () => {
beforeEach(() => {
__resetStartupRefreshFlag();
});
it("ttlForFrequency: maps frequencies to expected windows", () => {
const day = 24 * 60 * 60 * 1000;
expect(ttlForFrequency(undefined)).toBe(day);
expect(ttlForFrequency("daily")).toBe(day);
expect(ttlForFrequency("weekly")).toBe(7 * day);
expect(ttlForFrequency("manual")).toBe(Number.POSITIVE_INFINITY);
expect(ttlForFrequency("on-startup")).toBe(Number.POSITIVE_INFINITY);
});
it("weekly: serves cache for up to 7 days, refetches on day 8", async () => {
// Day-6 cache: weekly should still serve it
const cached: UpdateCheckResult = {
currentVersion: "0.6.0",
latestVersion: "0.7.0",
updateAvailable: true,
lastChecked: Date.now() - 6 * 24 * 60 * 60 * 1000,
};
await writeFile(join(fusionDir, "update-check.json"), JSON.stringify(cached), "utf-8");
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const result = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "weekly" });
expect(result).toEqual(cached);
expect(fetchSpy).not.toHaveBeenCalled();
// Day-8 cache: weekly should refetch
await writeFile(
join(fusionDir, "update-check.json"),
JSON.stringify({ ...cached, lastChecked: Date.now() - 8 * 24 * 60 * 60 * 1000 }),
"utf-8",
);
fetchSpy.mockResolvedValueOnce({
json: async () => ({ "dist-tags": { latest: "0.9.0" } }),
});
const refetched = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "weekly" });
expect(fetchSpy).toHaveBeenCalledOnce();
expect(refetched.latestVersion).toBe("0.9.0");
});
it("manual: never hits the network unless force=true, returns cached or empty", async () => {
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
// No cache → returns synthetic empty result without fetching.
const empty = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "manual" });
expect(fetchSpy).not.toHaveBeenCalled();
expect(empty.latestVersion).toBeNull();
expect(empty.updateAvailable).toBe(false);
// With cache → returns cache without fetching even if "stale" by daily standards.
const cached: UpdateCheckResult = {
currentVersion: "0.6.0",
latestVersion: "0.7.0",
updateAvailable: true,
lastChecked: Date.now() - 30 * 24 * 60 * 60 * 1000,
};
await writeFile(join(fusionDir, "update-check.json"), JSON.stringify(cached), "utf-8");
const fromCache = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "manual" });
expect(fetchSpy).not.toHaveBeenCalled();
expect(fromCache).toEqual(cached);
// force=true (used by /update-check/refresh) overrides manual.
fetchSpy.mockResolvedValueOnce({
json: async () => ({ "dist-tags": { latest: "1.0.0" } }),
});
const forced = await performUpdateCheck(fusionDir, "0.6.0", {
frequency: "manual",
force: true,
});
expect(fetchSpy).toHaveBeenCalledOnce();
expect(forced.latestVersion).toBe("1.0.0");
});
it("on-startup: refreshes once per process, then serves cache", async () => {
const fetchSpy = vi.fn().mockResolvedValue({
json: async () => ({ "dist-tags": { latest: "0.8.0" } }),
});
vi.stubGlobal("fetch", fetchSpy);
// First call within the process: hits the network, writes cache.
const first = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "on-startup" });
expect(first.latestVersion).toBe("0.8.0");
expect(fetchSpy).toHaveBeenCalledOnce();
// Subsequent call: serves the just-written cache, no network.
const second = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "on-startup" });
expect(second.latestVersion).toBe("0.8.0");
expect(fetchSpy).toHaveBeenCalledOnce();
// Reset the per-process flag (simulates a fresh server boot) → next
// call refreshes again.
__resetStartupRefreshFlag();
fetchSpy.mockResolvedValueOnce({
json: async () => ({ "dist-tags": { latest: "0.9.0" } }),
});
const afterReboot = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "on-startup" });
expect(afterReboot.latestVersion).toBe("0.9.0");
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
});
it("persists fetched results to the cache file", async () => {
vi.stubGlobal(
"fetch",

View File

@@ -41,7 +41,9 @@ export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => {
return;
}
const result = await performUpdateCheck(resolveGlobalDir(), CLI_PACKAGE_VERSION);
const result = await performUpdateCheck(resolveGlobalDir(), CLI_PACKAGE_VERSION, {
frequency: globalSettings.updateCheckFrequency,
});
res.json(result);
} catch (error) {
rethrowAsApiError(error, "Failed to perform update check");
@@ -52,7 +54,11 @@ export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => {
try {
const fusionDir = resolveGlobalDir();
await clearUpdateCheckCache(fusionDir);
const result = await performUpdateCheck(fusionDir, CLI_PACKAGE_VERSION);
// Explicit `force: true` so a "manual" frequency setting doesn't short
// out the network fetch on the user's deliberate "Check now" click.
const result = await performUpdateCheck(fusionDir, CLI_PACKAGE_VERSION, {
force: true,
});
res.json(result);
} catch (error) {
rethrowAsApiError(error, "Failed to refresh update check");

View File

@@ -3,9 +3,13 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
const CACHE_FILENAME = "update-check.json";
const CHECK_TTL_MS = 24 * 60 * 60 * 1000;
const REGISTRY_URL = "https://registry.npmjs.org/@runfusion%2Ffusion";
const DAY_MS = 24 * 60 * 60 * 1000;
/** Allowed update-check cadences from GlobalSettings. */
export type UpdateCheckFrequency = "manual" | "on-startup" | "daily" | "weekly";
export type UpdateCheckResult = {
currentVersion: string;
latestVersion: string | null;
@@ -14,6 +18,24 @@ export type UpdateCheckResult = {
error?: string;
};
/**
* Cache TTL in ms for the given frequency. Frequencies that don't expire by
* elapsed time (`manual`, `on-startup`) return Infinity — those modes rely on
* external triggers (the `/refresh` endpoint or a server-startup hook).
*/
export function ttlForFrequency(frequency: UpdateCheckFrequency | undefined): number {
switch (frequency) {
case "manual":
case "on-startup":
return Number.POSITIVE_INFINITY;
case "weekly":
return 7 * DAY_MS;
case "daily":
default:
return DAY_MS;
}
}
function getCachePath(fusionDir: string): string {
return join(fusionDir, CACHE_FILENAME);
}
@@ -54,6 +76,19 @@ function isValidResult(value: unknown): value is UpdateCheckResult {
);
}
/**
* Tracks whether we've refreshed the cache during the current process
* lifetime. Used to implement `on-startup` frequency: the first /update-check
* after server boot bypasses the cache; subsequent calls within the same
* process return whatever was just written.
*/
let hasRefreshedThisProcess = false;
/** Test-only hook to reset the per-process startup flag. */
export function __resetStartupRefreshFlag(): void {
hasRefreshedThisProcess = false;
}
export function readCachedUpdateCheck(fusionDir: string): UpdateCheckResult | null {
try {
const raw = readFileSync(getCachePath(fusionDir), "utf-8");
@@ -68,14 +103,47 @@ export async function clearUpdateCheckCache(fusionDir: string): Promise<void> {
await rm(getCachePath(fusionDir), { force: true });
}
export async function performUpdateCheck(fusionDir: string, currentVersion: string): Promise<UpdateCheckResult> {
export async function performUpdateCheck(
fusionDir: string,
currentVersion: string,
options: { frequency?: UpdateCheckFrequency; force?: boolean } = {},
): Promise<UpdateCheckResult> {
const now = Date.now();
const cached = readCachedUpdateCheck(fusionDir);
const ttl = ttlForFrequency(options.frequency);
const cacheStillFresh = cached && now - cached.lastChecked < ttl;
if (cached && now - cached.lastChecked < CHECK_TTL_MS) {
// `on-startup`: refresh exactly once per process lifetime; afterwards
// serve the freshly-written cache for the rest of the run.
if (
!options.force &&
options.frequency === "on-startup" &&
hasRefreshedThisProcess &&
cached
) {
return cached;
}
if (!options.force && options.frequency !== "on-startup" && cacheStillFresh) {
return cached;
}
// For `manual`, never go to the network on a regular check — only the
// `/update-check/refresh` endpoint (which sets `force: true`) should.
// Return whatever's in the cache so the UI can still display the last
// known result; if there's nothing cached, return a no-op disabled-style
// payload.
if (!options.force && options.frequency === "manual") {
return (
cached ?? {
currentVersion,
latestVersion: null,
updateAvailable: false,
lastChecked: now,
}
);
}
try {
const response = await fetch(REGISTRY_URL);
const payload = (await response.json()) as {
@@ -97,6 +165,7 @@ export async function performUpdateCheck(fusionDir: string, currentVersion: stri
await mkdir(fusionDir, { recursive: true });
await writeFile(getCachePath(fusionDir), JSON.stringify(result, null, 2), "utf-8");
hasRefreshedThisProcess = true;
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);