diff --git a/webui/backend/app/api/events.py b/webui/backend/app/api/events.py new file mode 100644 index 000000000..73cb3158c --- /dev/null +++ b/webui/backend/app/api/events.py @@ -0,0 +1,32 @@ +"""Server-sent stream of change notices for the app shell. + +The frontend opens one long-lived GET /api/events (EventSource) per tab. Every +notice published on the in-process `event_bus` — one per successful management +write — is forwarded here as an SSE `change` event, so a mutation made by ANY +caller (including an external script) refreshes every open page immediately. + +Not enveloped: this is a stream, so the router deliberately omits EnvelopeRoute +(which buffers and re-wraps JSON bodies). +""" +import json + +from fastapi import APIRouter +from sse_starlette.sse import EventSourceResponse + +from app.core.events import event_bus + +router = APIRouter(prefix="/api", tags=["events"]) + + +@router.get("/events") +async def events(): + # `@ant-design/x-sdk` is not involved here (native EventSource consumes this), + # but keep LF frame separation consistent with the chat stream. sse-starlette + # sends periodic pings on its own, which both keep the connection alive and + # detect a dropped client so the subscriber generator is cancelled. + async def stream(): + yield {"event": "ready"} + async for event in event_bus.subscribe(): + yield {"event": "change", "data": json.dumps(event)} + + return EventSourceResponse(stream(), sep="\n") diff --git a/webui/backend/app/core/envelope.py b/webui/backend/app/core/envelope.py index 8514bcd6a..e783ebfb7 100644 --- a/webui/backend/app/core/envelope.py +++ b/webui/backend/app/core/envelope.py @@ -41,6 +41,28 @@ def error_response(status_code: int, message: str, *, ) +_CHANGE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) +# Successful writes under these prefixes must NOT broadcast: the presence poll +# would feed back into itself (every browser would refresh on every heartbeat), +# the chat stream/control own their own SSE, and recovery runs before the app is +# even usable. Everything else that mutates state notifies the open pages. +_NO_BROADCAST_PREFIXES = ("/api/presence", "/api/chat", "/api/recovery", "/api/events") + + +def _broadcast_change(request: Request, status: int) -> None: + """Announce a successful management write so every open page can refresh. + Carries the path and method; the frontend maps them to the lists to reload.""" + if request.method not in _CHANGE_METHODS or not (200 <= status < 300): + return + path = request.url.path + if any(path.startswith(p) for p in _NO_BROADCAST_PREFIXES): + return + # Imported lazily so this core module stays free of app-package import order + # concerns; publishing never blocks or raises. + from app.core.events import event_bus + event_bus.publish({"path": path, "method": request.method}) + + class EnvelopeRoute(APIRoute): """Wraps a route's serialized success payload into the standard envelope. @@ -68,6 +90,7 @@ async def custom(request: Request) -> Response: # Preserve the RESTful status code (e.g. 201 Created). A 204 becomes # 200 since the envelope now carries a body. status = 200 if response.status_code == 204 else response.status_code + _broadcast_change(request, status) return success_response(data, status_code=status) return custom diff --git a/webui/backend/app/core/events.py b/webui/backend/app/core/events.py new file mode 100644 index 000000000..d3cb05355 --- /dev/null +++ b/webui/backend/app/core/events.py @@ -0,0 +1,61 @@ +"""Process-local pub/sub for pushing "something changed" notices to the UI. + +A single-process broadcast hub: management endpoints publish a change notice +after a successful write, and every connected browser (subscribed via +GET /api/events) receives it and refreshes the affected lists. This is what +lets a change made by an EXTERNAL caller (a script hitting the REST API) reach +an already-open page, which the in-tab event bus on the frontend cannot do. + +Single-process only: subscribers live in one event loop's memory. A multi-worker +deployment would need a cross-process channel (e.g. Redis pub/sub) behind the +same `publish`/`subscribe` surface. +""" +from __future__ import annotations + +import asyncio +from typing import Any, AsyncIterator + +# Per-subscriber buffer. Change notices are tiny and rare; this only bounds a +# subscriber that has stopped reading (a wedged connection) so it cannot grow +# without limit. On overflow the oldest notice is dropped for the LATEST, since +# every notice triggers the same "refresh" and the freshest one wins. +_QUEUE_MAXSIZE = 128 + + +class EventBus: + """Fan out change notices to every live subscriber, in-process.""" + + def __init__(self, max_queue: int = _QUEUE_MAXSIZE) -> None: + self._subscribers: set[asyncio.Queue] = set() + self._max = max_queue + + def publish(self, event: dict[str, Any]) -> None: + """Deliver `event` to every subscriber. Never raises or blocks: a full + buffer drops its oldest notice so the newest still lands.""" + for q in list(self._subscribers): + try: + q.put_nowait(event) + except asyncio.QueueFull: + try: + q.get_nowait() + q.put_nowait(event) + except Exception: + pass + + async def subscribe(self) -> AsyncIterator[dict[str, Any]]: + """Yield notices until the consumer stops iterating (client disconnect + cancels the generator, and the `finally` unregisters the queue).""" + q: asyncio.Queue = asyncio.Queue(maxsize=self._max) + self._subscribers.add(q) + try: + while True: + yield await q.get() + finally: + self._subscribers.discard(q) + + @property + def subscriber_count(self) -> int: + return len(self._subscribers) + + +event_bus = EventBus() diff --git a/webui/backend/app/main.py b/webui/backend/app/main.py index a7f8a8a9e..a9a157894 100644 --- a/webui/backend/app/main.py +++ b/webui/backend/app/main.py @@ -6,6 +6,7 @@ from app.api import ( agent_settings, chat, + events, instructions, mcps, memory, @@ -88,6 +89,7 @@ async def _shutdown() -> None: skill_index.stop() app.include_router(chat.router) + app.include_router(events.router) app.include_router(presence.router) app.include_router(projects.router) app.include_router(sessions.router) diff --git a/webui/frontend/app/components/common/Composer.tsx b/webui/frontend/app/components/common/Composer.tsx index 72ce629a4..311b818f3 100644 --- a/webui/frontend/app/components/common/Composer.tsx +++ b/webui/frontend/app/components/common/Composer.tsx @@ -11,7 +11,13 @@ import { PillButton } from './PillButton' import { api } from '~/lib/api' import { useSessionModel, type SessionModelSelection } from '~/lib/sessionModel' import { useModelChanged } from '~/lib/modelChanged' -import { useOnMcpSkillChanged, dispatchWorkspaceChanged } from '~/lib/events' +import { + useOnMcpSkillChanged, + useOnModelsChanged, + useOnProjectSettingsChanged, + useOnProjectsChanged, + dispatchWorkspaceChanged +} from '~/lib/events' import type { ChatFileRef } from '~/lib/agentProvider' import { useT } from '~/lib/i18n' import type { @@ -299,6 +305,50 @@ export function Composer({ }, [effectiveProject?.id]) useOnMcpSkillChanged(refreshMcpSkill) + // Re-fetch when the model catalog changes elsewhere (Settings → Models, or an + // external API call relayed by the server-event bridge). The picker seeds + // these from the loader once at mount, so without this a new model never + // appears until the component remounts. + const refreshModels = useCallback(() => { + api + .listProviders() + .then(setProviders) + .catch(() => {}) + api + .listModels() + .then(setModels) + .catch(() => {}) + api + .getAgentSettings() + .then(setSettings) + .catch(() => {}) + }, []) + useOnModelsChanged(refreshModels) + + // Web-search config is edited on Settings → Search and can arrive via an + // external API call; both relay as a project-settings change. The pill seeds + // from the loader once at mount, so without this a toggle elsewhere never + // reflects here until the Composer remounts. + const refreshSearchSettings = useCallback(() => { + api + .getSearchSettings() + .then(setSearchSettings) + .catch(() => {}) + }, []) + useOnProjectSettingsChanged(refreshSearchSettings) + + // The picker seeds its project list from the loader once at mount; a project + // added, renamed or removed elsewhere (or by an external API call) reaches it + // through this event. Only meaningful when the picker is shown (homepage). + const refreshProjects = useCallback(() => { + if (!hasProjectPicker) return + api + .listProjects() + .then(setProjects) + .catch(() => {}) + }, [hasProjectPicker]) + useOnProjectsChanged(refreshProjects) + const mergedMcps = useMemo( () => [...globalMcps, ...projectMcps], [globalMcps, projectMcps] diff --git a/webui/frontend/app/components/layout/Sidebar.tsx b/webui/frontend/app/components/layout/Sidebar.tsx index ea9594848..2a9670289 100644 --- a/webui/frontend/app/components/layout/Sidebar.tsx +++ b/webui/frontend/app/components/layout/Sidebar.tsx @@ -457,12 +457,16 @@ function ProjectRowActions({ okButtonProps: { danger: true }, onOk: async () => { await api.deleteProject(project.id) - // Awaited: see the create handler — a navigation in the same tick would - // interrupt this refresh, and the route change itself no longer triggers - // one, so the deleted project would linger in the sidebar. + // Leave the deleted project's page and let that navigation fully settle + // before revalidating. Revalidating while the switch is still in flight + // aborts it (the router restarts the pending load), and revalidating in + // place re-runs the now-missing project's loader and flashes its 404 — + // awaiting navigate lands us on a live URL, so the refresh repaints the + // sidebar there instead. + if (location.pathname.startsWith(`/projects/${project.id}`)) { + await navigate('/', { replace: true }) + } await revalidator.revalidate() - if (location.pathname.startsWith(`/projects/${project.id}`)) - navigate('/') } }) } @@ -946,11 +950,14 @@ function SessionItem({ okButtonProps: { danger: true }, onOk: async () => { await api.deleteSession(session.id) - // Awaited for the same reason as project delete: the route change that - // follows no longer revalidates on its own. - await revalidator.revalidate() + // Same as project delete: leave the deleted session's page and let that + // navigation settle before revalidating, so the refresh runs on a live + // URL instead of aborting the switch or flashing the session's 404. const isActive = location.pathname.includes(`/sessions/${session.id}`) - if (isActive) navigate(`/projects/${projectId}`) + if (isActive) { + await navigate(`/projects/${projectId}`, { replace: true }) + } + await revalidator.revalidate() } }) } diff --git a/webui/frontend/app/components/project/McpTabPanel.tsx b/webui/frontend/app/components/project/McpTabPanel.tsx index 0ac2b5320..888f14547 100644 --- a/webui/frontend/app/components/project/McpTabPanel.tsx +++ b/webui/frontend/app/components/project/McpTabPanel.tsx @@ -6,7 +6,7 @@ import { CardSkeletonGrid } from '~/components/common/CardSkeletonGrid' import { EmptyState, EmptyStateAction } from '~/components/common/EmptyState' import { MsaButton } from '~/components/common/MsaButton' import { api } from '~/lib/api' -import { dispatchMcpSkillChanged } from '~/lib/events' +import { dispatchMcpSkillChanged, useOnMcpSkillChanged } from '~/lib/events' import { useT } from '~/lib/i18n' import { useMcpHealth } from '~/lib/mcpHealth' import type { Mcp, Project, Scope } from '~/lib/types' @@ -105,6 +105,10 @@ export function McpTabPanel({ project }: Props) { setPage(1) }, [activeScope]) + // Refresh when the set changes elsewhere (the other tab, or an external API + // call relayed by the server-event bridge). `fresh` re-runs the health sweep. + useOnMcpSkillChanged(() => refresh(true)) + // Reset state when project changes (the scope itself is URL-driven; a // cross-project navigation carries no ?scope, which already means global). // Closing the JSON dialog matters: its document belongs to the scope it was diff --git a/webui/frontend/app/components/project/SkillTabPanel.tsx b/webui/frontend/app/components/project/SkillTabPanel.tsx index b7478f99d..04698fcdf 100644 --- a/webui/frontend/app/components/project/SkillTabPanel.tsx +++ b/webui/frontend/app/components/project/SkillTabPanel.tsx @@ -5,7 +5,7 @@ import { CardSkeletonGrid } from '~/components/common/CardSkeletonGrid' import { EmptyState, EmptyStateAction } from '~/components/common/EmptyState' import { MsaButton } from '~/components/common/MsaButton' import { api } from '~/lib/api' -import { dispatchMcpSkillChanged } from '~/lib/events' +import { dispatchMcpSkillChanged, useOnMcpSkillChanged } from '~/lib/events' import { useT } from '~/lib/i18n' import type { Project, Scope, Skill } from '~/lib/types' import { SkillCard } from '~/components/resources/SkillCard' @@ -58,6 +58,10 @@ export function SkillTabPanel({ project }: Props) { setPage(1) }, [activeScope]) + // Refresh when the set changes elsewhere (the other tab, or an external API + // call relayed by the server-event bridge). + useOnMcpSkillChanged(refresh) + // Reset state when project changes (the scope itself is URL-driven). useEffect(() => { setPage(1) diff --git a/webui/frontend/app/components/resources/McpsPanel.tsx b/webui/frontend/app/components/resources/McpsPanel.tsx index 2243734c0..f5fb69034 100644 --- a/webui/frontend/app/components/resources/McpsPanel.tsx +++ b/webui/frontend/app/components/resources/McpsPanel.tsx @@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router' import { CardSkeletonGrid } from '~/components/common/CardSkeletonGrid' import { EmptyState, EmptyStateAction } from '~/components/common/EmptyState' import { api } from '~/lib/api' +import { useOnMcpSkillChanged } from '~/lib/events' import { useT } from '~/lib/i18n' import { useMcpHealth } from '~/lib/mcpHealth' import type { Mcp, Scope } from '~/lib/types' @@ -108,6 +109,10 @@ export function McpsPanel({ refresh() }, [activeScope]) + // Refresh when the set changes elsewhere (e.g. an external API call relayed by + // the server-event bridge). `fresh` re-runs the health sweep. + useOnMcpSkillChanged(() => refresh(true)) + const scopeBadge = activeScope === 'global' ? t.mcpImport.hubGlobalBadge diff --git a/webui/frontend/app/components/resources/SkillsPanel.tsx b/webui/frontend/app/components/resources/SkillsPanel.tsx index 9132848b8..2d8904d9e 100644 --- a/webui/frontend/app/components/resources/SkillsPanel.tsx +++ b/webui/frontend/app/components/resources/SkillsPanel.tsx @@ -5,6 +5,7 @@ import { CardSkeletonGrid } from '~/components/common/CardSkeletonGrid' import { EmptyState, EmptyStateAction } from '~/components/common/EmptyState' import { MsaSwitch } from '~/components/common/MsaSwitch' import { api } from '~/lib/api' +import { useOnMcpSkillChanged } from '~/lib/events' import { useT } from '~/lib/i18n' import type { Scope, Skill } from '~/lib/types' import { SkillCard } from './SkillCard' @@ -53,6 +54,10 @@ export function SkillsPanel({ refresh() }, [activeScope]) + // Refresh when the set changes elsewhere (e.g. an external API call relayed by + // the server-event bridge). + useOnMcpSkillChanged(refresh) + return (
diff --git a/webui/frontend/app/components/widgets/MemoryDocCard.tsx b/webui/frontend/app/components/widgets/MemoryDocCard.tsx index b2b4b3196..96118301f 100644 --- a/webui/frontend/app/components/widgets/MemoryDocCard.tsx +++ b/webui/frontend/app/components/widgets/MemoryDocCard.tsx @@ -5,6 +5,7 @@ import { DeferredSkeleton } from '~/components/common/DeferredSkeleton' import { EmptyState } from '~/components/common/EmptyState' import { Markdown } from '~/components/common/Markdown' import { api } from '~/lib/api' +import { useOnProjectSettingsChanged } from '~/lib/events' import { useT } from '~/lib/i18n' import type { Project } from '~/lib/types' import { WidgetCard } from './WidgetCard' @@ -57,6 +58,10 @@ export function MemoryDocCard({ project }: { project: Project }) { setLoaded(false) refresh() }, [refresh]) + // The document is also written from the chat side and by external API calls + // (both relayed as a project-settings change); re-fetch so the preview here + // never lags what the agent now reads. + useOnProjectSettingsChanged(refresh) const openEditor = () => { setDraft(content) diff --git a/webui/frontend/app/lib/events.ts b/webui/frontend/app/lib/events.ts index e13620be7..aa681ec02 100644 --- a/webui/frontend/app/lib/events.ts +++ b/webui/frontend/app/lib/events.ts @@ -81,6 +81,29 @@ export function useOnMcpSkillChanged(callback: () => void) { }, [callback]) } +// ─── Model catalog (providers / models / default) ────────────────────────── + +/** Dispatch after adding, editing, removing or reordering a provider or model, + * or changing the default model. Consumers that seed the model catalog into + * their own state (the Composer picker, the Settings → Models page) re-fetch + * providers + models + agent settings. Fired by the server-event bridge for + * `/api/models` and `/api/providers`, so an external API call updates the + * picker the same way an in-tab edit does. */ +export function dispatchModelsChanged() { + if (typeof window !== 'undefined') + window.dispatchEvent(new Event('msa:models-changed')) +} + +/** Re-run `callback` whenever the model catalog is changed by another component + * or an external API call. */ +export function useOnModelsChanged(callback: () => void) { + useEffect(() => { + if (typeof window === 'undefined') return + window.addEventListener('msa:models-changed', callback) + return () => window.removeEventListener('msa:models-changed', callback) + }, [callback]) +} + // ─── Session turn completion ─────────────────────────────────────────── /** Dispatch when a session's agent turn completes (done frame received). @@ -144,3 +167,25 @@ export function useOnProjectSettingsChanged(callback: () => void) { window.removeEventListener('msa:project-settings-changed', callback) }, [callback]) } + +// ─── Project list (create / rename / delete a project) ───────────────────── + +/** Dispatch after a project is created, renamed or deleted. Loader-backed views + * (sidebar, project detail) refresh through the layout revalidate, but the + * homepage Composer's project picker seeds its list into its own state once at + * mount — it re-fetches on this event so an externally added project shows up. + * Fired by the server-event bridge for `/api/projects`. */ +export function dispatchProjectsChanged() { + if (typeof window !== 'undefined') + window.dispatchEvent(new Event('msa:projects-changed')) +} + +/** Re-run `callback` whenever the project list changes elsewhere or via an + * external API call. */ +export function useOnProjectsChanged(callback: () => void) { + useEffect(() => { + if (typeof window === 'undefined') return + window.addEventListener('msa:projects-changed', callback) + return () => window.removeEventListener('msa:projects-changed', callback) + }, [callback]) +} diff --git a/webui/frontend/app/lib/serverEvents.tsx b/webui/frontend/app/lib/serverEvents.tsx new file mode 100644 index 000000000..4d2e6657c --- /dev/null +++ b/webui/frontend/app/lib/serverEvents.tsx @@ -0,0 +1,96 @@ +import { useEffect, useRef } from 'react' +import { useRevalidator } from 'react-router' +import { + dispatchMcpSkillChanged, + dispatchModelsChanged, + dispatchProjectSettingsChanged, + dispatchProjectsChanged, + dispatchWorkspaceChanged +} from '~/lib/events' + +/** + * Server → client change stream. + * + * Opens one long-lived EventSource on GET /api/events per tab and refreshes the + * affected lists whenever the backend reports a successful write. This is what + * lets a change made OUTSIDE this tab — an external script hitting the REST API, + * or another browser tab — reach the open page; the in-tab event bus in + * `~/lib/events` only fires for mutations this tab performs itself. + * + * Mounted once at the app shell (root) so it covers both the app and settings + * layouts with a single connection. + */ + +// An external batch (e.g. a script installing several skills in a row) arrives +// as a burst of notices; coalesce them into one refresh. +const COALESCE_MS = 250 + +export function ServerEventsBridge() { + const revalidator = useRevalidator() + const revalidateRef = useRef(revalidator.revalidate) + revalidateRef.current = revalidator.revalidate + + useEffect(() => { + if (typeof window === 'undefined' || typeof EventSource === 'undefined') + return + let timer = 0 + const pendingPaths = new Set() + + const flush = () => { + const paths = [...pendingPaths] + pendingPaths.clear() + // Loader-backed lists (models, projects, sessions, global MCPs/skills, + // agent/search settings) all refresh through one revalidate of the active + // routes — `shouldRevalidate` lets a same-URL revalidate pass. + revalidateRef.current() + // Consumers that hold their own state instead of reading loader data + // listen on the in-tab bus, so replay the matching in-tab event: an + // external change then looks exactly like a local one to them. + if (paths.some((p) => p.includes('/mcps') || p.includes('/skills'))) + dispatchMcpSkillChanged() + // Providers and models both feed the model picker, which seeds its own + // state from the loader and so never sees a bare revalidate. + if (paths.some((p) => p.includes('/models') || p.includes('/providers'))) + dispatchModelsChanged() + if (paths.some((p) => p.includes('/workspace'))) dispatchWorkspaceChanged() + // Project create/rename/delete: loader-backed lists ride the revalidate + // above, the Composer picker rides this. Excludes the nested settings + // routes (/projects/:id/mcps …) already covered by their own events. + if (paths.some((p) => /\/projects(\/[^/]+)?$/.test(p))) + dispatchProjectsChanged() + if ( + paths.some((p) => + /\/(memory|agent-settings|instructions|profile|search-settings)/.test( + p + ) + ) + ) + dispatchProjectSettingsChanged() + } + + const onChange = (e: MessageEvent) => { + try { + const parsed = JSON.parse(e.data) as { path?: string } + if (parsed?.path) pendingPaths.add(parsed.path) + } catch { + // Malformed frame — still refresh; a change definitely happened. + } + window.clearTimeout(timer) + timer = window.setTimeout(flush, COALESCE_MS) + } + + // EventSource reconnects on its own after a drop (honouring the server's + // retry hint), so there is nothing to rebuild by hand — bind the handler and + // close the connection on unmount. + const es = new EventSource('/api/events') + es.addEventListener('change', onChange) + + return () => { + window.clearTimeout(timer) + es.removeEventListener('change', onChange) + es.close() + } + }, []) + + return null +} diff --git a/webui/frontend/app/root.tsx b/webui/frontend/app/root.tsx index e26b9b913..fffb06a86 100644 --- a/webui/frontend/app/root.tsx +++ b/webui/frontend/app/root.tsx @@ -37,6 +37,7 @@ import { ThemeProvider, useTheme } from '~/lib/theme' +import { ServerEventsBridge } from '~/lib/serverEvents' import { MsaButton } from './components/common/MsaButton' interface RootData { @@ -258,6 +259,7 @@ function ThemedRoot({ children }: { children: React.ReactNode }) { + {children} diff --git a/webui/frontend/app/routes/settings/models.tsx b/webui/frontend/app/routes/settings/models.tsx index 2f1d4fe99..1feff2a3d 100644 --- a/webui/frontend/app/routes/settings/models.tsx +++ b/webui/frontend/app/routes/settings/models.tsx @@ -1,5 +1,5 @@ import { Button, Popconfirm, Select, Tooltip } from 'antd' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router' import { AddProviderModal } from '~/components/models/AddProviderModal' import { ModelEditModal } from '~/components/models/ModelEditModal' @@ -10,6 +10,7 @@ import { KeyStatusTag } from '~/components/common/KeyStatus' import { DeferredSkeleton } from '~/components/common/DeferredSkeleton' import { ScrollArea } from '~/components/common/ScrollArea' import { api } from '~/lib/api' +import { useOnModelsChanged } from '~/lib/events' import { useT } from '~/lib/i18n' import type { AgentSettings, Model, Provider } from '~/lib/types' import EditIcon from '~/assets/icons/edit.svg?react' @@ -59,32 +60,43 @@ export default function ModelsSettings() { const [dragId, setDragId] = useState(null) const [dropId, setDropId] = useState(null) - const refresh = () => - Promise.all([api.listProviders(), api.listModels(), api.getAgentSettings()]) - .then(([ps, ms, s]) => { - setProviders(ps) - setModels(ms) - setSettings(s) - // Default-select the first provider when nothing is selected. A seed - // naming a provider this instance does not have is dropped here rather - // than left selected, which would render as a blank detail pane. - setActiveProviderId((prev) => - prev && ps.some((p) => p.id === prev) ? prev : (ps[0]?.id ?? null) - ) - }) - // `null` gates the skeletons on this page, so a failure has to settle the - // lists to `[]` or they stay skeletons for good. `Promise.all` means any - // one of the three failing takes the other two down with it — the page - // then reads as empty rather than broken, which the toast has to explain. - // `settings` stays `null`: every read of it is optional-chained. - .catch(() => { - setProviders([]) - setModels([]) - }) + const refresh = useCallback( + () => + Promise.all([ + api.listProviders(), + api.listModels(), + api.getAgentSettings() + ]) + .then(([ps, ms, s]) => { + setProviders(ps) + setModels(ms) + setSettings(s) + // Default-select the first provider when nothing is selected. A seed + // naming a provider this instance does not have is dropped here rather + // than left selected, which would render as a blank detail pane. + setActiveProviderId((prev) => + prev && ps.some((p) => p.id === prev) ? prev : (ps[0]?.id ?? null) + ) + }) + // `null` gates the skeletons on this page, so a failure has to settle the + // lists to `[]` or they stay skeletons for good. `Promise.all` means any + // one of the three failing takes the other two down with it — the page + // then reads as empty rather than broken, which the toast has to explain. + // `settings` stays `null`: every read of it is optional-chained. + .catch(() => { + setProviders([]) + setModels([]) + }), + [] + ) useEffect(() => { refresh() - }, []) + }, [refresh]) + + // An external API call (or another tab) that adds/edits a model is relayed + // here by the server-event bridge, so this page reflects it without a reload. + useOnModelsChanged(refresh) const activeProvider = useMemo( () => providers?.find((p) => p.id === activeProviderId) ?? null, diff --git a/webui/frontend/app/routes/settings/personalization.tsx b/webui/frontend/app/routes/settings/personalization.tsx index abb83d31b..7f4be2f2b 100644 --- a/webui/frontend/app/routes/settings/personalization.tsx +++ b/webui/frontend/app/routes/settings/personalization.tsx @@ -1,5 +1,5 @@ import { Input, Radio } from 'antd' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { MemoryModelConfig, type MemoryModelValue @@ -7,6 +7,7 @@ import { import { MsaSwitch } from '~/components/common/MsaSwitch' import { MsaTextArea } from '~/components/common/MsaTextArea' import { api } from '~/lib/api' +import { useOnProjectSettingsChanged } from '~/lib/events' import { useT } from '~/lib/i18n' import type { AgentSettings, Profile } from '~/lib/types' import { metaDict, pageTitle } from '~/lib/pageTitle' @@ -24,7 +25,7 @@ export default function PersonalizationSettings() { const [profile, setProfile] = useState(null) const [settings, setSettings] = useState(null) - useEffect(() => { + const load = useCallback(() => { api.getInstruction('global').then((r) => { setContent(r.content) setLoaded(true) @@ -33,6 +34,12 @@ export default function PersonalizationSettings() { api.getAgentSettings().then(setSettings) }, []) + useEffect(load, [load]) + // These same globals are edited from the project rail (InstructionsCard, + // memory config) and by external API calls, all relayed as a project-settings + // change — re-read so this page reflects them without a manual reload. + useOnProjectSettingsChanged(load) + const saveInstruction = async () => { if (!loaded) return try { diff --git a/webui/frontend/app/routes/settings/search.tsx b/webui/frontend/app/routes/settings/search.tsx index 8f2aea1fa..432393375 100644 --- a/webui/frontend/app/routes/settings/search.tsx +++ b/webui/frontend/app/routes/settings/search.tsx @@ -1,7 +1,8 @@ import { Input, Select } from 'antd' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { MsaSwitch } from '~/components/common/MsaSwitch' import { api } from '~/lib/api' +import { useOnProjectSettingsChanged } from '~/lib/events' import { useT } from '~/lib/i18n' import type { SearchProvider, SearchSettings } from '~/lib/types' import { metaDict, pageTitle } from '~/lib/pageTitle' @@ -29,8 +30,17 @@ export default function SearchSettingsPage() { useEffect(() => { api.listSearchProviders().then(setProviders) + }, []) + + const loadSettings = useCallback(() => { api.getSearchSettings().then(setSettings) }, []) + useEffect(loadSettings, [loadSettings]) + // Web-search config is a global singleton edited from the composer pill and by + // external API calls (relayed as a project-settings change) — re-read so this + // page stays in sync. Typed key drafts live in separate state, so a refresh + // here never discards what the user is entering. + useOnProjectSettingsChanged(loadSettings) const provider = providers.find((p) => p.id === settings?.provider) ?? null const needsKey = provider?.requires_key ?? true