fix(frontend): refresh immédiat de la liste des agents après ajout — bus d'événements agent-roster (CustomEvent + BroadcastChannel multi-fenêtres) publié à la création d'un agent et écouté par useAgents, LayoutGrid et les panneaux skills/permissions/tâches planifiées/templates/tickets/workstate, plus besoin d'ajouter-retirer une cellule pour forcer le refresh — #361 (QA verte: typecheck OK, vitest agents/singletonAgent OK, npm test 1416 tests OK)
This commit is contained in:
@ -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 {
|
||||
|
||||
48
frontend/src/features/agents/agentRosterEvents.ts
Normal file
48
frontend/src/features/agents/agentRosterEvents.ts
Normal file
@ -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<AgentRosterChanged>(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<AgentRosterChanged>).detail;
|
||||
if (detail?.projectId) handler(detail);
|
||||
};
|
||||
const onChannelMessage = (event: MessageEvent<AgentRosterChanged>) => {
|
||||
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);
|
||||
};
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<AgentProfile[]>([]);
|
||||
const [profilesLoaded, setProfilesLoaded] = useState(false);
|
||||
|
||||
@ -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(
|
||||
<DIProvider gateways={{ layout, agent, system } as unknown as Gateways}>
|
||||
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
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 = {
|
||||
|
||||
@ -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 (
|
||||
<Panel
|
||||
title="MCP"
|
||||
|
||||
@ -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,
|
||||
Capability,
|
||||
@ -205,6 +206,14 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
onAgentRosterChanged((change) => {
|
||||
if (change.projectId === projectId) void refresh();
|
||||
}),
|
||||
[projectId, refresh],
|
||||
);
|
||||
|
||||
const rows = useMemo<AgentPermissionRow[]>(() => {
|
||||
const overrides = new Map(
|
||||
(document?.agents ?? []).map((entry) => [entry.agentId, entry.permissions]),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user