feat: add capability discovery route
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CapabilityDiscoveryController } from "./useCapabilityDiscovery.js";
|
||||
import { useCapabilityDiscovery } from "./useCapabilityDiscovery.js";
|
||||
import { DiscoverRoute } from "./DiscoverRoute.js";
|
||||
|
||||
vi.mock("./useCapabilityDiscovery.js", () => ({
|
||||
useCapabilityDiscovery: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseCapabilityDiscovery = vi.mocked(useCapabilityDiscovery);
|
||||
|
||||
const summary = {
|
||||
kind: "node_spec" as const,
|
||||
name: "local.documents.read",
|
||||
sourceId: "local.documents",
|
||||
description: "Read project documents.",
|
||||
outcomes: ["ok", "error"],
|
||||
inputFields: ["names"],
|
||||
outputFields: ["documents"],
|
||||
};
|
||||
|
||||
const controller = (
|
||||
overrides: Partial<CapabilityDiscoveryController> = {},
|
||||
): CapabilityDiscoveryController => ({
|
||||
phase: "ready",
|
||||
query: "",
|
||||
sourceId: "",
|
||||
items: [summary],
|
||||
selected: null,
|
||||
nextCursor: null,
|
||||
message: null,
|
||||
setQuery: vi.fn(),
|
||||
setSourceId: vi.fn(),
|
||||
search: vi.fn(),
|
||||
loadMore: vi.fn(),
|
||||
inspect: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => mockedUseCapabilityDiscovery.mockReturnValue(controller()));
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("DiscoverRoute", () => {
|
||||
it("shows the discovery heading and searchable source-filtered controls", () => {
|
||||
render(<DiscoverRoute />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Discover capabilities" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Search capabilities" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Filter by source" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Search" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders compact capability rows with contract summary fields", async () => {
|
||||
const inspect = vi.fn();
|
||||
mockedUseCapabilityDiscovery.mockReturnValue(controller({ inspect }));
|
||||
render(<DiscoverRoute />);
|
||||
|
||||
expect(screen.getByText("Node spec")).toBeInTheDocument();
|
||||
expect(screen.getByText("Source: local.documents")).toBeInTheDocument();
|
||||
expect(screen.getByText("Inputs: names")).toBeInTheDocument();
|
||||
expect(screen.getByText("Outputs: documents")).toBeInTheDocument();
|
||||
expect(screen.getByText("Outcomes: ok, error")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /local\.documents\.read/i }));
|
||||
expect(inspect).toHaveBeenCalledWith("local.documents.read");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["disconnected", "Connect a workflow server to discover capabilities."],
|
||||
["loading", "Loading capabilities..."],
|
||||
["error", "CapabilityPage is malformed"],
|
||||
] as const)("renders an explicit %s state", (phase, message) => {
|
||||
mockedUseCapabilityDiscovery.mockReturnValue(
|
||||
controller({ phase, message: phase === "error" ? message : null, items: [] }),
|
||||
);
|
||||
render(<DiscoverRoute />);
|
||||
|
||||
expect(screen.getByText(message)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders selected input/output schemas and wrapper hints", () => {
|
||||
mockedUseCapabilityDiscovery.mockReturnValue(
|
||||
controller({
|
||||
selected: {
|
||||
...summary,
|
||||
isAsync: false,
|
||||
inputSchema: { type: "object", properties: { names: { type: "array" } } },
|
||||
outputSchema: { type: "object", properties: { documents: { type: "array" } } },
|
||||
wrapperHints: { input: "names", output: "documents" },
|
||||
acceptsContext: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(<DiscoverRoute />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Input schema" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Output schema" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Wrapper hints" })).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/"names"/)).toHaveLength(2);
|
||||
expect(screen.queryByRole("button", { name: /add to draft/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows load more only when the controller has a next cursor", async () => {
|
||||
const loadMore = vi.fn();
|
||||
mockedUseCapabilityDiscovery.mockReturnValue(
|
||||
controller({ nextCursor: "page-2", loadMore }),
|
||||
);
|
||||
render(<DiscoverRoute />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Load more capabilities" }));
|
||||
expect(loadMore).toHaveBeenCalledOnce();
|
||||
|
||||
cleanup();
|
||||
mockedUseCapabilityDiscovery.mockReturnValue(controller());
|
||||
render(<DiscoverRoute />);
|
||||
expect(screen.queryByRole("button", { name: "Load more capabilities" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Boxes, PackageOpen } from "lucide-react";
|
||||
import type {
|
||||
CapabilityDetail,
|
||||
CapabilitySummary,
|
||||
} from "../domain/capability-models.js";
|
||||
import { useCapabilityDiscovery } from "./useCapabilityDiscovery.js";
|
||||
|
||||
const formatKind = (kind: CapabilitySummary["kind"]): string =>
|
||||
kind === "node_spec" ? "Node spec" : "Wrapper artifact";
|
||||
|
||||
const KindIcon = ({ kind }: { readonly kind: CapabilitySummary["kind"] }) => {
|
||||
const Icon = kind === "node_spec" ? Boxes : PackageOpen;
|
||||
return <Icon aria-hidden="true" size={16} strokeWidth={1.8} />;
|
||||
};
|
||||
|
||||
const SchemaBlock = ({
|
||||
heading,
|
||||
value,
|
||||
}: {
|
||||
readonly heading: string;
|
||||
readonly value: Record<string, unknown>;
|
||||
}) => (
|
||||
<div className="capability-discovery__schema-block">
|
||||
<h3>{heading}</h3>
|
||||
<pre>{JSON.stringify(value, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CapabilityRow = ({
|
||||
item,
|
||||
selected,
|
||||
onInspect,
|
||||
}: {
|
||||
readonly item: CapabilitySummary;
|
||||
readonly selected: boolean;
|
||||
readonly onInspect: (qualifiedName: string) => void;
|
||||
}) => (
|
||||
<li>
|
||||
<button
|
||||
className="capability-discovery__row"
|
||||
data-selected={selected}
|
||||
onClick={() => onInspect(item.name)}
|
||||
type="button"
|
||||
>
|
||||
<span className="capability-discovery__row-heading">
|
||||
<span className="capability-discovery__kind">
|
||||
<KindIcon kind={item.kind} />
|
||||
<span>{formatKind(item.kind)}</span>
|
||||
</span>
|
||||
<strong>{item.name}</strong>
|
||||
</span>
|
||||
<span className="capability-discovery__row-meta">
|
||||
<span>Source: {item.sourceId}</span>
|
||||
<span>Inputs: {item.inputFields.length > 0 ? item.inputFields.join(", ") : "none"}</span>
|
||||
<span>Outputs: {item.outputFields.length > 0 ? item.outputFields.join(", ") : "none"}</span>
|
||||
<span>Outcomes: {item.outcomes.length > 0 ? item.outcomes.join(", ") : "none"}</span>
|
||||
</span>
|
||||
{item.description && <span className="capability-discovery__description">{item.description}</span>}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
||||
const DetailView = ({ detail }: { readonly detail: CapabilityDetail }) => (
|
||||
<section aria-labelledby="capability-detail-heading" className="capability-discovery__detail">
|
||||
<p className="workspace-route-pending__eyebrow">Selected contract</p>
|
||||
<h2 id="capability-detail-heading">{detail.name}</h2>
|
||||
<dl className="capability-discovery__facts">
|
||||
<div><dt>Kind</dt><dd>{formatKind(detail.kind)}</dd></div>
|
||||
<div><dt>Source</dt><dd>{detail.sourceId}</dd></div>
|
||||
<div><dt>Async</dt><dd>{detail.isAsync ? "yes" : "no"}</dd></div>
|
||||
<div><dt>Outcomes</dt><dd>{detail.outcomes.join(", ") || "none"}</dd></div>
|
||||
</dl>
|
||||
<div className="capability-discovery__schemas">
|
||||
<SchemaBlock heading="Input schema" value={detail.inputSchema} />
|
||||
<SchemaBlock heading="Output schema" value={detail.outputSchema} />
|
||||
<SchemaBlock heading="Wrapper hints" value={detail.wrapperHints} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export const DiscoverRoute = () => {
|
||||
const discovery = useCapabilityDiscovery();
|
||||
const isReady = discovery.phase === "ready";
|
||||
|
||||
return (
|
||||
<div className="capability-discovery">
|
||||
<header className="capability-discovery__header">
|
||||
<p className="workspace-route-pending__eyebrow">Capability catalog</p>
|
||||
<h1>Discover capabilities</h1>
|
||||
<p>Inspect the typed contracts available to workflow authors before drafting.</p>
|
||||
</header>
|
||||
|
||||
<form
|
||||
className="capability-discovery__filters"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
discovery.search();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="capability-search">Search capabilities</label>
|
||||
<input
|
||||
id="capability-search"
|
||||
onChange={(event) => discovery.setQuery(event.target.value)}
|
||||
type="text"
|
||||
value={discovery.query}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="capability-source">Filter by source</label>
|
||||
<input
|
||||
id="capability-source"
|
||||
onChange={(event) => discovery.setSourceId(event.target.value)}
|
||||
type="text"
|
||||
value={discovery.sourceId}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
<div className="capability-discovery__panes">
|
||||
<section aria-labelledby="capability-results-heading" className="capability-discovery__results">
|
||||
<div className="capability-discovery__section-heading">
|
||||
<div>
|
||||
<p className="workspace-route-pending__eyebrow">Available interfaces</p>
|
||||
<h2 id="capability-results-heading">Capabilities</h2>
|
||||
</div>
|
||||
{isReady && <span className="capability-discovery__count">{discovery.items.length} shown</span>}
|
||||
</div>
|
||||
|
||||
{discovery.phase === "disconnected" && (
|
||||
<p role="status">Connect a workflow server to discover capabilities.</p>
|
||||
)}
|
||||
{discovery.phase === "loading" && <p role="status">Loading capabilities...</p>}
|
||||
{discovery.phase === "error" && (
|
||||
<p role="alert">{discovery.message ?? "Capability discovery failed."}</p>
|
||||
)}
|
||||
{isReady && discovery.items.length === 0 && (
|
||||
<p role="status">No capabilities match the current filters.</p>
|
||||
)}
|
||||
{discovery.items.length > 0 && (
|
||||
<ul className="capability-discovery__list">
|
||||
{discovery.items.map((item) => (
|
||||
<CapabilityRow
|
||||
item={item}
|
||||
key={item.name}
|
||||
onInspect={discovery.inspect}
|
||||
selected={discovery.selected?.name === item.name}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{discovery.nextCursor && (
|
||||
<button onClick={discovery.loadMore} type="button">
|
||||
Load more capabilities
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{discovery.selected ? (
|
||||
<DetailView detail={discovery.selected} />
|
||||
) : (
|
||||
<section aria-labelledby="capability-detail-empty-heading" className="capability-discovery__detail capability-discovery__detail--empty">
|
||||
<p className="workspace-route-pending__eyebrow">Contract detail</p>
|
||||
<h2 id="capability-detail-empty-heading">Select a capability</h2>
|
||||
<p>Choose a result to inspect its input, output, and wrapper contract.</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectionState } from "../../app/state.js";
|
||||
import { useConsoleWorkspace } from "../context.js";
|
||||
import type { CapabilityClient } from "../domain/capability-client.js";
|
||||
import type {
|
||||
CapabilityDetail,
|
||||
CapabilityPage,
|
||||
CapabilitySummary,
|
||||
} from "../domain/capability-models.js";
|
||||
import { createCapabilityClient } from "../domain/capability-client.js";
|
||||
import type { ConsoleReadExecutor } from "../domain/read-executor.js";
|
||||
import { useCapabilityDiscovery } from "./useCapabilityDiscovery.js";
|
||||
|
||||
vi.mock("../context.js", () => ({
|
||||
useConsoleWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../domain/capability-client.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../domain/capability-client.js")>(
|
||||
"../domain/capability-client.js",
|
||||
);
|
||||
return { ...actual, createCapabilityClient: vi.fn() };
|
||||
});
|
||||
|
||||
const mockedUseConsoleWorkspace = vi.mocked(useConsoleWorkspace);
|
||||
const mockedCreateCapabilityClient = vi.mocked(createCapabilityClient);
|
||||
|
||||
const connectedState = {
|
||||
phase: "connected",
|
||||
connectedTarget: "http://workflow.example/rpc",
|
||||
} as ConnectionState;
|
||||
|
||||
const disconnectedState = {
|
||||
phase: "not_configured",
|
||||
connectedTarget: null,
|
||||
} as ConnectionState;
|
||||
|
||||
type NodeSpecSummary = Extract<CapabilitySummary, { readonly kind: "node_spec" }>;
|
||||
|
||||
const summary = (name: string, sourceId = "local.documents"): NodeSpecSummary => ({
|
||||
kind: "node_spec",
|
||||
name,
|
||||
sourceId,
|
||||
description: `${name} description`,
|
||||
outcomes: ["ok", "error"],
|
||||
inputFields: ["input"],
|
||||
outputFields: ["output"],
|
||||
});
|
||||
|
||||
const detail = (name: string): CapabilityDetail => ({
|
||||
...summary(name),
|
||||
isAsync: false,
|
||||
inputSchema: { type: "object", properties: { input: { type: "string" } } },
|
||||
outputSchema: { type: "object", properties: { output: { type: "string" } } },
|
||||
wrapperHints: { note: "Use the selected input field." },
|
||||
acceptsContext: true,
|
||||
});
|
||||
|
||||
const page = (
|
||||
capabilities: ReadonlyArray<CapabilitySummary>,
|
||||
nextCursor: string | null = null,
|
||||
): CapabilityPage => ({
|
||||
capabilities: [...capabilities],
|
||||
nextCursor,
|
||||
total: capabilities.length,
|
||||
});
|
||||
|
||||
const deferred = <T,>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
const client = {
|
||||
list: vi.fn<CapabilityClient["list"]>(),
|
||||
inspect: vi.fn<CapabilityClient["inspect"]>(),
|
||||
} satisfies CapabilityClient;
|
||||
|
||||
const readExecutor = {} as ConsoleReadExecutor;
|
||||
|
||||
beforeEach(() => {
|
||||
client.list.mockReset();
|
||||
client.inspect.mockReset();
|
||||
mockedCreateCapabilityClient.mockReset();
|
||||
mockedCreateCapabilityClient.mockReturnValue(client);
|
||||
mockedUseConsoleWorkspace.mockReturnValue({
|
||||
connection: connectedState,
|
||||
connectedTarget: connectedState.connectedTarget,
|
||||
recordEvidence: vi.fn(),
|
||||
readExecutor,
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCapabilityDiscovery", () => {
|
||||
it("loads the first capability page with the bounded default limit", async () => {
|
||||
client.list.mockResolvedValue(page([summary("local.documents.read")]));
|
||||
|
||||
const { result } = renderHook(() => useCapabilityDiscovery());
|
||||
|
||||
await waitFor(() => expect(result.current.phase).toBe("ready"));
|
||||
|
||||
expect(client.list).toHaveBeenCalledWith({ limit: 50 });
|
||||
expect(result.current.items[0]?.name).toBe("local.documents.read");
|
||||
});
|
||||
|
||||
it("searches with the current query and replaces the list", async () => {
|
||||
client.list
|
||||
.mockResolvedValueOnce(page([summary("local.documents.read")]))
|
||||
.mockResolvedValueOnce(page([summary("local.documents.write")]));
|
||||
const { result } = renderHook(() => useCapabilityDiscovery());
|
||||
await waitFor(() => expect(result.current.phase).toBe("ready"));
|
||||
|
||||
act(() => result.current.setQuery("write"));
|
||||
act(() => result.current.search());
|
||||
|
||||
await waitFor(() => expect(result.current.items[0]?.name).toBe("local.documents.write"));
|
||||
expect(client.list).toHaveBeenLastCalledWith({ query: "write", limit: 50 });
|
||||
});
|
||||
|
||||
it("uses the source filter when an explicit search starts", async () => {
|
||||
client.list
|
||||
.mockResolvedValueOnce(page([summary("local.documents.read")]))
|
||||
.mockResolvedValueOnce(page([summary("remote.documents.read", "remote.documents")]));
|
||||
const { result } = renderHook(() => useCapabilityDiscovery());
|
||||
await waitFor(() => expect(result.current.phase).toBe("ready"));
|
||||
|
||||
act(() => result.current.setSourceId("remote.documents"));
|
||||
act(() => result.current.search());
|
||||
|
||||
await waitFor(() => expect(result.current.items[0]?.sourceId).toBe("remote.documents"));
|
||||
expect(client.list).toHaveBeenLastCalledWith({
|
||||
sourceId: "remote.documents",
|
||||
limit: 50,
|
||||
});
|
||||
expect(result.current.selected).toBeNull();
|
||||
});
|
||||
|
||||
it("reloads and clears selection when the connected target changes", async () => {
|
||||
client.list
|
||||
.mockResolvedValueOnce(page([summary("local.documents.read")]))
|
||||
.mockResolvedValueOnce(page([summary("remote.documents.read", "remote.documents")]));
|
||||
client.inspect.mockResolvedValue(detail("local.documents.read"));
|
||||
const { result, rerender } = renderHook(() => useCapabilityDiscovery());
|
||||
await waitFor(() => expect(result.current.phase).toBe("ready"));
|
||||
act(() => result.current.inspect("local.documents.read"));
|
||||
await waitFor(() => expect(result.current.selected?.name).toBe("local.documents.read"));
|
||||
|
||||
mockedUseConsoleWorkspace.mockReturnValue({
|
||||
connection: connectedState,
|
||||
connectedTarget: "http://new-workflow.example/rpc",
|
||||
recordEvidence: vi.fn(),
|
||||
readExecutor: {} as ConsoleReadExecutor,
|
||||
});
|
||||
rerender();
|
||||
|
||||
await waitFor(() => expect(result.current.items[0]?.sourceId).toBe("remote.documents"));
|
||||
expect(result.current.selected).toBeNull();
|
||||
expect(client.list).toHaveBeenLastCalledWith({ limit: 50 });
|
||||
});
|
||||
|
||||
it("appends the next page without duplicating capability names", async () => {
|
||||
client.list
|
||||
.mockResolvedValueOnce(page([summary("local.documents.read")], "page-2"))
|
||||
.mockResolvedValueOnce(
|
||||
page([summary("local.documents.read"), summary("local.documents.write")]),
|
||||
);
|
||||
const { result } = renderHook(() => useCapabilityDiscovery());
|
||||
await waitFor(() => expect(result.current.nextCursor).toBe("page-2"));
|
||||
|
||||
act(() => result.current.loadMore());
|
||||
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(2));
|
||||
expect(client.list).toHaveBeenLastCalledWith({ cursor: "page-2", limit: 50 });
|
||||
expect(result.current.items.map((item) => item.name)).toEqual([
|
||||
"local.documents.read",
|
||||
"local.documents.write",
|
||||
]);
|
||||
});
|
||||
|
||||
it("loads the selected capability detail", async () => {
|
||||
client.list.mockResolvedValue(page([summary("local.documents.read")]));
|
||||
client.inspect.mockResolvedValue(detail("local.documents.read"));
|
||||
const { result } = renderHook(() => useCapabilityDiscovery());
|
||||
await waitFor(() => expect(result.current.phase).toBe("ready"));
|
||||
|
||||
act(() => result.current.inspect("local.documents.read"));
|
||||
|
||||
await waitFor(() => expect(result.current.selected?.name).toBe("local.documents.read"));
|
||||
expect(client.inspect).toHaveBeenCalledWith("local.documents.read");
|
||||
});
|
||||
|
||||
it("surfaces malformed capability results as an error phase", async () => {
|
||||
client.list.mockRejectedValue(new Error("CapabilityPage is malformed"));
|
||||
|
||||
const { result } = renderHook(() => useCapabilityDiscovery());
|
||||
|
||||
await waitFor(() => expect(result.current.phase).toBe("error"));
|
||||
|
||||
expect(result.current.message).toBe("CapabilityPage is malformed");
|
||||
});
|
||||
|
||||
it("does not create a client or request while disconnected", () => {
|
||||
mockedUseConsoleWorkspace.mockReturnValue({
|
||||
connection: disconnectedState,
|
||||
connectedTarget: null,
|
||||
recordEvidence: vi.fn(),
|
||||
readExecutor: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCapabilityDiscovery());
|
||||
|
||||
expect(result.current.phase).toBe("disconnected");
|
||||
expect(mockedCreateCapabilityClient).not.toHaveBeenCalled();
|
||||
expect(client.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores stale list and inspect responses after newer requests", async () => {
|
||||
const firstList = deferred<CapabilityPage>();
|
||||
const secondList = deferred<CapabilityPage>();
|
||||
const firstDetail = deferred<CapabilityDetail>();
|
||||
const secondDetail = deferred<CapabilityDetail>();
|
||||
client.list.mockReturnValueOnce(firstList.promise).mockReturnValueOnce(secondList.promise);
|
||||
client.inspect
|
||||
.mockReturnValueOnce(firstDetail.promise)
|
||||
.mockReturnValueOnce(secondDetail.promise);
|
||||
const { result } = renderHook(() => useCapabilityDiscovery());
|
||||
|
||||
act(() => result.current.setQuery("newer"));
|
||||
act(() => result.current.search());
|
||||
expect(client.list).toHaveBeenCalledTimes(2);
|
||||
|
||||
firstList.resolve(page([summary("local.stale.list")]));
|
||||
secondList.resolve(page([summary("local.current.list")]));
|
||||
await waitFor(() => expect(result.current.items[0]?.name).toBe("local.current.list"));
|
||||
|
||||
act(() => result.current.inspect("local.first"));
|
||||
act(() => result.current.inspect("local.second"));
|
||||
firstDetail.resolve(detail("local.first"));
|
||||
secondDetail.resolve(detail("local.second"));
|
||||
|
||||
await waitFor(() => expect(result.current.selected?.name).toBe("local.second"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useConsoleWorkspace } from "../context.js";
|
||||
import {
|
||||
createCapabilityClient,
|
||||
type CapabilityClient,
|
||||
} from "../domain/capability-client.js";
|
||||
import type {
|
||||
CapabilityDetail,
|
||||
CapabilitySummary,
|
||||
} from "../domain/capability-models.js";
|
||||
|
||||
const PAGE_LIMIT = 50;
|
||||
|
||||
export type CapabilityDiscoveryController = {
|
||||
readonly phase: "disconnected" | "loading" | "ready" | "error";
|
||||
readonly query: string;
|
||||
readonly sourceId: string;
|
||||
readonly items: ReadonlyArray<CapabilitySummary>;
|
||||
readonly selected: CapabilityDetail | null;
|
||||
readonly nextCursor: string | null;
|
||||
readonly message: string | null;
|
||||
readonly setQuery: (value: string) => void;
|
||||
readonly setSourceId: (value: string) => void;
|
||||
readonly search: () => void;
|
||||
readonly loadMore: () => void;
|
||||
readonly inspect: (qualifiedName: string) => void;
|
||||
};
|
||||
|
||||
type DiscoveryState = Omit<CapabilityDiscoveryController, "setQuery" | "setSourceId" | "search" | "loadMore" | "inspect">;
|
||||
|
||||
const initialState: DiscoveryState = {
|
||||
phase: "disconnected",
|
||||
query: "",
|
||||
sourceId: "",
|
||||
items: [],
|
||||
selected: null,
|
||||
nextCursor: null,
|
||||
message: null,
|
||||
};
|
||||
|
||||
const errorMessage = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
const requestParams = (
|
||||
query: string,
|
||||
sourceId: string,
|
||||
cursor?: string,
|
||||
): { readonly query?: string; readonly sourceId?: string; readonly cursor?: string; readonly limit: number } => ({
|
||||
...(query ? { query } : {}),
|
||||
...(sourceId ? { sourceId } : {}),
|
||||
...(cursor ? { cursor } : {}),
|
||||
limit: PAGE_LIMIT,
|
||||
});
|
||||
|
||||
const appendUnique = (
|
||||
existing: ReadonlyArray<CapabilitySummary>,
|
||||
additions: ReadonlyArray<CapabilitySummary>,
|
||||
): ReadonlyArray<CapabilitySummary> => {
|
||||
const names = new Set(existing.map((item) => item.name));
|
||||
return [...existing, ...additions.filter((item) => !names.has(item.name))];
|
||||
};
|
||||
|
||||
export const useCapabilityDiscovery = (): CapabilityDiscoveryController => {
|
||||
const { connectedTarget, readExecutor } = useConsoleWorkspace();
|
||||
const client = useMemo<CapabilityClient | null>(
|
||||
() => (readExecutor ? createCapabilityClient(readExecutor) : null),
|
||||
[readExecutor],
|
||||
);
|
||||
const [state, setState] = useState<DiscoveryState>(initialState);
|
||||
const listGenerationRef = useRef(0);
|
||||
const inspectGenerationRef = useRef(0);
|
||||
|
||||
const runList = useCallback(
|
||||
(query: string, sourceId: string, cursor: string | undefined, append: boolean): void => {
|
||||
if (!client) return;
|
||||
const generation = ++listGenerationRef.current;
|
||||
if (append === false) inspectGenerationRef.current++;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
phase: "loading",
|
||||
items: append ? current.items : [],
|
||||
selected: append ? current.selected : null,
|
||||
nextCursor: append ? current.nextCursor : null,
|
||||
message: null,
|
||||
}));
|
||||
|
||||
void client
|
||||
.list(requestParams(query, sourceId, cursor))
|
||||
.then((page) => {
|
||||
if (generation !== listGenerationRef.current) return;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
phase: "ready",
|
||||
items: appendUnique(append ? current.items : [], page.capabilities),
|
||||
nextCursor: page.nextCursor,
|
||||
message: null,
|
||||
}));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (generation !== listGenerationRef.current) return;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
message: errorMessage(error),
|
||||
}));
|
||||
});
|
||||
},
|
||||
[client],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !connectedTarget) {
|
||||
listGenerationRef.current++;
|
||||
inspectGenerationRef.current++;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
phase: "disconnected",
|
||||
items: [],
|
||||
selected: null,
|
||||
nextCursor: null,
|
||||
message: null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
runList(state.query, state.sourceId, undefined, false);
|
||||
// The executor identity changes with the connected target. Query and source
|
||||
// filters are intentionally retained so reconnecting preserves the view.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client, connectedTarget, runList]);
|
||||
|
||||
const setQuery = useCallback((query: string) => {
|
||||
setState((current) => ({ ...current, query }));
|
||||
}, []);
|
||||
|
||||
const setSourceId = useCallback(
|
||||
(sourceId: string) => {
|
||||
setState((current) => ({ ...current, sourceId }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const search = useCallback(() => {
|
||||
runList(state.query, state.sourceId, undefined, false);
|
||||
}, [runList, state.query, state.sourceId]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!state.nextCursor) return;
|
||||
runList(state.query, state.sourceId, state.nextCursor, true);
|
||||
}, [runList, state.nextCursor, state.query, state.sourceId]);
|
||||
|
||||
const inspect = useCallback(
|
||||
(qualifiedName: string) => {
|
||||
if (!client) return;
|
||||
const generation = ++inspectGenerationRef.current;
|
||||
setState((current) => ({ ...current, phase: "loading", selected: null, message: null }));
|
||||
void client
|
||||
.inspect(qualifiedName)
|
||||
.then((detail) => {
|
||||
if (generation !== inspectGenerationRef.current) return;
|
||||
setState((current) => ({ ...current, phase: "ready", selected: detail, message: null }));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (generation !== inspectGenerationRef.current) return;
|
||||
setState((current) => ({ ...current, phase: "error", message: errorMessage(error) }));
|
||||
});
|
||||
},
|
||||
[client],
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
setQuery,
|
||||
setSourceId,
|
||||
search,
|
||||
loadMore,
|
||||
inspect,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user