feat(domain,application,backend,app-tauri,frontend): lot utilisateur #328 #329 #330 #331 — section plugins projet, erreurs menus plugin visibles, refresh auto des pages, droplist agents (QA verte)
Lot en un seul commit: les quatre tickets partagent les mêmes fichiers (événements domaine/DTO, ProjectPluginsPanel, useProjectPlugins) — une séparation par hunks aurait risqué des états intermédiaires non compilables (convention lot des batches #310-#313, #315 et #325-#326). #328 — section dédiée plugins dans Paramètres du projet: - frontend: ProjectPluginsPanel intégré à ProjectSettingsPanel (Panneaux > Paramètres du projet) — activation/désactivation par plugin avec état actif/inactif explicite par projet, distinct de l'assignation agents/skills/tools - domaine: événement ProjectPluginSettingsChanged publié par SetProjectPluginEnabled, MutateAgentPluginSkill et MutateAgentPluginTool après persistance de .ideai/project-plugins.json (application + backend + DTO camelCase, test dto.rs) #329 — les menus plugin qui échouent ne sont plus muets: - usePluginMenus: dispatcher strict (runCommandStrict) pour les commandes réellement enregistrées au lieu du dispatcher laxiste qui journalisait et avalait les rejets — rendait les échecs d'ouverture de fenêtres natifs invisibles pour l'hôte de menus - le no-op SDK historique (commande déclarée sans handler) est préservé - échec remonté à l'hôte via commandError/clearCommandError (journal console + état exposé); test window_usecases adapté à un plugin installé - le correctif côté SDK/GitPlugin vit dans le projet GitPlugin (son commit/merge local y est fait, hors périmètre de ce dépôt) #330 — refresh automatique des pages: - frontend/domain: typage des événements pluginEnabled, pluginDisabled (restartRequired), pluginUninstalled (restartRequired), pluginLoadFailed et projectPluginSettingsChanged - useProjectPlugins et PluginRuntimeProvider: souscription onDomainEvent — refresh sur changement catalogue global ou sur projectPluginSettingsChanged du projet courant; gardes révision/projet contre les réponses obsolètes - useProjectAgents: refresh auto quand un agent est ajouté au projet; ProjectsView et AgentsPanel suivent (erreur de rafraîchissement affichée avec bouton Réessayer) #331 — droplist agents des cellules: - SmallDropdown: liste bg-overlay + bordure border-strong (plus de fond blanc hors thème), hover/focus bg-raised, sélection bg-primary/15, disabled text-faint opacity-60, role=combobox - LayoutGrid: sélecteur d'agent des cellules migré du <select> natif vers SmallDropdown (mêmes libellés Terminal/visible ailleurs/arrière-plan) QA verte réelle sur les quatre lots (tests ciblés + exécution). sdk/IdeaSDK: contenu modifié non committable ici (pointeur inchangé). Aucun push.
This commit is contained in:
@ -160,10 +160,11 @@ function controlsFor(cellId: string) {
|
||||
|
||||
type CellAction = "selector" | "split" | "close";
|
||||
|
||||
function performAction(action: CellAction, cellId: string) {
|
||||
async function performAction(action: CellAction, cellId: string) {
|
||||
const { selector, splitColumns, close } = controlsFor(cellId);
|
||||
if (action === "selector") {
|
||||
fireEvent.change(selector, { target: { value: "" } });
|
||||
fireEvent.click(selector);
|
||||
fireEvent.click(await screen.findByRole("option", { name: "Terminal" }));
|
||||
} else if (action === "split") {
|
||||
fireEvent.click(splitColumns);
|
||||
} else {
|
||||
@ -443,7 +444,8 @@ describe("LayoutGrid — ticket #48 cell control layering", () => {
|
||||
);
|
||||
|
||||
const selector = screen.getByLabelText(`Sélecteur d’agent ${setup.bId}`);
|
||||
fireEvent.change(selector, { target: { value: setup.otherAgentId } });
|
||||
fireEvent.click(selector);
|
||||
fireEvent.click(await screen.findByRole("option", { name: "Reviewer" }));
|
||||
await waitFor(() =>
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
"p1",
|
||||
@ -509,7 +511,7 @@ describe("LayoutGrid — ticket #48 cell control layering", () => {
|
||||
expect(controls.style.zIndex).toBe("5");
|
||||
expect(error.style.zIndex).toBe("3");
|
||||
|
||||
performAction(action, setup.bId);
|
||||
await performAction(action, setup.bId);
|
||||
await waitFor(() =>
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
"p1",
|
||||
@ -538,7 +540,7 @@ describe("LayoutGrid — ticket #48 cell control layering", () => {
|
||||
expect(overlay.className).toContain("z-[4]");
|
||||
expect(overlay.className).toContain("pointer-events-none");
|
||||
|
||||
performAction(action, setup.bId);
|
||||
await performAction(action, setup.bId);
|
||||
await waitFor(() =>
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
"p1",
|
||||
|
||||
@ -29,6 +29,7 @@ import type {
|
||||
import { TerminalView, useWritePortal } from "@/features/terminals";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { SmallDropdown } from "@/shared";
|
||||
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
||||
import {
|
||||
AgentRestartOverlay,
|
||||
@ -1359,12 +1360,11 @@ function LeafView({
|
||||
}}
|
||||
>
|
||||
{/* Agent selector */}
|
||||
<select
|
||||
<SmallDropdown
|
||||
aria-label={`${tr("Sélecteur d’agent", "Agent selector")} ${id}`}
|
||||
value={agentId ?? ""}
|
||||
disabled={cellRestartBusy}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
onChange={(val) => {
|
||||
if (val === "") {
|
||||
setBusyNotice(null);
|
||||
void (async () => {
|
||||
@ -1415,29 +1415,23 @@ function LeafView({
|
||||
setBusyNotice(await noticeFromError(err, val)),
|
||||
);
|
||||
}}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
background: "var(--color-surface, #1e1e1e)",
|
||||
color: "var(--color-content, #e0e0e0)",
|
||||
border: "1px solid var(--color-border, #3a3a3a)",
|
||||
borderRadius: 3,
|
||||
padding: "1px 2px",
|
||||
width: 96,
|
||||
minWidth: 56,
|
||||
}}
|
||||
>
|
||||
<option value="">{tr("Terminal", "Plain")}</option>
|
||||
{agents.map((a) => {
|
||||
const elsewhere = visibleElsewhere(a.id);
|
||||
const background = backgroundLive(a.id);
|
||||
return (
|
||||
<option key={a.id} value={a.id} disabled={Boolean(elsewhere)}>
|
||||
{a.name}
|
||||
{elsewhere ? tr(" (visible ailleurs)", " (visible elsewhere)") : background ? tr(" (arrière-plan)", " (background)") : ""}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
className="h-6 w-24 min-w-14 rounded px-1.5 py-0 text-[11px]"
|
||||
options={[
|
||||
{ value: "", label: tr("Terminal", "Plain") },
|
||||
...agents.map((a) => {
|
||||
const elsewhere = visibleElsewhere(a.id);
|
||||
const background = backgroundLive(a.id);
|
||||
return {
|
||||
value: a.id,
|
||||
label: `${a.name}${elsewhere ? tr(" (visible ailleurs)", " (visible elsewhere)") : background ? tr(" (arrière-plan)", " (background)") : ""}`,
|
||||
disabled: Boolean(elsewhere),
|
||||
};
|
||||
}),
|
||||
...(agents.length === 0
|
||||
? [{ value: "__no-agents__", label: tr("Aucun agent disponible", "No agent available"), disabled: true }]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
|
||||
{(effectiveCustomCliAvailable || (agentId && cellMode === "custom")) && (
|
||||
<div
|
||||
|
||||
@ -34,7 +34,13 @@ function renderGrid(
|
||||
}
|
||||
|
||||
async function waitForAgentOption(name = "MyAgent") {
|
||||
await screen.findByRole("option", { name });
|
||||
const selector = await screen.findByRole("combobox");
|
||||
if (selector.getAttribute("aria-expanded") !== "true") fireEvent.click(selector);
|
||||
return screen.findByRole("option", { name });
|
||||
}
|
||||
|
||||
async function chooseAgent(name = "MyAgent") {
|
||||
fireEvent.click(await waitForAgentOption(name));
|
||||
}
|
||||
|
||||
describe("setCellAgent (agent dropdown per cell)", () => {
|
||||
@ -63,10 +69,7 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toBeTruthy();
|
||||
});
|
||||
await waitForAgentOption();
|
||||
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: a.id } });
|
||||
await chooseAgent();
|
||||
|
||||
// The layout should persist the agent on the leaf.
|
||||
const initial = await layout.loadLayout("p1");
|
||||
@ -129,9 +132,9 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
||||
expect(sel).toBeTruthy();
|
||||
});
|
||||
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
// Switch back to Plain.
|
||||
fireEvent.change(select, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("combobox"));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "Terminal" }));
|
||||
|
||||
// After clearing, the layout should have no agent on the leaf.
|
||||
await waitFor(async () => {
|
||||
@ -157,9 +160,7 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
||||
|
||||
renderGrid(layout, agent);
|
||||
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
||||
await waitForAgentOption();
|
||||
|
||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: a.id } });
|
||||
await chooseAgent();
|
||||
|
||||
await waitFor(async () => {
|
||||
const fresh = await layout.loadLayout("p1");
|
||||
@ -175,7 +176,7 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const terminal = new MockTerminalGateway();
|
||||
const closeSpy = vi.spyOn(terminal, "closeTerminal");
|
||||
const a = await agent.createAgent("p1", { name: "MyAgent", profileId: "p" });
|
||||
await agent.createAgent("p1", { name: "MyAgent", profileId: "p" });
|
||||
|
||||
const tree = await layout.loadLayout("p1");
|
||||
const leafId = leaves(tree)[0].id;
|
||||
@ -187,9 +188,7 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
||||
|
||||
renderGrid(layout, agent, "p1", terminal);
|
||||
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
||||
await waitForAgentOption();
|
||||
|
||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: a.id } });
|
||||
await chooseAgent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(closeSpy).toHaveBeenCalledWith("old-session");
|
||||
@ -205,9 +204,7 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
||||
|
||||
renderGrid(layout, agent, "p1", terminal);
|
||||
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
||||
await waitForAgentOption();
|
||||
|
||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: a.id } });
|
||||
await chooseAgent();
|
||||
|
||||
await waitFor(async () => {
|
||||
const fresh = await layout.loadLayout("p1");
|
||||
@ -262,9 +259,17 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
||||
|
||||
renderGrid(layout, agent);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Agent Alpha")).toBeTruthy();
|
||||
expect(screen.getByText("Agent Beta")).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(await screen.findByRole("combobox"));
|
||||
expect(await screen.findByRole("option", { name: "Agent Alpha" })).toBeTruthy();
|
||||
expect(screen.getByRole("option", { name: "Agent Beta" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a themed empty state when the project has no agents", async () => {
|
||||
renderGrid(new MockLayoutGateway(), new MockAgentGateway());
|
||||
|
||||
fireEvent.click(await screen.findByRole("combobox"));
|
||||
const list = await screen.findByRole("listbox");
|
||||
expect(list.className).toContain("bg-overlay");
|
||||
expect((screen.getByRole("option", { name: "Aucun agent disponible" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -112,6 +112,11 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
||||
);
|
||||
}
|
||||
|
||||
async function openAgentOption(name: RegExp | string) {
|
||||
fireEvent.click(await screen.findByRole("combobox"));
|
||||
return screen.findByRole("option", { name });
|
||||
}
|
||||
|
||||
it("opens an agent already running elsewhere by rebinding its live session", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
@ -129,13 +134,9 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
||||
renderGrid(layout, agent);
|
||||
|
||||
// The option is selectable: choosing it rebinds the running session here.
|
||||
const option = await screen.findByRole("option", {
|
||||
name: /Busy/,
|
||||
});
|
||||
expect((option as HTMLOptionElement).disabled).toBe(false);
|
||||
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: a.id } });
|
||||
const option = await openAgentOption(/Busy/);
|
||||
expect((option as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(option);
|
||||
|
||||
await waitFor(async () => {
|
||||
const updated = await layout.loadLayout("p1");
|
||||
@ -165,13 +166,9 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
||||
|
||||
renderGrid(layout, agent);
|
||||
|
||||
const option = await screen.findByRole("option", {
|
||||
name: /Busy/,
|
||||
});
|
||||
expect((option as HTMLOptionElement).disabled).toBe(false);
|
||||
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: a.id } });
|
||||
const option = await openAgentOption(/Busy/);
|
||||
expect((option as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(option);
|
||||
|
||||
expect((await screen.findByRole("status")).textContent).toMatch(
|
||||
/Session active introuvable/,
|
||||
@ -198,11 +195,9 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
||||
|
||||
renderGrid(layout, agent);
|
||||
|
||||
const option = await screen.findByRole("option", { name: /Headless/ });
|
||||
expect((option as HTMLOptionElement).disabled).toBe(false);
|
||||
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: a.id } });
|
||||
const option = await openAgentOption(/Headless/);
|
||||
expect((option as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(option);
|
||||
|
||||
await waitFor(async () => {
|
||||
const updated = await layout.loadLayout("p1");
|
||||
@ -234,9 +229,9 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
||||
|
||||
renderGrid(layout, agent);
|
||||
|
||||
const option = await screen.findByRole("option", { name: "Mine" });
|
||||
const option = await openAgentOption("Mine");
|
||||
// Live on its own cell ⇒ NOT disabled (it can keep running here).
|
||||
expect((option as HTMLOptionElement).disabled).toBe(false);
|
||||
expect((option as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -291,23 +286,13 @@ describe("R0d — dropdown disables agents live elsewhere + go-to-cell", () => {
|
||||
renderGrid(layout, agent);
|
||||
|
||||
// In cell A's dropdown the option is DISABLED (cannot run in two cells).
|
||||
const selectA = (await screen.findByLabelText(
|
||||
const selectA = await screen.findByLabelText(
|
||||
`Sélecteur d’agent ${a}`,
|
||||
)) as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
const opt = Array.from(selectA.options).find((o) => o.value === ag.id)!;
|
||||
expect(opt.disabled).toBe(true);
|
||||
expect(opt.textContent).toMatch(/visible ailleurs/);
|
||||
});
|
||||
|
||||
// Selecting it surfaces a clear notice + an "aller à la cellule" action.
|
||||
fireEvent.change(selectA, { target: { value: ag.id } });
|
||||
expect((await screen.findByRole("status")).textContent).toMatch(
|
||||
/déjà actif dans une autre cellule/,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "aller à la cellule" }),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(selectA);
|
||||
const opt = await screen.findByRole("option", { name: /Busy.*visible ailleurs/ });
|
||||
expect((opt as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(opt.textContent).toMatch(/visible ailleurs/);
|
||||
});
|
||||
|
||||
it("an agent NOT live anywhere stays selectable (no regression)", async () => {
|
||||
@ -320,13 +305,13 @@ describe("R0d — dropdown disables agents live elsewhere + go-to-cell", () => {
|
||||
|
||||
renderGrid(layout, agent);
|
||||
|
||||
fireEvent.click(await screen.findByRole("combobox"));
|
||||
const option = await screen.findByRole("option", { name: "Free" });
|
||||
expect((option as HTMLOptionElement).disabled).toBe(false);
|
||||
expect((option as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
// And selecting it pins the agent on the cell (no notice, no block).
|
||||
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: ag.id } });
|
||||
fireEvent.click(option);
|
||||
await waitFor(async () => {
|
||||
const leaf = leaves(await layout.loadLayout("p1")).find(
|
||||
(l) => l.id === leafId,
|
||||
@ -396,18 +381,16 @@ describe("ticket #56 — 'visible ailleurs' derives from the layout, not stale l
|
||||
// In cell B's dropdown, X is ENABLED and flagged as a background session —
|
||||
// NOT "visible ailleurs" (regression: it used to be disabled via the stale
|
||||
// live nodeId still pointing at the now-visible cell A).
|
||||
const selectB = (await screen.findByLabelText(
|
||||
const selectB = await screen.findByLabelText(
|
||||
`Sélecteur d’agent ${b}`,
|
||||
)) as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
const opt = Array.from(selectB.options).find((o) => o.value === x.id)!;
|
||||
expect(opt.disabled).toBe(false);
|
||||
expect(opt.textContent).toMatch(/arrière-plan/);
|
||||
expect(opt.textContent).not.toMatch(/visible ailleurs/);
|
||||
});
|
||||
);
|
||||
fireEvent.click(selectB);
|
||||
const opt = await screen.findByRole("option", { name: /Xavier.*arrière-plan/ });
|
||||
expect((opt as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(opt.textContent).not.toMatch(/visible ailleurs/);
|
||||
|
||||
// Selecting X in B re-attaches its live session (no re-spawn) and pins it here.
|
||||
fireEvent.change(selectB, { target: { value: x.id } });
|
||||
fireEvent.click(opt);
|
||||
await waitFor(async () => {
|
||||
const leaf = leaves(await layout.loadLayout("p1")).find((l) => l.id === b)!;
|
||||
expect(leaf.agent).toBe(x.id);
|
||||
@ -438,13 +421,11 @@ describe("ticket #56 — 'visible ailleurs' derives from the layout, not stale l
|
||||
|
||||
renderGrid(layout, agent);
|
||||
|
||||
const selectB = (await screen.findByLabelText(
|
||||
const selectB = await screen.findByLabelText(
|
||||
`Sélecteur d’agent ${b}`,
|
||||
)) as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
const opt = Array.from(selectB.options).find((o) => o.value === x.id)!;
|
||||
expect(opt.disabled).toBe(true);
|
||||
expect(opt.textContent).toMatch(/visible ailleurs/);
|
||||
});
|
||||
);
|
||||
fireEvent.click(selectB);
|
||||
const opt = await screen.findByRole("option", { name: /Xavier.*visible ailleurs/ });
|
||||
expect((opt as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user