From 0cc182844a8d5e749aed55e5c0eb0a1976cc98b1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 19 Jul 2026 06:23:46 -0700 Subject: [PATCH] fix(ui): refresh agent roster on config changes (#111395) --- ui/src/app/app-host.test.ts | 141 ++++++++++++++++++++++++++++++++ ui/src/app/app-host.ts | 70 ++++++++++++++++ ui/src/lib/agents/index.test.ts | 23 ++++++ ui/src/lib/agents/index.ts | 15 ++++ 4 files changed, 249 insertions(+) diff --git a/ui/src/app/app-host.test.ts b/ui/src/app/app-host.test.ts index 94cd4d69844..6b14301bf16 100644 --- a/ui/src/app/app-host.test.ts +++ b/ui/src/app/app-host.test.ts @@ -3,6 +3,7 @@ import { render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../api/gateway.ts"; +import type { AgentsListResult, GatewayAgentRow } from "../api/types.ts"; import { COMMAND_PALETTE_OPEN_EVENT, SHELL_NAV_DRAWER_TOGGLE_EVENT, @@ -74,6 +75,59 @@ type ShellUiCommandState = ShellKeyboardState & { handleGatewayEvent: (event: { event: string; payload: unknown }) => void; }; +function roster(defaultId: string, agents: GatewayAgentRow[]): AgentsListResult { + return { defaultId, mainKey: "main", scope: "per-sender", agents }; +} + +function createRosterRefreshContext(params: { + previous: AgentsListResult; + next: AgentsListResult; + selectedId: string; +}) { + const agentsState = { agentsList: params.previous }; + const selectionState = { selectedId: params.selectedId, scopeId: params.selectedId }; + const refreshList = vi.fn(async () => { + agentsState.agentsList = params.next; + return params.next; + }); + const invalidateFiles = vi.fn(); + const invalidateIdentity = vi.fn(); + const ensureIdentity = vi.fn(async () => undefined); + const setSelection = vi.fn((agentId: string) => { + selectionState.selectedId = agentId; + selectionState.scopeId = agentId; + }); + const refreshConfig = vi.fn(async () => null); + const context = { + agents: { + state: agentsState, + refreshList, + invalidateFiles, + }, + agentIdentity: { + invalidate: invalidateIdentity, + ensure: ensureIdentity, + }, + agentSelection: { + state: selectionState, + set: setSelection, + }, + runtimeConfig: { + state: { configFormDirty: false }, + refresh: refreshConfig, + }, + } as unknown as ApplicationContext; + return { + context, + refreshList, + invalidateFiles, + invalidateIdentity, + ensureIdentity, + setSelection, + refreshConfig, + }; +} + let lazyElementSequence = 0; function createLazyElementSpec(label: string): TestOptionalCustomElement { @@ -135,6 +189,7 @@ type MacosTitlebarControlsState = HTMLElement & { }; afterEach(() => { + vi.useRealTimers(); Reflect.deleteProperty(window, "webkit"); document.documentElement.classList.remove( "openclaw-native-macos", @@ -599,6 +654,92 @@ describe("OpenClaw shell keyboard shortcuts", () => { window.removeEventListener(UI_COMMAND_EVENT, uiCommandEvent); }); + it("refreshes the roster on config.changed and invalidates removed or changed agents", async () => { + vi.useFakeTimers(); + const harness = createRosterRefreshContext({ + previous: roster("main", [ + { id: "main", name: "Main" }, + { id: "writer", name: "Writer" }, + { id: "retired", name: "Retired" }, + ]), + next: roster("main", [ + { id: "main", name: "Main" }, + { id: "writer", name: "Editor" }, + { id: "new-agent", name: "New" }, + ]), + selectedId: "main", + }); + const shell = document.createElement("openclaw-app-shell") as unknown as ShellUiCommandState; + shell.runtime = { context: harness.context }; + + shell.handleGatewayEvent({ event: "config.changed", payload: {} }); + await vi.advanceTimersByTimeAsync(100); + + expect(harness.refreshConfig).toHaveBeenCalledOnce(); + expect(harness.refreshList).toHaveBeenCalledOnce(); + expect(harness.invalidateFiles).toHaveBeenCalledWith(["writer", "retired"]); + expect(harness.invalidateIdentity).toHaveBeenCalledWith(["writer", "retired"]); + expect(harness.ensureIdentity).toHaveBeenCalledWith(["writer"]); + expect(harness.setSelection).not.toHaveBeenCalled(); + }); + + it("moves a deleted active agent to the refreshed roster default", async () => { + vi.useFakeTimers(); + const harness = createRosterRefreshContext({ + previous: roster("writer", [{ id: "fallback" }, { id: "main" }, { id: "writer" }]), + next: roster("main", [{ id: "fallback" }, { id: "main" }]), + selectedId: "writer", + }); + const shell = document.createElement("openclaw-app-shell") as unknown as ShellUiCommandState; + shell.runtime = { context: harness.context }; + + shell.handleGatewayEvent({ event: "config.changed", payload: {} }); + await vi.advanceTimersByTimeAsync(100); + + expect(harness.setSelection).toHaveBeenCalledExactlyOnceWith("main"); + }); + + it("keeps caches intact when a config.changed refresh returns the same roster", async () => { + vi.useFakeTimers(); + const unchanged = roster("main", [{ id: "main", name: "Main" }, { id: "writer" }]); + const harness = createRosterRefreshContext({ + previous: unchanged, + next: structuredClone(unchanged), + selectedId: "main", + }); + const shell = document.createElement("openclaw-app-shell") as unknown as ShellUiCommandState; + shell.runtime = { context: harness.context }; + + shell.handleGatewayEvent({ event: "config.changed", payload: {} }); + await vi.advanceTimersByTimeAsync(100); + + expect(harness.refreshList).toHaveBeenCalledOnce(); + expect(harness.invalidateFiles).not.toHaveBeenCalled(); + expect(harness.invalidateIdentity).not.toHaveBeenCalled(); + expect(harness.ensureIdentity).not.toHaveBeenCalled(); + }); + + it("coalesces config.changed bursts into one roster refresh", async () => { + vi.useFakeTimers(); + const unchanged = roster("main", [{ id: "main" }]); + const harness = createRosterRefreshContext({ + previous: unchanged, + next: unchanged, + selectedId: "main", + }); + const shell = document.createElement("openclaw-app-shell") as unknown as ShellUiCommandState; + shell.runtime = { context: harness.context }; + + shell.handleGatewayEvent({ event: "config.changed", payload: {} }); + shell.handleGatewayEvent({ event: "config.changed", payload: {} }); + shell.handleGatewayEvent({ event: "config.changed", payload: {} }); + await vi.advanceTimersByTimeAsync(99); + expect(harness.refreshList).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + + expect(harness.refreshList).toHaveBeenCalledOnce(); + }); + it("opens Settings with Shift-Command-Comma", () => { const navigate = vi.fn(); const shell = document.createElement("openclaw-app-shell") as unknown as ShellKeyboardState; diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index 1d6f13b3995..d38494b75ca 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -8,6 +8,7 @@ import { hasStoredGatewayAuth, type GatewayBrowserClient, } from "../api/gateway.ts"; +import type { GatewayAgentRow } from "../api/types.ts"; import "../components/app-sidebar.ts"; import "../components/app-topbar.ts"; import "../components/connection-banner.ts"; @@ -115,6 +116,26 @@ type AppSidebarElement = HTMLElement & { // Stable references so the sidebar's enabledRouteIds property does not churn // on every shell render. const ROUTE_IDS_WITHOUT_WORKBOARD = APP_ROUTE_IDS.filter((routeId) => routeId !== "workboard"); +const AGENT_ROSTER_REFRESH_DEBOUNCE_MS = 100; + +function diffAgentRoster( + previous: readonly GatewayAgentRow[], + next: readonly GatewayAgentRow[], +): { invalidatedIds: string[]; changedIds: string[] } { + const nextById = new Map(next.map((agent) => [agent.id, agent])); + const invalidatedIds: string[] = []; + const changedIds: string[] = []; + for (const agent of previous) { + const replacement = nextById.get(agent.id); + if (!replacement) { + invalidatedIds.push(agent.id); + } else if (JSON.stringify(replacement) !== JSON.stringify(agent)) { + invalidatedIds.push(agent.id); + changedIds.push(agent.id); + } + } + return { invalidatedIds, changedIds }; +} function selectShellRouteState(routerState: RouterState): ShellRouteState { const match = selectRenderedRouteMatch(routerState.matches[0], routerState.pendingMatches[0]); @@ -473,6 +494,7 @@ class OpenClawShell extends OpenClawLightDomElement { private sessionKeyClient: GatewayBrowserClient | null = null; private runtimeConfigClient: GatewayBrowserClient | null = null; private runtimeConfigSource: ApplicationContext["runtimeConfig"] | null = null; + private agentRosterRefreshTimer: ReturnType | null = null; private lastNativeNavState: NativeNavState | undefined; private didConsiderNativeRouteRestore = false; private pendingNativeNewSession = false; @@ -652,6 +674,10 @@ class OpenClawShell extends OpenClawLightDomElement { this.sessionKeyClient = null; this.runtimeConfigClient = null; this.runtimeConfigSource = null; + if (this.agentRosterRefreshTimer !== null) { + globalThis.clearTimeout(this.agentRosterRefreshTimer); + this.agentRosterRefreshTimer = null; + } resetServerUiPrefsSync(); for (const timer of this.settingsPreloadTimers.values()) { globalThis.clearTimeout(timer); @@ -668,6 +694,7 @@ class OpenClawShell extends OpenClawLightDomElement { if (runtimeConfig && !runtimeConfig.state.configFormDirty) { void runtimeConfig.refresh(); } + this.scheduleAgentRosterRefresh(); return; } if (event.event !== "ui.command" || !event.payload) { @@ -714,6 +741,49 @@ class OpenClawShell extends OpenClawLightDomElement { this.navigate("chat", { search: searchForSession(command.sessionKey) }); }; + private scheduleAgentRosterRefresh() { + // Persisted config writes can arrive as a tight sequence; roster state is + // snapshot-based, so only the final forced read in that burst is useful. + if (this.agentRosterRefreshTimer !== null) { + globalThis.clearTimeout(this.agentRosterRefreshTimer); + } + this.agentRosterRefreshTimer = globalThis.setTimeout(() => { + this.agentRosterRefreshTimer = null; + void this.refreshAgentRoster(); + }, AGENT_ROSTER_REFRESH_DEBOUNCE_MS); + } + + private async refreshAgentRoster() { + const context = this.context; + if (!context) { + return; + } + const previous = context.agents.state.agentsList; + const activeAgentId = context.agentSelection.state.selectedId; + const next = await context.agents.refreshList(); + if (!next || this.context !== context) { + return; + } + const rosterDiff = diffAgentRoster(previous?.agents ?? [], next.agents); + if (rosterDiff.invalidatedIds.length > 0) { + context.agents.invalidateFiles(rosterDiff.invalidatedIds); + context.agentIdentity.invalidate(rosterDiff.invalidatedIds); + } + if (rosterDiff.changedIds.length > 0) { + void context.agentIdentity.ensure(rosterDiff.changedIds); + } + const previousIds = new Set(previous?.agents.map((agent) => agent.id) ?? []); + const nextIds = new Set(next.agents.map((agent) => agent.id)); + if ( + activeAgentId && + context.agentSelection.state.selectedId === activeAgentId && + previousIds.has(activeAgentId) && + !nextIds.has(activeAgentId) + ) { + context.agentSelection.set(next.defaultId); + } + } + private readonly handleThemeChange = (event: CustomEvent) => { const context = this.context; if (!context) { diff --git a/ui/src/lib/agents/index.test.ts b/ui/src/lib/agents/index.test.ts index e9db79fca45..16a4c4a2a46 100644 --- a/ui/src/lib/agents/index.test.ts +++ b/ui/src/lib/agents/index.test.ts @@ -184,6 +184,29 @@ describe("createAgentCapability lifecycle", () => { agents.dispose(); }); + it("invalidates cached and in-flight file lists for changed agents", async () => { + const pending = deferred(); + const request = vi + .fn() + .mockResolvedValueOnce({ agentId: "main", workspace: "old", files: [] }) + .mockReturnValueOnce(pending.promise); + const client = { request } as unknown as GatewayBrowserClient; + const harness = createGatewayHarness(client); + const agents = createAgentCapability(harness.gateway); + + await agents.refreshFiles("main"); + expect(agents.files("main").list?.workspace).toBe("old"); + + const staleLoad = agents.refreshFiles("main"); + agents.invalidateFiles(["main"]); + pending.resolve({ agentId: "main", workspace: "stale", files: [] }); + await staleLoad; + + expect(agents.files("main").list).toBeNull(); + expect(agents.files("main").loading).toBe(false); + agents.dispose(); + }); + it("does not commit a list request after disposal", async () => { const pending = deferred(); const request = vi.fn().mockReturnValue(pending.promise); diff --git a/ui/src/lib/agents/index.ts b/ui/src/lib/agents/index.ts index 320158c230a..78f7f72e7a7 100644 --- a/ui/src/lib/agents/index.ts +++ b/ui/src/lib/agents/index.ts @@ -88,6 +88,7 @@ export type AgentCapability = { ensureList: () => Promise; refreshList: () => Promise; files: (agentId: string | null | undefined) => AgentFilesStatus; + invalidateFiles: (agentIds: readonly (string | null | undefined)[]) => void; ensureFiles: (agentId: string) => Promise; refreshFiles: (agentId: string) => Promise; subscribe: (listener: (state: AgentCapabilityState) => void) => () => void; @@ -417,6 +418,20 @@ export function createAgentCapability(gateway: AgentGateway): AgentCapability { ? (files.get(normalized) ?? emptyAgentFilesStatus()) : emptyAgentFilesStatus(); }, + invalidateFiles(agentIds) { + let changed = false; + const normalizedIds = new Set( + agentIds.map(normalizeAgentId).filter((agentId): agentId is string => agentId !== null), + ); + for (const agentId of normalizedIds) { + changed = files.delete(agentId) || changed; + changed = fileRequests.delete(agentId) || changed; + changed = fileRequestOwners.delete(agentId) || changed; + } + if (changed) { + publish(); + } + }, ensureFiles: (agentId) => loadFiles(agentId, false), refreshFiles: (agentId) => loadFiles(agentId, true), subscribe(listener) {