diff --git a/ui/package-lock.json b/ui/package-lock.json
index 35d7fc7c5..274c2d096 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -21,6 +21,10 @@
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.5",
+ "@radix-ui/react-separator": "^1.1.7",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
@@ -42,6 +46,8 @@
"rehype-external-links": "^3.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
+ "swr": "^2.3.6",
+ "tailwind-merge": "^2.6.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^13.0.0",
@@ -3826,6 +3832,194 @@
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-switch": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
+ "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="
+ },
+ "node_modules/@radix-ui/react-tabs": {
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
+ "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
+ "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
+ "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz",
+ "integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.10",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.7",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-visually-hidden": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
+ "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
+ "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
@@ -14782,6 +14976,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/swr": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz",
+ "integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==",
+ "dependencies": {
+ "dequal": "^2.0.3",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -15790,6 +15996,14 @@
}
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
diff --git a/ui/package.json b/ui/package.json
index f534122f5..004c69c7d 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -26,6 +26,10 @@
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.5",
+ "@radix-ui/react-separator": "^1.1.7",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
@@ -47,6 +51,8 @@
"rehype-external-links": "^3.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
+ "swr": "^2.3.6",
+ "tailwind-merge": "^2.6.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^13.0.0",
diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx
index 43f9c75e0..257394b9b 100644
--- a/ui/src/app/layout.tsx
+++ b/ui/src/app/layout.tsx
@@ -8,6 +8,7 @@ import { Footer } from "@/components/Footer";
import { ThemeProvider } from "@/components/ThemeProvider";
import { Toaster } from "@/components/ui/sonner";
import { AppInitializer } from "@/components/AppInitializer";
+import { SettingsProvider } from "@/contexts/SettingsContext";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -21,20 +22,22 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
);
}
diff --git a/ui/src/components/AgentList.tsx b/ui/src/components/AgentList.tsx
index 2cda4f683..a5a0fce13 100644
--- a/ui/src/components/AgentList.tsx
+++ b/ui/src/components/AgentList.tsx
@@ -7,6 +7,9 @@ import { ErrorState } from "./ErrorState";
import { Button } from "./ui/button";
import { LoadingState } from "./LoadingState";
import { useAgents } from "./AgentsProvider";
+import { RefreshIndicator } from "./RefreshIndicator";
+import { SettingsPanel } from "./SettingsPanel";
+import { ClientOnly } from "./ClientOnly";
export default function AgentList() {
const { agents , loading, error } = useAgents();
@@ -24,7 +27,11 @@ export default function AgentList() {
{agents?.length === 0 ? (
diff --git a/ui/src/components/AgentsProvider.tsx b/ui/src/components/AgentsProvider.tsx
index c6ae25b65..06d043066 100644
--- a/ui/src/components/AgentsProvider.tsx
+++ b/ui/src/components/AgentsProvider.tsx
@@ -1,11 +1,13 @@
"use client";
-import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react";
+import React, { createContext, useContext, ReactNode, useCallback, useMemo } from "react";
import { getAgent as getAgentAction, createAgent, getAgents } from "@/app/actions/agents";
import { getTools } from "@/app/actions/tools";
+import { getModels } from "@/app/actions/models";
import type { Agent, Tool, AgentResponse, BaseResponse, ModelConfig, ToolsResponse, AgentType, EnvVar } from "@/types";
-import { getModelConfigs } from "@/app/actions/modelConfigs";
import { isResourceNameValid } from "@/lib/utils";
+import { useSettings } from "@/contexts/SettingsContext";
+import useSWR from "swr";
interface ValidationErrors {
name?: string;
@@ -58,6 +60,7 @@ interface AgentsContextType {
updateAgent: (agentData: AgentFormData) => Promise>;
getAgent: (name: string, namespace: string) => Promise;
validateAgentData: (data: Partial) => ValidationErrors;
+ isRefreshing: boolean;
}
const AgentsContext = createContext(undefined);
@@ -75,60 +78,68 @@ interface AgentsProviderProps {
}
export function AgentsProvider({ children }: AgentsProviderProps) {
- const [agents, setAgents] = useState([]);
- const [error, setError] = useState("");
- const [loading, setLoading] = useState(true);
- const [tools, setTools] = useState([]);
- const [models, setModels] = useState([]);
-
- const fetchAgents = useCallback(async () => {
- try {
- setLoading(true);
- const agentsResult = await getAgents();
-
- if (!agentsResult.data || agentsResult.error) {
- throw new Error(agentsResult.error || "Failed to fetch agents");
- }
-
- setAgents(agentsResult.data);
- setError("");
- } catch (err) {
- setError(err instanceof Error ? err.message : "An unexpected error occurred");
- } finally {
- setLoading(false);
- }
+ const { autoRefreshEnabled, autoRefreshInterval } = useSettings();
+
+ // Use existing server action fetchers (they include sorting logic)
+ const agentsFetcher = useCallback(async () => {
+ const result = await getAgents();
+ if (result.error) throw new Error(result.error);
+ return result.data || [];
}, []);
- const fetchModels = useCallback(async () => {
- try {
- const response = await getModelConfigs();
- if (!response.data || response.error) {
- throw new Error(response.error || "Failed to fetch models");
- }
-
- setModels(response.data);
- setError("");
- } catch (err) {
- console.error("Error fetching models:", err);
- setError(err instanceof Error ? err.message : "An unexpected error occurred");
- } finally {
- setLoading(false);
- }
+ const toolsFetcher = useCallback(async () => {
+ // getTools returns ToolsResponse[] directly, throws on error
+ return await getTools();
}, []);
- const fetchTools = useCallback(async () => {
- try {
- setLoading(true);
- const response = await getTools();
- setTools(response);
- setError("");
- } catch (err) {
- console.error("Error fetching tools:", err);
- setError(err instanceof Error ? err.message : "An unexpected error occurred");
- } finally {
- setLoading(false);
- }
+ const modelsFetcher = useCallback(async () => {
+ // Note: models in AgentsProvider appears unused - model selection uses getModelConfigs() directly
+ // Return empty array to satisfy type
+ return [];
}, []);
+
+ // Memoize SWR config so it's stable and reactive
+ const swrConfig = useMemo(() => ({
+ refreshInterval: autoRefreshEnabled ? autoRefreshInterval : 0,
+ revalidateOnFocus: true,
+ revalidateOnReconnect: true,
+ dedupingInterval: 0, // Disable deduping to ensure every request goes through
+ revalidateIfStale: true, // Always revalidate if data is stale
+ refreshWhenHidden: false, // Don't refresh when tab is hidden
+ refreshWhenOffline: false, // Don't refresh when offline
+ }), [autoRefreshEnabled, autoRefreshInterval]);
+
+ // SWR hooks using server actions (already includes alphabetical sorting)
+ const { data: agents = [], error: agentsError, isLoading: agentsLoading, mutate: mutateAgents } = useSWR(
+ 'agents-list',
+ agentsFetcher,
+ swrConfig
+ );
+
+ const { data: tools = [], error: toolsError, isLoading: toolsLoading } = useSWR(
+ 'tools-list',
+ toolsFetcher,
+ // Tools change less frequently, so less aggressive refresh
+ { revalidateOnFocus: false, refreshInterval: undefined }
+ );
+
+ const { data: models = [], error: modelsError, isLoading: modelsLoading } = useSWR(
+ 'models-list',
+ modelsFetcher,
+ // Models change even less frequently
+ { revalidateOnFocus: false, refreshInterval: undefined }
+ );
+
+ // Combine loading states
+ const loading = agentsLoading || toolsLoading || modelsLoading;
+ const error = agentsError?.message || toolsError?.message || modelsError?.message || "";
+ const isRefreshing = agentsLoading && agents.length > 0; // Only consider it "refreshing" if we have data already
+
+ // Manual refresh functions using SWR's mutate
+ const refreshAgents = useCallback(async () => {
+ await mutateAgents();
+ }, [mutateAgents]);
+
// Validation logic moved from the component
const validateAgentData = useCallback((data: Partial): ValidationErrors => {
@@ -174,11 +185,10 @@ export function AgentsProvider({ children }: AgentsProviderProps) {
// Get agent by ID function
const getAgent = useCallback(async (name: string, namespace: string): Promise => {
try {
- // Fetch all agents
+ // Fetch single agent
const agentResult = await getAgentAction(name, namespace);
if (!agentResult.data || agentResult.error) {
console.error("Failed to get agent:", agentResult.error);
- setError("Failed to get agent");
return null;
}
@@ -191,12 +201,11 @@ export function AgentsProvider({ children }: AgentsProviderProps) {
return agent;
} catch (error) {
console.error("Error getting agent by name and namespace:", error);
- setError(error instanceof Error ? error.message : "Failed to get agent");
return null;
}
}, []);
- // Agent creation logic moved from the component
+ // Agent creation logic
const createNewAgent = useCallback(async (agentData: AgentFormData) => {
try {
const errors = validateAgentData(agentData);
@@ -208,7 +217,7 @@ export function AgentsProvider({ children }: AgentsProviderProps) {
if (!result.error) {
// Refresh agents to get the newly created one
- await fetchAgents();
+ await mutateAgents();
}
return result;
@@ -219,7 +228,7 @@ export function AgentsProvider({ children }: AgentsProviderProps) {
error: error instanceof Error ? error.message : "Failed to create agent",
};
}
- }, [fetchAgents, validateAgentData]);
+ }, [mutateAgents, validateAgentData]);
// Update existing agent
const updateAgent = useCallback(async (agentData: AgentFormData): Promise> => {
@@ -236,7 +245,7 @@ export function AgentsProvider({ children }: AgentsProviderProps) {
if (!result.error) {
// Refresh agents to get the updated one
- await fetchAgents();
+ await mutateAgents();
}
return result;
@@ -247,14 +256,7 @@ export function AgentsProvider({ children }: AgentsProviderProps) {
error: error instanceof Error ? error.message : "Failed to update agent",
};
}
- }, [fetchAgents, validateAgentData]);
-
- // Initial fetches
- useEffect(() => {
- fetchAgents();
- fetchTools();
- fetchModels();
- }, [fetchAgents, fetchTools, fetchModels]);
+ }, [mutateAgents, validateAgentData]);
const value = {
agents,
@@ -262,12 +264,12 @@ export function AgentsProvider({ children }: AgentsProviderProps) {
loading,
error,
tools,
- refreshAgents: fetchAgents,
- refreshModels: fetchModels,
+ refreshAgents,
createNewAgent,
updateAgent,
getAgent,
validateAgentData,
+ isRefreshing,
};
return {children};
diff --git a/ui/src/components/ClientOnly.tsx b/ui/src/components/ClientOnly.tsx
new file mode 100644
index 000000000..50883f978
--- /dev/null
+++ b/ui/src/components/ClientOnly.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { useEffect, useState } from 'react';
+
+interface ClientOnlyProps {
+ children: React.ReactNode;
+ fallback?: React.ReactNode;
+}
+
+export function ClientOnly({ children, fallback = null }: ClientOnlyProps) {
+ const [hasMounted, setHasMounted] = useState(false);
+
+ useEffect(() => {
+ setHasMounted(true);
+ }, []);
+
+ if (!hasMounted) {
+ return fallback as React.ReactElement;
+ }
+
+ return children as React.ReactElement;
+}
\ No newline at end of file
diff --git a/ui/src/components/RefreshIndicator.tsx b/ui/src/components/RefreshIndicator.tsx
new file mode 100644
index 000000000..bd6839b5b
--- /dev/null
+++ b/ui/src/components/RefreshIndicator.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { RefreshCw } from "lucide-react";
+import { useAgents } from "./AgentsProvider";
+import { cn } from "@/lib/utils";
+
+export function RefreshIndicator() {
+ const { isRefreshing } = useAgents();
+
+ if (!isRefreshing) {
+ return null;
+ }
+
+ return (
+
+
+ Updating...
+
+ );
+}
diff --git a/ui/src/components/SettingsPanel.tsx b/ui/src/components/SettingsPanel.tsx
new file mode 100644
index 000000000..c75e21789
--- /dev/null
+++ b/ui/src/components/SettingsPanel.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import { Settings, RefreshCw } from "lucide-react";
+import { Button } from "./ui/button";
+import { Switch } from "./ui/switch";
+import { Label } from "./ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "./ui/popover";
+import { useSettings } from "@/contexts/SettingsContext";
+import { useAgents } from "./AgentsProvider";
+import { ClientOnly } from "./ClientOnly";
+
+const REFRESH_INTERVALS = [
+ { label: "5 seconds", value: 5000 },
+ { label: "10 seconds", value: 10000 },
+ { label: "30 seconds", value: 30000 },
+ { label: "1 minute", value: 60000 },
+ { label: "5 minutes", value: 300000 },
+];
+
+function SettingsPanelComponent() {
+ const {
+ autoRefreshEnabled,
+ autoRefreshInterval,
+ setAutoRefreshEnabled,
+ setAutoRefreshInterval
+ } = useSettings();
+ const { refreshAgents } = useAgents();
+
+ const handleManualRefresh = () => {
+ // This will trigger SWR to refetch all agent data
+ refreshAgents();
+ };
+
+ const getCurrentIntervalLabel = () => {
+ const interval = REFRESH_INTERVALS.find(i => i.value === autoRefreshInterval);
+ return interval ? interval.label : "30 seconds";
+ };
+
+ return (
+
+ {/* Manual refresh button */}
+
+
+ {/* Settings popover */}
+
+
+
+
+
+
+
+
Auto-refresh Settings
+
+ Configure automatic data refresh preferences
+
+
+
+
+
+
+
+
+ Automatically update data in the background
+
+
+
+
+
+ {autoRefreshEnabled && (
+
+
+
+
+ How often to check for updates
+
+
+ )}
+
+
+
+
+
+ );
+}
+
+export function SettingsPanel() {
+ return (
+
+
+
+ );
+}
diff --git a/ui/src/components/ui/switch.tsx b/ui/src/components/ui/switch.tsx
new file mode 100644
index 000000000..b7f4d8a19
--- /dev/null
+++ b/ui/src/components/ui/switch.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+import * as React from "react"
+import * as SwitchPrimitives from "@radix-ui/react-switch"
+
+import { cn } from "@/lib/utils"
+
+const Switch = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+Switch.displayName = SwitchPrimitives.Root.displayName
+
+export { Switch }
\ No newline at end of file
diff --git a/ui/src/contexts/SettingsContext.tsx b/ui/src/contexts/SettingsContext.tsx
new file mode 100644
index 000000000..e91a59297
--- /dev/null
+++ b/ui/src/contexts/SettingsContext.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import React, { createContext, useContext, useState, useEffect, ReactNode } from "react";
+
+interface SettingsContextType {
+ autoRefreshEnabled: boolean;
+ autoRefreshInterval: number; // in milliseconds
+ setAutoRefreshEnabled: (enabled: boolean) => void;
+ setAutoRefreshInterval: (interval: number) => void;
+ // SWR configuration helpers
+ getSwrConfig: () => {
+ refreshInterval?: number;
+ revalidateOnFocus: boolean;
+ revalidateOnReconnect: boolean;
+ };
+}
+
+const SettingsContext = createContext(undefined);
+
+export function useSettings() {
+ const context = useContext(SettingsContext);
+ if (context === undefined) {
+ throw new Error("useSettings must be used within a SettingsProvider");
+ }
+ return context;
+}
+
+interface SettingsProviderProps {
+ children: ReactNode;
+}
+
+// Default settings
+const DEFAULT_AUTO_REFRESH_ENABLED = true;
+const DEFAULT_AUTO_REFRESH_INTERVAL = 5000; // 5 seconds
+
+export function SettingsProvider({ children }: SettingsProviderProps) {
+ const [autoRefreshEnabled, setAutoRefreshEnabledState] = useState(DEFAULT_AUTO_REFRESH_ENABLED);
+ const [autoRefreshInterval, setAutoRefreshIntervalState] = useState(DEFAULT_AUTO_REFRESH_INTERVAL);
+ const [isClient, setIsClient] = useState(false);
+
+ // Set isClient to true after hydration to avoid SSR mismatch
+ useEffect(() => {
+ setIsClient(true);
+ }, []);
+
+ // Load settings from localStorage on mount (only on client)
+ useEffect(() => {
+ if (isClient) {
+ const savedAutoRefreshEnabled = localStorage.getItem('kagent.autoRefreshEnabled');
+ const savedAutoRefreshInterval = localStorage.getItem('kagent.autoRefreshInterval');
+
+ if (savedAutoRefreshEnabled !== null) {
+ setAutoRefreshEnabledState(JSON.parse(savedAutoRefreshEnabled));
+ }
+
+ if (savedAutoRefreshInterval !== null) {
+ setAutoRefreshIntervalState(parseInt(savedAutoRefreshInterval, 10));
+ }
+ }
+ }, [isClient]);
+
+ const setAutoRefreshEnabled = (enabled: boolean) => {
+ setAutoRefreshEnabledState(enabled);
+ if (isClient) {
+ localStorage.setItem('kagent.autoRefreshEnabled', JSON.stringify(enabled));
+ }
+ };
+
+ const setAutoRefreshInterval = (interval: number) => {
+ setAutoRefreshIntervalState(interval);
+ if (isClient) {
+ localStorage.setItem('kagent.autoRefreshInterval', interval.toString());
+ }
+ };
+
+ const getSwrConfig = () => ({
+ refreshInterval: (isClient && autoRefreshEnabled) ? autoRefreshInterval : undefined,
+ revalidateOnFocus: true,
+ revalidateOnReconnect: true,
+ });
+
+ const value = {
+ autoRefreshEnabled: isClient ? autoRefreshEnabled : false, // Disable auto-refresh until client-side
+ autoRefreshInterval,
+ setAutoRefreshEnabled,
+ setAutoRefreshInterval,
+ getSwrConfig,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
\ No newline at end of file
diff --git a/ui/src/lib/fetcher.ts b/ui/src/lib/fetcher.ts
new file mode 100644
index 000000000..62b3d7ca9
--- /dev/null
+++ b/ui/src/lib/fetcher.ts
@@ -0,0 +1,48 @@
+/**
+ * Fetcher function for SWR
+ * Handles API requests with proper error handling and BaseResponse unwrapping
+ */
+export const fetcher = async (url: string): Promise => {
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ const error = new Error(`HTTP ${response.status}: ${response.statusText}`) as Error & {
+ info?: unknown;
+ status?: number;
+ };
+ // Attach extra info to the error object
+ error.info = await response.json().catch(() => ({}));
+ error.status = response.status;
+ throw error;
+ }
+
+ const jsonData = await response.json();
+
+ // If the response follows the BaseResponse format, return the data property
+ // Otherwise, return the raw response
+ if (jsonData && typeof jsonData === 'object' && 'data' in jsonData) {
+ return jsonData.data;
+ }
+
+ return jsonData;
+};
+
+import { getBackendUrl } from '@/lib/utils';
+
+/**
+ * Build full API URL for a given endpoint using existing backend URL utility
+ */
+export const buildApiUrl = (endpoint: string): string => {
+ const baseUrl = getBackendUrl();
+ return `${baseUrl}${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`;
+};
+
+/**
+ * Type-safe fetcher for specific data types
+ */
+export const createTypedFetcher = () => {
+ return async (url: string): Promise => {
+ const result = await fetcher(url);
+ return result as T;
+ };
+};