feat: add console workspace shell

This commit is contained in:
lda
2026-08-04 20:25:31 +07:00 Verified
parent 38b1cb8d03
commit ad3090e5be
11 changed files with 789 additions and 190 deletions
+44 -185
View File
@@ -1,192 +1,26 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App.js";
import { afterEach, describe, expect, it } from "vitest";
import { AppRoutes } from "./AppRoutes.js";
import { callOperation, connectToServer } from "../connection/api.js";
import type { RpcResponse } from "../connection/contracts.js";
vi.mock("../connection/api.js", () => ({
connectToServer: vi.fn(),
callOperation: vi.fn(),
}));
afterEach(() => cleanup());
const mockedConnectToServer = vi.mocked(connectToServer);
const mockedCallOperation = vi.mocked(callOperation);
const successfulConnection = (target: string) => ({
ok: true as const,
connection: {
status: "connected" as const,
target,
serverStatus: "ok" as const,
storeRoot: "/tmp/store",
durationMs: 11,
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf status",
});
const successfulSources = (id: string): RpcResponse => ({
ok: true,
operation: "workflow.sources.list",
label: "List sources",
interpreted: {
sources: [
{
id,
kind: "python",
enabled: true,
description: null,
counts: {
tools: 1,
nodeSpecs: 1,
reducers: 0,
prompts: 0,
resources: 0,
},
},
],
total: 1,
nextCursor: null,
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf source list",
durationMs: 7,
});
const deferred = <T,>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
beforeEach(() => {
mockedConnectToServer.mockReset();
mockedCallOperation.mockReset();
sessionStorage.clear();
});
afterEach(() => {
cleanup();
});
const lifecycleOk = {
ok: true as const,
operation: "workflow.artifacts.list" as const,
label: "List artifacts",
interpreted: { items: [], total: 0, nextCursor: null },
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf artifact list",
durationMs: 5,
};
describe("App", () => {
it("shows source inventory errors from rejected source refreshes", async () => {
mockedConnectToServer.mockResolvedValue(
successfulConnection("http://127.0.0.1:8765/rpc"),
describe("AppRoutes", () => {
it.each([
["/", "Discover"],
["/console", "Discover"],
])("redirects %s to the discover workspace leaf", async (entry, label) => {
render(
<MemoryRouter initialEntries={[entry]}>
<AppRoutes />
</MemoryRouter>,
);
mockedCallOperation.mockRejectedValue(new Error("source refresh failed"));
render(<App />);
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
expect(await screen.findByTestId("sources-error")).toHaveTextContent(
"source refresh failed",
);
expect(await screen.findByRole("heading", { name: label })).toBeInTheDocument();
});
it("ignores stale source inventory responses after reconnect", async () => {
const firstSources = deferred<RpcResponse>();
const secondSources = deferred<RpcResponse>();
let latestSourcesDeferred = firstSources;
mockedConnectToServer
.mockResolvedValueOnce(successfulConnection("http://first.example/rpc"))
.mockResolvedValueOnce(successfulConnection("http://second.example/rpc"));
mockedCallOperation.mockImplementation((op: string) => {
if (op === "workflow.sources.list") return latestSourcesDeferred.promise;
return Promise.resolve(lifecycleOk);
});
render(<App />);
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
latestSourcesDeferred = secondSources;
await userEvent.click(screen.getByRole("button", { name: "Reconnect" }));
secondSources.resolve(successfulSources("local.second"));
firstSources.resolve(successfulSources("local.first"));
expect(await screen.findByTestId("source-id-local.second")).toHaveTextContent(
"local.second",
);
await waitFor(() => {
expect(screen.queryByTestId("source-id-local.first")).toBeNull();
});
});
it("mounts lifecycle explorer after connect", async () => {
mockedConnectToServer.mockResolvedValue(
successfulConnection("http://127.0.0.1:8765/rpc"),
);
mockedCallOperation.mockImplementation((op: string) => {
if (op === "workflow.sources.list") {
return Promise.resolve({
ok: true as const,
operation: "workflow.sources.list" as const,
label: "List sources",
interpreted: { sources: [], total: 0, nextCursor: null },
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf source list",
durationMs: 5,
});
}
if (op === "workflow.deployments.inspect") {
return Promise.resolve({
ok: false as const,
error: { code: "rpc_remote_error", message: "not found" },
exchange: { request: {}, response: {} },
});
}
return Promise.resolve(lifecycleOk);
});
render(<App />);
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
await waitFor(() => {
expect(screen.getByTestId("lifecycle-explorer")).toBeInTheDocument();
});
expect(screen.getByLabelText("lda report workflow demo")).toBeInTheDocument();
});
it("always renders demo panel even without connection", async () => {
mockedConnectToServer.mockRejectedValue(new Error("connection refused"));
render(<App />);
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
await waitFor(() => {
expect(screen.getByLabelText("lda report workflow demo")).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /start presentation/i })).toBeDisabled();
});
it("shows replay mode button even without connection", async () => {
render(<App />);
expect(screen.getByRole("button", { name: "Replay" })).toBeVisible();
expect(screen.getByRole("button", { name: /start presentation/i })).toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: "Replay" }));
expect(screen.getByRole("button", { name: /start presentation/i })).toBeEnabled();
});
it("routes to presentation mode separately from the console", () => {
it("keeps presentation routes outside console navigation", async () => {
render(
<MemoryRouter initialEntries={["/present"]}>
<AppRoutes />
@@ -194,10 +28,9 @@ describe("App", () => {
);
expect(screen.getByRole("main", { name: /lda.chat presentation/i })).toBeInTheDocument();
expect(screen.queryByLabelText("Lifecycle Explorer")).toBeNull();
});
expect(screen.queryByRole("navigation", { name: "Workflow lifecycle" })).toBeNull();
it("routes to read-only presenter notes separately from presentation mode", async () => {
cleanup();
window.location.hash = "#scene/thesis/title";
render(
<MemoryRouter initialEntries={["/presenter"]}>
@@ -208,7 +41,33 @@ describe("App", () => {
expect(
await screen.findByRole("main", { name: /lda.chat presenter notes/i }, { timeout: 5_000 }),
).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
expect(screen.queryByRole("main", { name: /lda.chat presentation/i })).not.toBeInTheDocument();
expect(screen.queryByRole("navigation", { name: "Workflow lifecycle" })).toBeNull();
});
it("shows the connection prompt and pending route message while disconnected", () => {
render(
<MemoryRouter initialEntries={["/console/discover"]}>
<AppRoutes />
</MemoryRouter>,
);
expect(screen.getByLabelText("Workflow JSON-RPC URL")).toBeInTheDocument();
expect(
screen.getByText("Discover is unavailable until a workflow server is connected."),
).toBeInTheDocument();
expect(screen.getByText("Connect a workflow server to view Discover.")).toBeInTheDocument();
});
it("navigates between lifecycle links without leaving the workspace shell", async () => {
render(
<MemoryRouter initialEntries={["/console/discover"]}>
<AppRoutes />
</MemoryRouter>,
);
await userEvent.click(screen.getByRole("link", { name: "Runs" }));
expect(await screen.findByRole("heading", { name: "Runs" })).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "Workflow lifecycle" })).toBeInTheDocument();
});
});
+35 -4
View File
@@ -1,6 +1,7 @@
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { ConsoleHome } from "./ConsoleHome.js";
import { ConsoleWorkspace } from "../workspace/ConsoleWorkspace.js";
import { useConsoleWorkspace } from "../workspace/context.js";
import { PresentationRoute } from "../presentation/PresentationRoute.js";
const PresenterRoute = lazy(() => import("../presentation/presenter/PresenterRoute.js").then((module) => ({
@@ -13,12 +14,42 @@ const PresenterRouteFallback = () => (
</main>
);
const WorkspaceRoutePending = ({ label }: { readonly label: string }) => {
const { connection } = useConsoleWorkspace();
const connected = connection.phase === "connected";
return (
<section className="workspace-route-pending">
<p className="workspace-route-pending__eyebrow">Workspace route</p>
<h1>{label}</h1>
<p>
{connected
? `${label} is ready for its workflow surface.`
: `${label} is unavailable until a workflow server is connected.`}
</p>
{!connected && <p>Connect a workflow server to view {label}.</p>}
</section>
);
};
export const AppRoutes = () => (
<Routes>
<Route path="/" element={<ConsoleHome />} />
<Route path="/console" element={<ConsoleHome />} />
<Route path="/" element={<Navigate to="/console/discover" replace />} />
<Route path="/console" element={<ConsoleWorkspace />}>
<Route index element={<Navigate to="discover" replace />} />
<Route path="discover" element={<WorkspaceRoutePending label="Discover" />} />
<Route path="drafts" element={<WorkspaceRoutePending label="Drafts" />} />
<Route path="drafts/:workspaceId" element={<WorkspaceRoutePending label="Draft" />} />
<Route path="artifacts" element={<WorkspaceRoutePending label="Artifacts" />} />
<Route path="artifacts/:artifactId/:version" element={<WorkspaceRoutePending label="Artifact" />} />
<Route path="deployments" element={<WorkspaceRoutePending label="Deployments" />} />
<Route path="deployments/:deploymentId" element={<WorkspaceRoutePending label="Deployment" />} />
<Route path="runs" element={<WorkspaceRoutePending label="Runs" />} />
<Route path="runs/:runId" element={<WorkspaceRoutePending label="Run" />} />
<Route path="results" element={<WorkspaceRoutePending label="Results" />} />
</Route>
<Route path="/present" element={<PresentationRoute />} />
<Route path="/presenter" element={<Suspense fallback={<PresenterRouteFallback />}><PresenterRoute /></Suspense>} />
<Route path="*" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to="/console/discover" replace />} />
</Routes>
);