diff --git a/src/components/chat/GlobalChatbox.test.tsx b/src/components/chat/GlobalChatbox.test.tsx
index f2a3d51..9fd71e3 100644
--- a/src/components/chat/GlobalChatbox.test.tsx
+++ b/src/components/chat/GlobalChatbox.test.tsx
@@ -6,6 +6,7 @@ import { GlobalChatbox } from "./GlobalChatbox";
const createSession = jest.fn();
const mockFetchAgentRuntimeHealth = jest.fn();
+const mockFetchAgentModels = jest.fn();
let mockCurrentProjectId = "project-1";
jest.mock("@refinedev/core", () => ({
@@ -13,7 +14,7 @@ jest.mock("@refinedev/core", () => ({
}));
jest.mock("@/lib/chatModels", () => ({
- fetchAgentModels: jest.fn(() => new Promise(() => {})),
+ fetchAgentModels: (...args: unknown[]) => mockFetchAgentModels(...args),
}));
jest.mock("@/lib/agentRuntime", () => ({
@@ -100,12 +101,15 @@ describe("GlobalChatbox lifecycle", () => {
createSession.mockClear();
mockFetchAgentRuntimeHealth.mockReset();
mockFetchAgentRuntimeHealth.mockImplementation(() => new Promise(() => {}));
+ mockFetchAgentModels.mockReset();
+ mockFetchAgentModels.mockImplementation(() => new Promise(() => {}));
mockCurrentProjectId = "project-1";
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
+ jest.restoreAllMocks();
});
it("keeps content mounted and preserves the session across close and reopen", async () => {
@@ -146,4 +150,45 @@ describe("GlobalChatbox lifecycle", () => {
expect(screen.getByText("Workspace state: unavailable")).toBeInTheDocument();
});
+
+ it("reserves models unavailable for an empty model configuration", async () => {
+ mockFetchAgentRuntimeHealth.mockResolvedValueOnce(true);
+ mockFetchAgentModels.mockResolvedValueOnce({ models: [] });
+ render();
+
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(screen.getByText("Workspace state: models_unavailable")).toBeInTheDocument();
+ });
+
+ it("reports an auth dependency failure as unavailable and retries quickly", async () => {
+ jest.spyOn(console, "error").mockImplementation(() => undefined);
+ mockFetchAgentRuntimeHealth.mockResolvedValue(true);
+ mockFetchAgentModels
+ .mockRejectedValueOnce(new Error("authentication service unavailable"))
+ .mockResolvedValueOnce({
+ defaultModel: "provider/model",
+ models: [{ id: "provider/model", label: "Model" }],
+ });
+
+ render();
+
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(screen.getByText("Workspace state: unavailable")).toBeInTheDocument();
+ expect(mockFetchAgentModels).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ jest.advanceTimersByTime(5_000);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(screen.getByText("Workspace state: ready")).toBeInTheDocument();
+ expect(mockFetchAgentModels).toHaveBeenCalledTimes(2);
+ });
});
diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx
index cf19fb7..ef308fc 100644
--- a/src/components/chat/GlobalChatbox.tsx
+++ b/src/components/chat/GlobalChatbox.tsx
@@ -31,6 +31,7 @@ import { useAgentToolActions } from "./hooks/useAgentToolActions";
const STREAMING_BOTTOM_RESERVE_PX = 180;
const STREAMING_SCROLL_RESTORE_AT_PX = STREAMING_BOTTOM_RESERVE_PX - 36;
const AGENT_RUNTIME_POLL_MS = 30_000;
+const AGENT_RUNTIME_RETRY_MS = 5_000;
const AGENT_RUNTIME_TIMEOUT_MS = 8_000;
export const GlobalChatbox: React.FC = ({ open, onClose }) => {
@@ -97,7 +98,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => {
setRuntimeState("unavailable");
setModelOptions([]);
setSelectedModel(undefined);
- return;
+ return "unavailable" as const;
}
const modelConfig = await fetchAgentModels(controller.signal);
@@ -106,7 +107,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => {
setRuntimeState("models_unavailable");
setModelOptions([]);
setSelectedModel(undefined);
- return;
+ return "models_unavailable" as const;
}
setModelOptions(modelConfig.models);
@@ -117,12 +118,14 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => {
return modelConfig.defaultModel;
});
setRuntimeState("ready");
+ return "ready" as const;
} catch (error) {
if (requestId !== runtimeRequestIdRef.current) return;
console.error("[GlobalChatbox] Failed to check agent runtime:", error);
- setRuntimeState(runtimeHealthy ? "models_unavailable" : "unavailable");
+ setRuntimeState("unavailable");
setModelOptions([]);
setSelectedModel(undefined);
+ return "unavailable" as const;
} finally {
window.clearTimeout(timeoutId);
if (runtimeAbortRef.current === controller) {
@@ -134,14 +137,23 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => {
useEffect(() => {
if (!open) return;
- void refreshAgentRuntime(true);
- const intervalId = window.setInterval(
- () => void refreshAgentRuntime(false),
- AGENT_RUNTIME_POLL_MS,
- );
+ let cancelled = false;
+ let pollTimerId: number | undefined;
+
+ const pollRuntime = async (showChecking: boolean) => {
+ const nextState = await refreshAgentRuntime(showChecking);
+ if (cancelled || !nextState) return;
+ pollTimerId = window.setTimeout(
+ () => void pollRuntime(false),
+ nextState === "ready" ? AGENT_RUNTIME_POLL_MS : AGENT_RUNTIME_RETRY_MS,
+ );
+ };
+
+ void pollRuntime(true);
return () => {
- window.clearInterval(intervalId);
+ cancelled = true;
+ if (pollTimerId !== undefined) window.clearTimeout(pollTimerId);
runtimeRequestIdRef.current += 1;
runtimeAbortRef.current?.abort();
runtimeAbortRef.current = null;