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:
gsxdsm
2026-04-02 09:31:24 -07:00
2 changed files with 8 additions and 95 deletions

View File

@@ -13,7 +13,7 @@ import { applyPresetToSelection, generatePresetId, validatePresetId } from "../u
* *
* Each section groups related settings fields under a sidebar nav item. * Each section groups related settings fields under a sidebar nav item.
* Sections have a `scope` to indicate where their settings are stored: * 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 * - "project": Project-specific settings stored in .fusion/config.json
* - undefined: Section operates independently of settings storage (e.g. authentication) * - undefined: Section operates independently of settings storage (e.g. authentication)
* *
@@ -52,11 +52,6 @@ const SETTINGS_SECTIONS = [
] as const; ] as const;
export type SectionId = (typeof SETTINGS_SECTIONS)[number]["id"]; 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 { interface SettingsModalProps {
onClose: () => void; onClose: () => void;
@@ -88,7 +83,7 @@ export function SettingsModal({
const [prefixError, setPrefixError] = useState<string | null>(null); const [prefixError, setPrefixError] = useState<string | null>(null);
/** Get the scope of the currently active section */ /** 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) // Auth state (independent of the settings save flow)
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]); const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
@@ -98,7 +93,6 @@ export function SettingsModal({
// Model state // Model state
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]); const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [modelsLoading, setModelsLoading] = useState(false); const [modelsLoading, setModelsLoading] = useState(false);
// Test notification state // Test notification state
@@ -146,10 +140,7 @@ export function SettingsModal({
if (activeSection === "default-model" || activeSection === "execution-model") { if (activeSection === "default-model" || activeSection === "execution-model") {
setModelsLoading(true); setModelsLoading(true);
fetchModels() fetchModels()
.then((response) => { .then((models) => setAvailableModels(models))
setAvailableModels(response.models);
setFavoriteProviders(response.favoriteProviders);
})
.catch(() => setAvailableModels([])) .catch(() => setAvailableModels([]))
.finally(() => setModelsLoading(false)); .finally(() => setModelsLoading(false));
} }
@@ -319,7 +310,7 @@ export function SettingsModal({
try { try {
const result = await importSettings(importPreview, { scope: importScope, merge: importMerge }); const result = await importSettings(importPreview, { scope: importScope, merge: importMerge });
if (result.success) { if (result.success) {
const parts: string[] = []; const parts = [];
if (result.globalCount > 0) parts.push(`${result.globalCount} global`); if (result.globalCount > 0) parts.push(`${result.globalCount} global`);
if (result.projectCount > 0) parts.push(`${result.projectCount} project`); if (result.projectCount > 0) parts.push(`${result.projectCount} project`);
addToast(`Imported ${parts.join(", ")} setting(s)`, "success"); addToast(`Imported ${parts.join(", ")} setting(s)`, "success");
@@ -436,24 +427,6 @@ export function SettingsModal({
setPresetIdTouched(false); 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 */ /** Render a scope indicator banner for the current section */
const renderScopeBanner = () => { const renderScopeBanner = () => {
if (activeSectionScope === "global") { if (activeSectionScope === "global") {
@@ -626,8 +599,6 @@ export function SettingsModal({
} }
}} }}
placeholder="Use default" 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> <small>AI model used for task planning and specification (triage). Falls back to Default Model when not set.</small>
</div> </div>
@@ -651,8 +622,6 @@ export function SettingsModal({
} }
}} }}
placeholder="Use default" 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> <small>AI model used for code and specification review. Falls back to Default Model when not set.</small>
</div> </div>
@@ -801,8 +770,6 @@ export function SettingsModal({
} : current); } : current);
}} }}
placeholder="Use default" placeholder="Use default"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
@@ -825,8 +792,6 @@ export function SettingsModal({
} : current); } : current);
}} }}
placeholder="Use default" placeholder="Use default"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
/> />
</div> </div>
</> </>
@@ -939,8 +904,6 @@ export function SettingsModal({
})); }));
}} }}
placeholder="Use fallback model" placeholder="Use fallback model"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
/> />
)} )}
<small> <small>
@@ -1437,37 +1400,6 @@ export function SettingsModal({
</button> </button>
</div> </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": case "authentication":

View File

@@ -32,10 +32,10 @@ vi.mock("../../api", () => ({
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }] })), fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }] })),
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })), loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
logoutProvider: vi.fn(() => Promise.resolve({ success: true })), 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: "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 }, { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
], favoriteProviders: [] })), ])),
testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })), testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })),
})); }));
@@ -578,7 +578,7 @@ describe("SettingsModal", () => {
}); });
it("shows empty state when no models available", async () => { 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} />); render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
@@ -886,26 +886,7 @@ describe("SettingsModal", () => {
// General content should be visible // General content should be visible
expect(screen.getByLabelText("Task Prefix")).toBeTruthy(); expect(screen.getByLabelText("Task Prefix")).toBeTruthy();
// Authentication content should NOT be visible // Authentication content should NOT be visible
expect(screen.queryByText("Anthropic")).toBeNull(); expect(screen.queryByText("✗ Not authenticated")).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();
}); });
it("shows sign-in hint when no providers are authenticated", async () => { it("shows sign-in hint when no providers are authenticated", async () => {