diff --git a/core/http/react-ui/src/contexts/OperationsContext.jsx b/core/http/react-ui/src/contexts/OperationsContext.jsx
new file mode 100644
index 000000000..716fd8ada
--- /dev/null
+++ b/core/http/react-ui/src/contexts/OperationsContext.jsx
@@ -0,0 +1,100 @@
+import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
+import { operationsApi } from '../utils/api'
+import { useAuth } from '../context/AuthContext'
+
+// Serialize ops into a stable comparison key. Each op is a flat map of
+// primitives, so JSON.stringify is good enough and stable as long as the
+// server emits keys in the same order (Go's map iteration into JSON happens
+// to be stable here because we build an explicit map[string]any).
+function serializeOps(ops) {
+ return JSON.stringify(ops)
+}
+
+const OperationsContext = createContext(null)
+
+// Single shared poller for /api/operations. Before this provider existed,
+// each useOperations() call ran its own setInterval; with OperationsBar
+// always mounted plus the per-page consumers (Models, Backends, Chat), the
+// browser was firing 2-3 polls per second against the API for the lifetime
+// of the session.
+export function OperationsProvider({ children, pollInterval = 1000 }) {
+ const [operations, setOperations] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const { isAdmin } = useAuth()
+ const intervalRef = useRef(null)
+ const lastSerializedRef = useRef('[]')
+
+ const fetchOperations = useCallback(async () => {
+ if (!isAdmin) {
+ setLoading((prev) => (prev ? false : prev))
+ return
+ }
+ try {
+ const data = await operationsApi.list()
+ const ops = data?.operations || (Array.isArray(data) ? data : [])
+
+ const serialized = serializeOps(ops)
+ if (serialized !== lastSerializedRef.current) {
+ lastSerializedRef.current = serialized
+ setOperations(ops)
+ }
+
+ setError((prev) => (prev === null ? prev : null))
+ } catch (err) {
+ setError((prev) => (prev === err.message ? prev : err.message))
+ } finally {
+ setLoading((prev) => (prev ? false : prev))
+ }
+ }, [isAdmin])
+
+ useEffect(() => {
+ if (!isAdmin) return
+ fetchOperations()
+ intervalRef.current = setInterval(fetchOperations, pollInterval)
+ return () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current)
+ intervalRef.current = null
+ }
+ }
+ }, [fetchOperations, pollInterval, isAdmin])
+
+ const cancelOperation = useCallback(async (jobID) => {
+ try {
+ await operationsApi.cancel(jobID)
+ await fetchOperations()
+ } catch (err) {
+ setError(err.message)
+ }
+ }, [fetchOperations])
+
+ const dismissFailedOp = useCallback(async (opId) => {
+ try {
+ const op = operations.find((o) => o.id === opId)
+ if (op?.jobID) {
+ await operationsApi.dismiss(op.jobID)
+ await fetchOperations()
+ }
+ } catch {
+ // Ignore dismiss errors
+ }
+ }, [operations, fetchOperations])
+
+ const value = {
+ operations,
+ loading,
+ error,
+ cancelOperation,
+ dismissFailedOp,
+ refetch: fetchOperations,
+ }
+
+ return {children}
+}
+
+export function useOperations() {
+ const ctx = useContext(OperationsContext)
+ if (!ctx) throw new Error('useOperations must be used within OperationsProvider')
+ return ctx
+}
diff --git a/core/http/react-ui/src/hooks/useOperations.js b/core/http/react-ui/src/hooks/useOperations.js
index 56c2ad846..a4d65f4ad 100644
--- a/core/http/react-ui/src/hooks/useOperations.js
+++ b/core/http/react-ui/src/hooks/useOperations.js
@@ -1,98 +1,4 @@
-import { useState, useEffect, useCallback, useRef } from 'react'
-import { operationsApi } from '../utils/api'
-import { useAuth } from '../context/AuthContext'
-
-// Serialize ops into a stable comparison key. Each op is a flat map of
-// primitives, so JSON.stringify is good enough and stable as long as the
-// server emits keys in the same order (Go's map iteration into JSON happens
-// to be stable here because we build an explicit map[string]any).
-function serializeOps(ops) {
- return JSON.stringify(ops)
-}
-
-export function useOperations(pollInterval = 1000) {
- const [operations, setOperations] = useState([])
- const [loading, setLoading] = useState(true)
- const [error, setError] = useState(null)
- const intervalRef = useRef(null)
- const { isAdmin } = useAuth()
-
- const previousCountRef = useRef(0)
- const onAllCompleteRef = useRef(null)
- // Track the last payload we wrote into state. Each poll otherwise produces
- // a fresh array reference even when nothing changed, and that re-render
- // ripples into the Chat page — wiping the user's text selection mid-read
- // (#9904).
- const lastSerializedRef = useRef('[]')
-
- const fetchOperations = useCallback(async () => {
- if (!isAdmin) {
- setLoading((prev) => (prev ? false : prev))
- return
- }
- try {
- const data = await operationsApi.list()
- const ops = data?.operations || (Array.isArray(data) ? data : [])
-
- const serialized = serializeOps(ops)
- if (serialized !== lastSerializedRef.current) {
- lastSerializedRef.current = serialized
- setOperations(ops)
- }
-
- // Separate active (non-failed) operations from failed ones
- const activeOps = ops.filter(op => !op.error)
- const failedOps = ops.filter(op => op.error)
-
- // Notify when all operations complete (no active or failed remaining)
- if (previousCountRef.current > 0 && activeOps.length === 0 && failedOps.length === 0) {
- onAllCompleteRef.current?.()
- }
- previousCountRef.current = activeOps.length
-
- setError((prev) => (prev === null ? prev : null))
- } catch (err) {
- setError((prev) => (prev === err.message ? prev : err.message))
- } finally {
- setLoading((prev) => (prev ? false : prev))
- }
- }, [isAdmin])
-
- const cancelOperation = useCallback(async (jobID) => {
- try {
- await operationsApi.cancel(jobID)
- await fetchOperations()
- } catch (err) {
- setError(err.message)
- }
- }, [fetchOperations])
-
- // Dismiss a failed operation (acknowledge the error and remove it)
- const dismissFailedOp = useCallback(async (opId) => {
- try {
- const op = operations.find(o => o.id === opId)
- if (op?.jobID) {
- await operationsApi.dismiss(op.jobID)
- await fetchOperations()
- }
- } catch {
- // Ignore dismiss errors
- }
- }, [operations, fetchOperations])
-
- useEffect(() => {
- if (!isAdmin) return
- fetchOperations()
- intervalRef.current = setInterval(fetchOperations, pollInterval)
- return () => {
- if (intervalRef.current) clearInterval(intervalRef.current)
- }
- }, [fetchOperations, pollInterval, isAdmin])
-
- // Allow callers to register a callback for when all operations finish
- const onAllComplete = useCallback((cb) => {
- onAllCompleteRef.current = cb
- }, [])
-
- return { operations, loading, error, cancelOperation, dismissFailedOp, refetch: fetchOperations, onAllComplete }
-}
+// useOperations now lives in OperationsContext so all consumers
+// (OperationsBar, Models, Backends, Chat) share a single poller instead
+// of each spinning up its own setInterval against /api/operations.
+export { useOperations } from '../contexts/OperationsContext'
diff --git a/core/http/react-ui/src/main.jsx b/core/http/react-ui/src/main.jsx
index ec62ed766..2cf7acef6 100644
--- a/core/http/react-ui/src/main.jsx
+++ b/core/http/react-ui/src/main.jsx
@@ -4,6 +4,7 @@ import { RouterProvider } from 'react-router-dom'
import { ThemeProvider } from './contexts/ThemeContext'
import { BrandingProvider } from './contexts/BrandingContext'
import { AuthProvider } from './context/AuthContext'
+import { OperationsProvider } from './contexts/OperationsContext'
import { router } from './router'
import './i18n'
import '@fortawesome/fontawesome-free/css/all.min.css'
@@ -30,7 +31,9 @@ createRoot(document.getElementById('root')).render(
-
+
+
+