Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ full `LABKIT_DB_URL` instead.

| variable | effect | default |
| --- | --- | --- |
| `LABKIT_WORKSPACES_FILE` | A JSON object mapping workspace slugs to git repositories, `{ "<slug>": "<folder>" }`; each folder is absolute or starts with `~/`. A mapped workspace serves its committed files and history. Read once, when the API starts. A missing file maps no workspace; an entry whose folder is not a repository is skipped with a warning. The file is machine-local and ignored by git. | `packages/app-web/workspaces.local.json` |
| `LABKIT_WORKSPACES_FILE` | A JSON object mapping workspace slugs to folders, `{ "<slug>": "<folder>" }`; each folder is absolute or starts with `~/`. The API reads it once, when it starts: a mapped workspace whose folder is a git repository serves its committed files and history. The dev server reads it on each request to `/agent-workspace/<slug>`: the agent's chat opens its sessions in the mapped folder, and a workspace with no folder has no chat. A missing file maps no workspace; an entry that is not an absolute folder, or (for the API) not a repository, is skipped with a warning. The file is machine-local and ignored by git. | `packages/app-web/workspaces.local.json` |

## MCP

Expand Down
1 change: 1 addition & 0 deletions packages/app-web/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ Text that is not UTF-8, or a binary file of a text type, gets the note.
|--------|-------|
| Left | The sidebar, in three sections that fold: File Explorer, Source Explorer, and Record Explorer, which lists two collections: the one picked (`?list=`) and the collection it is listed in. Collections takes at most 40% of Record Explorer and scrolls beyond that; Items has the rest and a filter box that narrows the listed items. |
| Centre | The graph, a card that folds: the resources opened in the workspace since its last reset and what they relate to, on a 3D canvas (`GraphView`). Clicking a node opens its resource. Below it, the open resource. Tabs: Overview (properties, then a card for each related resource, outbound then inbound), Debug, drawn as a wrench (the response; the events of the record, or of the acts that ran against the commit, each a line with chips for the records it names; and the commits those acts ran against). `?tab=` names the tab. The tabs and Reload sit at the right of the line with the resource's name. |
| Right | In development, in a workspace: the agent's chat, which slides open and shut beside a rail that stays. The rail's button expands or minimises it. The session opens in the workspace's folder, which the dev server reads from the workspaces mapping file (`/agent-workspace/<slug>`), connects the first time the chat is expanded, and stays connected while it is minimised. `?session=` names it, so a reload reopens it; on leaving the workspace the session leaves the address. A workspace the file does not map has no chat, and the button is disabled with the reason. `/new` starts a session in place. The border at the chat's left edge moves by drag, by Left and Right (16 px), and by Home and End (to 320 px, and to 60% of the window); a double-click returns it to 400 px, and the browser keeps the width. |

The top bar holds the LabKit mark and name, which are one button that opens the list of workspaces,
and the theme button, which has no border or background. The browser's own Back and Forward buttons
Expand Down
185 changes: 184 additions & 1 deletion packages/app-web/e2e/app.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ const test = base.extend<{ errors: string[] }>({
errors: [
async ({ page }, use) => {
const errors: string[] = [];
// Closing an agent connection ends its event stream and then sends DELETE; the ACP server
// forgets a connection whose stream has ended, so the DELETE can be answered 404, which the
// browser logs. The connection is closed either way. Each such answer excuses one log line.
let closed404 = 0;
page.on("response", (res) => {
if (
res.request().method() === "DELETE" &&
res.status() === 404 &&
res.url().endsWith("/acp")
)
closed404++;
});
page.on("pageerror", (err) => errors.push(`pageerror: ${err.message}`));
page.on("console", (msg) => {
const { url } = msg.location();
Expand All @@ -21,7 +33,11 @@ const test = base.extend<{ errors: string[] }>({
}
});
await use(errors);
expect(errors, "errors in the browser").toEqual([]);
const left = errors.filter(
(e) =>
!(closed404 > 0 && e.includes("404 (Not Found)") && e.endsWith("/acp") && closed404--),
);
expect(left, "errors in the browser").toEqual([]);
},
{ auto: true },
],
Expand Down Expand Up @@ -94,6 +110,16 @@ test.describe("getting around", () => {
await expect(bar.getByRole("link", { name: "sessions" })).toHaveCount(0);
});

test("the gallery draws every tile without an error, the failed turns' included", async ({
page,
}, info) => {
test.skip(info.project.name !== "dev", "the gallery exists only in development");
await page.goto("/app/gallery");
await expect(page.locator("section[aria-label]").first()).toBeVisible();
// A failed turn's tile drew again on every render until its indicator kept an equal activity.
await page.waitForTimeout(1500);
});

test("the sessions page has the same top bar as the Trace Console, with the theme button at its right", async ({
page,
}, info) => {
Expand Down Expand Up @@ -1305,3 +1331,160 @@ test.describe("the API keeps its own paths", () => {
}
});
});

test.describe("the agent", () => {
test("its page starts a session and takes its address; /new starts another in place", async ({
page,
}, info) => {
test.skip(info.project.name !== "dev", "the agent exists only in development");
await page.goto("/app/agent");
await expect(page.locator(".agent-status")).toContainText("ready");
await expect(page).toHaveURL(/\/agent\/[^/]+$/);
const first = page.url();
await page.evaluate(() => {
(window as unknown as { kept: boolean }).kept = true;
});
const message = page.getByRole("textbox", { name: "Message" });
await message.fill("/new");
// The first Enter picks the command from the list the slash opens; the second sends it.
await message.press("Enter");
await expect(message).toHaveValue(/^\/new\s*$/);
await message.press("Enter");
await expect(page).not.toHaveURL(first);
await expect(page).toHaveURL(/\/agent\/[^/]+$/);
await expect(page.locator(".agent-status")).toContainText("ready");
expect(await page.evaluate(() => (window as unknown as { kept?: boolean }).kept)).toBe(true);
});
});

test.describe("the agent's chat in the Trace Console", () => {
const rail = (page: import("@playwright/test").Page) =>
page.getByRole("navigation", { name: "Agent sidebar" });
const chatToggle = (page: import("@playwright/test").Page) =>
page.getByRole("button", { name: "Show or hide the agent" });

test("the chat's rail is at the right in a workspace, and absent outside one or in a build", async ({
page,
}, info) => {
await page.goto("/app/workspace/alpha/Q_1");
await expect(header(page)).toHaveText("Q_1");
if (info.project.name !== "dev") {
await expect(rail(page)).toHaveCount(0);
return;
}
await expect(rail(page)).toBeVisible();
const box = await rail(page).boundingBox();
expect((box?.x ?? 0) + (box?.width ?? 0)).toBe(page.viewportSize()?.width);
await page.goto("/app/");
await expect(page.getByText("Pick a type on the left")).toBeVisible();
await expect(rail(page)).toHaveCount(0);
});

test("expanding the chat connects a session in the workspace's folder and names it in the address; minimising keeps it", async ({
page,
}, info) => {
test.skip(info.project.name !== "dev", "the agent exists only in development");
const opened: string[] = [];
page.on("request", (req) => {
const body = req.postData();
if (req.url().endsWith("/acp") && body?.includes('"session/new"')) opened.push(body);
});
await page.goto("/app/workspace/alpha/Q_1");
const folder = await page.evaluate(async () => {
const res = await fetch("/agent-workspace/alpha");
return ((await res.json()) as { cwd: string }).cwd;
});
const chat = page.getByRole("complementary", { name: "Agent" });
await chatToggle(page).click();
await expect(chat.locator(".agent-status")).toContainText("ready");
await expect(chat.getByRole("textbox", { name: "Message" })).toBeVisible();
await expect(page).toHaveURL(/[?&]session=fake-\d+/);
const named = new URL(page.url()).searchParams.get("session");
// The session opened in the folder the workspaces mapping file gives alpha.
expect(opened).toHaveLength(1);
expect(JSON.parse(opened[0] ?? "{}").params.cwd).toBe(folder);

await chatToggle(page).click();
await expect(chat).toBeHidden();
await chatToggle(page).click();
await expect(chat.locator(".agent-status")).toContainText("ready");
expect(new URL(page.url()).searchParams.get("session")).toBe(named);

// Opening a record keeps the session in the address, and a reload reopens it.
await page.locator(".card-head", { hasText: "LOE_1" }).click();
await expect(header(page)).toHaveText("LOE_1");
expect(new URL(page.url()).searchParams.get("session")).toBe(named);
await page.reload();
await expect(chat.locator(".agent-status")).toContainText("ready");
expect(new URL(page.url()).searchParams.get("session")).toBe(named);
});

test("in a workspace the mapping file does not map, the chat's button is disabled and says why; going home drops the session", async ({
page,
}, info) => {
test.skip(info.project.name !== "dev", "the agent exists only in development");
await page.goto("/app/workspace/alpha/Q_1");
await chatToggle(page).click();
await expect(page).toHaveURL(/[?&]session=fake-\d+/);
await page.getByRole("button", { name: "LabKit" }).click();
await expect(page).toHaveURL(/\/app\/$/);
await row(page, "beta").click();
await expect(chatToggle(page)).toBeDisabled();
await expect(chatToggle(page)).toHaveAttribute("title", /maps no folder to the workspace beta/);
expect(new URL(page.url()).searchParams.get("session")).toBeNull();
});

test("a session the address names that the agent cannot reopen says so, and /new starts another", async ({
page,
}, info) => {
test.skip(info.project.name !== "dev", "the agent exists only in development");
await page.goto("/app/workspace/alpha/Q_1?session=no-such-session");
const chat = page.getByRole("complementary", { name: "Agent" });
await chatToggle(page).click();
await expect(chat.locator(".agent-status")).toContainText("failed");
await expect(chat).toContainText("Send /new to start a new session.");
const message = chat.getByRole("textbox", { name: "Message" });
await expect(message).toBeEnabled();
await message.fill("/new");
await message.press("Enter");
await expect(message).toHaveValue(/^\/new\s*$/);
await message.press("Enter");
await expect(chat.locator(".agent-status")).toContainText("ready");
await expect(page).toHaveURL(/[?&]session=fake-\d+/);
});

test("dragging the chat's border widens it, a reload keeps the width, and its keys and double-click move it", async ({
page,
}, info) => {
test.skip(info.project.name !== "dev", "the agent exists only in development");
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/app/workspace/alpha/Q_1");
const chat = page.getByRole("complementary", { name: "Agent" });
await chatToggle(page).click();
await expect(chat.locator(".agent-status")).toContainText("ready");
const width = async () => Math.round((await chat.boundingBox())?.width ?? -1);
await expect.poll(width).toBe(400);
const border = page.getByRole("separator", { name: "Resize the agent" });
const at = await border.boundingBox();
if (at === null) throw new Error("the border has no box");
await page.mouse.move(at.x + at.width / 2, at.y + at.height / 2);
await page.mouse.down();
await page.mouse.move(at.x + at.width / 2 - 100, at.y + at.height / 2, { steps: 5 });
await page.mouse.up();
await expect.poll(width).toBe(500);

await page.reload();
await expect(chat.locator(".agent-status")).toContainText("ready");
await expect.poll(width).toBe(500);

await border.focus();
await page.keyboard.press("ArrowRight");
await expect.poll(width).toBe(484);
await page.keyboard.press("Home");
await expect.poll(width).toBe(320);
await page.keyboard.press("End");
await expect.poll(width).toBe(768);
await border.dblclick();
await expect.poll(width).toBe(400);
});
});
7 changes: 6 additions & 1 deletion packages/app-web/e2e/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ const api = serve(
);
// The bundle goes in a directory of its own, so a test run leaves nothing in the package.
const built = await mkdtemp(path.join(tmpdir(), "labkit-e2e-"));
const env = { ...process.env, LABKIT_PORT_WEB: ports.api };
// The dev server maps workspaces to folders from this file, for the agent's sessions: `alpha` to
// the git fixture's folder, as the API maps it to the fixture's repository, and `beta` to none.
const workspacesFile = path.join(built, "..", `${path.basename(built)}-workspaces.json`);
await Bun.write(workspacesFile, JSON.stringify({ alpha: git.folder }));
const env = { ...process.env, LABKIT_PORT_WEB: ports.api, LABKIT_WORKSPACES_FILE: workspacesFile };
const vite = "node_modules/vite/bin/vite.js";
const inherit = { env, stdout: "inherit", stderr: "inherit" } as const;

Expand All @@ -61,6 +65,7 @@ async function stop(code: number): Promise<never> {
await fixture.close();
await git.close();
await rm(built, { recursive: true, force: true });
await rm(workspacesFile, { force: true });
}
process.exit(code);
}
Expand Down
41 changes: 41 additions & 0 deletions packages/app-web/src/infra/workspace-folder-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Plugin } from "vite";
import { readWorkspaceDirectories, workspacesFile } from "../server/workspace-directories";

/** Where the browser asks for a workspace's folder: `GET /agent-workspace/<slug>`. */
export const AGENT_WORKSPACE_PATH = "/agent-workspace";

/**
* Answers `GET /agent-workspace/<slug>` on the dev server with `{ "cwd": "<folder>" }`: the folder
* the workspaces mapping file (`workspacesFile`) maps the workspace to, which the browser sends to
* the agent as the session's working folder. A workspace the file does not map is a workspace with
* no folder, not a missing resource: it is answered `{ "cwd": null, "reason": "…" }`. The file is
* read on each request, so an edit needs no restart. The agent exists only in the dev server, so
* this does too.
*/
export function workspaceFolders(): Plugin {
return {
name: "labkit-workspace-folders",
apply: "serve",
configureServer(server) {
server.middlewares.use(AGENT_WORKSPACE_PATH, async (req, res) => {
const slug = decodeURIComponent((req.url ?? "").replace(/^\//, "").replace(/[?#].*$/, ""));
const answer = (status: number, body: object) => {
res.statusCode = status;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(body));
};
if (req.method !== "GET" || slug === "" || slug.includes("/")) {
answer(404, { error: `no workspace named in ${req.method} ${req.originalUrl}` });
return;
}
const file = workspacesFile();
const cwd = (await readWorkspaceDirectories(file)).get(slug);
if (cwd === undefined) {
answer(200, { cwd: null, reason: `${file} maps no folder to the workspace ${slug}` });
return;
}
answer(200, { cwd });
});
},
};
}
14 changes: 12 additions & 2 deletions packages/app-web/src/routes/$.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ interface Search {
list?: string | undefined;
/** The centre column's tab, when it is not the overview. */
tab?: Exclude<Tab, "overview"> | undefined;
/** The agent's session the chat reopens. */
session?: string | undefined;
}

// Every path the other routes leave is an API path, opened in the Trace Console: `/app/workspace/w/Q_1`
Expand All @@ -21,13 +23,21 @@ export const Route = createFileRoute("/$")({
return {
list: typeof search.list === "string" ? search.list : undefined,
tab: search.tab === "debug" ? search.tab : undefined,
session: typeof search.session === "string" ? search.session : undefined,
};
},
component: Page,
});

function Page() {
const { _splat } = Route.useParams();
const { list, tab } = Route.useSearch();
return <TraceConsole path={_splat ? `/${_splat}` : ""} list={list} tab={tab ?? "overview"} />;
const { list, tab, session } = Route.useSearch();
return (
<TraceConsole
path={_splat ? `/${_splat}` : ""}
list={list}
tab={tab ?? "overview"}
session={session}
/>
);
}
56 changes: 5 additions & 51 deletions packages/app-web/src/server/repositories.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,18 @@
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
import { isAbsolute, join } from "node:path";
import { openRepository, type Repository } from "es-git";
import type { WorkspaceDirectories } from "./workspace-directories";

/** The git repository each workspace serves its files and history from, by workspace slug. */
export type Repositories = ReadonlyMap<string, Repository>;

/** `workspaces.local.json` beside this package's `package.json`, unless `LABKIT_WORKSPACES_FILE` names another. */
export function repositoriesFile(): string {
return (
process.env.LABKIT_WORKSPACES_FILE ??
fileURLToPath(new URL("../../workspaces.local.json", import.meta.url))
);
}

/**
* The repositories the JSON object in `file` maps workspace slugs to: `{ "<slug>": "<folder>" }`,
* where each folder is absolute or starts with `~/` (under `home`), and holds `.git`. A missing file maps no
* workspace. An entry that is not a string, not absolute, or not a repository is left out with a
* warning, and its workspace serves no files or history.
* The repository each workspace's folder holds, by workspace slug. A folder that is not a git
* repository is left out with a warning, and its workspace serves no files or history.
*/
export async function openRepositories(
file: string,
home: string = homedir(),
directories: WorkspaceDirectories,
): Promise<Map<string, Repository>> {
const repositories = new Map<string, Repository>();
let text: string;
try {
text = await readFile(file, "utf8");
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
console.info("workspace repositories: no mapping file; no workspace serves files", { file });
return repositories;
}
console.warn("workspace repositories: the mapping file was not read", { file, err });
return repositories;
}
let mapping: unknown;
try {
mapping = JSON.parse(text);
} catch (err) {
console.warn("workspace repositories: the mapping file is not JSON", { file, err });
return repositories;
}
if (typeof mapping !== "object" || mapping === null || Array.isArray(mapping)) {
console.warn("workspace repositories: the mapping file is not a JSON object", { file });
return repositories;
}
for (const [slug, value] of Object.entries(mapping)) {
if (typeof value !== "string") {
console.warn("workspace repositories: skipped, the folder is not a string", { slug, value });
continue;
}
const folder = value.startsWith("~/") ? join(home, value.slice(2)) : value;
if (!isAbsolute(folder)) {
console.warn("workspace repositories: skipped, the folder is not absolute", { slug, value });
continue;
}
for (const [slug, folder] of directories) {
try {
repositories.set(slug, await openRepository(folder, { noSearch: true }));
console.info("workspace repositories: mapped", { slug, folder });
Expand Down
Loading
Loading