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:
5
.changeset/add-auto-reload-setting.md
Normal file
5
.changeset/add-auto-reload-setting.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add `autoReloadOnVersionChange` global setting to make the dashboard's automatic reload on version changes optional. Users can disable auto-reload in Settings → General → Updates.
|
||||||
@@ -56,6 +56,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
|||||||
| `favoriteModels` | `string[]` | `undefined` | Pinned models in `{provider}/{modelId}` format. |
|
| `favoriteModels` | `string[]` | `undefined` | Pinned models in `{provider}/{modelId}` format. |
|
||||||
| `openrouterModelSync` | `boolean` | `true` | Sync OpenRouter model catalog into model pickers at startup. |
|
| `openrouterModelSync` | `boolean` | `true` | Sync OpenRouter model catalog into model pickers at startup. |
|
||||||
| `updateCheckEnabled` | `boolean` | `true` | When enabled, Fusion performs a daily npm registry check for new `@runfusion/fusion` versions and shows update notices in CLI/dashboard. |
|
| `updateCheckEnabled` | `boolean` | `true` | When enabled, Fusion performs a daily npm registry check for new `@runfusion/fusion` versions and shows update notices in CLI/dashboard. |
|
||||||
|
| `autoReloadOnVersionChange` | `boolean` | `true` | When enabled (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. |
|
||||||
| `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. |
|
| `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. |
|
||||||
| `executionGlobalProvider` | `string` | `undefined` | Global baseline provider for task execution. Project `executionProvider` overrides this. |
|
| `executionGlobalProvider` | `string` | `undefined` | Global baseline provider for task execution. Project `executionProvider` overrides this. |
|
||||||
| `executionGlobalModelId` | `string` | `undefined` | Global baseline model ID for task execution. |
|
| `executionGlobalModelId` | `string` | `undefined` | Global baseline model ID for task execution. |
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
|||||||
updateCheckEnabled: true,
|
updateCheckEnabled: true,
|
||||||
fnBinaryCheckEnabled: true,
|
fnBinaryCheckEnabled: true,
|
||||||
updateCheckFrequency: "daily",
|
updateCheckFrequency: "daily",
|
||||||
|
autoReloadOnVersionChange: true,
|
||||||
showGitHubStarButton: true,
|
showGitHubStarButton: true,
|
||||||
modelOnboardingComplete: undefined,
|
modelOnboardingComplete: undefined,
|
||||||
useClaudeCli: undefined,
|
useClaudeCli: undefined,
|
||||||
|
|||||||
@@ -1365,6 +1365,11 @@ export interface GlobalSettings {
|
|||||||
* - `weekly`: 7-day cache TTL
|
* - `weekly`: 7-day cache TTL
|
||||||
*/
|
*/
|
||||||
updateCheckFrequency?: "manual" | "on-startup" | "daily" | "weekly";
|
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
|
/** When true, indicates the user has completed the AI model onboarding flow
|
||||||
* (connected at least one provider and selected a default model). When
|
* (connected at least one provider and selected a default model). When
|
||||||
* false/undefined, the dashboard will auto-open the onboarding modal.
|
* false/undefined, the dashboard will auto-open the onboarding modal.
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
checkVersion,
|
checkVersion,
|
||||||
consumeVersionUpdateFlag,
|
consumeVersionUpdateFlag,
|
||||||
_resetCheckState,
|
_resetCheckState,
|
||||||
|
_resetState,
|
||||||
|
setAutoReloadEnabled,
|
||||||
|
_isAutoReloadEnabled,
|
||||||
MIN_CHECK_INTERVAL_MS,
|
MIN_CHECK_INTERVAL_MS,
|
||||||
} from "../versionCheck";
|
} from "../versionCheck";
|
||||||
|
|
||||||
@@ -169,3 +172,103 @@ describe("checkVersion cooldown", () => {
|
|||||||
expect(reloadSpy).not.toHaveBeenCalled();
|
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.
|
immediate check at any time.
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</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": {
|
case "global-models": {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
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.
|
* Settings state and actions consumed by the dashboard App shell.
|
||||||
@@ -20,10 +21,12 @@ export interface UseAppSettingsResult {
|
|||||||
memoryEnabled: boolean;
|
memoryEnabled: boolean;
|
||||||
devServerEnabled: boolean;
|
devServerEnabled: boolean;
|
||||||
todosEnabled: boolean;
|
todosEnabled: boolean;
|
||||||
|
autoReloadOnVersionChange: boolean;
|
||||||
toggleAutoMerge: () => Promise<void>;
|
toggleAutoMerge: () => Promise<void>;
|
||||||
toggleGlobalPause: () => Promise<void>;
|
toggleGlobalPause: () => Promise<void>;
|
||||||
toggleEnginePause: () => Promise<void>;
|
toggleEnginePause: () => Promise<void>;
|
||||||
toggleShowQuickChatFAB: () => Promise<void>;
|
toggleShowQuickChatFAB: () => Promise<void>;
|
||||||
|
toggleAutoReloadOnVersionChange: () => Promise<void>;
|
||||||
/** Re-fetches settings from the backend to pick up changes made externally (e.g., by SettingsModal). */
|
/** Re-fetches settings from the backend to pick up changes made externally (e.g., by SettingsModal). */
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -47,6 +50,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
|||||||
const [memoryEnabled, setMemoryEnabled] = useState(false);
|
const [memoryEnabled, setMemoryEnabled] = useState(false);
|
||||||
const [devServerEnabled, setDevServerEnabled] = useState(false);
|
const [devServerEnabled, setDevServerEnabled] = useState(false);
|
||||||
const [todosEnabled, setTodosEnabled] = useState(false);
|
const [todosEnabled, setTodosEnabled] = useState(false);
|
||||||
|
const [autoReloadOnVersionChange, setAutoReloadOnVersionChangeState] = useState(true);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches config and settings from the backend and updates local state.
|
* 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);
|
setMemoryEnabled(features.memoryView === true);
|
||||||
setDevServerEnabled(features.devServerView === true || features.devServer === true);
|
setDevServerEnabled(features.devServerView === true || features.devServer === true);
|
||||||
setTodosEnabled(features.todoView === 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);
|
setSettingsLoaded(true);
|
||||||
@@ -144,6 +152,19 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
|||||||
}
|
}
|
||||||
}, [showQuickChatFAB, projectId]);
|
}, [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 {
|
return {
|
||||||
maxConcurrent,
|
maxConcurrent,
|
||||||
rootDir,
|
rootDir,
|
||||||
@@ -160,10 +181,12 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
|||||||
memoryEnabled,
|
memoryEnabled,
|
||||||
devServerEnabled,
|
devServerEnabled,
|
||||||
todosEnabled,
|
todosEnabled,
|
||||||
|
autoReloadOnVersionChange,
|
||||||
toggleAutoMerge,
|
toggleAutoMerge,
|
||||||
toggleGlobalPause,
|
toggleGlobalPause,
|
||||||
toggleEnginePause,
|
toggleEnginePause,
|
||||||
toggleShowQuickChatFAB,
|
toggleShowQuickChatFAB,
|
||||||
|
toggleAutoReloadOnVersionChange,
|
||||||
refresh,
|
refresh,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,33 @@ declare const __BUILD_VERSION__: string;
|
|||||||
const RELOAD_FLAG = "fusion:version-reload";
|
const RELOAD_FLAG = "fusion:version-reload";
|
||||||
const VERSION_UPDATE_FLAG = "fusion:version-update";
|
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 {
|
export function consumeVersionUpdateFlag(): boolean {
|
||||||
try {
|
try {
|
||||||
if (sessionStorage.getItem(VERSION_UPDATE_FLAG)) {
|
if (sessionStorage.getItem(VERSION_UPDATE_FLAG)) {
|
||||||
@@ -21,6 +48,10 @@ export function reloadOnce(reason: string): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sessionStorage.setItem(RELOAD_FLAG, "1");
|
sessionStorage.setItem(RELOAD_FLAG, "1");
|
||||||
|
if (!autoReloadEnabled) {
|
||||||
|
console.info("[versionCheck] auto-reload disabled by setting, skipping reload:", reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.info("[versionCheck] reloading:", reason);
|
console.info("[versionCheck] reloading:", reason);
|
||||||
window.location.reload();
|
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
|
export const MIN_CHECK_INTERVAL_MS = 60_000; // 1 minute
|
||||||
let lastCheckTime = 0;
|
let lastCheckTime = 0;
|
||||||
let checkInFlight = false;
|
let checkInFlight = false;
|
||||||
@@ -91,6 +147,8 @@ export async function checkVersion(): Promise<void> {
|
|||||||
|
|
||||||
export function installVersionCheck(): void {
|
export function installVersionCheck(): void {
|
||||||
if (!import.meta.env.PROD) return;
|
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.
|
// Clear stale flag once a fresh page has rendered successfully.
|
||||||
window.setTimeout(() => sessionStorage.removeItem(RELOAD_FLAG), 5_000);
|
window.setTimeout(() => sessionStorage.removeItem(RELOAD_FLAG), 5_000);
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user