feat(FN-2943): improve auth UX, agent panel status, and task lifecycle handling
- Add settings content scroll refs and trigger scroll-to-top after successful sign-in and sign-up flows with regression coverage - Keep task card merge timers live during active merges and add SSE support for task timing refresh - Hoist Active Agents panel above the main list, add next-heartbeat visibility, and fix stale "Connecting..." card states - Enforce unavailable-node scheduling guards and reset task steps when reopening tasks to triage or todo Fusion-Task-Id: FN-2943
This commit is contained in:
@@ -318,6 +318,7 @@ export function SettingsModal({
|
||||
}: SettingsModalProps) {
|
||||
const { confirm } = useConfirm();
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const settingsContentRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, true, "fusion:settings-modal-size");
|
||||
const [form, setForm] = useState<SettingsFormState>({
|
||||
maxConcurrent: 2,
|
||||
@@ -789,6 +790,10 @@ export function SettingsModal({
|
||||
};
|
||||
}, [activeSection, loadAuthStatus]);
|
||||
|
||||
const scrollSettingsToTop = useCallback(() => {
|
||||
settingsContentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
const handleLogin = useCallback(async (providerId: string) => {
|
||||
setAuthActionInProgress(providerId);
|
||||
setLoginInstructions((prev) => {
|
||||
@@ -828,6 +833,7 @@ export function SettingsModal({
|
||||
return next;
|
||||
});
|
||||
addToast("Login successful", "success");
|
||||
scrollSettingsToTop();
|
||||
}
|
||||
} catch {
|
||||
// Continue polling on transient errors
|
||||
@@ -845,7 +851,7 @@ export function SettingsModal({
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [addToast]);
|
||||
}, [addToast, scrollSettingsToTop]);
|
||||
|
||||
const handleLogout = useCallback(async (providerId: string) => {
|
||||
setAuthActionInProgress(providerId);
|
||||
@@ -881,12 +887,13 @@ export function SettingsModal({
|
||||
});
|
||||
await loadAuthStatus();
|
||||
addToast("API key saved", "success");
|
||||
scrollSettingsToTop();
|
||||
} catch (err) {
|
||||
setApiKeyErrors((prev) => ({ ...prev, [providerId]: getErrorMessage(err) || "Failed to save API key" }));
|
||||
} finally {
|
||||
setAuthActionInProgress(null);
|
||||
}
|
||||
}, [apiKeyInputs, addToast, loadAuthStatus]);
|
||||
}, [apiKeyInputs, addToast, loadAuthStatus, scrollSettingsToTop]);
|
||||
|
||||
const handleClearApiKey = useCallback(async (providerId: string) => {
|
||||
setAuthActionInProgress(providerId);
|
||||
@@ -4776,7 +4783,7 @@ export function SettingsModal({
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="settings-content">
|
||||
<div className="settings-content" ref={settingsContentRef}>
|
||||
{renderSectionFields()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ const mockUpdateGlobalSettings = vi.fn();
|
||||
const mockFetchAuthStatus = vi.fn();
|
||||
const mockLoginProvider = vi.fn();
|
||||
const mockLogoutProvider = vi.fn();
|
||||
const mockSaveApiKey = vi.fn();
|
||||
const mockFetchModels = vi.fn();
|
||||
const mockTestNtfyNotification = vi.fn();
|
||||
const mockTestNotification = vi.fn();
|
||||
@@ -54,6 +55,7 @@ vi.mock("../../api", () => ({
|
||||
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
|
||||
loginProvider: (...args: unknown[]) => mockLoginProvider(...args),
|
||||
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
|
||||
saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args),
|
||||
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
|
||||
testNtfyNotification: (...args: unknown[]) => mockTestNtfyNotification(...args),
|
||||
testNotification: (...args: unknown[]) => mockTestNotification(...args),
|
||||
@@ -193,6 +195,7 @@ describe("SettingsModal", () => {
|
||||
mockFetchSettingsByScope.mockResolvedValue({ global: defaultSettings, project: {} });
|
||||
mockFetchAuthStatus.mockResolvedValue({ providers: [] });
|
||||
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
mockSaveApiKey.mockResolvedValue(undefined);
|
||||
mockTestNotification.mockResolvedValue({ success: true });
|
||||
mockFetchBackups.mockResolvedValue({ backups: [], totalSize: 0 });
|
||||
mockFetchMemoryFiles.mockResolvedValue({
|
||||
@@ -719,6 +722,71 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getByTestId("auth-status-github")).toHaveTextContent("✓ Active");
|
||||
expect(screen.getByTestId("auth-status-openai")).toHaveTextContent("✗ Not connected");
|
||||
});
|
||||
|
||||
it("scrolls settings content to top after OAuth login succeeds", async () => {
|
||||
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
mockLoginProvider.mockResolvedValue({ url: "https://example.com/auth", instructions: "" });
|
||||
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||
providers: [{ id: "github", name: "GitHub", authenticated: false, type: "oauth" }],
|
||||
});
|
||||
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||
providers: [{ id: "github", name: "GitHub", authenticated: true, type: "oauth" }],
|
||||
});
|
||||
|
||||
vi.spyOn(globalThis, "setInterval").mockImplementation((callback: TimerHandler) => {
|
||||
void Promise.resolve().then(() => {
|
||||
if (typeof callback === "function") callback();
|
||||
});
|
||||
return 1 as unknown as ReturnType<typeof setInterval>;
|
||||
});
|
||||
|
||||
const { container } = renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const settingsContent = container.querySelector(".settings-content") as HTMLDivElement;
|
||||
expect(settingsContent).toBeInTheDocument();
|
||||
const scrollToSpy = vi.fn();
|
||||
Object.defineProperty(settingsContent, "scrollTo", {
|
||||
value: scrollToSpy,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Login" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(scrollToSpy).toHaveBeenCalledWith({ top: 0, behavior: "smooth" });
|
||||
});
|
||||
expect(openSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scrolls settings content to top after API key save succeeds", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||
providers: [{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" }],
|
||||
});
|
||||
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||
providers: [{ id: "openai", name: "OpenAI", authenticated: true, type: "api_key" }],
|
||||
});
|
||||
|
||||
const { container } = renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const settingsContent = container.querySelector(".settings-content") as HTMLDivElement;
|
||||
expect(settingsContent).toBeInTheDocument();
|
||||
const scrollToSpy = vi.fn();
|
||||
Object.defineProperty(settingsContent, "scrollTo", {
|
||||
value: scrollToSpy,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const openAiCard = screen.getByTestId("auth-provider-icon-openai").closest(".auth-provider-card") as HTMLElement;
|
||||
await userEvent.type(within(openAiCard).getByPlaceholderText("Enter API key"), "sk-test-key");
|
||||
await userEvent.click(within(openAiCard).getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSaveApiKey).toHaveBeenCalledWith("openai", "sk-test-key");
|
||||
expect(scrollToSpy).toHaveBeenCalledWith({ top: 0, behavior: "smooth" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Plugins section navigation", () => {
|
||||
|
||||
Reference in New Issue
Block a user