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.
|
* 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":
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user