From 6492838e2c288567f5dbad2a6833f3b4455e2661 Mon Sep 17 00:00:00 2001 From: Dan Barua Date: Mon, 12 Oct 2026 12:53:32 +0100 Subject: [PATCH 1/3] The files API lists every file's path at HEAD, linked from the top directory as paths GET /workspace/{slug}/paths answers the paths of the tree's files, in name order, without directories or submodules: what the chat will offer to mention. At most 5,000 are listed; total counts them all and truncated says when some are left out. Co-Authored-By: Claude Opus 5.5 --- packages/app-web/docs/architecture.md | 1 + packages/app-web/src/server/git-handler.ts | 64 +++++++++++++++++++++- packages/app-web/tests/git-api.test.ts | 26 +++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/packages/app-web/docs/architecture.md b/packages/app-web/docs/architecture.md index bd66f43a..b34ed4cb 100644 --- a/packages/app-web/docs/architecture.md +++ b/packages/app-web/docs/architecture.md @@ -56,6 +56,7 @@ collections, `file` (type `File`) and `commit` (type `Commit`). | `/workspace/{slug}/commit/{id}/diff/{path}` | One changed file's diff: its status and unified diff, cut at 20,000 characters (`patch_truncated`). | | `/workspace/{slug}/file/{path}` | The tree at HEAD: a directory as a collection of its entries, a file as a resource with its text, cut at 100,000 characters (`content_truncated`). A listed file carries `media_type` only when the extension table names its extension; the file's own resource always carries it. | | `/workspace/{slug}/commit/{id}/file/{path}` | The same, at that commit. | +| `/workspace/{slug}/paths` | Every file's path in the tree at HEAD, in name order, as a `paths` array: what the chat offers to mention. Directories and submodules are not listed. At most 5,000 paths; `total` counts them all and `truncated` says when some are left out. The top directory at HEAD links it as `paths`. | | `/workspace/{slug}/raw/{path}` | A file's committed bytes at HEAD, as its media type. A file over 20 MiB gets a 413 that names its size. | | `/workspace/{slug}/commit/{id}/raw/{path}` | The same, at that commit. | | `/workspace/{slug}/commit/{id}/changes` | The files the commit changed, with their status, paged. | diff --git a/packages/app-web/src/server/git-handler.ts b/packages/app-web/src/server/git-handler.ts index 0bab1abf..82f682ca 100644 --- a/packages/app-web/src/server/git-handler.ts +++ b/packages/app-web/src/server/git-handler.ts @@ -59,12 +59,16 @@ const RAW = /^(?:\/commit\/([0-9a-f]{40}|[0-9a-f]{64}))?\/raw\/(.+)$/; // The commits that changed a file, from HEAD, `/history/{path}`, or from a commit, // `/commit/{id}/history/{path}`. const HISTORY = /^(?:\/commit\/([0-9a-f]{40}|[0-9a-f]{64}))?\/history\/(.+)$/; +// Every file's path in the tree at HEAD, `/paths`. +const PATHS = /^\/paths\/?$/; +/** The most paths `/paths` lists; it says how many there are in all. */ +export const MAX_PATHS = 5000; // The tree at HEAD, `/file/{path}`, or at a commit, `/commit/{id}/file/{path}`. const IN_TREE = /^(?:\/commit\/([0-9a-f]{40}|[0-9a-f]{64}))?\/file(?:\/(.*))?$/; /** Whether `rest`, the path after `/workspace/{slug}`, names the workspace's files or history. */ export function isGitPath(rest: string): boolean { - return /^\/(commit|file|history|raw|branch|timeline)(\/|$)/.test(rest); + return /^\/(commit|file|history|raw|branch|timeline|paths)(\/|$)/.test(rest); } /** @@ -601,7 +605,14 @@ function typeOf(entry: TreeEntry): string { * BY_NAME order. `at` is the tree's address, `/file` or `/commit/{id}/file`; `up` is the parent * directory's, or the workspace's for the top. */ -function directory(req: Request, tree: Tree, at: string, path: string, up: string): Response { +function directory( + req: Request, + tree: Tree, + at: string, + path: string, + up: string, + more: Record = {}, +): Response { const url = new URL(req.url); const limit = pageParam(url.searchParams.get("limit"), DEFAULT_LIMIT, 1, MAX_LIMIT); const offset = pageParam(url.searchParams.get("offset"), 0, 0, Number.MAX_SAFE_INTEGER); @@ -617,6 +628,7 @@ function directory(req: Request, tree: Tree, at: string, path: string, up: strin _links: { self: { href: `${self}?limit=${limit}&offset=${offset}` }, ...Object.fromEntries(links.map((l) => [l.rel, { href: l.href }])), + ...more, }, path, offset, @@ -759,7 +771,16 @@ function inTree( const at = id === undefined ? `${base}/${FILE_SLUG}` : `${base}/${COMMIT_SLUG}/${id}/${FILE_SLUG}`; const path = decodedPath(encoded); - if (path === "") return directory(req, tree, at, "", base); + // The top of the tree at HEAD links every file's path, which the chat's mentions are made from. + if (path === "") + return directory( + req, + tree, + at, + "", + base, + id === undefined ? { paths: { href: `${base}/paths` } } : {}, + ); const entry = tree.getPath(path); if (entry === null) return problem(404, "Not Found", `${path} is not in ${slug} at ${commitId}`); const type = typeOf(entry); @@ -839,6 +860,42 @@ function rawFile( }); } +/** + * Every file's path in the tree at HEAD, in BY_NAME order of the whole path: what can be mentioned + * in a message to the agent. Directories and submodules are not listed. At most MAX_PATHS are + * listed; `total` counts them all, and `truncated` says when some are left out. + */ +function filePaths(req: Request, repo: Repository, base: string, slug: string): Response { + let tree: Tree; + try { + tree = repo.getCommit(repo.revparseSingle("HEAD")).tree(); + } catch (err) { + if (isNotFound(err)) return problem(404, "Not Found", `${slug}'s repository has no commits`); + throw err; + } + const paths: string[] = []; + const walk = (at: Tree, prefix: string) => { + for (const entry of at.iter()) { + const kind = entry.type(); + if (kind === "Tree") walk(repo.getTree(entry.id()), `${prefix}${entry.name()}/`); + else if (kind === "Blob") paths.push(`${prefix}${entry.name()}`); + } + }; + walk(tree, ""); + paths.sort((a, b) => BY_NAME.compare(a, b)); + const listed = paths.slice(0, MAX_PATHS); + return halJson(req, { + _links: { + self: { href: `${base}/paths` }, + index: { href: `${base}/${FILE_SLUG}` }, + }, + total: paths.length, + count: listed.length, + truncated: listed.length < paths.length, + paths: listed, + }); +} + /** * The workspace's files, git history and branches, read from `repo`, the repository mapped to it, * at the paths `isGitPath` names, where `rest` is the path after `/workspace/{slug}`. Without a @@ -876,6 +933,7 @@ export async function gitHandler( const fileHistoryOf = HISTORY.exec(rest); if (fileHistoryOf !== null) return fileHistory(req, repo, base, scope.slug, fileHistoryOf[1], fileHistoryOf[2] ?? ""); + if (PATHS.test(rest)) return filePaths(req, repo, base, scope.slug); const tree = IN_TREE.exec(rest); if (tree !== null) return inTree(req, repo, base, scope.slug, tree[1], tree[2] ?? ""); return problem(404, "Not Found", `${rest} names no commit or file in ${scope.slug}`); diff --git a/packages/app-web/tests/git-api.test.ts b/packages/app-web/tests/git-api.test.ts index 93090040..1da69e63 100644 --- a/packages/app-web/tests/git-api.test.ts +++ b/packages/app-web/tests/git-api.test.ts @@ -82,6 +82,32 @@ describe("a workspace's index", () => { const fileHref = (path: string) => `${ORIGIN}/workspace/alpha/file/${path}`; describe("a workspace's files", () => { + test("the top directory at HEAD links every file's path, a list of the tree's files alone", async () => { + const top = await get("/workspace/alpha/file"); + expect(top.body._links.paths.href).toBe(`${ORIGIN}/workspace/alpha/paths`); + const { status, body } = await get("/workspace/alpha/paths"); + expect(status).toBe(200); + expect(body._links.index.href).toBe(`${ORIGIN}/workspace/alpha/file`); + expect(body.paths).toContain("README.md"); + expect(body.paths).toContain("src/a.ts"); + // Directories are not listed, only the files in them. + expect(body.paths).not.toContain("src"); + expect(body.paths).not.toContain("notes"); + const byName = new Intl.Collator("en", { sensitivity: "base", numeric: true }); + expect(body.paths).toEqual([...body.paths].sort(byName.compare)); + expect(body).toMatchObject({ + total: body.paths.length, + count: body.paths.length, + truncated: false, + }); + }); + + test("a directory below the top, or the tree at a commit, links no list of paths", async () => { + expect((await get("/workspace/alpha/file/src")).body._links.paths).toBeUndefined(); + const at = await get(`/workspace/alpha/commit/${git.commits[0]}/file`); + expect(at.body._links.paths).toBeUndefined(); + }); + test("the top directory at HEAD lists its directories, then its files, by name, case aside", async () => { const { status, body } = await get("/workspace/alpha/file"); expect(status).toBe(200); From 5104f271d4eebcd513d17b43452ea395af6476d0 Mon Sep 17 00:00:00 2001 From: Dan Barua Date: Mon, 12 Oct 2026 12:54:53 +0100 Subject: [PATCH 2/3] A prompt can name files by address: each link is a resource_link, after the text ACP requires every agent to take resource_link blocks, so no capability is asked for. The blocks go text, links, then attached files; a prompt of links alone has no text block. Co-Authored-By: Claude Opus 5.5 --- packages/acp-client/index.ts | 8 ++++++- packages/acp-client/prompt-blocks.ts | 22 +++++++++++++---- packages/acp-client/session.ts | 13 +++++----- .../acp-client/tests/prompt-blocks.test.ts | 24 +++++++++++++++++++ 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/packages/acp-client/index.ts b/packages/acp-client/index.ts index 631bfd06..4ca530fa 100644 --- a/packages/acp-client/index.ts +++ b/packages/acp-client/index.ts @@ -1,4 +1,10 @@ -export { attachmentUri, FileNotTaken, type PromptFile, promptBlocks } from "./prompt-blocks"; +export { + attachmentUri, + FileNotTaken, + type PromptFile, + type PromptLink, + promptBlocks, +} from "./prompt-blocks"; export { type ConnectOptions, connectSession, diff --git a/packages/acp-client/prompt-blocks.ts b/packages/acp-client/prompt-blocks.ts index 6f149a91..86247659 100644 --- a/packages/acp-client/prompt-blocks.ts +++ b/packages/acp-client/prompt-blocks.ts @@ -9,6 +9,15 @@ import type * as acp from "@agentclientprotocol/sdk"; +/** + * A file the prompt names by its address rather than sends: a `resource_link`, which the agent + * reads itself. ACP requires every agent to take one, so no capability is asked for. + */ +export interface PromptLink { + readonly uri: string; + readonly name: string; +} + /** What a prompt needs of a file: a browser's `File` has all of it. */ export type PromptFile = Pick; @@ -71,16 +80,21 @@ async function blockFor( } /** - * The blocks for `text` and `files`, in that order. A prompt of files alone has no text block. - * Rejects with `FileNotTaken` for a file the agent takes in no form, so no file is left out. + * The blocks for `text`, `links` and `files`, in that order. A prompt of links or files alone has + * no text block. Rejects with `FileNotTaken` for a file the agent takes in no form, so no file is + * left out. */ export async function promptBlocks( text: string, files: readonly PromptFile[], takes: acp.PromptCapabilities, + links: readonly PromptLink[] = [], ): Promise { const attached = await Promise.all(files.map((file) => blockFor(file, takes))); + const linked = links.map( + ({ uri, name }): acp.ContentBlock => ({ type: "resource_link", uri, name }), + ); const said: acp.ContentBlock[] = - text === "" && attached.length > 0 ? [] : [{ type: "text", text }]; - return [...said, ...attached]; + text === "" && attached.length + linked.length > 0 ? [] : [{ type: "text", text }]; + return [...said, ...linked, ...attached]; } diff --git a/packages/acp-client/session.ts b/packages/acp-client/session.ts index c2ac74d9..51fe024e 100644 --- a/packages/acp-client/session.ts +++ b/packages/acp-client/session.ts @@ -15,7 +15,7 @@ import * as Client from "effective-acp/client"; import * as Http from "effective-acp/http"; import * as Protocol from "effective-acp/protocol"; import * as V1 from "effective-acp/schema/v1"; -import { type PromptFile, promptBlocks } from "./prompt-blocks"; +import { type PromptFile, type PromptLink, promptBlocks } from "./prompt-blocks"; export interface ConnectOptions { /** The agent's ACP endpoint. */ @@ -35,10 +35,11 @@ export interface SessionClient { /** What the agent takes in a prompt besides text, as its `initialize` answer advertises it. */ readonly promptCapabilities: acp.PromptCapabilities; /** - * Sends a prompt: `text`, and `files` as `promptBlocks` turns them into blocks. Resolves when the - * turn has ended; what happened arrives through `onEvent`, as does a file the agent does not take. + * Sends a prompt: `text`, `links` and `files` as `promptBlocks` turns them into blocks. Resolves + * when the turn has ended; what happened arrives through `onEvent`, as does a file the agent does + * not take. */ - prompt(text: string, files?: readonly PromptFile[]): Promise; + prompt(text: string, files?: readonly PromptFile[], links?: readonly PromptLink[]): Promise; /** * Asks the agent to stop the turn (`session/cancel`), then answers every permission request still * open as `cancelled`, as ACP requires of a client that cancels a turn. The prompt then resolves @@ -313,10 +314,10 @@ export async function connectSession(options: ConnectOptions): Promise { expect(decodeURIComponent(uri.split("/").at(-1) ?? "")).toBe("Δ loss.csv"); }); }); + +describe("files a prompt names by address", () => { + test("each is a resource_link, after the text and before attached files, whatever the agent takes", async () => { + const blocks = await promptBlocks( + "look at @src/a.ts", + [new File(["a"], "a.txt", { type: "text/plain" })], + BOTH, + [{ uri: "file:///work/src/a.ts", name: "src/a.ts" }], + ); + expect(blocks.map((b) => b.type)).toEqual(["text", "resource_link", "resource"]); + expect(blocks[1]).toEqual({ + type: "resource_link", + uri: "file:///work/src/a.ts", + name: "src/a.ts", + }); + const plain = await promptBlocks("x", [], {}, [{ uri: "file:///w/b", name: "b" }]); + expect(plain.map((b) => b.type)).toEqual(["text", "resource_link"]); + }); + + test("a prompt of links alone has no text block", async () => { + const blocks = await promptBlocks("", [], {}, [{ uri: "file:///w/b", name: "b" }]); + expect(blocks).toEqual([{ type: "resource_link", uri: "file:///w/b", name: "b" }]); + }); +}); From ac3b9767ec2528db96c3ecbcc6ef640c353cebc5 Mon Sep 17 00:00:00 2001 From: Dan Barua Date: Mon, 12 Oct 2026 13:03:03 +0100 Subject: [PATCH 3/3] @ in the chat offers the workspace's files; each one mentioned is sent with the message The chat reads every file's path at HEAD through the files API (the workspace's file collection links paths) once the chat is shown. A file a message mentions is sent as a resource_link to it in the workspace's folder; an @word that names no file stays text. Co-Authored-By: Claude Opus 5.5 --- packages/app-web/docs/architecture.md | 2 +- packages/app-web/e2e/app.e2e.ts | 31 ++++++++++++ packages/app-web/src/ui/agent/AgentChat.tsx | 9 +++- .../app-web/src/ui/agent/agent-session.ts | 6 +-- packages/app-web/src/ui/agent/mentions.ts | 48 ++++++++++++++++++ .../app-web/src/ui/trace/chat-sidebar.tsx | 49 ++++++++++++++++++- packages/app-web/tests/mentions.test.ts | 39 +++++++++++++++ 7 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 packages/app-web/src/ui/agent/mentions.ts create mode 100644 packages/app-web/tests/mentions.test.ts diff --git a/packages/app-web/docs/architecture.md b/packages/app-web/docs/architecture.md index b34ed4cb..bc7cdf2a 100644 --- a/packages/app-web/docs/architecture.md +++ b/packages/app-web/docs/architecture.md @@ -133,7 +133,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. At full width it slides open and shut beside a rail that stays; the rail's button expands or minimises it. In a window 860 px wide or narrower it is a drawer over the page from the right, opened by a button in the top bar. Its sessions open in the workspace's folder, which the dev server reads from the workspaces mapping file (`/agent-workspace/`). A workspace's session connects the first time the chat is shown in that workspace, and stays connected while the chat is hidden; a workspace the chat was never shown in has no session. Each workspace keeps its session for the life of the page: leaving it and coming back reopens that session. `?session=` names the session of the workspace in scope, so a reload reopens it. A workspace the file does not map has no chat, and the button is disabled with the reason. `/new` starts a session in place, and `/sessions` lists the sessions the agent keeps for the workspace's folder (ACP `session/list` by `cwd`), newest first, in a palette; picking one reopens it. 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. | +| Right | In development, in a workspace: the agent's chat. At full width it slides open and shut beside a rail that stays; the rail's button expands or minimises it. In a window 860 px wide or narrower it is a drawer over the page from the right, opened by a button in the top bar. Its sessions open in the workspace's folder, which the dev server reads from the workspaces mapping file (`/agent-workspace/`). A workspace's session connects the first time the chat is shown in that workspace, and stays connected while the chat is hidden; a workspace the chat was never shown in has no session. Each workspace keeps its session for the life of the page: leaving it and coming back reopens that session. `?session=` names the session of the workspace in scope, so a reload reopens it. A workspace the file does not map has no chat, and the button is disabled with the reason. `/new` starts a session in place, and `/sessions` lists the sessions the agent keeps for the workspace's folder (ACP `session/list` by `cwd`), newest first, in a palette; picking one reopens it. `@` offers the workspace's files at HEAD (`/paths`); each file a message mentions is sent with it as a `resource_link` to the file in the workspace's folder (`file:///`), which the agent reads itself. 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 c40511cf..c4acd795 100644 --- a/packages/app-web/e2e/app.e2e.ts +++ b/packages/app-web/e2e/app.e2e.ts @@ -1574,4 +1574,35 @@ test.describe("the agent's chat in the Trace Console", () => { await expect(page).toHaveURL(new RegExp(`[?&]session=${first}(&|$)`)); await expect(chat.locator(".agent-status")).toContainText("ready"); }); + + test("@ offers the workspace's files; a mentioned file is sent as a link to it in the agent's folder", async ({ + page, + }, info) => { + test.skip(info.project.name !== "dev", "the agent exists only in development"); + const prompts: string[] = []; + page.on("request", (req) => { + const body = req.postData(); + if (req.url().endsWith("/acp") && body?.includes('"session/prompt"')) prompts.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"); + const message = chat.getByRole("textbox", { name: "Message" }); + await message.fill("look at @README.m"); + await expect(chat.getByRole("option", { name: /README\.md/ })).toBeVisible(); + await message.press("Enter"); + await expect(message).toHaveValue(/^look at @README\.md\s*$/); + await message.press("Enter"); + await expect.poll(() => prompts.length).toBe(1); + const prompt = JSON.parse(prompts[0] ?? "{}").params.prompt; + expect(prompt).toEqual([ + { type: "text", text: "look at @README.md" }, + { type: "resource_link", uri: `file://${folder}/README.md`, name: "README.md" }, + ]); + }); }); diff --git a/packages/app-web/src/ui/agent/AgentChat.tsx b/packages/app-web/src/ui/agent/AgentChat.tsx index 57989efe..024cc12f 100644 --- a/packages/app-web/src/ui/agent/AgentChat.tsx +++ b/packages/app-web/src/ui/agent/AgentChat.tsx @@ -1,11 +1,12 @@ import { SCENARIOS } from "@labkit/acp-scenarios"; -import { Conversation } from "@labkit/ui"; +import { Conversation, type PickItem } 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 { useState } from "react"; import { CONNECTING, NEW, resolveLink, SESSIONS, useAgentSession } from "./agent-session"; +import { mentionedLinks } from "./mentions"; import { SessionPicker } from "./SessionPicker"; /** @@ -19,6 +20,7 @@ export function AgentChat({ sessionId, onStarted, onPick, + mentions, shown = true, themeButton = false, }: { @@ -30,6 +32,8 @@ export function AgentChat({ onStarted: (sessionId: string) => void; /** Opens another session in this folder: `/sessions` lists them. Without it there is no `/sessions`. */ onPick?: (sessionId: string) => void; + /** The files a message can mention with `@`; each one mentioned is sent with it as a link. */ + mentions?: readonly PickItem[]; shown?: boolean; /** Whether the conversation draws its own theme button, for a page with no top bar. */ themeButton?: boolean; @@ -62,11 +66,12 @@ export function AgentChat({ theme={theme} {...(themeButton ? { onThemeChange: setTheme } : {})} hostCommands={onPick === undefined ? [NEW] : [NEW, SESSIONS]} + {...(mentions === undefined ? {} : { mentions })} {...(attach === undefined ? {} : { attach })} composerUnavailable={status === "connecting" ? CONNECTING : undefined} onSend={(text, files) => { if (onPick !== undefined && text.trim() === `/${SESSIONS.name}`) setPicking(true); - else send(text, files); + else send(text, files, mentions === undefined ? [] : mentionedLinks(text, mentions)); }} onCancel={cancel} onAnswer={answer} diff --git a/packages/app-web/src/ui/agent/agent-session.ts b/packages/app-web/src/ui/agent/agent-session.ts index 5103493f..bb5e6500 100644 --- a/packages/app-web/src/ui/agent/agent-session.ts +++ b/packages/app-web/src/ui/agent/agent-session.ts @@ -3,7 +3,7 @@ import type { PromptCapabilities, RequestPermissionOutcome, } from "@agentclientprotocol/sdk"; -import { connectSession, type SessionClient } from "@labkit/acp-client"; +import { connectSession, type PromptLink, 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"; @@ -157,7 +157,7 @@ export function useAgentSession({ }, []); const send = useCallback( - (text: string, files: readonly File[]) => { + (text: string, files: readonly File[], links: readonly PromptLink[] = []) => { if (text.trim() === `/${NEW.name}`) { setLeft(sessionId); setStarted(undefined); @@ -171,7 +171,7 @@ export function useAgentSession({ }); return; } - void client.current.prompt(text, files); + void client.current.prompt(text, files, links); }, [sessionId], ); diff --git a/packages/app-web/src/ui/agent/mentions.ts b/packages/app-web/src/ui/agent/mentions.ts new file mode 100644 index 00000000..dea188a3 --- /dev/null +++ b/packages/app-web/src/ui/agent/mentions.ts @@ -0,0 +1,48 @@ +import type { PromptLink } from "@labkit/acp-client"; +import type { PickItem } from "@labkit/ui"; + +/** + * The `file:` URI of `path`, a file in `folder`, each segment escaped. This is an address for the + * agent, which reads the file from its working folder; it is not an address of the API. + */ +export function fileUri(folder: string, path: string): string { + const root = folder.replace(/\/+$/, ""); + const escaped = path.split("/").map(encodeURIComponent).join("/"); + return `file://${root.split("/").map(encodeURIComponent).join("/")}/${escaped}`; +} + +/** What can be mentioned in a message: each file at `paths` in `folder`, named by its path. */ +export function mentionItems(folder: string, paths: readonly string[]): PickItem[] { + return paths.map((path) => ({ id: fileUri(folder, path), label: path })); +} + +const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** + * The files `text` mentions, as links to send with it: each `@