diff --git a/docs/environment.md b/docs/environment.md index fbd129ef..67cc9852 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -44,7 +44,7 @@ full `LABKIT_DB_URL` instead. | variable | effect | default | | --- | --- | --- | -| `LABKIT_WORKSPACES_FILE` | A JSON object mapping workspace slugs to git repositories, `{ "": "" }`; 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, `{ "": "" }`; 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/`: 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 diff --git a/packages/app-web/docs/architecture.md b/packages/app-web/docs/architecture.md index 4b8a7e94..55637b59 100644 --- a/packages/app-web/docs/architecture.md +++ b/packages/app-web/docs/architecture.md @@ -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/`), 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 diff --git a/packages/app-web/e2e/app.e2e.ts b/packages/app-web/e2e/app.e2e.ts index 7c8e8783..2e63d141 100644 --- a/packages/app-web/e2e/app.e2e.ts +++ b/packages/app-web/e2e/app.e2e.ts @@ -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(); @@ -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 }, ], @@ -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) => { @@ -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); + }); +}); diff --git a/packages/app-web/e2e/stack.ts b/packages/app-web/e2e/stack.ts index 987c5194..e680410d 100644 --- a/packages/app-web/e2e/stack.ts +++ b/packages/app-web/e2e/stack.ts @@ -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; @@ -61,6 +65,7 @@ async function stop(code: number): Promise { await fixture.close(); await git.close(); await rm(built, { recursive: true, force: true }); + await rm(workspacesFile, { force: true }); } process.exit(code); } diff --git a/packages/app-web/src/infra/workspace-folder-plugin.ts b/packages/app-web/src/infra/workspace-folder-plugin.ts new file mode 100644 index 00000000..774df4d0 --- /dev/null +++ b/packages/app-web/src/infra/workspace-folder-plugin.ts @@ -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/`. */ +export const AGENT_WORKSPACE_PATH = "/agent-workspace"; + +/** + * Answers `GET /agent-workspace/` on the dev server with `{ "cwd": "" }`: 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 }); + }); + }, + }; +} diff --git a/packages/app-web/src/routes/$.tsx b/packages/app-web/src/routes/$.tsx index 09e11296..56f58a9a 100644 --- a/packages/app-web/src/routes/$.tsx +++ b/packages/app-web/src/routes/$.tsx @@ -6,6 +6,8 @@ interface Search { list?: string | undefined; /** The centre column's tab, when it is not the overview. */ tab?: Exclude | 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` @@ -21,6 +23,7 @@ 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, @@ -28,6 +31,13 @@ export const Route = createFileRoute("/$")({ function Page() { const { _splat } = Route.useParams(); - const { list, tab } = Route.useSearch(); - return ; + const { list, tab, session } = Route.useSearch(); + return ( + + ); } diff --git a/packages/app-web/src/server/repositories.ts b/packages/app-web/src/server/repositories.ts index 9ecf246e..05cdff5c 100644 --- a/packages/app-web/src/server/repositories.ts +++ b/packages/app-web/src/server/repositories.ts @@ -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; -/** `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: `{ "": "" }`, - * 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> { const repositories = new Map(); - 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 }); diff --git a/packages/app-web/src/server/runtime.ts b/packages/app-web/src/server/runtime.ts index fd3e59b2..92f8991c 100644 --- a/packages/app-web/src/server/runtime.ts +++ b/packages/app-web/src/server/runtime.ts @@ -3,7 +3,8 @@ import { APP_ROLE } from "@labkit/core-db/schema"; import { TENANT_SETTING } from "@labkit/core-db/scoped"; import { worktreeName } from "@labkit/app-cli/worktree"; import { type Connections, pgConnections } from "./connections"; -import { openRepositories, type Repositories, repositoriesFile } from "./repositories"; +import { openRepositories, type Repositories } from "./repositories"; +import { readWorkspaceDirectories, workspacesFile } from "./workspace-directories"; // Goes into query text, so it is checked once here rather than trusted downstream. const GRAPH_NAME = /^[a-z0-9_]+$/; @@ -45,11 +46,11 @@ export function createRuntime( /** * A runtime on the Postgres server `LABKIT_DB_URL` names, with the repositories the workspaces - * mapping file names (`repositoriesFile`), read once, here. + * mapping file names (`workspacesFile`), read once, here. */ export async function openRuntime(): Promise { process.env.LABKIT_DB_URL ??= "postgresql://postgres:agens@127.0.0.1:5433/labkit"; - const repositories = await openRepositories(repositoriesFile()); + const repositories = await openRepositories(await readWorkspaceDirectories(workspacesFile())); return createRuntime(pgConnections(process.env.LABKIT_DB_URL), repositories); } diff --git a/packages/app-web/src/server/workspace-directories.ts b/packages/app-web/src/server/workspace-directories.ts new file mode 100644 index 00000000..4c7af094 --- /dev/null +++ b/packages/app-web/src/server/workspace-directories.ts @@ -0,0 +1,67 @@ +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * The folder on this machine each workspace is, by workspace slug: the folder whose files and git + * history the web app shows, and the folder the agent opens a session in. + */ +export type WorkspaceDirectories = ReadonlyMap; + +/** `workspaces.local.json` beside this package's `package.json`, unless `LABKIT_WORKSPACES_FILE` names another. */ +export function workspacesFile(): string { + return ( + process.env.LABKIT_WORKSPACES_FILE ?? + fileURLToPath(new URL("../../workspaces.local.json", import.meta.url)) + ); +} + +/** + * The folders the JSON object in `file` maps workspace slugs to: `{ "": "" }`, where + * each folder is absolute or starts with `~/` (under `home`). A missing file maps no workspace. An + * entry that is not a string or not absolute is left out with a warning, and its workspace has no + * folder. + */ +export async function readWorkspaceDirectories( + file: string, + home: string = homedir(), +): Promise> { + const directories = new Map(); + let text: string; + try { + text = await readFile(file, "utf8"); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + console.info("workspace directories: no mapping file; no workspace has a folder", { file }); + return directories; + } + console.warn("workspace directories: the mapping file was not read", { file, err }); + return directories; + } + let mapping: unknown; + try { + mapping = JSON.parse(text); + } catch (err) { + console.warn("workspace directories: the mapping file is not JSON", { file, err }); + return directories; + } + if (typeof mapping !== "object" || mapping === null || Array.isArray(mapping)) { + console.warn("workspace directories: the mapping file is not a JSON object", { file }); + return directories; + } + for (const [slug, value] of Object.entries(mapping)) { + if (typeof value !== "string") { + console.warn("workspace directories: 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 directories: skipped, the folder is not absolute", { slug, value }); + continue; + } + directories.set(slug, folder); + } + return directories; +} diff --git a/packages/app-web/src/ui/AgentSession.tsx b/packages/app-web/src/ui/AgentSession.tsx index db023125..d1f187be 100644 --- a/packages/app-web/src/ui/AgentSession.tsx +++ b/packages/app-web/src/ui/AgentSession.tsx @@ -1,224 +1,25 @@ -import type { - CreateElicitationResponse, - PromptCapabilities, - RequestPermissionOutcome, -} from "@agentclientprotocol/sdk"; -import { connectSession, type SessionClient } from "@labkit/acp-client"; -import { SCENARIOS } from "@labkit/acp-scenarios"; -import { type AttachLimits, Conversation, type ResolveLink } from "@labkit/ui"; -import { RECORD_TYPES } from "./record-types"; -import "@labkit/ui/ui.css"; -import { initialState, reduce } from "@labkit/view-model"; import { useNavigate } from "@tanstack/react-router"; -import { useCallback, useEffect, useReducer, useRef, useState } from "react"; -import { AGENT_CWD, AGENT_URL } from "./agent-endpoint"; +import { useCallback } from "react"; +import { AgentChat } from "./agent/AgentChat"; +import { AGENT_CWD } from "./agent-endpoint"; import { Frame } from "./chrome/Frame"; -import { useSavedTheme } from "./saved-theme"; - -type Status = "connecting" | "ready" | "failed"; - -/** - * Why the composer takes no input before the session is open: there is no session to send to, and - * the files, commands and settings it offers come from what the agent answers. - */ -const UNAVAILABLE: Record, string> = { - connecting: "Connecting to the agent…", - failed: "Not connected to the agent", -}; - -type ViewAction = Parameters[1] | { type: "reset" }; - -/** - * The files the composer takes, from what the agent advertises: any file when it takes embedded - * files, images when it takes only images, none otherwise. At most four files of 20 MiB each. - */ -function attachLimits(takes: PromptCapabilities | undefined): AttachLimits | undefined { - const most = { maxFiles: 4, maxBytes: 20 * 1024 * 1024 }; - if (takes?.embeddedContext) return most; - if (takes?.image) return { ...most, accept: ["image/*"] }; - return undefined; -} - -/** - * Where the page fetches a file the agent links to as `blob://.`: the dev server's - * `/blob/` route, which the agent's HTTP host answers from the files its sessions stored. - */ -const resolveLink: ResolveLink = (uri) => - uri.startsWith("blob://") ? `/blob/${uri.slice("blob://".length)}` : undefined; - -/** The view of a session, emptied when another session is opened in its place. */ -const reduceView = (state: typeof initialState, action: ViewAction) => - action.type === "reset" ? initialState : reduce(state, action); /** - * One ACP session at `url`, as a view that follows it and the calls that drive it: the session - * `sessionId` names, reopened, or a new one when it names none. `started` is the id of the session - * this page started; being asked for that one afterwards is not a request to reopen it. + * The agent on a page of its own: `/agent` starts a session and `/agent/{sessionId}` reopens one + * the agent still has. A session started here takes its own address, so reloading the page + * reopens it. Its sessions open in the folder `VITE_LABKIT_ACP_CWD` names, not a workspace's. + * Nothing links here; the Trace Console has the agent in its right-hand sidebar. */ -function useAgentSession(url: string, cwd: string | undefined, sessionId: string | undefined) { - const [state, dispatch] = useReducer(reduceView, initialState); - const [status, setStatus] = useState("connecting"); - const [started, setStarted] = useState(); - const [takes, setTakes] = useState(); - const client = useRef(null); - const reopen = sessionId === started ? undefined : sessionId; - - useEffect(() => { - let cancelled = false; - let opened: SessionClient | undefined; - setStatus("connecting"); - setTakes(undefined); - dispatch({ type: "reset" }); - // React's development-mode double-invoke runs this effect, its cleanup, then this effect - // again, synchronously, with no gap for a second connect to see or reuse the first's. Deferring - // the real connect by a tick lets the first invocation's cleanup cancel it before it ever - // calls connectSession, so exactly one real ACP connection opens. Opening two at once is not - // merely wasteful: confirmed against a real agent's logs, the two occasionally race to open - // one connection's own per-session event stream, which the agent answers 409 to and the - // transport then treats as connection-fatal — even though the session itself was created - // successfully — surfacing as an error the person never caused. - const timer = setTimeout(() => { - connectSession({ - url, - onEvent: dispatch, - ...(cwd === undefined ? {} : { cwd }), - ...(reopen === undefined ? {} : { sessionId: reopen }), - }).then( - (connected) => { - if (cancelled) { - void connected.close(); - return; - } - opened = connected; - client.current = connected; - if (reopen === undefined) setStarted(connected.sessionId); - setTakes(connected.promptCapabilities); - setStatus("ready"); - }, - (err: unknown) => { - if (cancelled) return; - dispatch({ type: "failed", message: err instanceof Error ? err.message : String(err) }); - setStatus("failed"); - }, - ); - }, 0); - return () => { - cancelled = true; - clearTimeout(timer); - client.current = null; - void opened?.close(); - }; - }, [url, cwd, reopen]); - - // A reload or a closed tab runs no effect cleanup, so the session is left as the page unloads. - // By `pagehide` the browser has already ended the prompt's request on a reload, so the client - // no longer counts the turn as running; `beforeunload` comes before that. `pagehide` covers a - // browser that does not fire `beforeunload`. - useEffect(() => { - const leave = () => client.current?.leave(); - window.addEventListener("beforeunload", leave); - window.addEventListener("pagehide", leave); - return () => { - window.removeEventListener("beforeunload", leave); - window.removeEventListener("pagehide", leave); - }; - }, []); - - const send = useCallback((text: string, files: readonly File[]) => { - if (client.current === null) { - dispatch({ - type: "failed", - message: "The message was not sent: the agent is not connected.", - }); - return; - } - void client.current.prompt(text, files); - }, []); - const cancel = useCallback(() => void client.current?.cancel(), []); - const answer = useCallback( - (requestId: string, outcome: RequestPermissionOutcome) => - client.current?.answerPermission(requestId, outcome), - [], - ); - const answerQuestion = useCallback( - (requestId: string, response: CreateElicitationResponse) => - client.current?.answerQuestion(requestId, response), - [], - ); - const setConfig = useCallback( - (configId: string, value: string | boolean) => - void client.current?.setConfigOption(configId, value), - [], - ); - const attach = attachLimits(takes); - return { state, status, started, attach, send, cancel, answer, answerQuestion, setConfig }; -} - -/** - * A live session with the agent the dev server mounts at `/acp`: the fake agent by default, or a - * real one when `LABKIT_ACP_AGENT_URL` names it. A real agent needs an absolute, writable `cwd` - * for its session store; `VITE_LABKIT_ACP_CWD` (set by `dev-with-agent.ts`) supplies one, and the - * fake agent ignores it. Without either, `connectSession` sends its own default `cwd` of `/`, which - * fails on a read-only root. ACP has no default `cwd`. - * `/agent` starts a session and `/agent/{sessionId}` reopens one the agent still has. - */ -/** Commands this page carries out itself. */ -const HOST_COMMANDS = [{ name: "new", description: "Start a new session" }]; - export default function AgentSession({ sessionId }: { sessionId?: string }) { - const real = AGENT_CWD !== undefined; - const { state, status, started, attach, send, cancel, answer, answerQuestion, setConfig } = - useAgentSession(AGENT_URL, AGENT_CWD, sessionId); - const [theme, setTheme] = useSavedTheme(); - // A session started here takes its own address, so reloading the page reopens it. const navigate = useNavigate(); - useEffect(() => { - if (started === undefined) return; - void navigate({ to: "/agent/{-$sessionId}", params: { sessionId: started }, replace: true }); - }, [started, navigate]); + const onStarted = useCallback( + (started: string) => + void navigate({ to: "/agent/{-$sessionId}", params: { sessionId: started }, replace: true }), + [navigate], + ); return ( -
- {real ? ( - `Agent (${status})` - ) : ( - <> - Fake agent ({status}). Try{" "} - {SCENARIOS.map((s) => ( - - /scenario {s.id} - - ))} - - )} -
-
- { - // A full page load: the page keeps the session it has open, and this starts one. - if (text.trim() === "/new") - void navigate({ - to: "/agent/{-$sessionId}", - // Named as absent: a route left to fill its own params keeps the current session. - params: { sessionId: undefined }, - reloadDocument: true, - }); - else send(text, files); - }} - onCancel={cancel} - onAnswer={answer} - onAnswerQuestion={answerQuestion} - onSetConfig={setConfig} - resolveLink={resolveLink} - /> -
+ ); } diff --git a/packages/app-web/src/ui/agent/AgentChat.tsx b/packages/app-web/src/ui/agent/AgentChat.tsx new file mode 100644 index 00000000..283e6e70 --- /dev/null +++ b/packages/app-web/src/ui/agent/AgentChat.tsx @@ -0,0 +1,71 @@ +import { SCENARIOS } from "@labkit/acp-scenarios"; +import { Conversation } from "@labkit/ui"; +import "@labkit/ui/ui.css"; +import { AGENT_CWD, AGENT_URL } from "../agent-endpoint"; +import { RECORD_TYPES } from "../record-types"; +import { useSavedTheme } from "../saved-theme"; +import { CONNECTING, HOST_COMMANDS, resolveLink, useAgentSession } from "./agent-session"; + +/** + * A live session with the agent the dev server mounts at `/acp`: the fake agent by default, or a + * real one when `LABKIT_ACP_AGENT_URL` names it. The session opens in `cwd`, the folder the agent's + * tools work in; a real agent needs it absolute, and the fake agent ignores it. ACP has no default + * `cwd`. The session stays connected while `shown` is false; only its view is not drawn. + */ +export function AgentChat({ + cwd, + sessionId, + onStarted, + shown = true, + themeButton = false, +}: { + /** The folder the session opens in. */ + cwd: string | undefined; + /** The session to reopen; a new one is started when this names none. */ + sessionId: string | undefined; + /** Called with the id of each session this starts, so the page can put it in its address. */ + onStarted: (sessionId: string) => void; + shown?: boolean; + /** Whether the conversation draws its own theme button, for a page with no top bar. */ + themeButton?: boolean; +}) { + // The dev stack names a folder for the real agent and none for the fake one. + const real = AGENT_CWD !== undefined; + const { state, status, attach, send, cancel, answer, answerQuestion, setConfig } = + useAgentSession({ url: AGENT_URL, cwd, sessionId, onStarted }); + const [theme, setTheme] = useSavedTheme(); + if (!shown) return null; + return ( +
+
+ {real ? ( + `Agent (${status})` + ) : ( + <> + Fake agent ({status}). Try{" "} + {SCENARIOS.map((s) => ( + /scenario {s.id} + ))} + + )} +
+
+ +
+
+ ); +} diff --git a/packages/app-web/src/ui/agent/agent-session.ts b/packages/app-web/src/ui/agent/agent-session.ts new file mode 100644 index 00000000..3c71c50d --- /dev/null +++ b/packages/app-web/src/ui/agent/agent-session.ts @@ -0,0 +1,193 @@ +import type { + CreateElicitationResponse, + PromptCapabilities, + RequestPermissionOutcome, +} from "@agentclientprotocol/sdk"; +import { connectSession, type SessionClient } from "@labkit/acp-client"; +import type { AttachLimits, ResolveLink } from "@labkit/ui"; +import { initialState, reduce } from "@labkit/view-model"; +import { useCallback, useEffect, useReducer, useRef, useState } from "react"; + +/** The command the chat carries out itself rather than sending: start a new session in place. */ +export const NEW = { name: "new", description: "Start a new session" } as const; +/** Commands the chat carries out itself. */ +export const HOST_COMMANDS = [NEW]; + +export type Status = "connecting" | "ready" | "failed"; + +/** + * Why the composer takes no input while the session is opening: there is no session to send to, + * and the files, commands and settings it offers come from what the agent answers. Once opening has + * failed the composer takes input again, so `/new` can start another session. + */ +export const CONNECTING = "Connecting to the agent…"; + +/** What a failure to reach the agent's session adds: the way on from it. */ +const TRY_NEW = `Send /${NEW.name} to start a new session.`; + +type ViewAction = Parameters[1] | { type: "reset" }; + +/** + * The files the composer takes, from what the agent advertises: any file when it takes embedded + * files, images when it takes only images, none otherwise. At most four files of 20 MiB each. + */ +function attachLimits(takes: PromptCapabilities | undefined): AttachLimits | undefined { + const most = { maxFiles: 4, maxBytes: 20 * 1024 * 1024 }; + if (takes?.embeddedContext) return most; + if (takes?.image) return { ...most, accept: ["image/*"] }; + return undefined; +} + +/** + * Where the page fetches a file the agent links to as `blob://.`: the dev server's + * `/blob/` route, which the agent's HTTP host answers from the files its sessions stored. + */ +export const resolveLink: ResolveLink = (uri) => + uri.startsWith("blob://") ? `/blob/${uri.slice("blob://".length)}` : undefined; + +/** The view of a session, emptied when another session is opened in its place. */ +const reduceView = (state: typeof initialState, action: ViewAction) => + action.type === "reset" ? initialState : reduce(state, action); + +/** + * One ACP session at `url`, as a view that follows it and the calls that drive it: the session + * `sessionId` names, reopened, or a new one when it names none. `started` is the id of the session + * this hook started; being asked for that one afterwards is not a request to reopen it. + * `onStarted` is called with the id of each session the hook starts, so the page can put it in its + * address. Sending `/new` starts a new session in place: the session `sessionId` named until then + * is not reopened, even before the page's address drops it. + */ +export function useAgentSession({ + url, + cwd, + sessionId, + onStarted, +}: { + url: string; + cwd: string | undefined; + sessionId: string | undefined; + onStarted: (sessionId: string) => void; +}) { + const [state, dispatch] = useReducer(reduceView, initialState); + const [status, setStatus] = useState("connecting"); + const [started, setStarted] = useState(); + const [takes, setTakes] = useState(); + // Counts the `/new` commands, each of which connects again; and the session named before the + // last one, which is not reopened. + const [generation, setGeneration] = useState(0); + const [left, setLeft] = useState(); + const client = useRef(null); + const reopen = sessionId === started || sessionId === left ? undefined : sessionId; + + useEffect(() => { + let cancelled = false; + let opened: SessionClient | undefined; + setStatus("connecting"); + setTakes(undefined); + dispatch({ type: "reset" }); + // React's development-mode double-invoke runs this effect, its cleanup, then this effect + // again, synchronously, with no gap for a second connect to see or reuse the first's. Deferring + // the real connect by a tick lets the first invocation's cleanup cancel it before it ever + // calls connectSession, so exactly one real ACP connection opens. Opening two at once is not + // merely wasteful: confirmed against a real agent's logs, the two occasionally race to open + // one connection's own per-session event stream, which the agent answers 409 to and the + // transport then treats as connection-fatal — even though the session itself was created + // successfully — surfacing as an error the person never caused. + const timer = setTimeout(() => { + connectSession({ + url, + onEvent: dispatch, + ...(cwd === undefined ? {} : { cwd }), + ...(reopen === undefined ? {} : { sessionId: reopen }), + }).then( + (connected) => { + if (cancelled) { + void connected.close(); + return; + } + opened = connected; + client.current = connected; + if (reopen === undefined) setStarted(connected.sessionId); + setTakes(connected.promptCapabilities); + setStatus("ready"); + }, + (err: unknown) => { + if (cancelled) return; + const reason = err instanceof Error ? err.message : String(err); + dispatch({ type: "failed", message: `${reason} ${TRY_NEW}` }); + setStatus("failed"); + }, + ); + }, 0); + return () => { + cancelled = true; + clearTimeout(timer); + client.current = null; + void opened?.close(); + }; + // `generation` is read so that `/new` connects again even when `reopen` is unchanged. + }, [url, cwd, reopen, generation]); + + // Once the address names another session, the one left by `/new` may be reopened again. + useEffect(() => { + if (left !== undefined && sessionId !== left) setLeft(undefined); + }, [sessionId, left]); + + const announce = useRef(onStarted); + announce.current = onStarted; + useEffect(() => { + if (started !== undefined) announce.current(started); + }, [started]); + + // A reload or a closed tab runs no effect cleanup, so the session is left as the page unloads. + // By `pagehide` the browser has already ended the prompt's request on a reload, so the client + // no longer counts the turn as running; `beforeunload` comes before that. `pagehide` covers a + // browser that does not fire `beforeunload`. + useEffect(() => { + const leave = () => client.current?.leave(); + window.addEventListener("beforeunload", leave); + window.addEventListener("pagehide", leave); + return () => { + window.removeEventListener("beforeunload", leave); + window.removeEventListener("pagehide", leave); + }; + }, []); + + const send = useCallback( + (text: string, files: readonly File[]) => { + if (text.trim() === `/${NEW.name}`) { + setLeft(sessionId); + setStarted(undefined); + setGeneration((n) => n + 1); + return; + } + if (client.current === null) { + dispatch({ + type: "failed", + message: `The message was not sent: the agent is not connected. ${TRY_NEW}`, + }); + return; + } + void client.current.prompt(text, files); + }, + [sessionId], + ); + const cancel = useCallback(() => void client.current?.cancel(), []); + const answer = useCallback( + (requestId: string, outcome: RequestPermissionOutcome) => + client.current?.answerPermission(requestId, outcome), + [], + ); + const answerQuestion = useCallback( + (requestId: string, response: CreateElicitationResponse) => + client.current?.answerQuestion(requestId, response), + [], + ); + const setConfig = useCallback( + (configId: string, value: string | boolean) => + void client.current?.setConfigOption(configId, value), + [], + ); + const attach = attachLimits(takes); + return { state, status, started, attach, send, cancel, answer, answerQuestion, setConfig }; +} diff --git a/packages/app-web/src/ui/chrome/chrome.css b/packages/app-web/src/ui/chrome/chrome.css index b6d3ebc8..62e7c868 100644 --- a/packages/app-web/src/ui/chrome/chrome.css +++ b/packages/app-web/src/ui/chrome/chrome.css @@ -3,7 +3,9 @@ * resizable border. A page's root carries `.lk-root` for the palette and `.lk-frame` for these * rules; the Trace Console adds `.trace` for its own. */ -.lk-frame { +/* Two classes, so these win over `.lk-root`'s own rules in `@labkit/ui/ui.css`, which the agent's + chat loads into the same page. */ +.lk-root.lk-frame { --ink: var(--lk-bg); --panel: var(--lk-panel); --panel-2: var(--lk-panel-2); @@ -108,20 +110,6 @@ flex: 1 1 auto; } -.lk-frame .dev-links { - display: flex; - gap: 10px; - font-size: 12px; - white-space: nowrap; -} -.lk-frame .dev-links a { - color: var(--text-dim); - text-decoration: none; -} -.lk-frame .dev-links a:hover { - color: var(--accent); -} - .lk-frame .theme-btn { display: inline-flex; align-items: center; @@ -161,6 +149,11 @@ border-right: 1px solid var(--line); background: var(--panel); } +/* The rail at the right of the page, beside the agent's chat. */ +.lk-frame .rail.right { + border-right: 0; + border-left: 1px solid var(--line); +} .lk-frame .rail-btn { display: flex; align-items: center; @@ -214,6 +207,21 @@ .lk-frame .splitter:focus-visible { outline: none; } +/* A border between two columns, such as the agent's chat and the page: it takes no width of its own. */ +.lk-frame .splitter.vertical { + width: 7px; + height: auto; + margin: 0 -3px 0 -4px; + cursor: col-resize; +} +.lk-frame .splitter.vertical::after { + top: 0; + bottom: 0; + left: 2px; + right: auto; + width: 3px; + height: auto; +} /* ---------- pages other than the Trace Console: gallery, sessions, replays, errors ---------- */ .lk-frame .crumb { @@ -291,3 +299,25 @@ border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent); background: color-mix(in srgb, var(--danger) 12%, transparent); } + +/* ---------- the agent's chat: on its own page, or in the Trace Console's right-hand sidebar ---------- */ +.lk-frame .agent-chat { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; +} +.lk-frame .agent-status { + flex: none; + padding: 8px 16px; + color: var(--text-dim); + font-size: 12px; +} +.lk-frame .agent-status code { + margin-right: 8px; +} +.lk-frame .agent-conversation { + flex: 1 1 auto; + min-height: 0; +} diff --git a/packages/app-web/src/ui/trace/TraceConsole.tsx b/packages/app-web/src/ui/trace/TraceConsole.tsx index 4a8fb918..b0168b3d 100644 --- a/packages/app-web/src/ui/trace/TraceConsole.tsx +++ b/packages/app-web/src/ui/trace/TraceConsole.tsx @@ -32,7 +32,7 @@ import { WrenchIcon, XCircleIcon, } from "@phosphor-icons/react"; -import { Link, useNavigate, useRouterState } from "@tanstack/react-router"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; import { type CSSProperties, type ReactNode, @@ -82,6 +82,7 @@ import { } from "./hal-store"; import { FileTree, History } from "./explorers"; import { ActiveBox } from "./active-box"; +import { ChatSidebar, HAS_CHAT } from "./chat-sidebar"; import { CommitView, FileView, RELATIONS_APART, SHOWN_APART } from "./git-views"; import { type Playback, usePlayback } from "./playback"; import { proseSegments } from "./prose"; @@ -152,9 +153,11 @@ export interface TraceConsoleProps { /** The API path of the collection the left column lists, when one was picked. */ list: string | undefined; tab: Tab; + /** The agent's session the chat reopens, when the address names one. */ + session: string | undefined; } -export function TraceConsole({ path, list, tab }: TraceConsoleProps) { +export function TraceConsole({ path, list, tab, session }: TraceConsoleProps) { const navigate = useNavigate(); const [theme] = useSavedTheme(); const [drawer, setDrawer] = useState<"items" | undefined>(); @@ -162,6 +165,19 @@ export function TraceConsole({ path, list, tab }: TraceConsoleProps) { // window it is a drawer over the page, closed until opened. const [sidebarShown, setSidebarShown] = useSavedFlag("labkit.trace.sidebar", true); const narrow = useMediaQuery(NARROW); + // The agent's chat at the right: expanded or minimised, as this browser last left it. Its session + // connects the first time it is expanded on this page. + const [chatShown, setChatShown] = useSavedFlag("labkit.trace.chat", false); + const [chatConnected, setChatConnected] = useState(chatShown); + const toggleChat = () => { + setChatShown(!chatShown); + setChatConnected(true); + }; + const onChatStarted = useCallback( + (started: string) => + void navigate({ to: ".", search: (s) => ({ ...s, session: started }), replace: true }), + [navigate], + ); const [graphOpen, setGraphOpen] = useState(true); const explored = useExplored(path); const workspace = workspaceOf(path); @@ -213,12 +229,27 @@ export function TraceConsole({ path, list, tab }: TraceConsoleProps) { }; const listKey = openIsCollection ? path : (list ?? ENTRY); + // The workspace of the open resource, or else of the collection Record Explorer lists. + const scope = workspace ?? workspaceOf(`${listKey}/`); + // A session is its workspace's own, opened in the workspace's folder. When the workspace in scope + // changes, the session the address names is not reopened in the new one, and leaves the address. + const sessionScope = useRef(scope); + const leftSession = useRef(undefined); + if (sessionScope.current !== scope) { + sessionScope.current = scope; + leftSession.current = session; + } + const chatSession = session === leftSession.current ? undefined : session; + useEffect(() => { + if (session !== undefined && session === leftSession.current) + void navigate({ to: ".", search: (s) => ({ ...s, session: undefined }), replace: true }); + }, [scope, session, navigate]); const prose = proseFor(held, path, open); return (
- : null} /> +
+ {HAS_CHAT && scope !== undefined ? ( + + ) : null}
); } -/** Pages that exist only in development; a host with no address bar reaches them from here. */ -function DevLinks() { - return ( - - ); -} - /** Prose with each handle it names drawn as a link to the resource, where the page holds one. */ function proseFor(held: HeldIndex, path: string, onOpen: (key: string) => void): Prose { // A handle in the open resource's text may sit beside it, or beside anything it links to. diff --git a/packages/app-web/src/ui/trace/chat-sidebar.tsx b/packages/app-web/src/ui/trace/chat-sidebar.tsx new file mode 100644 index 00000000..e8b986ec --- /dev/null +++ b/packages/app-web/src/ui/trace/chat-sidebar.tsx @@ -0,0 +1,201 @@ +import { ChatCircleDotsIcon } from "@phosphor-icons/react"; +import { type CSSProperties, lazy, Suspense, useEffect, useRef, useState } from "react"; +import { useSavedNumber } from "../saved-flag"; + +// The agent exists only in the dev server, so the chat is dev-only: in a production build +// `import.meta.env.DEV` is false, the import is dropped, and there is no chat to show. +const AgentChat = import.meta.env.DEV + ? lazy(() => import("../agent/AgentChat").then((m) => ({ default: m.AgentChat }))) + : undefined; + +/** The chat's width until its border is moved, and the least it can be, in pixels. */ +const CHAT_WIDTH = 400; +const MIN_CHAT = 320; +/** The most of the window's width the chat can take. */ +const MOST_OF_WINDOW = 0.6; +/** How far one press of Left or Right moves the chat's border, in pixels. */ +const BORDER_STEP = 16; + +/** The widest the chat can be in this window, and never less than its least. */ +const widest = () => Math.max(MIN_CHAT, Math.floor(window.innerWidth * MOST_OF_WINDOW)); + +/** + * The border at the chat's left edge, which a drag or the window splitter pattern's keys move: + * Left and Right by BORDER_STEP, Home to the chat's least width, End to its widest. A double-click + * returns the chat to CHAT_WIDTH. The browser keeps the width for the next visit. + */ +function ChatBorder({ + width, + setWidth, + onDragging, +}: { + width: number; + setWidth: (next: number) => void; + onDragging: (dragging: boolean) => void; +}) { + const drag = useRef<{ x: number; width: number } | undefined>(undefined); + const [dragging, setDragging] = useState(false); + const place = (next: number) => + setWidth(Math.round(Math.min(Math.max(next, MIN_CHAT), widest()))); + const stop = () => { + drag.current = undefined; + setDragging(false); + onDragging(false); + }; + return ( + // biome-ignore lint/a11y/useSemanticElements: an
takes no focus or drag; this is the ARIA window splitter pattern. +
{ + if (e.button !== 0) return; + e.currentTarget.setPointerCapture(e.pointerId); + drag.current = { x: e.clientX, width }; + setDragging(true); + onDragging(true); + }} + onPointerMove={(e) => { + const start = drag.current; + // The chat is at the right, so dragging the border left widens it. + if (start !== undefined) place(start.width + start.x - e.clientX); + }} + onPointerUp={stop} + onPointerCancel={stop} + onDoubleClick={() => place(CHAT_WIDTH)} + onKeyDown={(e) => { + const next = + e.key === "ArrowLeft" + ? width + BORDER_STEP + : e.key === "ArrowRight" + ? width - BORDER_STEP + : e.key === "Home" + ? MIN_CHAT + : e.key === "End" + ? widest() + : undefined; + if (next === undefined) return; + e.preventDefault(); + place(next); + }} + /> + ); +} + +/** Whether this build has the agent's chat at all. */ +export const HAS_CHAT = AgentChat !== undefined; + +type Folder = + | { kind: "loading" } + | { kind: "mapped"; cwd: string } + | { kind: "unmapped"; reason: string }; + +/** + * The folder the workspace `slug` is, from the dev server (`/agent-workspace/`, which reads + * the workspaces mapping file). The agent's sessions for the workspace open in it. + */ +function useWorkspaceFolder(slug: string): Folder { + const [folder, setFolder] = useState({ kind: "loading" }); + useEffect(() => { + const controller = new AbortController(); + setFolder({ kind: "loading" }); + fetch(`/agent-workspace/${encodeURIComponent(slug)}`, { signal: controller.signal }) + .then(async (res) => { + const body = (await res.json()) as { cwd?: string | null; reason?: string; error?: string }; + if (res.ok && typeof body.cwd === "string") setFolder({ kind: "mapped", cwd: body.cwd }); + else + setFolder({ + kind: "unmapped", + reason: `The agent cannot open a session in ${slug}: ${body.reason ?? body.error ?? `the dev server answered ${res.status}`}.`, + }); + }) + .catch((err: unknown) => { + if (controller.signal.aborted) return; + setFolder({ + kind: "unmapped", + reason: `The agent cannot open a session in ${slug}: its folder was not found (${err instanceof Error ? err.message : String(err)}).`, + }); + }); + return () => controller.abort(); + }, [slug]); + return folder; +} + +/** + * The agent's chat at the right of the page, and the rail beside it that stays when the chat is + * minimised. The chat's sessions open in the workspace's folder; a workspace the mapping file does + * not map has no chat, and the rail's button says why. The session connects the first time the chat + * is shown and stays connected while it is minimised; only its view is not drawn. + */ +export function ChatSidebar({ + workspace, + expanded, + onToggle, + connected, + sessionId, + onStarted, +}: { + /** The slug of the workspace in scope. */ + workspace: string; + expanded: boolean; + onToggle: () => void; + /** Whether the chat has been shown on this page, which is when the session connects. */ + connected: boolean; + sessionId: string | undefined; + onStarted: (sessionId: string) => void; +}) { + const folder = useWorkspaceFolder(workspace); + const [width, setWidth] = useSavedNumber("labkit.trace.chat.width", CHAT_WIDTH); + const [resizing, setResizing] = useState(false); + if (AgentChat === undefined) return null; + const unavailable = + folder.kind === "unmapped" + ? folder.reason + : folder.kind === "loading" + ? `Finding ${workspace}'s folder…` + : undefined; + const shown = expanded && folder.kind === "mapped"; + return ( + <> + {shown ? : null} + + + + ); +} diff --git a/packages/app-web/src/ui/trace/trace.css b/packages/app-web/src/ui/trace/trace.css index 3834fcc0..4603dbe3 100644 --- a/packages/app-web/src/ui/trace/trace.css +++ b/packages/app-web/src/ui/trace/trace.css @@ -40,6 +40,7 @@ height: 100vh; } @media (prefers-reduced-motion: reduce) { + .trace .chat-sidebar, .trace .spinner, .trace .skeleton .bar, .trace .active-box, @@ -1178,6 +1179,38 @@ border: 0; } +/* ---------- the agent's chat, at the right ---------- */ +/* It slides shut to nothing and then leaves the tab order, as the left sidebar does. Its contents + keep their width, so they are cut off at its edge rather than squeezed. */ +.trace .chat-sidebar { + flex: 0 0 auto; + width: var(--chat-width, 400px); + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; + border-left: 1px solid var(--line); + background: var(--panel); + transition: + width 220ms ease, + visibility 0s; +} +/* While its border is dragged, the chat follows the pointer at once. */ +.trace .chat-sidebar.resizing { + transition: none; +} +.trace .chat-sidebar > * { + min-width: var(--chat-width, 400px); +} +.trace .chat-sidebar.collapsed { + width: 0; + border-left-width: 0; + visibility: hidden; + transition: + width 220ms ease, + visibility 0s 220ms; +} + /* ---------- full width: the sidebar is shown or hidden ---------- */ @media (min-width: 861px) { /* The sidebar slides shut to nothing and then leaves the tab order. Its contents keep their width, @@ -1208,9 +1241,6 @@ /* ---------- narrow windows: the side column becomes a drawer ---------- */ @media (max-width: 860px) { - .trace .dev-links { - display: none; - } .trace .scrim.open { display: block; } diff --git a/packages/app-web/tests/repositories.test.ts b/packages/app-web/tests/repositories.test.ts index 8a98b87e..ece8514d 100644 --- a/packages/app-web/tests/repositories.test.ts +++ b/packages/app-web/tests/repositories.test.ts @@ -1,5 +1,5 @@ import { afterAll, describe, expect, spyOn, test } from "bun:test"; -import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { initRepository } from "es-git"; @@ -9,69 +9,23 @@ import { openRepositories } from "../src/server/repositories"; const scratch = await realpath(await mkdtemp(join(tmpdir(), "labkit-repositories-"))); afterAll(() => rm(scratch, { recursive: true, force: true })); -async function mapping(name: string, content: string): Promise { - const file = join(scratch, name); - await writeFile(file, content); - return file; -} - -describe("the workspaces mapping file", () => { - test("maps each workspace to the repository its folder holds", async () => { +describe("the workspaces' repositories", () => { + test("opens the repository each workspace's folder holds", async () => { const folder = join(scratch, "repo"); await initRepository(folder); - const repositories = await openRepositories( - await mapping("ok.json", JSON.stringify({ alpha: folder })), - ); + const repositories = await openRepositories(new Map([["alpha", folder]])); expect([...repositories.keys()]).toEqual(["alpha"]); expect(repositories.get("alpha")?.workdir()).toBe(`${folder}/`); }); - test("reads a folder that starts with ~/ as one under the home folder", async () => { - const folder = join(scratch, "home-repo"); - await initRepository(folder); - const repositories = await openRepositories( - await mapping("home.json", JSON.stringify({ alpha: "~/home-repo" })), - scratch, - ); - expect(repositories.get("alpha")?.workdir()).toBe(`${folder}/`); - }); - - test("maps no workspace when there is no file", async () => { - const info = spyOn(console, "info").mockImplementation(() => {}); - const repositories = await openRepositories(join(scratch, "missing.json")); - expect(repositories.size).toBe(0); - expect(info).toHaveBeenCalledWith( - "workspace repositories: no mapping file; no workspace serves files", - { file: join(scratch, "missing.json") }, - ); - info.mockRestore(); - }); - - test("maps no workspace, with a warning, when the file is not a JSON object", async () => { - const warn = spyOn(console, "warn").mockImplementation(() => {}); - expect((await openRepositories(await mapping("bad.json", "{ nope"))).size).toBe(0); - expect((await openRepositories(await mapping("list.json", "[]"))).size).toBe(0); - expect(warn.mock.calls.map((call) => call[0])).toEqual([ - "workspace repositories: the mapping file is not JSON", - "workspace repositories: the mapping file is not a JSON object", - ]); - warn.mockRestore(); - }); - - test("skips, with a warning naming the workspace and folder, an entry that names no repository", async () => { + test("skips, with a warning naming the workspace and folder, a folder that is not a repository", async () => { const plain = join(scratch, "plain"); await mkdir(plain); const warn = spyOn(console, "warn").mockImplementation(() => {}); - const repositories = await openRepositories( - await mapping("skips.json", JSON.stringify({ number: 7, relative: "some/folder", plain })), - ); + const repositories = await openRepositories(new Map([["plain", plain]])); expect(repositories.size).toBe(0); - expect(warn.mock.calls.map((call) => [call[0], (call[1] as { slug: string }).slug])).toEqual([ - ["workspace repositories: skipped, the folder is not a string", "number"], - ["workspace repositories: skipped, the folder is not absolute", "relative"], - ["workspace repositories: skipped, not a git repository", "plain"], - ]); - expect(warn.mock.calls[2]?.[1]).toMatchObject({ folder: plain }); + expect(warn.mock.calls[0]?.[0]).toBe("workspace repositories: skipped, not a git repository"); + expect(warn.mock.calls[0]?.[1]).toMatchObject({ slug: "plain", folder: plain }); warn.mockRestore(); }); }); diff --git a/packages/app-web/tests/workspace-directories.test.ts b/packages/app-web/tests/workspace-directories.test.ts new file mode 100644 index 00000000..4621da86 --- /dev/null +++ b/packages/app-web/tests/workspace-directories.test.ts @@ -0,0 +1,62 @@ +import { afterAll, describe, expect, spyOn, test } from "bun:test"; +import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readWorkspaceDirectories } from "../src/server/workspace-directories"; + +const scratch = await realpath(await mkdtemp(join(tmpdir(), "labkit-workspace-directories-"))); +afterAll(() => rm(scratch, { recursive: true, force: true })); + +async function mapping(name: string, content: string): Promise { + const file = join(scratch, name); + await writeFile(file, content); + return file; +} + +describe("the workspaces mapping file", () => { + test("maps each workspace to its absolute folder, and a ~/ folder to one under the home folder", async () => { + const directories = await readWorkspaceDirectories( + await mapping("ok.json", JSON.stringify({ alpha: "/srv/alpha", beta: "~/code/beta" })), + "/home/someone", + ); + expect([...directories]).toEqual([ + ["alpha", "/srv/alpha"], + ["beta", "/home/someone/code/beta"], + ]); + }); + + test("maps no workspace when there is no file", async () => { + const info = spyOn(console, "info").mockImplementation(() => {}); + const directories = await readWorkspaceDirectories(join(scratch, "missing.json")); + expect(directories.size).toBe(0); + expect(info).toHaveBeenCalledWith( + "workspace directories: no mapping file; no workspace has a folder", + { file: join(scratch, "missing.json") }, + ); + info.mockRestore(); + }); + + test("maps no workspace, with a warning, when the file is not a JSON object", async () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + expect((await readWorkspaceDirectories(await mapping("bad.json", "{ nope"))).size).toBe(0); + expect((await readWorkspaceDirectories(await mapping("list.json", "[]"))).size).toBe(0); + expect(warn.mock.calls.map((call) => call[0])).toEqual([ + "workspace directories: the mapping file is not JSON", + "workspace directories: the mapping file is not a JSON object", + ]); + warn.mockRestore(); + }); + + test("skips, with a warning naming the workspace, an entry that is not a string or not absolute", async () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const directories = await readWorkspaceDirectories( + await mapping("skips.json", JSON.stringify({ number: 7, relative: "some/folder" })), + ); + expect(directories.size).toBe(0); + expect(warn.mock.calls.map((call) => [call[0], (call[1] as { slug: string }).slug])).toEqual([ + ["workspace directories: skipped, the folder is not a string", "number"], + ["workspace directories: skipped, the folder is not absolute", "relative"], + ]); + warn.mockRestore(); + }); +}); diff --git a/packages/app-web/vite.config.ts b/packages/app-web/vite.config.ts index c52d870e..718900c8 100644 --- a/packages/app-web/vite.config.ts +++ b/packages/app-web/vite.config.ts @@ -6,6 +6,7 @@ import { labkitDev } from "./src/infra/dev-plugin"; import { APP_BASE } from "./src/app-base"; import { agentBackend } from "./src/infra/agent-backend"; import { transcripts } from "./src/infra/transcripts-plugin"; +import { workspaceFolders } from "./src/infra/workspace-folder-plugin"; const explorerPort = Number(process.env.LABKIT_PORT_EXPLORER ?? "8850"); const apiPort = Number(process.env.LABKIT_PORT_WEB ?? "8899"); @@ -27,6 +28,7 @@ export default defineConfig(({ command, isPreview }) => ({ react(), labkitDev(), transcripts(), + workspaceFolders(), ...agent.plugins, ], resolve: { diff --git a/packages/ui/activity.ts b/packages/ui/activity.ts index ff9dc1f8..ee72a70f 100644 --- a/packages/ui/activity.ts +++ b/packages/ui/activity.ts @@ -48,6 +48,18 @@ export function currentActivity(state: TranscriptState): Activity | undefined { return last?.kind === "thought" ? { kind: "thinking" } : { kind: "waiting" }; } +/** + * Whether two activities say the same thing. `currentActivity` makes a new object on each call, so + * a component that keeps an activity in state compares with this, not by reference: a new but equal + * object kept in state would draw again, derive another new object, and never settle. + */ +export const sameActivity = (a: Activity | undefined, b: Activity | undefined): boolean => + a === b || + (a !== undefined && + b !== undefined && + a.kind === b.kind && + (a.kind !== "tool" || (b.kind === "tool" && a.label === b.label))); + /** How long the stream may stay quiet before the indicator says it is waiting again. */ export const PAUSE_MS = 1500; diff --git a/packages/ui/blocks.tsx b/packages/ui/blocks.tsx index aaca90e3..10d3bf89 100644 --- a/packages/ui/blocks.tsx +++ b/packages/ui/blocks.tsx @@ -9,7 +9,7 @@ import { PencilSimpleIcon, } from "@phosphor-icons/react"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { type Activity, activityLabel, activityMood } from "./activity"; +import { type Activity, activityLabel, activityMood, sameActivity } from "./activity"; import { MessageAttachments } from "./attachments"; import { MarkdownText } from "./markdown"; import { Loader, type LoaderMood } from "./loader"; @@ -249,7 +249,8 @@ export function useLingering(activity: Activity | undefined): { const [last, setLast] = useState(activity); useEffect(() => { if (activity !== undefined) { - setLast(activity); + // An equal activity keeps the one held, so the state does not change and nothing draws again. + setLast((held) => (sameActivity(held, activity) ? held : activity)); return; } const timer = setTimeout(() => setLast(undefined), DONE_MS); diff --git a/packages/ui/tests/activity.test.tsx b/packages/ui/tests/activity.test.tsx index 3ad9840e..8d434d45 100644 --- a/packages/ui/tests/activity.test.tsx +++ b/packages/ui/tests/activity.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test"; import { initialState, reduce, type TranscriptState, type ViewEvent } from "@labkit/view-model"; import { renderToStaticMarkup } from "react-dom/server"; -import { activityMood, afterPause, currentActivity } from "../activity"; +import { activityMood, afterPause, currentActivity, sameActivity } from "../activity"; import { Conversation } from "../conversation"; const started: ViewEvent = { type: "prompt_started", content: [{ type: "text", text: "Go" }] }; @@ -155,3 +155,29 @@ describe("the thinking block", () => { expect(speaking).not.toContain('class="lk-working"'); }); }); + +describe("two activities compared", () => { + test("a new object that says the same is the same activity", () => { + expect(sameActivity({ kind: "failed" }, { kind: "failed" })).toBe(true); + expect(sameActivity({ kind: "tool", label: "ls" }, { kind: "tool", label: "ls" })).toBe(true); + expect(sameActivity(undefined, undefined)).toBe(true); + }); + + test("another kind, another tool label, or one missing is a different activity", () => { + expect(sameActivity({ kind: "failed" }, { kind: "waiting" })).toBe(false); + expect(sameActivity({ kind: "tool", label: "ls" }, { kind: "tool", label: "cat" })).toBe(false); + expect(sameActivity({ kind: "waiting" }, undefined)).toBe(false); + }); + + test("a failed turn's activity is a new object on each call, and the same activity", () => { + const failed = currentActivity( + state(started, { type: "failed", message: "The request failed" } as ViewEvent), + ); + const again = currentActivity( + state(started, { type: "failed", message: "The request failed" } as ViewEvent), + ); + expect(failed).toEqual({ kind: "failed" }); + expect(failed).not.toBe(again); + expect(sameActivity(failed, again)).toBe(true); + }); +});