feat(orchestrator): create_skill action + wire per-project watchers (§14.3)
- domain: OrchestratorCommand::CreateSkill with optional scope field (defaults to Project, case-insensitive, UnknownScope on bad value) - application: dispatch CreateSkill through the CreateSkill use case - app-tauri: build OrchestratorService and start/stop per-project request watchers on open/create/close_project (ensure/stop_orchestrator_watch) - tests: domain validation, service dispatch, infra watcher e2e, app-tauri wiring lifecycle (idempotent, isolated, stop) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -19,7 +19,12 @@ import {
|
||||
fireEvent,
|
||||
} from "@testing-library/react";
|
||||
|
||||
import { MockAgentGateway, MockProfileGateway, MockTemplateGateway } from "@/adapters/mock";
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockProfileGateway,
|
||||
MockSystemGateway,
|
||||
MockTemplateGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { AgentsPanel } from "./AgentsPanel";
|
||||
@ -384,3 +389,45 @@ describe("MockAgentGateway (unit)", () => {
|
||||
expect(listB).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentsPanel live refresh on domain events", () => {
|
||||
it("refreshes the list when an `agentLaunched` event fires (out-of-band creation)", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const profile = new MockProfileGateway();
|
||||
const system = new MockSystemGateway();
|
||||
const template = new MockTemplateGateway(agent);
|
||||
const gateways = { agent, profile, system, template } as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<AgentsPanel projectId={PROJECT_ID} projectRoot="/home/me/proj" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
// Initially empty.
|
||||
await waitFor(() => expect(screen.getByText("No agents yet.")).toBeTruthy());
|
||||
|
||||
// Simulate the orchestrator creating an agent directly in the store
|
||||
// (bypassing the UI's createAgent path), then emitting `agentLaunched`.
|
||||
const created = await agent.createAgent(PROJECT_ID, {
|
||||
name: "Orchestrated",
|
||||
profileId: "p1",
|
||||
});
|
||||
system.emit({
|
||||
type: "agentLaunched",
|
||||
agentId: created.id,
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
// The panel must reflect the new agent without any manual reload.
|
||||
expect(await screen.findByText("Orchestrated")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not crash when the system gateway is absent", async () => {
|
||||
// The existing renderPanel helper injects no `system` gateway; the
|
||||
// subscription effect must no-op rather than throw.
|
||||
renderPanel();
|
||||
await waitForIdle();
|
||||
expect(screen.getByText("No agents yet.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -64,7 +64,7 @@ function describe(e: unknown): string {
|
||||
}
|
||||
|
||||
export function useAgents(projectId: string): AgentsViewModel {
|
||||
const { agent, profile } = useGateways();
|
||||
const { agent, profile, system } = useGateways();
|
||||
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
@ -95,6 +95,32 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Live refresh: the agents list is otherwise only reloaded on mount or after a
|
||||
// UI-driven CRUD. An agent created/launched/stopped *out of band* — most
|
||||
// notably by the orchestrator (`spawn_agent`, ARCHITECTURE §14.3) when an AI
|
||||
// delegates agent creation to IdeA — emits `agentLaunched` / `agentExited`.
|
||||
// Subscribing here makes the tab reflect those changes without a manual reload.
|
||||
// `system` may be absent in unit tests that inject a partial gateway set; guard.
|
||||
useEffect(() => {
|
||||
if (!system) return;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
void system
|
||||
.onDomainEvent((event) => {
|
||||
if (event.type === "agentLaunched" || event.type === "agentExited") {
|
||||
void refresh();
|
||||
}
|
||||
})
|
||||
.then((un) => {
|
||||
if (cancelled) un();
|
||||
else unsubscribe = un;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [system, refresh]);
|
||||
|
||||
const createAgent = useCallback(
|
||||
async (name: string, profileId: string, initialContent?: string) => {
|
||||
setBusy(true);
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
* bailed.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
import { MockLayoutGateway, MockTerminalGateway } from "@/adapters/mock";
|
||||
@ -90,6 +90,72 @@ describe("LayoutGrid (with MockLayoutGateway)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("closing a cell removes THAT cell and keeps its sibling (closes the right terminal)", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const initial = await layout.loadLayout("p1");
|
||||
const a = leaves(initial)[0].id;
|
||||
// Split A → container c with children [A (index 0), b (index 1)].
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "split",
|
||||
target: a,
|
||||
direction: "row",
|
||||
newLeaf: "b",
|
||||
container: "c",
|
||||
});
|
||||
|
||||
renderGrid(layout);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(2),
|
||||
);
|
||||
|
||||
// Click the close button on cell `b` — `b` must disappear, `a` must remain.
|
||||
fireEvent.click(screen.getByLabelText("close b"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1),
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("layout-leaf").getAttribute("data-node-id"),
|
||||
).toBe(a);
|
||||
});
|
||||
|
||||
it("closing the other cell keeps the opposite sibling", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const initial = await layout.loadLayout("p1");
|
||||
const a = leaves(initial)[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "split",
|
||||
target: a,
|
||||
direction: "row",
|
||||
newLeaf: "b",
|
||||
container: "c",
|
||||
});
|
||||
|
||||
renderGrid(layout);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(2),
|
||||
);
|
||||
|
||||
// Closing `a` (index 0) must keep `b` (index 1).
|
||||
fireEvent.click(screen.getByLabelText(`close ${a}`));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1),
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("layout-leaf").getAttribute("data-node-id"),
|
||||
).toBe("b");
|
||||
});
|
||||
|
||||
it("the sole root cell has no close button (cannot be closed)", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
renderGrid(layout);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1),
|
||||
);
|
||||
expect(screen.queryByLabelText(/^close /)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the loading/empty container when the layout gateway is absent", () => {
|
||||
// A DI subset without the layout gateway → useLayout is defensive (null).
|
||||
const gateways = { terminal: new MockTerminalGateway() } as unknown as Gateways;
|
||||
|
||||
@ -113,7 +113,14 @@ interface LeafViewProps {
|
||||
}
|
||||
|
||||
function LeafView({ id, session, agent, cwd, vm, parentSplit, projectId }: LeafViewProps) {
|
||||
const canMerge = parentSplit !== null && parentSplit.siblings > 1;
|
||||
// A cell can be closed only when it lives inside a (binary) split: closing it
|
||||
// collapses the parent split, keeping the *sibling*. Splits are always binary
|
||||
// in this model (a split wraps a leaf into a 2-child container), so the kept
|
||||
// child is simply the other index. Guarding on `siblings === 2` keeps the
|
||||
// operation correct (merge keeps exactly one child) and avoids ever dropping
|
||||
// the wrong terminal. The root cell (no parent split) cannot be closed.
|
||||
const canClose = parentSplit !== null && parentSplit.siblings === 2;
|
||||
const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0;
|
||||
const { agent: agentGateway } = useGateways();
|
||||
|
||||
// Load the project's agents for the dropdown.
|
||||
@ -159,7 +166,10 @@ function LeafView({ id, session, agent, cwd, vm, parentSplit, projectId }: LeafV
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 2,
|
||||
right: 2,
|
||||
// Clear the xterm viewport scrollbar (rendered flush against the right
|
||||
// edge, ~15px wide). Without this offset the right-most control — the
|
||||
// close button — sits *behind* the scrollbar and is hard to click.
|
||||
right: 16,
|
||||
zIndex: 2,
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
@ -208,16 +218,14 @@ function LeafView({ id, session, agent, cwd, vm, parentSplit, projectId }: LeafV
|
||||
>
|
||||
⬍
|
||||
</button>
|
||||
{canMerge && (
|
||||
{canClose && (
|
||||
<button
|
||||
type="button"
|
||||
title="Merge: keep this cell, drop its siblings"
|
||||
aria-label={`merge ${id}`}
|
||||
onClick={() =>
|
||||
void vm.merge(parentSplit.container, parentSplit.index)
|
||||
}
|
||||
title="Close this terminal"
|
||||
aria-label={`close ${id}`}
|
||||
onClick={() => void vm.merge(parentSplit.container, siblingIndex)}
|
||||
>
|
||||
⤬
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user