diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index 69eef2aa..be7c0b74 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -24,6 +24,7 @@ import { useDrift } from "@/features/templates/useDrift"; import { templateProfileLabel } from "@/features/templates/templatePresentation"; import { useGateways } from "@/app/di"; import { useAgents } from "./useAgents"; +import { publishAgentRosterChanged } from "./agentRosterEvents"; import { correlateModelServerStatus } from "./modelServerLaunch"; import { AgentLimitBadge } from "./AgentLimitBadge"; import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge"; @@ -206,6 +207,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { name: newName.trim(), synchronized: true, }); + publishAgentRosterChanged(projectId); // Refresh the agents list since we bypassed the hook's createAgent path. await vm.refresh(); } else { diff --git a/frontend/src/features/agents/agentRosterEvents.ts b/frontend/src/features/agents/agentRosterEvents.ts new file mode 100644 index 00000000..45deae93 --- /dev/null +++ b/frontend/src/features/agents/agentRosterEvents.ts @@ -0,0 +1,48 @@ +export const AGENT_ROSTER_CHANGED_EVENT = "idea:agent-roster-changed"; +const AGENT_ROSTER_CHANNEL = "idea:agent-roster"; + +export interface AgentRosterChanged { + projectId: string; +} + +let agentRosterChannel: BroadcastChannel | null | undefined; + +function sharedAgentRosterChannel(): BroadcastChannel | null { + if (agentRosterChannel !== undefined) return agentRosterChannel; + agentRosterChannel = + typeof BroadcastChannel === "undefined" + ? null + : new BroadcastChannel(AGENT_ROSTER_CHANNEL); + return agentRosterChannel; +} + +/** Invalidates every open frontend projection of a project's agent roster. */ +export function publishAgentRosterChanged(projectId: string): void { + const change = { projectId }; + window.dispatchEvent( + new CustomEvent(AGENT_ROSTER_CHANGED_EVENT, { + detail: change, + }), + ); + sharedAgentRosterChannel()?.postMessage(change); +} + +/** Subscribes in this window and in sibling detached webviews. */ +export function onAgentRosterChanged( + handler: (change: AgentRosterChanged) => void, +): () => void { + const onWindowEvent = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail?.projectId) handler(detail); + }; + const onChannelMessage = (event: MessageEvent) => { + if (event.data?.projectId) handler(event.data); + }; + const channel = sharedAgentRosterChannel(); + window.addEventListener(AGENT_ROSTER_CHANGED_EVENT, onWindowEvent); + channel?.addEventListener("message", onChannelMessage); + return () => { + window.removeEventListener(AGENT_ROSTER_CHANGED_EVENT, onWindowEvent); + channel?.removeEventListener("message", onChannelMessage); + }; +} diff --git a/frontend/src/features/agents/agents.test.tsx b/frontend/src/features/agents/agents.test.tsx index 1b9702ac..e20e3a32 100644 --- a/frontend/src/features/agents/agents.test.tsx +++ b/frontend/src/features/agents/agents.test.tsx @@ -29,6 +29,7 @@ import { import type { Gateways } from "@/ports"; import { DIProvider } from "@/app/di"; import { AgentsPanel } from "./AgentsPanel"; +import { AGENT_ROSTER_CHANGED_EVENT } from "./agentRosterEvents"; // --------------------------------------------------------------------------- // Helpers @@ -130,6 +131,20 @@ describe("AgentsPanel (with MockAgentGateway)", () => { expect(agent).toBeTruthy(); }); + it("invalidates the project agent roster after successful creation", async () => { + const changed = vi.fn(); + window.addEventListener(AGENT_ROSTER_CHANGED_EVENT, changed); + renderPanel(); + + await createAgent("Fresh Worker"); + + await waitFor(() => expect(changed).toHaveBeenCalledTimes(1)); + expect((changed.mock.calls[0][0] as CustomEvent).detail).toEqual({ + projectId: PROJECT_ID, + }); + window.removeEventListener(AGENT_ROSTER_CHANGED_EVENT, changed); + }); + it("shows the resolved network permission badge for an agent", async () => { const agent = new MockAgentGateway(); const permission = new MockPermissionGateway(); diff --git a/frontend/src/features/agents/useAgents.ts b/frontend/src/features/agents/useAgents.ts index 948970bd..1ac669db 100644 --- a/frontend/src/features/agents/useAgents.ts +++ b/frontend/src/features/agents/useAgents.ts @@ -19,6 +19,10 @@ import type { } from "@/domain"; import type { LiveAgent, OpenTerminalOptions, TerminalHandle } from "@/ports"; import { useGateways } from "@/app/di"; +import { + onAgentRosterChanged, + publishAgentRosterChanged, +} from "./agentRosterEvents"; /** * Per-agent session-limit state (ARCHITECTURE ยง21), populated from the limit @@ -223,6 +227,14 @@ export function useAgents(projectId: string): AgentsViewModel { void refresh(); }, [refresh]); + useEffect( + () => + onAgentRosterChanged((change) => { + if (change.projectId === projectId) void refresh(); + }), + [projectId, refresh], + ); + useEffect(() => { void refreshLiveAgents(); }, [refreshLiveAgents]); @@ -360,6 +372,7 @@ export function useAgents(projectId: string): AgentsViewModel { initialContent, }); setAgents((prev) => [...prev, created]); + publishAgentRosterChanged(projectId); } catch (e) { setError(describe(e)); } finally { diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 6d41a3cb..ed6a7b0e 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -28,6 +28,7 @@ import type { } from "@/ports"; import { TerminalView, useWritePortal } from "@/features/terminals"; import { useGateways } from "@/app/di"; +import { onAgentRosterChanged } from "@/features/agents/agentRosterEvents"; import { useI18n } from "@/i18n"; import { SmallDropdown } from "@/shared"; import { useProjectWorkState } from "@/features/workstate/useProjectWorkState"; @@ -602,6 +603,13 @@ function LeafView({ useEffect(() => { refreshAgents(true); }, [refreshAgents]); + useEffect( + () => + onAgentRosterChanged((change) => { + if (change.projectId === projectId) refreshAgents(); + }), + [projectId, refreshAgents], + ); const [profiles, setProfiles] = useState([]); const [profilesLoaded, setProfilesLoaded] = useState(false); diff --git a/frontend/src/features/layout/singletonAgent.test.tsx b/frontend/src/features/layout/singletonAgent.test.tsx index f26ad27b..dcea7723 100644 --- a/frontend/src/features/layout/singletonAgent.test.tsx +++ b/frontend/src/features/layout/singletonAgent.test.tsx @@ -19,6 +19,7 @@ import { import { DIProvider } from "@/app/di"; import { LayoutGrid } from "./LayoutGrid"; import { leaves } from "./layout"; +import { publishAgentRosterChanged } from "@/features/agents/agentRosterEvents"; const noop = () => {}; @@ -323,6 +324,34 @@ describe("R0d โ€” dropdown disables agents live elsewhere + go-to-cell", () => { }); +describe("ticket #361 โ€” agent roster invalidation", () => { + it("updates an already-open selector after agent creation without changing the layout", async () => { + const layout = new MockLayoutGateway(); + const agent = new MockAgentGateway(); + const initialLayout = await layout.loadLayout("p1"); + const system = { + onDomainEvent: vi.fn().mockResolvedValue(() => {}), + }; + + render( + + + , + ); + + const selector = await screen.findByRole("combobox"); + fireEvent.click(selector); + expect(await screen.findByRole("option", { name: "Aucun agent disponible" })).toBeTruthy(); + + await agent.createAgent("p1", { name: "Fresh Agent", profileId: "profile-new" }); + publishAgentRosterChanged("p1"); + + const option = await screen.findByRole("option", { name: "Fresh Agent" }); + expect((option as HTMLButtonElement).disabled).toBe(false); + expect(await layout.loadLayout("p1")).toEqual(initialLayout); + }); +}); + describe("ticket #56 โ€” 'visible ailleurs' derives from the layout, not stale liveAgents.nodeId", () => { function renderGrid(layout: MockLayoutGateway, agent: MockAgentGateway) { const system = { diff --git a/frontend/src/features/permissions/McpPanel.tsx b/frontend/src/features/permissions/McpPanel.tsx index 5c1d6c5b..9482003b 100644 --- a/frontend/src/features/permissions/McpPanel.tsx +++ b/frontend/src/features/permissions/McpPanel.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { useGateways } from "@/app/di"; +import { onAgentRosterChanged } from "@/features/agents/agentRosterEvents"; import type { Agent } from "@/domain"; import { Button, Panel, Spinner } from "@/shared"; import { McpToolPermissionsPanel } from "./McpToolPermissionsPanel"; @@ -33,6 +34,14 @@ export function McpPanel({ projectId }: McpPanelProps) { void refresh(); }, [refresh]); + useEffect( + () => + onAgentRosterChanged((change) => { + if (change.projectId === projectId) void refresh(); + }), + [projectId, refresh], + ); + return ( + onAgentRosterChanged((change) => { + if (change.projectId === projectId) void refresh(); + }), + [projectId, refresh], + ); + const rows = useMemo(() => { const overrides = new Map( (document?.agents ?? []).map((entry) => [entry.agentId, entry.permissions]), diff --git a/frontend/src/features/scheduled-tasks/useScheduledTasks.ts b/frontend/src/features/scheduled-tasks/useScheduledTasks.ts index d9b0ab35..5dc32c0c 100644 --- a/frontend/src/features/scheduled-tasks/useScheduledTasks.ts +++ b/frontend/src/features/scheduled-tasks/useScheduledTasks.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useGateways } from "@/app/di"; +import { onAgentRosterChanged } from "@/features/agents/agentRosterEvents"; import type { Agent, BackgroundCompletion, GatewayError, ScheduledProjectTask } from "@/domain"; import type { ScheduledTaskDefinitionInput } from "@/ports"; @@ -42,6 +43,15 @@ export function useScheduledTasks(projectId: string) { void refresh(); }, [refresh]); + useEffect( + () => + onAgentRosterChanged((change) => { + if (change.projectId !== projectId) return; + void agent.listAgents(projectId).then(setAgents).catch(() => {}); + }), + [agent, projectId], + ); + useEffect(() => { let unsubscribe: (() => void) | undefined; let cancelled = false; diff --git a/frontend/src/features/skills/SkillsPanel.tsx b/frontend/src/features/skills/SkillsPanel.tsx index e4c6ba7a..860ad6f6 100644 --- a/frontend/src/features/skills/SkillsPanel.tsx +++ b/frontend/src/features/skills/SkillsPanel.tsx @@ -16,6 +16,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useGateways } from "@/app/di"; +import { onAgentRosterChanged } from "@/features/agents/agentRosterEvents"; import type { Agent, PluginSkillRef, Skill, SkillRef, SkillScope } from "@/domain"; import { useProjectPlugins } from "@/features/plugins/useProjectPlugins"; import { Button, Input, Panel, Spinner, cn } from "@/shared"; @@ -69,6 +70,14 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) { void loadAgents(); }, [loadAgents]); + useEffect( + () => + onAgentRosterChanged((change) => { + if (change.projectId === projectId) void loadAgents(); + }), + [loadAgents, projectId], + ); + const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null; const nativeSkills = useMemo( () => [...vm.projectSkills, ...vm.globalSkills], diff --git a/frontend/src/features/templates/useTemplates.ts b/frontend/src/features/templates/useTemplates.ts index 6b368b56..100706ef 100644 --- a/frontend/src/features/templates/useTemplates.ts +++ b/frontend/src/features/templates/useTemplates.ts @@ -11,6 +11,7 @@ import { useCallback, useEffect, useState } from "react"; import type { GatewayError, Template } from "@/domain"; import type { CreateTemplateInput, UpdateTemplateInput } from "@/ports"; import { useGateways } from "@/app/di"; +import { publishAgentRosterChanged } from "@/features/agents/agentRosterEvents"; /** What the templates UI needs from this hook. */ export interface TemplatesViewModel { @@ -127,6 +128,7 @@ export function useTemplates(): TemplatesViewModel { setError(null); try { await template.createAgentFromTemplate(projectId, templateId, opts); + publishAgentRosterChanged(projectId); } catch (e) { setError(describe(e)); } finally { diff --git a/frontend/src/features/tickets/useProjectAgents.ts b/frontend/src/features/tickets/useProjectAgents.ts index 14226b9d..b0518776 100644 --- a/frontend/src/features/tickets/useProjectAgents.ts +++ b/frontend/src/features/tickets/useProjectAgents.ts @@ -9,6 +9,7 @@ import { useEffect, useMemo, useState } from "react"; import type { Agent } from "@/domain"; import { useGateways } from "@/app/di"; +import { onAgentRosterChanged } from "@/features/agents/agentRosterEvents"; export interface ProjectAgents { agents: Agent[]; @@ -44,6 +45,10 @@ export function useProjectAgents(projectId: string): ProjectAgents { }); void refresh(); + const stopRoster = onAgentRosterChanged((change) => { + if (change.projectId === projectId) void refresh(); + }); + let unsubscribe: (() => void) | undefined; if (system?.onDomainEvent) { void system @@ -61,6 +66,7 @@ export function useProjectAgents(projectId: string): ProjectAgents { } return () => { cancelled = true; + stopRoster(); unsubscribe?.(); }; }, [projectId, agent, system]); diff --git a/frontend/src/features/workstate/useProjectWorkState.ts b/frontend/src/features/workstate/useProjectWorkState.ts index 018d4fc8..c13aa3de 100644 --- a/frontend/src/features/workstate/useProjectWorkState.ts +++ b/frontend/src/features/workstate/useProjectWorkState.ts @@ -9,6 +9,7 @@ import { useCallback, useEffect, useState } from "react"; import type { GatewayError, ProjectWorkState } from "@/domain"; import { useGateways } from "@/app/di"; +import { onAgentRosterChanged } from "@/features/agents/agentRosterEvents"; export interface ProjectWorkStateViewModel { state: ProjectWorkState | null; @@ -46,6 +47,14 @@ export function useProjectWorkState(projectId: string): ProjectWorkStateViewMode void refresh(); }, [refresh]); + useEffect( + () => + onAgentRosterChanged((change) => { + if (change.projectId === projectId) void refresh(); + }), + [projectId, refresh], + ); + useEffect(() => { if (!system) return; let unsubscribe: (() => void) | undefined;