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:
Fusion
2026-04-29 14:41:22 -07:00
committed by gsxdsm
parent 3ac07f3207
commit fb31f37b99
2 changed files with 78 additions and 3 deletions

View File

@@ -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>

View File

@@ -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", () => {