From af4f74f9e2450fb865ce6e26e2b8247694814a02 Mon Sep 17 00:00:00 2001 From: Dan Barua Date: Mon, 12 Oct 2026 12:26:18 +0100 Subject: [PATCH 1/2] The chat connects in a workspace only once shown there, keeps each workspace's session, and is a drawer in narrow windows - A workspace's session connects the first time the chat is shown in that workspace; visiting a workspace with the chat minimised opens none. Each workspace keeps its session for the life of the page, so leaving and coming back reopens it instead of starting another. - In a window 860 px wide or narrower, the chat is a drawer over the page from the right, and its button is in the top bar. The rail and the border to drag are left out there. - A production build asks the dev server for no workspace folder. Co-Authored-By: Claude Opus 5.5 --- packages/app-web/docs/architecture.md | 2 +- packages/app-web/e2e/app.e2e.ts | 56 ++++++ .../app-web/src/ui/trace/TraceConsole.tsx | 60 +++--- .../app-web/src/ui/trace/chat-sidebar.tsx | 183 +++++++++++++----- packages/app-web/src/ui/trace/trace.css | 27 +++ 5 files changed, 245 insertions(+), 83 deletions(-) diff --git a/packages/app-web/docs/architecture.md b/packages/app-web/docs/architecture.md index 55637b59..ba80bfaa 100644 --- a/packages/app-web/docs/architecture.md +++ b/packages/app-web/docs/architecture.md @@ -132,7 +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. | +| 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. 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 2e63d141..cb400d9e 100644 --- a/packages/app-web/e2e/app.e2e.ts +++ b/packages/app-web/e2e/app.e2e.ts @@ -1487,4 +1487,60 @@ test.describe("the agent's chat in the Trace Console", () => { await border.dblclick(); await expect.poll(width).toBe(400); }); + + test("no session opens until the chat is shown; leaving the workspace and coming back reopens its session", async ({ + page, + }, info) => { + test.skip(info.project.name !== "dev", "the agent exists only in development"); + const sent: string[] = []; + page.on("request", (req) => { + const method = /"method":"(session\/(?:new|load))"/.exec(req.postData() ?? "")?.[1]; + if (req.url().endsWith("/acp") && method !== undefined) sent.push(method); + }); + await page.goto("/app/workspace/alpha/Q_1"); + await expect(chatToggle(page)).toBeEnabled(); + await page.locator(".card-head", { hasText: "LOE_1" }).click(); + await expect(header(page)).toHaveText("LOE_1"); + expect(sent).toEqual([]); + + const chat = page.getByRole("complementary", { name: "Agent" }); + await chatToggle(page).click(); + await expect(chat.locator(".agent-status")).toContainText("ready"); + await expect(page).toHaveURL(/[?&]session=fake-\d+/); + const named = new URL(page.url()).searchParams.get("session"); + expect(sent).toEqual(["session/new"]); + + await chatToggle(page).click(); + await page.getByRole("button", { name: "LabKit" }).click(); + await expect(page).toHaveURL(/\/app\/$/); + expect(new URL(page.url()).searchParams.get("session")).toBeNull(); + await row(page, "alpha").click(); + await expect(chatToggle(page)).toBeEnabled(); + await expect(page).toHaveURL(new RegExp(`[?&]session=${named}`)); + await expect.poll(() => sent).toEqual(["session/new", "session/load"]); + }); + + test("in a narrow window the chat's button is in the top bar, and the chat is a drawer over the page", async ({ + page, + }, info) => { + test.skip(info.project.name !== "dev", "the agent exists only in development"); + await page.setViewportSize({ width: 600, height: 800 }); + await page.goto("/app/workspace/alpha/Q_1"); + await expect(header(page)).toHaveText("Q_1"); + await expect(rail(page)).toHaveCount(0); + const button = page.locator(".topbar").getByRole("button", { name: "Show or hide the agent" }); + const chat = page.getByRole("complementary", { name: "Agent" }); + await expect(button).toBeEnabled(); + await expect(chat).not.toBeInViewport(); + await button.click(); + await expect(chat.locator(".agent-status")).toContainText("ready"); + await expect(chat).toBeInViewport(); + await expect(button).toHaveAttribute("aria-expanded", "true"); + // The drawer covers the right of the page; the scrim shows at its left. + await page + .getByRole("button", { name: "Close the column" }) + .click({ force: true, position: { x: 100, y: 400 } }); + await expect(chat).not.toBeInViewport(); + await expect(button).toHaveAttribute("aria-expanded", "false"); + }); }); diff --git a/packages/app-web/src/ui/trace/TraceConsole.tsx b/packages/app-web/src/ui/trace/TraceConsole.tsx index b0168b3d..e6a3bef5 100644 --- a/packages/app-web/src/ui/trace/TraceConsole.tsx +++ b/packages/app-web/src/ui/trace/TraceConsole.tsx @@ -82,7 +82,7 @@ import { } from "./hal-store"; import { FileTree, History } from "./explorers"; import { ActiveBox } from "./active-box"; -import { ChatSidebar, HAS_CHAT } from "./chat-sidebar"; +import { ChatButton, ChatSidebar, useChat } from "./chat-sidebar"; import { CommitView, FileView, RELATIONS_APART, SHOWN_APART } from "./git-views"; import { type Playback, usePlayback } from "./playback"; import { proseSegments } from "./prose"; @@ -160,22 +160,17 @@ export interface TraceConsoleProps { export function TraceConsole({ path, list, tab, session }: TraceConsoleProps) { const navigate = useNavigate(); const [theme] = useSavedTheme(); - const [drawer, setDrawer] = useState<"items" | undefined>(); + // In a narrow window, the drawer open over the page: the sidebar's, or the agent's chat. + const [drawer, setDrawer] = useState<"items" | "chat" | undefined>(); // At full width the sidebar is shown or hidden, as this browser last left it; in a narrow // 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. + // At full width the agent's chat is expanded or minimised, as this browser last left it. 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 }), + const setSession = useCallback( + (next: string | undefined) => + void navigate({ to: ".", search: (s) => ({ ...s, session: next }), replace: true }), [navigate], ); const [graphOpen, setGraphOpen] = useState(true); @@ -231,25 +226,25 @@ export function TraceConsole({ path, list, tab, session }: 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 chat = useChat({ + slug: scope?.split("/")[2], + expanded: narrow ? drawer === "chat" : chatShown, + session, + setSession, + }); + const toggleChat = () => + narrow ? setDrawer((d) => (d === "chat" ? undefined : "chat")) : setChatShown(!chatShown); const prose = proseFor(held, path, open); return (
- + : null + } + />
- {HAS_CHAT && scope !== undefined ? ( - - ) : null} + {chat === undefined ? null : ( + + )}
diff --git a/packages/app-web/src/ui/trace/chat-sidebar.tsx b/packages/app-web/src/ui/trace/chat-sidebar.tsx index e8b986ec..642f1755 100644 --- a/packages/app-web/src/ui/trace/chat-sidebar.tsx +++ b/packages/app-web/src/ui/trace/chat-sidebar.tsx @@ -1,5 +1,13 @@ import { ChatCircleDotsIcon } from "@phosphor-icons/react"; -import { type CSSProperties, lazy, Suspense, useEffect, useRef, useState } from "react"; +import { + type CSSProperties, + lazy, + Suspense, + useCallback, + 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 @@ -90,18 +98,20 @@ function ChatBorder({ /** Whether this build has the agent's chat at all. */ export const HAS_CHAT = AgentChat !== undefined; -type Folder = +export 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. + * the workspaces mapping file). The agent's sessions for the workspace open in it. No workspace in + * scope has no folder to find. */ -function useWorkspaceFolder(slug: string): Folder { +function useWorkspaceFolder(slug: string | undefined): Folder { const [folder, setFolder] = useState({ kind: "loading" }); useEffect(() => { + if (slug === undefined) return; const controller = new AbortController(); setFolder({ kind: "loading" }); fetch(`/agent-workspace/${encodeURIComponent(slug)}`, { signal: controller.signal }) @@ -126,76 +136,157 @@ function useWorkspaceFolder(slug: string): Folder { return folder; } +/** The agent's chat in the workspace in scope, as the page drives it. */ +export interface Chat { + /** The slug of the workspace in scope. */ + slug: string; + folder: Folder; + /** Whether the chat is shown: expanded at full width, or its drawer open in a narrow window. */ + shown: boolean; + /** Whether the chat's session is connected: it has been shown in this workspace on this page. */ + connected: boolean; + /** The session to reopen in this workspace. */ + sessionId: string | undefined; + onStarted: (sessionId: string) => void; + /** Why the chat cannot be shown, when it cannot. */ + unavailable: string | undefined; +} + /** - * 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. + * The agent's chat for the workspace `slug`, kept for the life of the page. + * - The session connects the first time the chat is shown in a 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: leaving one and coming back reopens the session it had. + * - `session` is the address's `?session=`, which names the session of the workspace in scope; + * `setSession` writes it. On leaving a workspace, the address names the next one's session. */ -export function ChatSidebar({ - workspace, +export function useChat({ + slug, expanded, - onToggle, - connected, - sessionId, - onStarted, + session, + setSession, }: { - /** The slug of the workspace in scope. */ - workspace: string; + slug: string | undefined; expanded: boolean; + session: string | undefined; + setSession: (sessionId: string | undefined) => void; +}): Chat | undefined { + // A build without the chat asks for no folder. + const folder = useWorkspaceFolder(AgentChat === undefined ? undefined : slug); + // The session each workspace had on this page, and the workspace the address's session is. + // Until the address is written again, the session it names is the workspace's just left. + const sessions = useRef(new Map()); + const addressed = useRef(slug); + const left = useRef(undefined); + if (addressed.current !== slug) { + if (addressed.current !== undefined && session !== undefined) + sessions.current.set(addressed.current, session); + addressed.current = slug; + left.current = session; + } + const named = session === left.current ? undefined : session; + if (slug !== undefined && named !== undefined) sessions.current.set(slug, named); + const sessionId = slug === undefined ? undefined : sessions.current.get(slug); + useEffect(() => { + if (session !== sessionId) setSession(sessionId); + }, [session, sessionId, setSession]); + + const [shownIn, setShownIn] = useState>(new Set()); + const shown = expanded && folder.kind === "mapped"; + useEffect(() => { + if (shown && slug !== undefined && !shownIn.has(slug)) setShownIn(new Set([...shownIn, slug])); + }, [shown, slug, shownIn]); + + const onStarted = useCallback( + (started: string) => { + if (addressed.current !== undefined) sessions.current.set(addressed.current, started); + setSession(started); + }, + [setSession], + ); + if (AgentChat === undefined || slug === undefined) return undefined; + return { + slug, + folder, + shown, + connected: shown || shownIn.has(slug), + sessionId, + onStarted, + unavailable: + folder.kind === "unmapped" + ? folder.reason + : folder.kind === "loading" + ? `Finding ${slug}'s folder…` + : undefined, + }; +} + +/** The button that shows or hides the agent's chat: in the rail, or in a narrow window, the top bar. */ +export function ChatButton({ chat, onToggle }: { chat: Chat; onToggle: () => void }) { + return ( + + ); +} + +/** + * The agent's chat at the right of the page. At full width the rail beside it stays when it is + * minimised; in a narrow window it is a drawer over the page, and its button is in the top bar. + * The chat's sessions open in the workspace's folder; a workspace the mapping file does not map has + * no chat, and the button says why. + */ +export function ChatSidebar({ + chat, + narrow, + onToggle, +}: { + chat: Chat; + narrow: 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"; + const { folder, shown } = chat; return ( <> - {shown ? : null} + {shown && !narrow ? ( + + ) : null} - + {narrow ? null : ( + + )} ); } diff --git a/packages/app-web/src/ui/trace/trace.css b/packages/app-web/src/ui/trace/trace.css index 4603dbe3..cb9f5ee2 100644 --- a/packages/app-web/src/ui/trace/trace.css +++ b/packages/app-web/src/ui/trace/trace.css @@ -1258,6 +1258,33 @@ .trace .sidebar.open { transform: translateX(0); } + /* The agent's chat is a drawer from the right, over the page, at most as wide as the window. Its + button is in the top bar, and there is no rail or border to drag. */ + .trace .chat-sidebar, + .trace .chat-sidebar.collapsed { + position: fixed; + z-index: 20; + top: 52px; + right: 0; + bottom: 0; + width: min(100vw, var(--chat-width, 400px)); + border-left-width: 1px; + transform: translateX(100%); + transition: + transform 0.18s ease, + visibility 0s 0.18s; + box-shadow: -2px 0 16px rgba(0, 0, 0, 0.35); + } + .trace .chat-sidebar.collapsed { + visibility: hidden; + } + .trace .chat-sidebar.open { + transform: translateX(0); + transition: transform 0.18s ease; + } + .trace .chat-sidebar > * { + min-width: 0; + } .trace .detail { padding: 20px 16px 60px; } From ef9ee35321f78f6a5113693b1b78f6218c8db3f4 Mon Sep 17 00:00:00 2001 From: Dan Barua Date: Mon, 12 Oct 2026 12:33:49 +0100 Subject: [PATCH 2/2] /sessions in the chat lists the workspace's sessions in a palette; picking one reopens it The agent is asked for its sessions in the workspace's folder (ACP session/list by cwd) each time the palette opens, newest first, with the open one marked. The /agent page, which has no workspace, has no /sessions. 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 | 27 +++++- .../app-web/src/ui/agent/SessionPicker.tsx | 95 +++++++++++++++++++ .../app-web/src/ui/agent/agent-session.ts | 7 +- .../app-web/src/ui/trace/chat-sidebar.tsx | 4 + 6 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 packages/app-web/src/ui/agent/SessionPicker.tsx diff --git a/packages/app-web/docs/architecture.md b/packages/app-web/docs/architecture.md index ba80bfaa..bd66f43a 100644 --- a/packages/app-web/docs/architecture.md +++ b/packages/app-web/docs/architecture.md @@ -132,7 +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. 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. 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. 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 cb400d9e..c40511cf 100644 --- a/packages/app-web/e2e/app.e2e.ts +++ b/packages/app-web/e2e/app.e2e.ts @@ -1543,4 +1543,35 @@ test.describe("the agent's chat in the Trace Console", () => { await expect(chat).not.toBeInViewport(); await expect(button).toHaveAttribute("aria-expanded", "false"); }); + + test("/sessions lists the workspace's sessions, and picking one reopens it", async ({ + page, + }, info) => { + test.skip(info.project.name !== "dev", "the agent exists only in development"); + 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"); + await expect(page).toHaveURL(/[?&]session=fake-\d+/); + const first = new URL(page.url()).searchParams.get("session") ?? ""; + const message = chat.getByRole("textbox", { name: "Message" }); + const command = async (name: string) => { + await message.fill(`/${name}`); + // 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 RegExp(`^/${name}\\s*$`)); + await message.press("Enter"); + }; + await command("new"); + await expect(page).not.toHaveURL(new RegExp(`[?&]session=${first}(&|$)`)); + await expect(chat.locator(".agent-status")).toContainText("ready"); + + await command("sessions"); + const sessions = page.getByRole("dialog", { name: "Sessions" }); + await expect(sessions).toBeVisible(); + await sessions.getByRole("option", { name: new RegExp(first) }).click(); + await expect(sessions).toBeHidden(); + await expect(page).toHaveURL(new RegExp(`[?&]session=${first}(&|$)`)); + await expect(chat.locator(".agent-status")).toContainText("ready"); + }); }); diff --git a/packages/app-web/src/ui/agent/AgentChat.tsx b/packages/app-web/src/ui/agent/AgentChat.tsx index 283e6e70..57989efe 100644 --- a/packages/app-web/src/ui/agent/AgentChat.tsx +++ b/packages/app-web/src/ui/agent/AgentChat.tsx @@ -4,7 +4,9 @@ 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"; +import { useState } from "react"; +import { CONNECTING, NEW, resolveLink, SESSIONS, useAgentSession } from "./agent-session"; +import { SessionPicker } from "./SessionPicker"; /** * A live session with the agent the dev server mounts at `/acp`: the fake agent by default, or a @@ -16,6 +18,7 @@ export function AgentChat({ cwd, sessionId, onStarted, + onPick, shown = true, themeButton = false, }: { @@ -25,15 +28,18 @@ export function AgentChat({ 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; + /** Opens another session in this folder: `/sessions` lists them. Without it there is no `/sessions`. */ + onPick?: (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 } = + const { state, status, started, attach, send, cancel, answer, answerQuestion, setConfig } = useAgentSession({ url: AGENT_URL, cwd, sessionId, onStarted }); const [theme, setTheme] = useSavedTheme(); + const [picking, setPicking] = useState(false); if (!shown) return null; return (
@@ -55,10 +61,13 @@ export function AgentChat({ records={{ types: RECORD_TYPES }} theme={theme} {...(themeButton ? { onThemeChange: setTheme } : {})} - hostCommands={HOST_COMMANDS} + hostCommands={onPick === undefined ? [NEW] : [NEW, SESSIONS]} {...(attach === undefined ? {} : { attach })} composerUnavailable={status === "connecting" ? CONNECTING : undefined} - onSend={send} + onSend={(text, files) => { + if (onPick !== undefined && text.trim() === `/${SESSIONS.name}`) setPicking(true); + else send(text, files); + }} onCancel={cancel} onAnswer={answer} onAnswerQuestion={answerQuestion} @@ -66,6 +75,16 @@ export function AgentChat({ resolveLink={resolveLink} />
+ {onPick === undefined ? null : ( + setPicking(false)} + onPick={onPick} + /> + )} ); } diff --git a/packages/app-web/src/ui/agent/SessionPicker.tsx b/packages/app-web/src/ui/agent/SessionPicker.tsx new file mode 100644 index 00000000..decd6ffd --- /dev/null +++ b/packages/app-web/src/ui/agent/SessionPicker.tsx @@ -0,0 +1,95 @@ +import type { SessionInfo } from "@agentclientprotocol/sdk"; +import { listSessions } from "@labkit/acp-client"; +import { CommandPalette, type PickItem } from "@labkit/ui"; +import { useEffect, useState } from "react"; + +type Listed = + | { kind: "loading" } + | { kind: "not_listed" } + | { kind: "failed"; message: string } + | { kind: "listed"; sessions: readonly SessionInfo[] }; + +/** Newest first; a session the agent gave no time for goes last. */ +const newestFirst = (a: SessionInfo, b: SessionInfo): number => + (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""); + +/** + * The sessions the agent keeps for the folder `cwd`, in a palette to pick one from: `/sessions` in + * the chat. The list is asked for each time the palette opens. Picking a session hands its id to + * `onPick`, which reopens it; `current` is marked. + */ +export function SessionPicker({ + url, + cwd, + current, + open, + onClose, + onPick, +}: { + url: string; + cwd: string | undefined; + current: string | undefined; + open: boolean; + onClose: () => void; + onPick: (sessionId: string) => void; +}) { + const [listed, setListed] = useState({ kind: "loading" }); + useEffect(() => { + if (!open) return; + let cancelled = false; + setListed({ kind: "loading" }); + listSessions({ url, ...(cwd === undefined ? {} : { cwd }) }).then( + (sessions) => { + if (cancelled) return; + setListed( + sessions === undefined + ? { kind: "not_listed" } + : { kind: "listed", sessions: [...sessions].sort(newestFirst) }, + ); + }, + (err: unknown) => { + if (cancelled) return; + setListed({ kind: "failed", message: err instanceof Error ? err.message : String(err) }); + }, + ); + return () => { + cancelled = true; + }; + }, [open, url, cwd]); + + const items: PickItem[] = + listed.kind === "listed" + ? listed.sessions.map((session) => ({ + id: session.sessionId, + label: session.title || "Untitled", + detail: [ + session.updatedAt ? new Date(session.updatedAt).toLocaleString() : undefined, + session.sessionId, + ] + .filter((part) => part !== undefined) + .join(" · "), + keywords: session.sessionId, + ...(session.sessionId === current ? { current: true } : {}), + })) + : []; + const empty = + listed.kind === "loading" + ? "Asking the agent for its sessions…" + : listed.kind === "not_listed" + ? "This agent does not list its sessions." + : listed.kind === "failed" + ? `The sessions could not be listed: ${listed.message}` + : `The agent has no sessions${cwd === undefined ? "" : ` in ${cwd}`}.`; + return ( + onPick(item.id)} + /> + ); +} diff --git a/packages/app-web/src/ui/agent/agent-session.ts b/packages/app-web/src/ui/agent/agent-session.ts index 3c71c50d..5103493f 100644 --- a/packages/app-web/src/ui/agent/agent-session.ts +++ b/packages/app-web/src/ui/agent/agent-session.ts @@ -10,8 +10,11 @@ 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]; +/** The command that lists the sessions in the chat's folder, to open another. */ +export const SESSIONS = { + name: "sessions", + description: "Open another session in this folder", +} as const; export type Status = "connecting" | "ready" | "failed"; diff --git a/packages/app-web/src/ui/trace/chat-sidebar.tsx b/packages/app-web/src/ui/trace/chat-sidebar.tsx index 642f1755..77133ef9 100644 --- a/packages/app-web/src/ui/trace/chat-sidebar.tsx +++ b/packages/app-web/src/ui/trace/chat-sidebar.tsx @@ -148,6 +148,8 @@ export interface Chat { /** The session to reopen in this workspace. */ sessionId: string | undefined; onStarted: (sessionId: string) => void; + /** Opens another of this workspace's sessions, from `/sessions`. */ + onPick: (sessionId: string) => void; /** Why the chat cannot be shown, when it cannot. */ unavailable: string | undefined; } @@ -212,6 +214,7 @@ export function useChat({ connected: shown || shownIn.has(slug), sessionId, onStarted, + onPick: onStarted, unavailable: folder.kind === "unmapped" ? folder.reason @@ -277,6 +280,7 @@ export function ChatSidebar({ cwd={folder.cwd} sessionId={chat.sessionId} onStarted={chat.onStarted} + onPick={chat.onPick} shown={shown} />