feat(FN-673): add dashboard reconnect resync and model settings split
- Resync tasks after SSE stream reconnect with debounced visibility refresh - Split Model settings into Default Model and Execution Model sections - Add planning and validator model dropdowns in Execution Model section - Remove beta header and add retry logic for Claude usage - Add comprehensive tests for 429 rate limiting and retry behavior - Add CLI fallback for Claude usage when OAuth API is rate limited
This commit is contained in:
@@ -13,7 +13,7 @@ import { applyPresetToSelection, generatePresetId, validatePresetId } from "../u
|
||||
*
|
||||
* Each section groups related settings fields under a sidebar nav item.
|
||||
* Sections have a `scope` to indicate where their settings are stored:
|
||||
* - "global": User-level settings stored in ~/.pi/fusion/settings.json (shared across projects)
|
||||
* - "global": User-level settings stored in ~/.pi/kb/settings.json (shared across projects)
|
||||
* - "project": Project-specific settings stored in .fusion/config.json
|
||||
* - undefined: Section operates independently of settings storage (e.g. authentication)
|
||||
*
|
||||
@@ -52,11 +52,6 @@ const SETTINGS_SECTIONS = [
|
||||
] as const;
|
||||
|
||||
export type SectionId = (typeof SETTINGS_SECTIONS)[number]["id"];
|
||||
type SectionScope = (typeof SETTINGS_SECTIONS)[number]["scope"];
|
||||
|
||||
function getSectionScope(sectionId: SectionId): SectionScope {
|
||||
return SETTINGS_SECTIONS.find((section) => section.id === sectionId)?.scope;
|
||||
}
|
||||
|
||||
interface SettingsModalProps {
|
||||
onClose: () => void;
|
||||
@@ -88,7 +83,7 @@ export function SettingsModal({
|
||||
const [prefixError, setPrefixError] = useState<string | null>(null);
|
||||
|
||||
/** Get the scope of the currently active section */
|
||||
const activeSectionScope = getSectionScope(activeSection);
|
||||
const activeSectionScope = SETTINGS_SECTIONS.find((s) => s.id === activeSection)?.scope;
|
||||
|
||||
// Auth state (independent of the settings save flow)
|
||||
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
|
||||
@@ -98,7 +93,6 @@ export function SettingsModal({
|
||||
|
||||
// Model state
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
|
||||
// Test notification state
|
||||
@@ -146,10 +140,7 @@ export function SettingsModal({
|
||||
if (activeSection === "default-model" || activeSection === "execution-model") {
|
||||
setModelsLoading(true);
|
||||
fetchModels()
|
||||
.then((response) => {
|
||||
setAvailableModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
})
|
||||
.then((models) => setAvailableModels(models))
|
||||
.catch(() => setAvailableModels([]))
|
||||
.finally(() => setModelsLoading(false));
|
||||
}
|
||||
@@ -319,7 +310,7 @@ export function SettingsModal({
|
||||
try {
|
||||
const result = await importSettings(importPreview, { scope: importScope, merge: importMerge });
|
||||
if (result.success) {
|
||||
const parts: string[] = [];
|
||||
const parts = [];
|
||||
if (result.globalCount > 0) parts.push(`${result.globalCount} global`);
|
||||
if (result.projectCount > 0) parts.push(`${result.projectCount} project`);
|
||||
addToast(`Imported ${parts.join(", ")} setting(s)`, "success");
|
||||
@@ -436,24 +427,6 @@ export function SettingsModal({
|
||||
setPresetIdTouched(false);
|
||||
};
|
||||
|
||||
/** Toggle provider favorite status */
|
||||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||||
const currentFavorites = favoriteProviders;
|
||||
const isFavorite = currentFavorites.includes(provider);
|
||||
const newFavorites = isFavorite
|
||||
? currentFavorites.filter((p) => p !== provider)
|
||||
: [provider, ...currentFavorites];
|
||||
|
||||
setFavoriteProviders(newFavorites);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders: newFavorites });
|
||||
} catch {
|
||||
// Revert on error
|
||||
setFavoriteProviders(currentFavorites);
|
||||
}
|
||||
}, [favoriteProviders]);
|
||||
|
||||
/** Render a scope indicator banner for the current section */
|
||||
const renderScopeBanner = () => {
|
||||
if (activeSectionScope === "global") {
|
||||
@@ -626,8 +599,6 @@ export function SettingsModal({
|
||||
}
|
||||
}}
|
||||
placeholder="Use default"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
<small>AI model used for task planning and specification (triage). Falls back to Default Model when not set.</small>
|
||||
</div>
|
||||
@@ -651,8 +622,6 @@ export function SettingsModal({
|
||||
}
|
||||
}}
|
||||
placeholder="Use default"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
<small>AI model used for code and specification review. Falls back to Default Model when not set.</small>
|
||||
</div>
|
||||
@@ -801,8 +770,6 @@ export function SettingsModal({
|
||||
} : current);
|
||||
}}
|
||||
placeholder="Use default"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
@@ -825,8 +792,6 @@ export function SettingsModal({
|
||||
} : current);
|
||||
}}
|
||||
placeholder="Use default"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -939,8 +904,6 @@ export function SettingsModal({
|
||||
}));
|
||||
}}
|
||||
placeholder="Use fallback model"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
)}
|
||||
<small>
|
||||
@@ -1437,37 +1400,6 @@ export function SettingsModal({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{form.ntfyEnabled && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="ntfyDashboardHost">Dashboard Hostname</label>
|
||||
<input
|
||||
id="ntfyDashboardHost"
|
||||
type="text"
|
||||
placeholder="http://localhost:3000"
|
||||
value={form.ntfyDashboardHost || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, ntfyDashboardHost: val || undefined }));
|
||||
}}
|
||||
/>
|
||||
<small>
|
||||
Base URL for deep links in notifications. When set, clicking a notification
|
||||
will open the dashboard directly to the task. Example: http://localhost:3000
|
||||
or https://fusion.example.com
|
||||
</small>
|
||||
{form.ntfyDashboardHost && (
|
||||
!/^https?:\/\/.+/.test(form.ntfyDashboardHost) ? (
|
||||
<small className="field-error">
|
||||
Must be a valid URL starting with http:// or https://
|
||||
</small>
|
||||
) : form.ntfyDashboardHost.includes("?") || form.ntfyDashboardHost.includes("#") ? (
|
||||
<small className="field-error">
|
||||
URL should not include query parameters or fragments
|
||||
</small>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "authentication":
|
||||
|
||||
@@ -32,10 +32,10 @@ vi.mock("../../api", () => ({
|
||||
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }] })),
|
||||
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
|
||||
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
|
||||
fetchModels: vi.fn(() => Promise.resolve({ models: [
|
||||
fetchModels: vi.fn(() => Promise.resolve([
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||
], favoriteProviders: [] })),
|
||||
])),
|
||||
testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })),
|
||||
}));
|
||||
|
||||
@@ -578,7 +578,7 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
|
||||
it("shows empty state when no models available", async () => {
|
||||
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ models: [], favoriteProviders: [] });
|
||||
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
@@ -886,26 +886,7 @@ describe("SettingsModal", () => {
|
||||
// General content should be visible
|
||||
expect(screen.getByLabelText("Task Prefix")).toBeTruthy();
|
||||
// Authentication content should NOT be visible
|
||||
expect(screen.queryByText("Anthropic")).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves section-scope behavior across authentication auto-open and general reopen states", async () => {
|
||||
const { unmount } = render(
|
||||
<SettingsModal onClose={onClose} addToast={addToast} initialSection="authentication" />,
|
||||
);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||||
|
||||
expect(screen.getByText("Anthropic")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("Task Prefix")).toBeNull();
|
||||
|
||||
unmount();
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(screen.getByLabelText("Task Prefix")).toBeTruthy();
|
||||
expect(screen.queryByText("Anthropic")).toBeNull();
|
||||
expect(screen.queryByText("✗ Not authenticated")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows sign-in hint when no providers are authenticated", async () => {
|
||||
|
||||
Reference in New Issue
Block a user