merge(frontend): fix #361 — refresh immédiat de la liste des agents après ajout (QA verte)

This commit is contained in:
2026-09-16 20:15:01 +02:00
13 changed files with 169 additions and 0 deletions

View File

@ -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 {

View 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);
};
}

View File

@ -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();

View File

@ -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 {

View File

@ -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);

View File

@ -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 = {

View File

@ -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"

View File

@ -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]),

View File

@ -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;

View File

@ -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],

View File

@ -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 {

View File

@ -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]);

View File

@ -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;