feat(FN-3334): add auto-reload setting and fix worktree project resolution
Merged two features: FN-3334 introduces a new `autoReloadOnVersionChange` setting with a toggle in the Settings modal, version-check logic in the dashboard, and corresponding tests; FN-3335 fixes worktree project root resolution in `createFnAgent` and the skill resolver, with tests for both paths. S Fusion-Task-Id: FN-3334
This commit is contained in:
@@ -48,6 +48,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
||||
updateCheckEnabled: true,
|
||||
fnBinaryCheckEnabled: true,
|
||||
updateCheckFrequency: "daily",
|
||||
autoReloadOnVersionChange: true,
|
||||
showGitHubStarButton: true,
|
||||
modelOnboardingComplete: undefined,
|
||||
useClaudeCli: undefined,
|
||||
|
||||
@@ -1365,6 +1365,11 @@ export interface GlobalSettings {
|
||||
* - `weekly`: 7-day cache TTL
|
||||
*/
|
||||
updateCheckFrequency?: "manual" | "on-startup" | "daily" | "weekly";
|
||||
/** When true (default), the dashboard automatically reloads when a new build
|
||||
* version is detected via /version.json polling or service worker activation.
|
||||
* Set to false to suppress automatic reloads — the user must manually
|
||||
* refresh to pick up updates. */
|
||||
autoReloadOnVersionChange?: boolean;
|
||||
/** When true, indicates the user has completed the AI model onboarding flow
|
||||
* (connected at least one provider and selected a default model). When
|
||||
* false/undefined, the dashboard will auto-open the onboarding modal.
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
checkVersion,
|
||||
consumeVersionUpdateFlag,
|
||||
_resetCheckState,
|
||||
_resetState,
|
||||
setAutoReloadEnabled,
|
||||
_isAutoReloadEnabled,
|
||||
MIN_CHECK_INTERVAL_MS,
|
||||
} from "../versionCheck";
|
||||
|
||||
@@ -169,3 +172,103 @@ describe("checkVersion cooldown", () => {
|
||||
expect(reloadSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoReloadOnVersionChange setting", () => {
|
||||
const reloadSpy = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", { reload: reloadSpy });
|
||||
window.sessionStorage.clear();
|
||||
reloadSpy.mockClear();
|
||||
_resetState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_resetState();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("reloadOnce with auto-reload setting", () => {
|
||||
it("calls window.location.reload() when auto-reload is enabled (default)", () => {
|
||||
expect(_isAutoReloadEnabled()).toBe(true);
|
||||
reloadOnce("test reason");
|
||||
expect(window.sessionStorage.getItem("fusion:version-reload")).toBe("1");
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT call reload when auto-reload is disabled", () => {
|
||||
const consoleInfoSpy = vi.spyOn(console, "info");
|
||||
setAutoReloadEnabled(false);
|
||||
expect(_isAutoReloadEnabled()).toBe(false);
|
||||
reloadOnce("test reason");
|
||||
// Should still set the flag to prevent retries
|
||||
expect(window.sessionStorage.getItem("fusion:version-reload")).toBe("1");
|
||||
expect(reloadSpy).not.toHaveBeenCalled();
|
||||
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
||||
"[versionCheck] auto-reload disabled by setting, skipping reload:",
|
||||
"test reason",
|
||||
);
|
||||
consoleInfoSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("re-enables reload after setAutoReloadEnabled(true)", () => {
|
||||
setAutoReloadEnabled(false);
|
||||
reloadOnce("suppressed");
|
||||
expect(reloadSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Reset for next call
|
||||
window.sessionStorage.clear();
|
||||
setAutoReloadEnabled(true);
|
||||
reloadOnce("now enabled");
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setAutoReloadEnabled", () => {
|
||||
it("toggles the guard correctly", () => {
|
||||
expect(_isAutoReloadEnabled()).toBe(true);
|
||||
setAutoReloadEnabled(false);
|
||||
expect(_isAutoReloadEnabled()).toBe(false);
|
||||
setAutoReloadEnabled(true);
|
||||
expect(_isAutoReloadEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bootstrap setting fetch", () => {
|
||||
it("respects autoReloadOnVersionChange=false from settings API", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: () => Promise.resolve({ autoReloadOnVersionChange: false }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
// Dynamically import to trigger bootstrap (we test the effect via setAutoReloadEnabled)
|
||||
// Instead, directly test the fetch + setAutoReloadEnabled integration:
|
||||
const res = await fetch("/api/settings", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.autoReloadOnVersionChange === false) {
|
||||
setAutoReloadEnabled(false);
|
||||
}
|
||||
expect(_isAutoReloadEnabled()).toBe(false);
|
||||
|
||||
// Now reloadOnce should not actually reload
|
||||
reloadOnce("bootstrap test");
|
||||
expect(reloadSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps default (true) if settings fetch fails", async () => {
|
||||
const fetchSpy = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
try {
|
||||
await fetch("/api/settings");
|
||||
} catch {
|
||||
// Expected — guard should remain true
|
||||
}
|
||||
expect(_isAutoReloadEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1970,6 +1970,25 @@ export function SettingsModal({
|
||||
immediate check at any time.
|
||||
</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoReloadOnVersionChange" className="checkbox-label">
|
||||
<input
|
||||
id="autoReloadOnVersionChange"
|
||||
type="checkbox"
|
||||
checked={form.autoReloadOnVersionChange !== false}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, autoReloadOnVersionChange: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Auto-reload dashboard on version change
|
||||
</label>
|
||||
<small>
|
||||
When enabled (default), the dashboard automatically reloads when it
|
||||
detects a new build version — either from server rebuilds or service
|
||||
worker updates. Disable this to stay on the current version until you
|
||||
manually refresh.
|
||||
</small>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "global-models": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { fetchConfig, fetchSettings, updateSettings } from "../api";
|
||||
import { fetchConfig, fetchSettings, updateSettings, updateGlobalSettings } from "../api";
|
||||
import { setAutoReloadEnabled } from "../versionCheck";
|
||||
|
||||
/**
|
||||
* Settings state and actions consumed by the dashboard App shell.
|
||||
@@ -20,10 +21,12 @@ export interface UseAppSettingsResult {
|
||||
memoryEnabled: boolean;
|
||||
devServerEnabled: boolean;
|
||||
todosEnabled: boolean;
|
||||
autoReloadOnVersionChange: boolean;
|
||||
toggleAutoMerge: () => Promise<void>;
|
||||
toggleGlobalPause: () => Promise<void>;
|
||||
toggleEnginePause: () => Promise<void>;
|
||||
toggleShowQuickChatFAB: () => Promise<void>;
|
||||
toggleAutoReloadOnVersionChange: () => Promise<void>;
|
||||
/** Re-fetches settings from the backend to pick up changes made externally (e.g., by SettingsModal). */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
@@ -47,6 +50,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
const [memoryEnabled, setMemoryEnabled] = useState(false);
|
||||
const [devServerEnabled, setDevServerEnabled] = useState(false);
|
||||
const [todosEnabled, setTodosEnabled] = useState(false);
|
||||
const [autoReloadOnVersionChange, setAutoReloadOnVersionChangeState] = useState(true);
|
||||
|
||||
/**
|
||||
* Fetches config and settings from the backend and updates local state.
|
||||
@@ -78,6 +82,10 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
setMemoryEnabled(features.memoryView === true);
|
||||
setDevServerEnabled(features.devServerView === true || features.devServer === true);
|
||||
setTodosEnabled(features.todoView === true);
|
||||
// Sync the module-level auto-reload guard with the persisted setting
|
||||
const autoReload = settings.autoReloadOnVersionChange !== false;
|
||||
setAutoReloadOnVersionChangeState(autoReload);
|
||||
setAutoReloadEnabled(autoReload);
|
||||
}
|
||||
|
||||
setSettingsLoaded(true);
|
||||
@@ -144,6 +152,19 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
}
|
||||
}, [showQuickChatFAB, projectId]);
|
||||
|
||||
const toggleAutoReloadOnVersionChange = useCallback(async () => {
|
||||
const next = !autoReloadOnVersionChange;
|
||||
setAutoReloadOnVersionChangeState(next);
|
||||
setAutoReloadEnabled(next);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ autoReloadOnVersionChange: next });
|
||||
} catch {
|
||||
setAutoReloadOnVersionChangeState(!next);
|
||||
setAutoReloadEnabled(!next);
|
||||
}
|
||||
}, [autoReloadOnVersionChange]);
|
||||
|
||||
return {
|
||||
maxConcurrent,
|
||||
rootDir,
|
||||
@@ -160,10 +181,12 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
memoryEnabled,
|
||||
devServerEnabled,
|
||||
todosEnabled,
|
||||
autoReloadOnVersionChange,
|
||||
toggleAutoMerge,
|
||||
toggleGlobalPause,
|
||||
toggleEnginePause,
|
||||
toggleShowQuickChatFAB,
|
||||
toggleAutoReloadOnVersionChange,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,33 @@ declare const __BUILD_VERSION__: string;
|
||||
const RELOAD_FLAG = "fusion:version-reload";
|
||||
const VERSION_UPDATE_FLAG = "fusion:version-update";
|
||||
|
||||
/**
|
||||
* Module-level guard for auto-reload behavior.
|
||||
* Default true (auto-reload enabled). Set to false when the user disables
|
||||
* the `autoReloadOnVersionChange` setting.
|
||||
*/
|
||||
let autoReloadEnabled = true;
|
||||
|
||||
/**
|
||||
* Allow the React app to toggle the auto-reload guard at runtime
|
||||
* (e.g. when the user changes the setting in the Settings modal).
|
||||
*/
|
||||
export function setAutoReloadEnabled(enabled: boolean): void {
|
||||
autoReloadEnabled = enabled;
|
||||
}
|
||||
|
||||
/** Exported for testing — reads the current guard value. */
|
||||
export function _isAutoReloadEnabled(): boolean {
|
||||
return autoReloadEnabled;
|
||||
}
|
||||
|
||||
/** Exported for testing — resets internal state. */
|
||||
export function _resetState(): void {
|
||||
lastCheckTime = 0;
|
||||
checkInFlight = false;
|
||||
autoReloadEnabled = true;
|
||||
}
|
||||
|
||||
export function consumeVersionUpdateFlag(): boolean {
|
||||
try {
|
||||
if (sessionStorage.getItem(VERSION_UPDATE_FLAG)) {
|
||||
@@ -21,6 +48,10 @@ export function reloadOnce(reason: string): void {
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(RELOAD_FLAG, "1");
|
||||
if (!autoReloadEnabled) {
|
||||
console.info("[versionCheck] auto-reload disabled by setting, skipping reload:", reason);
|
||||
return;
|
||||
}
|
||||
console.info("[versionCheck] reloading:", reason);
|
||||
window.location.reload();
|
||||
}
|
||||
@@ -59,6 +90,31 @@ async function fetchRemoteVersion(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap: fetch global settings to check `autoReloadOnVersionChange`.
|
||||
* Runs once during `installVersionCheck()`. If the fetch fails or times out,
|
||||
* the default (true = auto-reload enabled) is kept.
|
||||
*/
|
||||
async function bootstrapAutoReloadSetting(): Promise<void> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const res = await fetch("/api/settings", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { autoReloadOnVersionChange?: unknown };
|
||||
if (data.autoReloadOnVersionChange === false) {
|
||||
autoReloadEnabled = false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Network error, timeout, etc. — keep default (true).
|
||||
}
|
||||
}
|
||||
|
||||
export const MIN_CHECK_INTERVAL_MS = 60_000; // 1 minute
|
||||
let lastCheckTime = 0;
|
||||
let checkInFlight = false;
|
||||
@@ -91,6 +147,8 @@ export async function checkVersion(): Promise<void> {
|
||||
|
||||
export function installVersionCheck(): void {
|
||||
if (!import.meta.env.PROD) return;
|
||||
// Fetch settings to apply auto-reload guard before first version check.
|
||||
void bootstrapAutoReloadSetting();
|
||||
// Clear stale flag once a fresh page has rendered successfully.
|
||||
window.setTimeout(() => sessionStorage.removeItem(RELOAD_FLAG), 5_000);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
|
||||
Reference in New Issue
Block a user