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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/app-web/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>`), connects the first time the chat is expanded, and stays connected while it is minimised. `?session=` names it, so a reload reopens it; on leaving the workspace the session leaves the address. A workspace the file does not map has no chat, and the button is disabled with the reason. `/new` starts a session in place. The border at the chat's left edge moves by drag, by Left and Right (16 px), and by Home and End (to 320 px, and to 60% of the window); a double-click returns it to 400 px, and the browser keeps the width. |
| 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/<slug>`). 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
Expand Down
87 changes: 87 additions & 0 deletions packages/app-web/e2e/app.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1487,4 +1487,91 @@ 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");
});

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");
});
});
27 changes: 23 additions & 4 deletions packages/app-web/src/ui/agent/AgentChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +18,7 @@ export function AgentChat({
cwd,
sessionId,
onStarted,
onPick,
shown = true,
themeButton = false,
}: {
Expand All @@ -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 (
<div className="agent-chat">
Expand All @@ -55,17 +61,30 @@ 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}
onSetConfig={setConfig}
resolveLink={resolveLink}
/>
</div>
{onPick === undefined ? null : (
<SessionPicker
url={AGENT_URL}
cwd={cwd}
current={sessionId ?? started}
open={picking}
onClose={() => setPicking(false)}
onPick={onPick}
/>
)}
</div>
);
}
95 changes: 95 additions & 0 deletions packages/app-web/src/ui/agent/SessionPicker.tsx
Original file line number Diff line number Diff line change
@@ -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<Listed>({ 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 (
<CommandPalette
open={open}
onClose={onClose}
title="Sessions"
placeholder="Search sessions"
search
items={items}
empty={empty}
onPick={(item) => onPick(item.id)}
/>
);
}
7 changes: 5 additions & 2 deletions packages/app-web/src/ui/agent/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
60 changes: 24 additions & 36 deletions packages/app-web/src/ui/trace/TraceConsole.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string | undefined>(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 (
<IconContext.Provider value={ICONS}>
<div className="lk-root lk-frame trace" data-theme={theme === "system" ? undefined : theme}>
<TopBar onHome={home} />
<TopBar
onHome={home}
end={
narrow && chat !== undefined ? <ChatButton chat={chat} onToggle={toggleChat} /> : null
}
/>

<div className="body">
<button
Expand Down Expand Up @@ -311,16 +306,9 @@ export function TraceConsole({ path, list, tab, session }: TraceConsoleProps) {
/>
</div>
</main>
{HAS_CHAT && scope !== undefined ? (
<ChatSidebar
workspace={scope.split("/")[2] ?? ""}
expanded={chatShown}
onToggle={toggleChat}
connected={chatConnected}
sessionId={chatSession}
onStarted={onChatStarted}
/>
) : null}
{chat === undefined ? null : (
<ChatSidebar chat={chat} narrow={narrow} onToggle={toggleChat} />
)}
</div>
</div>
</IconContext.Provider>
Expand Down
Loading
Loading