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:
@ -294,6 +294,7 @@ export type AgentRestartFailureCode =
|
||||
|
||||
export type DomainEvent =
|
||||
| { type: "projectCreated"; projectId: string }
|
||||
| { type: "projectPluginSettingsChanged"; projectId: string }
|
||||
| { type: "pluginInstalled"; pluginId: string; version: string }
|
||||
| {
|
||||
type: "pluginReloaded";
|
||||
@ -302,6 +303,10 @@ export type DomainEvent =
|
||||
newContentHash: string;
|
||||
changed: boolean;
|
||||
}
|
||||
| { type: "pluginEnabled"; pluginId: string }
|
||||
| { type: "pluginDisabled"; pluginId: string; restartRequired: boolean }
|
||||
| { type: "pluginUninstalled"; pluginId: string; restartRequired: boolean }
|
||||
| { type: "pluginLoadFailed"; pluginId: string; reason: string }
|
||||
| { type: "agentLaunched"; agentId: string; sessionId: string }
|
||||
| { type: "agentExited"; agentId: string; code: number }
|
||||
| {
|
||||
|
||||
@ -264,12 +264,16 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
return (
|
||||
<Panel title={tr("Agents", "Agents")} className="flex flex-col gap-0">
|
||||
{vm.error && (
|
||||
<p
|
||||
<div
|
||||
role="alert"
|
||||
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||
className="mx-4 mt-3 flex flex-wrap items-center gap-2 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{vm.error}
|
||||
</p>
|
||||
<span>{tr("Mise à jour interrompue.", "Update interrupted.")}</span>
|
||||
<span>{vm.error}</span>
|
||||
<Button size="sm" variant="secondary" onClick={() => void vm.refresh()}>
|
||||
{tr("Réessayer", "Retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Create form ── */}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -132,6 +132,17 @@ describe("PluginRuntimeProvider soft refresh (#279)", () => {
|
||||
expect(mounts).toBe(1);
|
||||
expect(plugin.calls).toBe(3);
|
||||
expect(plugin.projectIds).toEqual(["project-1", "project-1", "project-1"]);
|
||||
|
||||
plugin.catalog = { plugins: [] };
|
||||
system.emit({ type: "projectPluginSettingsChanged", projectId: "another-project" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(plugin.calls).toBe(3);
|
||||
|
||||
system.emit({ type: "projectPluginSettingsChanged", projectId: "project-1" });
|
||||
expect(await screen.findByText("none")).toBeTruthy();
|
||||
expect(screen.getByTestId("workspace-state").textContent).toBe("preserved");
|
||||
expect(mounts).toBe(1);
|
||||
expect(plugin.calls).toBe(4);
|
||||
});
|
||||
|
||||
it("ignores a custom plugin layout ref when its contribution disappears", async () => {
|
||||
|
||||
@ -179,7 +179,16 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
|
||||
void gateways.system
|
||||
.onDomainEvent((event) => {
|
||||
if (event.type === "pluginInstalled" || event.type === "pluginReloaded") {
|
||||
const globalCatalogChanged =
|
||||
event.type === "pluginInstalled" ||
|
||||
event.type === "pluginReloaded" ||
|
||||
event.type === "pluginEnabled" ||
|
||||
event.type === "pluginDisabled" ||
|
||||
event.type === "pluginUninstalled";
|
||||
const currentProjectChanged =
|
||||
event.type === "projectPluginSettingsChanged" &&
|
||||
event.projectId === currentProjectId;
|
||||
if (globalCatalogChanged || currentProjectChanged) {
|
||||
void refreshRuntime(false);
|
||||
}
|
||||
})
|
||||
|
||||
@ -5,6 +5,7 @@ import { DIProvider } from "@/app/di";
|
||||
import {
|
||||
createMockGateways,
|
||||
MockPluginGateway,
|
||||
MockSystemGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { ProjectPluginManifest } from "@/domain";
|
||||
import { SkillsPanel } from "@/features/skills";
|
||||
@ -91,6 +92,53 @@ describe("project plugin settings #282", () => {
|
||||
expect(onAssignAgents).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("refreshes project settings on the matching domain event without remounting", async () => {
|
||||
const gateways = gatewaysWithPlugin();
|
||||
const system = new MockSystemGateway();
|
||||
const scoped = { ...gateways, system };
|
||||
render(
|
||||
<DIProvider gateways={scoped}>
|
||||
<ProjectPluginsPanel projectId="project-1" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const toggle = await screen.findByRole("checkbox", { name: "Activé pour ce projet" });
|
||||
expect((toggle as HTMLInputElement).checked).toBe(false);
|
||||
gateways.plugin._seedProjectPluginSettings("project-1", {
|
||||
version: 1,
|
||||
plugins: [{ pluginId: "dev.acme.unity", enabled: true }],
|
||||
agents: [],
|
||||
});
|
||||
|
||||
system.emit({ type: "projectPluginSettingsChanged", projectId: "other-project" });
|
||||
expect((toggle as HTMLInputElement).checked).toBe(false);
|
||||
system.emit({ type: "projectPluginSettingsChanged", projectId: "project-1" });
|
||||
await waitFor(() => expect((toggle as HTMLInputElement).checked).toBe(true));
|
||||
});
|
||||
|
||||
it("keeps the previous activation after failure and retries the requested state", async () => {
|
||||
const gateways = gatewaysWithPlugin();
|
||||
const original = gateways.plugin.setProjectEnabled.bind(gateways.plugin);
|
||||
const update = vi
|
||||
.spyOn(gateways.plugin, "setProjectEnabled")
|
||||
.mockRejectedValueOnce(new Error("settings unavailable"))
|
||||
.mockImplementation(original);
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ProjectPluginsPanel projectId="project-1" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const toggle = await screen.findByRole("checkbox", { name: "Activé pour ce projet" });
|
||||
fireEvent.click(toggle);
|
||||
expect(await screen.findByText("Erreur — état précédent conservé")).toBeTruthy();
|
||||
expect((toggle as HTMLInputElement).checked).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Réessayer" }));
|
||||
await waitFor(() => expect((toggle as HTMLInputElement).checked).toBe(true));
|
||||
expect(update).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("assigns plugin skills from the unified Skills panel and keeps tombstones removable", async () => {
|
||||
const gateways = gatewaysWithPlugin();
|
||||
const agent = await gateways.agent.createAgent("project-1", {
|
||||
|
||||
@ -48,7 +48,21 @@ export function ProjectPluginsPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{vm.error && <p role="alert" className="text-sm text-danger">{vm.error}</p>}
|
||||
{vm.error && (
|
||||
<div role="alert" className="flex flex-wrap items-center gap-2 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
|
||||
<span>{tr("Mise à jour interrompue.", "Update interrupted.")}</span>
|
||||
<span>{vm.error}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void (vm.failedActivation
|
||||
? vm.setEnabled(vm.failedActivation.pluginId, vm.failedActivation.enabled)
|
||||
: vm.refresh())}
|
||||
>
|
||||
{tr("Réessayer", "Retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vm.installed.length === 0 && missing.length === 0 ? (
|
||||
<Panel><p className="text-sm text-muted">{tr("Aucun plugin installé globalement.", "No globally installed plugins.")}</p></Panel>
|
||||
@ -60,6 +74,10 @@ export function ProjectPluginsPanel({
|
||||
) ?? false;
|
||||
const skills = plugin.contributes.skills ?? [];
|
||||
const servers = plugin.contributes.mcpServers ?? [];
|
||||
const pending = vm.pendingActivation?.pluginId === plugin.id
|
||||
? vm.pendingActivation
|
||||
: null;
|
||||
const failed = vm.failedActivation?.pluginId === plugin.id;
|
||||
return (
|
||||
<li key={plugin.id}>
|
||||
<Panel className="flex flex-col gap-3">
|
||||
@ -86,6 +104,15 @@ export function ProjectPluginsPanel({
|
||||
{tr("Activé pour ce projet", "Enabled for this project")}
|
||||
</label>
|
||||
</div>
|
||||
{(pending || failed) && (
|
||||
<p role={failed ? "alert" : "status"} className={`text-xs ${failed ? "text-danger" : "text-muted"}`}>
|
||||
{failed
|
||||
? tr("Erreur — état précédent conservé", "Error — previous state preserved")
|
||||
: pending?.enabled
|
||||
? tr("Activation…", "Enabling…")
|
||||
: tr("Désactivation…", "Disabling…")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<details>
|
||||
<summary className="cursor-pointer text-xs font-medium text-primary">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { MenuTargetId } from "@/domain";
|
||||
@ -16,6 +16,8 @@ const NO_CONTEXT: WhenContext = {
|
||||
function throwingRegistry(overrides: Record<string, unknown>): PluginRuntimeRegistry {
|
||||
return {
|
||||
runCommand: vi.fn(async () => {}),
|
||||
runCommandStrict: vi.fn(async () => {}),
|
||||
get: vi.fn(() => ({ commands: { has: () => true } })),
|
||||
topLevelMenus: vi.fn(() => []),
|
||||
menuItems: vi.fn(() => []),
|
||||
...overrides,
|
||||
@ -72,4 +74,50 @@ describe("usePluginMenus", () => {
|
||||
consoleWarn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("logs rejected commands and exposes retryable feedback to the host", async () => {
|
||||
const failure = new Error("native window refused");
|
||||
const runCommandStrict = vi.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce("opened");
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
const registry = throwingRegistry({ runCommandStrict });
|
||||
const { result } = renderHook(() => usePluginMenus(registry, NO_CONTEXT));
|
||||
|
||||
await act(() => result.current.runCommand("dev.acme.git", "git.configure"));
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
"[plugins] menu command rejected",
|
||||
{
|
||||
pluginId: "dev.acme.git",
|
||||
commandId: "git.configure",
|
||||
message: "native window refused",
|
||||
},
|
||||
);
|
||||
expect(result.current.commandError).toEqual({
|
||||
pluginId: "dev.acme.git",
|
||||
commandId: "git.configure",
|
||||
message: "native window refused",
|
||||
});
|
||||
|
||||
await act(() => result.current.runCommand("dev.acme.git", "git.configure"));
|
||||
expect(result.current.commandError).toBeNull();
|
||||
expect(runCommandStrict).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a missing command handler as a silent no-op", async () => {
|
||||
const runCommandStrict = vi.fn();
|
||||
const registry = throwingRegistry({
|
||||
runCommandStrict,
|
||||
get: vi.fn(() => ({ commands: { has: () => false } })),
|
||||
});
|
||||
const { result } = renderHook(() => usePluginMenus(registry, NO_CONTEXT));
|
||||
|
||||
await act(() => result.current.runCommand("dev.acme.git", "git.missing"));
|
||||
|
||||
expect(runCommandStrict).not.toHaveBeenCalled();
|
||||
expect(result.current.commandError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
* future lot threads real focus state up to this level).
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import type { MenuTargetId } from "@/domain";
|
||||
import type { MenuBarItem, MenuBarMenu } from "@/shared";
|
||||
@ -24,6 +24,14 @@ export interface UsePluginMenusResult {
|
||||
/** Resolved+ordered `MenuBarItem`s to append to a native menu's items. */
|
||||
itemsFor: (targetMenuId: MenuTargetId) => MenuBarItem[];
|
||||
runCommand: (pluginId: string, commandId: string) => Promise<unknown>;
|
||||
commandError: PluginMenuCommandError | null;
|
||||
clearCommandError: () => void;
|
||||
}
|
||||
|
||||
export interface PluginMenuCommandError {
|
||||
pluginId: string;
|
||||
commandId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function describeError(e: unknown): string {
|
||||
@ -49,8 +57,25 @@ export function usePluginMenus(
|
||||
registry: PluginRuntimeRegistry,
|
||||
whenCtx: WhenContext,
|
||||
): UsePluginMenusResult {
|
||||
const runCommand = useMemo(
|
||||
() => (pluginId: string, commandId: string) => registry.runCommand(pluginId, commandId),
|
||||
const [commandError, setCommandError] = useState<PluginMenuCommandError | null>(null);
|
||||
const clearCommandError = useCallback(() => setCommandError(null), []);
|
||||
const runCommand = useCallback(
|
||||
async (pluginId: string, commandId: string): Promise<unknown> => {
|
||||
setCommandError(null);
|
||||
try {
|
||||
// Preserve the SDK's historical no-op for a declared menu command whose
|
||||
// handler was not registered, but do not use the lenient dispatcher for
|
||||
// real handlers: it logs and swallows rejected promises, which made
|
||||
// native-window failures invisible to the menu host (#329).
|
||||
if (!registry.get(pluginId)?.commands.has(commandId)) return undefined;
|
||||
return await registry.runCommandStrict(pluginId, commandId);
|
||||
} catch (error) {
|
||||
const failure = { pluginId, commandId, message: describeError(error) };
|
||||
console.error("[plugins] menu command rejected", failure);
|
||||
setCommandError(failure);
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
[registry],
|
||||
);
|
||||
|
||||
@ -126,5 +151,5 @@ export function usePluginMenus(
|
||||
});
|
||||
}, [registry, itemsFor]);
|
||||
|
||||
return { topLevelMenus, itemsFor, runCommand };
|
||||
return { topLevelMenus, itemsFor, runCommand, commandError, clearCommandError };
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useGateways } from "@/app/di";
|
||||
import type {
|
||||
@ -21,6 +21,8 @@ export interface ProjectPluginsViewModel {
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
pendingActivation: { pluginId: string; enabled: boolean } | null;
|
||||
failedActivation: { pluginId: string; enabled: boolean } | null;
|
||||
refresh(): Promise<void>;
|
||||
setEnabled(pluginId: string, enabled: boolean): Promise<void>;
|
||||
setSkillAssigned(agentId: string, pluginId: string, skillId: string, assigned: boolean): Promise<void>;
|
||||
@ -28,31 +30,91 @@ export interface ProjectPluginsViewModel {
|
||||
}
|
||||
|
||||
export function useProjectPlugins(projectId: string): ProjectPluginsViewModel {
|
||||
const { plugin } = useGateways();
|
||||
const { plugin, system } = useGateways();
|
||||
const [installed, setInstalled] = useState<ProjectPluginManifest[]>([]);
|
||||
const [settings, setSettings] = useState<ProjectPluginSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingActivation, setPendingActivation] = useState<{
|
||||
pluginId: string;
|
||||
enabled: boolean;
|
||||
} | null>(null);
|
||||
const [failedActivation, setFailedActivation] = useState<{
|
||||
pluginId: string;
|
||||
enabled: boolean;
|
||||
} | null>(null);
|
||||
const refreshRevision = useRef(0);
|
||||
const currentProjectId = useRef(projectId);
|
||||
currentProjectId.current = projectId;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const revision = ++refreshRevision.current;
|
||||
const refreshedProjectId = projectId;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const view = await plugin.listProjectSettings(projectId);
|
||||
const view = await plugin.listProjectSettings(refreshedProjectId);
|
||||
if (
|
||||
revision !== refreshRevision.current ||
|
||||
currentProjectId.current !== refreshedProjectId
|
||||
) return;
|
||||
setInstalled(view.installed);
|
||||
setSettings(view.settings);
|
||||
} catch (cause) {
|
||||
if (
|
||||
revision !== refreshRevision.current ||
|
||||
currentProjectId.current !== refreshedProjectId
|
||||
) return;
|
||||
setError(describe(cause));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (
|
||||
revision === refreshRevision.current &&
|
||||
currentProjectId.current === refreshedProjectId
|
||||
) setLoading(false);
|
||||
}
|
||||
}, [plugin, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
setInstalled([]);
|
||||
setSettings(null);
|
||||
setBusy(false);
|
||||
setError(null);
|
||||
setPendingActivation(null);
|
||||
setFailedActivation(null);
|
||||
void refresh();
|
||||
return () => {
|
||||
refreshRevision.current += 1;
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!system?.onDomainEvent) return;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
void system
|
||||
.onDomainEvent((event) => {
|
||||
const globalCatalogChanged =
|
||||
event.type === "pluginInstalled" ||
|
||||
event.type === "pluginReloaded" ||
|
||||
event.type === "pluginEnabled" ||
|
||||
event.type === "pluginDisabled" ||
|
||||
event.type === "pluginUninstalled";
|
||||
const currentProjectChanged =
|
||||
event.type === "projectPluginSettingsChanged" &&
|
||||
event.projectId === currentProjectId.current;
|
||||
if (globalCatalogChanged || currentProjectChanged) void refresh();
|
||||
})
|
||||
.then((next) => {
|
||||
if (cancelled) next();
|
||||
else unsubscribe = next;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [refresh, system]);
|
||||
|
||||
const mutate = useCallback(async (operation: () => Promise<ProjectPluginSettings>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
@ -65,11 +127,30 @@ export function useProjectPlugins(projectId: string): ProjectPluginsViewModel {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setEnabled = useCallback(
|
||||
(pluginId: string, enabled: boolean) =>
|
||||
mutate(() => plugin.setProjectEnabled({ projectId, pluginId, enabled })),
|
||||
[mutate, plugin, projectId],
|
||||
);
|
||||
const setEnabled = useCallback(async (pluginId: string, enabled: boolean) => {
|
||||
const mutatedProjectId = projectId;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setFailedActivation(null);
|
||||
setPendingActivation({ pluginId, enabled });
|
||||
try {
|
||||
const next = await plugin.setProjectEnabled({
|
||||
projectId: mutatedProjectId,
|
||||
pluginId,
|
||||
enabled,
|
||||
});
|
||||
if (currentProjectId.current === mutatedProjectId) setSettings(next);
|
||||
} catch (cause) {
|
||||
if (currentProjectId.current !== mutatedProjectId) return;
|
||||
setError(describe(cause));
|
||||
setFailedActivation({ pluginId, enabled });
|
||||
} finally {
|
||||
if (currentProjectId.current === mutatedProjectId) {
|
||||
setPendingActivation(null);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
}, [plugin, projectId]);
|
||||
|
||||
const setSkillAssigned = useCallback(
|
||||
(agentId: string, pluginId: string, skillId: string, assigned: boolean) => {
|
||||
@ -97,6 +178,8 @@ export function useProjectPlugins(projectId: string): ProjectPluginsViewModel {
|
||||
loading,
|
||||
busy,
|
||||
error,
|
||||
pendingActivation,
|
||||
failedActivation,
|
||||
refresh,
|
||||
setEnabled,
|
||||
setSkillAssigned,
|
||||
|
||||
@ -19,6 +19,23 @@ function deferred<T>() {
|
||||
}
|
||||
|
||||
describe("ProjectSettingsPanel", () => {
|
||||
it("composes project plugin activation between appearance and context", async () => {
|
||||
const gateways = createMockGateways();
|
||||
const project = await gateways.project.createProject("Alpha", "/alpha");
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ProjectSettingsPanel projectId={project.id} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const appearance = await screen.findByText("Apparence");
|
||||
const plugins = screen.getByRole("region", { name: "Plugins du projet" });
|
||||
const context = screen.getByRole("heading", { name: "Contexte du projet", level: 2 });
|
||||
expect(appearance.compareDocumentPosition(plugins) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(plugins.compareDocumentPosition(context) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("persists a project colour, emits the scoped refresh, and resets to fallback", async () => {
|
||||
const gateways = createMockGateways();
|
||||
const project = await gateways.project.createProject("Alpha", "/alpha");
|
||||
|
||||
@ -7,6 +7,7 @@ import { Button, Panel, Spinner } from "@/shared";
|
||||
import { ProjectContextPanel } from "./ProjectContextPanel";
|
||||
import { ProjectIconPicker } from "./ProjectIconPicker";
|
||||
import { ProjectIcon, publishProjectIconChanged } from "./projectIcons";
|
||||
import { ProjectPluginsPanel } from "@/features/plugins";
|
||||
import {
|
||||
DEFAULT_PROJECT_COLOR,
|
||||
onProjectThemeChanged,
|
||||
@ -216,6 +217,8 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<ProjectPluginsPanel projectId={projectId} />
|
||||
|
||||
<section aria-labelledby="project-context-settings-title">
|
||||
<h2
|
||||
id="project-context-settings-title"
|
||||
|
||||
@ -1033,6 +1033,36 @@ export function ProjectsView() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{pluginMenus.commandError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mx-4 mt-2 flex shrink-0 items-center justify-between gap-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
<span>
|
||||
{tr("L’action du plugin a échoué.", "The plugin action failed.")} {pluginMenus.commandError.message}
|
||||
</span>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void pluginMenus.runCommand(
|
||||
pluginMenus.commandError!.pluginId,
|
||||
pluginMenus.commandError!.commandId,
|
||||
)}
|
||||
>
|
||||
{tr("Réessayer", "Retry")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={tr("Fermer l’erreur du plugin", "Dismiss plugin error")}
|
||||
onClick={pluginMenus.clearCommandError}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Project tab bar (#26): tabs = open projects, "+" opens Projects ── */}
|
||||
<ProjectTabs
|
||||
|
||||
@ -23,15 +23,14 @@ export interface ProjectAgents {
|
||||
}
|
||||
|
||||
export function useProjectAgents(projectId: string): ProjectAgents {
|
||||
const { agent } = useGateways();
|
||||
const { agent, system } = useGateways();
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoaded(false);
|
||||
void agent
|
||||
.listAgents(projectId)
|
||||
const refresh = () => agent.listAgents(projectId)
|
||||
.then((list) => {
|
||||
if (!cancelled) {
|
||||
setAgents(list);
|
||||
@ -40,14 +39,31 @@ export function useProjectAgents(projectId: string): ProjectAgents {
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setAgents([]);
|
||||
setLoaded(true);
|
||||
}
|
||||
});
|
||||
void refresh();
|
||||
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
if (system?.onDomainEvent) {
|
||||
void system
|
||||
.onDomainEvent((event) => {
|
||||
if (
|
||||
event.type === "agentLaunched" ||
|
||||
event.type === "agentExited" ||
|
||||
event.type === "agentProfileChanged"
|
||||
) void refresh();
|
||||
})
|
||||
.then((next) => {
|
||||
if (cancelled) next();
|
||||
else unsubscribe = next;
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [projectId, agent]);
|
||||
}, [projectId, agent, system]);
|
||||
|
||||
const byId = useMemo(
|
||||
() => new Map(agents.map((a) => [a.id, a.name])),
|
||||
|
||||
@ -213,6 +213,7 @@ export function SmallDropdown({
|
||||
ref={triggerRef}
|
||||
id={id}
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
@ -256,7 +257,7 @@ export function SmallDropdown({
|
||||
role="listbox"
|
||||
aria-label={ariaLabel}
|
||||
data-placement={position.placement}
|
||||
className="fixed overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-xl"
|
||||
className="fixed overflow-y-auto rounded-md border border-border-strong bg-overlay py-1 shadow-xl"
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
@ -281,8 +282,9 @@ export function SmallDropdown({
|
||||
"flex min-h-8 w-full items-center px-2 text-left text-content",
|
||||
"hover:bg-raised focus:bg-raised focus:outline-none",
|
||||
size === "md" ? "text-sm" : "text-xs",
|
||||
(option.value === value || index === activeIndex) && "bg-raised",
|
||||
option.disabled && "cursor-not-allowed opacity-50",
|
||||
option.value === value && "bg-primary/15 text-content",
|
||||
index === activeIndex && option.value !== value && "bg-raised",
|
||||
option.disabled && "cursor-not-allowed text-faint opacity-60",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{option.label}</span>
|
||||
|
||||
Reference in New Issue
Block a user