merge(frontend): fix #361 — refresh immédiat de la liste des agents après ajout (QA verte)
This commit is contained in:
@ -24,6 +24,7 @@ import { useDrift } from "@/features/templates/useDrift";
|
|||||||
import { templateProfileLabel } from "@/features/templates/templatePresentation";
|
import { templateProfileLabel } from "@/features/templates/templatePresentation";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { useAgents } from "./useAgents";
|
import { useAgents } from "./useAgents";
|
||||||
|
import { publishAgentRosterChanged } from "./agentRosterEvents";
|
||||||
import { correlateModelServerStatus } from "./modelServerLaunch";
|
import { correlateModelServerStatus } from "./modelServerLaunch";
|
||||||
import { AgentLimitBadge } from "./AgentLimitBadge";
|
import { AgentLimitBadge } from "./AgentLimitBadge";
|
||||||
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
||||||
@ -206,6 +207,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
name: newName.trim(),
|
name: newName.trim(),
|
||||||
synchronized: true,
|
synchronized: true,
|
||||||
});
|
});
|
||||||
|
publishAgentRosterChanged(projectId);
|
||||||
// Refresh the agents list since we bypassed the hook's createAgent path.
|
// Refresh the agents list since we bypassed the hook's createAgent path.
|
||||||
await vm.refresh();
|
await vm.refresh();
|
||||||
} else {
|
} 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 type { Gateways } from "@/ports";
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import { AgentsPanel } from "./AgentsPanel";
|
import { AgentsPanel } from "./AgentsPanel";
|
||||||
|
import { AGENT_ROSTER_CHANGED_EVENT } from "./agentRosterEvents";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@ -130,6 +131,20 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
|||||||
expect(agent).toBeTruthy();
|
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 () => {
|
it("shows the resolved network permission badge for an agent", async () => {
|
||||||
const agent = new MockAgentGateway();
|
const agent = new MockAgentGateway();
|
||||||
const permission = new MockPermissionGateway();
|
const permission = new MockPermissionGateway();
|
||||||
|
|||||||
@ -19,6 +19,10 @@ import type {
|
|||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import type { LiveAgent, OpenTerminalOptions, TerminalHandle } from "@/ports";
|
import type { LiveAgent, OpenTerminalOptions, TerminalHandle } from "@/ports";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
|
import {
|
||||||
|
onAgentRosterChanged,
|
||||||
|
publishAgentRosterChanged,
|
||||||
|
} from "./agentRosterEvents";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-agent session-limit state (ARCHITECTURE §21), populated from the limit
|
* Per-agent session-limit state (ARCHITECTURE §21), populated from the limit
|
||||||
@ -223,6 +227,14 @@ export function useAgents(projectId: string): AgentsViewModel {
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
onAgentRosterChanged((change) => {
|
||||||
|
if (change.projectId === projectId) void refresh();
|
||||||
|
}),
|
||||||
|
[projectId, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshLiveAgents();
|
void refreshLiveAgents();
|
||||||
}, [refreshLiveAgents]);
|
}, [refreshLiveAgents]);
|
||||||
@ -360,6 +372,7 @@ export function useAgents(projectId: string): AgentsViewModel {
|
|||||||
initialContent,
|
initialContent,
|
||||||
});
|
});
|
||||||
setAgents((prev) => [...prev, created]);
|
setAgents((prev) => [...prev, created]);
|
||||||
|
publishAgentRosterChanged(projectId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(describe(e));
|
setError(describe(e));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import type {
|
|||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
import { TerminalView, useWritePortal } from "@/features/terminals";
|
import { TerminalView, useWritePortal } from "@/features/terminals";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
|
import { onAgentRosterChanged } from "@/features/agents/agentRosterEvents";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
import { SmallDropdown } from "@/shared";
|
import { SmallDropdown } from "@/shared";
|
||||||
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
||||||
@ -602,6 +603,13 @@ function LeafView({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshAgents(true);
|
refreshAgents(true);
|
||||||
}, [refreshAgents]);
|
}, [refreshAgents]);
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
onAgentRosterChanged((change) => {
|
||||||
|
if (change.projectId === projectId) refreshAgents();
|
||||||
|
}),
|
||||||
|
[projectId, refreshAgents],
|
||||||
|
);
|
||||||
|
|
||||||
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
|
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
|
||||||
const [profilesLoaded, setProfilesLoaded] = useState(false);
|
const [profilesLoaded, setProfilesLoaded] = useState(false);
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import { LayoutGrid } from "./LayoutGrid";
|
import { LayoutGrid } from "./LayoutGrid";
|
||||||
import { leaves } from "./layout";
|
import { leaves } from "./layout";
|
||||||
|
import { publishAgentRosterChanged } from "@/features/agents/agentRosterEvents";
|
||||||
|
|
||||||
const noop = () => {};
|
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", () => {
|
describe("ticket #56 — 'visible ailleurs' derives from the layout, not stale liveAgents.nodeId", () => {
|
||||||
function renderGrid(layout: MockLayoutGateway, agent: MockAgentGateway) {
|
function renderGrid(layout: MockLayoutGateway, agent: MockAgentGateway) {
|
||||||
const system = {
|
const system = {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
|
import { onAgentRosterChanged } from "@/features/agents/agentRosterEvents";
|
||||||
import type { Agent } from "@/domain";
|
import type { Agent } from "@/domain";
|
||||||
import { Button, Panel, Spinner } from "@/shared";
|
import { Button, Panel, Spinner } from "@/shared";
|
||||||
import { McpToolPermissionsPanel } from "./McpToolPermissionsPanel";
|
import { McpToolPermissionsPanel } from "./McpToolPermissionsPanel";
|
||||||
@ -33,6 +34,14 @@ export function McpPanel({ projectId }: McpPanelProps) {
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
onAgentRosterChanged((change) => {
|
||||||
|
if (change.projectId === projectId) void refresh();
|
||||||
|
}),
|
||||||
|
[projectId, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
title="MCP"
|
title="MCP"
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
|
import { onAgentRosterChanged } from "@/features/agents/agentRosterEvents";
|
||||||
import type {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
Capability,
|
Capability,
|
||||||
@ -205,6 +206,14 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
onAgentRosterChanged((change) => {
|
||||||
|
if (change.projectId === projectId) void refresh();
|
||||||
|
}),
|
||||||
|
[projectId, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
const rows = useMemo<AgentPermissionRow[]>(() => {
|
const rows = useMemo<AgentPermissionRow[]>(() => {
|
||||||
const overrides = new Map(
|
const overrides = new Map(
|
||||||
(document?.agents ?? []).map((entry) => [entry.agentId, entry.permissions]),
|
(document?.agents ?? []).map((entry) => [entry.agentId, entry.permissions]),
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
|
import { onAgentRosterChanged } from "@/features/agents/agentRosterEvents";
|
||||||
import type { Agent, BackgroundCompletion, GatewayError, ScheduledProjectTask } from "@/domain";
|
import type { Agent, BackgroundCompletion, GatewayError, ScheduledProjectTask } from "@/domain";
|
||||||
import type { ScheduledTaskDefinitionInput } from "@/ports";
|
import type { ScheduledTaskDefinitionInput } from "@/ports";
|
||||||
|
|
||||||
@ -42,6 +43,15 @@ export function useScheduledTasks(projectId: string) {
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
onAgentRosterChanged((change) => {
|
||||||
|
if (change.projectId !== projectId) return;
|
||||||
|
void agent.listAgents(projectId).then(setAgents).catch(() => {});
|
||||||
|
}),
|
||||||
|
[agent, projectId],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubscribe: (() => void) | undefined;
|
let unsubscribe: (() => void) | undefined;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
|
import { onAgentRosterChanged } from "@/features/agents/agentRosterEvents";
|
||||||
import type { Agent, PluginSkillRef, Skill, SkillRef, SkillScope } from "@/domain";
|
import type { Agent, PluginSkillRef, Skill, SkillRef, SkillScope } from "@/domain";
|
||||||
import { useProjectPlugins } from "@/features/plugins/useProjectPlugins";
|
import { useProjectPlugins } from "@/features/plugins/useProjectPlugins";
|
||||||
import { Button, Input, Panel, Spinner, cn } from "@/shared";
|
import { Button, Input, Panel, Spinner, cn } from "@/shared";
|
||||||
@ -69,6 +70,14 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
|
|||||||
void loadAgents();
|
void loadAgents();
|
||||||
}, [loadAgents]);
|
}, [loadAgents]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
onAgentRosterChanged((change) => {
|
||||||
|
if (change.projectId === projectId) void loadAgents();
|
||||||
|
}),
|
||||||
|
[loadAgents, projectId],
|
||||||
|
);
|
||||||
|
|
||||||
const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null;
|
const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null;
|
||||||
const nativeSkills = useMemo(
|
const nativeSkills = useMemo(
|
||||||
() => [...vm.projectSkills, ...vm.globalSkills],
|
() => [...vm.projectSkills, ...vm.globalSkills],
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import type { GatewayError, Template } from "@/domain";
|
import type { GatewayError, Template } from "@/domain";
|
||||||
import type { CreateTemplateInput, UpdateTemplateInput } from "@/ports";
|
import type { CreateTemplateInput, UpdateTemplateInput } from "@/ports";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
|
import { publishAgentRosterChanged } from "@/features/agents/agentRosterEvents";
|
||||||
|
|
||||||
/** What the templates UI needs from this hook. */
|
/** What the templates UI needs from this hook. */
|
||||||
export interface TemplatesViewModel {
|
export interface TemplatesViewModel {
|
||||||
@ -127,6 +128,7 @@ export function useTemplates(): TemplatesViewModel {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await template.createAgentFromTemplate(projectId, templateId, opts);
|
await template.createAgentFromTemplate(projectId, templateId, opts);
|
||||||
|
publishAgentRosterChanged(projectId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(describe(e));
|
setError(describe(e));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
|
|
||||||
import type { Agent } from "@/domain";
|
import type { Agent } from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
|
import { onAgentRosterChanged } from "@/features/agents/agentRosterEvents";
|
||||||
|
|
||||||
export interface ProjectAgents {
|
export interface ProjectAgents {
|
||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
@ -44,6 +45,10 @@ export function useProjectAgents(projectId: string): ProjectAgents {
|
|||||||
});
|
});
|
||||||
void refresh();
|
void refresh();
|
||||||
|
|
||||||
|
const stopRoster = onAgentRosterChanged((change) => {
|
||||||
|
if (change.projectId === projectId) void refresh();
|
||||||
|
});
|
||||||
|
|
||||||
let unsubscribe: (() => void) | undefined;
|
let unsubscribe: (() => void) | undefined;
|
||||||
if (system?.onDomainEvent) {
|
if (system?.onDomainEvent) {
|
||||||
void system
|
void system
|
||||||
@ -61,6 +66,7 @@ export function useProjectAgents(projectId: string): ProjectAgents {
|
|||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
stopRoster();
|
||||||
unsubscribe?.();
|
unsubscribe?.();
|
||||||
};
|
};
|
||||||
}, [projectId, agent, system]);
|
}, [projectId, agent, system]);
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
|
|
||||||
import type { GatewayError, ProjectWorkState } from "@/domain";
|
import type { GatewayError, ProjectWorkState } from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
|
import { onAgentRosterChanged } from "@/features/agents/agentRosterEvents";
|
||||||
|
|
||||||
export interface ProjectWorkStateViewModel {
|
export interface ProjectWorkStateViewModel {
|
||||||
state: ProjectWorkState | null;
|
state: ProjectWorkState | null;
|
||||||
@ -46,6 +47,14 @@ export function useProjectWorkState(projectId: string): ProjectWorkStateViewMode
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
onAgentRosterChanged((change) => {
|
||||||
|
if (change.projectId === projectId) void refresh();
|
||||||
|
}),
|
||||||
|
[projectId, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!system) return;
|
if (!system) return;
|
||||||
let unsubscribe: (() => void) | undefined;
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user