Files
PaperClipAI/tests/e2e/agent-chat-sessions.spec.ts
T
DottaandPaperclip 1b48e73e0b feat(ui): add secondary navigation for agent chat (#14706)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent Chat already provides a persistent conversation with each
agent.
> - Its shortcuts share the primary navigation and do not give chats a
dedicated place.
> - People need to find agents, start a chat, and switch conversations
without moving the page layout.
> - This pull request adds a secondary chat sidebar and a landing page
around the existing chat surface.
> - The same conversation, composer, history, and context panel remain
in use.

## Linked Issues or Issue Description

Refs #13283 and #13420. This extends the existing experimental Agent
Chat navigation after review of the component and page stories. It
supports the CEO Chat roadmap item through the existing task-backed
conversation model.

**Subsystem affected**

The board UI and the company-scoped conversation list API.

**Current behavior**

Chat shortcuts sit inside the primary navigation. There is no dedicated
landing page with a searchable conversation list. A separate landing
header also moves the sidebar when an agent is selected.

**Proposed behavior**

Show a Chat entry in primary navigation. Keep a searchable agent sidebar
beside the chat content. The plus button starts or reopens the current
user's single conversation with that agent. Keep the header and sidebar
in the same positions before and after selection.

**Reason and benefit**

People can find agents and return to persistent conversations without
leaving the chat area or creating duplicate chats.

**Breaking changes**

The experimental chat navigation changes. Explicitly adding a chat now
resolves its conversation immediately. Direct visits to unused agent
chat URLs remain read-only. The existing per-agent routes and message
contracts remain compatible. No database migration is required.

## What Changed

- Add an account- and company-scoped conversation list endpoint with the
existing access checks, feature gate, and OpenAPI entry.
- Add the live secondary sidebar, landing page, avatars, search, loading
states, errors, and retry controls.
- Make the agent picker wait for chat creation and display failures.
Existing agents reopen the same conversation. A dismissed selection
cannot close a reopened picker or navigate over a newer choice.
- Preserve recent-activity ordering and terminated agents’ chat history.
Scope live list refreshes to the current user’s conversation events. A
failed historical-agent lookup leaves healthy chats usable and offers a
focused retry.
- Keep the sidebar and header stable across chat routes. Keep mobile
selection in the navigation drawer.
- Use the production components in Storybook. Prepare the theme and
mobile viewport before mounting the page to avoid the startup flash.
- Update product documentation, the design guide, and navigation tests.
Replace old browser expectations for stars and recent shortcuts with
persistent conversation and layout coverage.

## Verification

- `pnpm -r typecheck`, `pnpm build`, and `pnpm check:token-gates` pass
after rebasing onto master.
- Focused UI tests pass, including 105 sidebar, picker, and live-update
checks after review fixes. The 33 conversation service and route tests
and 10 OpenAPI checks pass, including ownership, feature gating, and
concurrent creation.
- The full local test run passed 14,163 server tests before three
environment or timeout failures. The embedded Postgres startup,
connector socket, and native runner failures all passed direct reruns.
- Browser test-drive verification covers a real provider reply, add and
reopen, persisted history after reload, no-match search recovery, mobile
drawer dismissal, and top-aligned context panels.
- Browser measurements confirm that the sidebar has the same position
and dimensions on the landing page and an agent conversation.
- Storybook builds and its add-and-reopen interaction passes.
- The revised browser regression passes locally against a freshly built
throwaway instance. It covers stable sidebar geometry, add/reopen
uniqueness, drafts, search, history, and terminated-agent history after
reload. The full CI browser suite also passes.
- Latest commit `b323577d9523180104df4000eaceedea2772608c`: all 54
completed checks pass, including the complete server/workspace/browser
suites, aggregate verification, build/typecheck, security scans, and
canary packaging. The two Storybook jobs are skipped by their workflow
conditions. [CI
run](https://github.com/paperclipai/paperclip/actions/runs/36714052050).
- Greptile reviewed this same commit at 5/5 with no remaining actionable
findings; all review threads are resolved.
- Reviewer path: enable Agent Chat, click Chat, use plus to choose an
agent, send a message, switch away, and reopen that agent. One
conversation must remain, with its history intact.

## Risks

- The new sidebar lists persistent conversations instead of starred and
recent shortcuts.
- Chat creation is asynchronous. Errors stay visible in the picker, and
delayed responses cannot navigate into a previous company or account.
- The shell adjustment is limited to chat routes and preserves the
existing conversation implementation.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, code execution, and browser
tools. The session does not expose the exact API model ID or
context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#123` / `Refs #123` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-30 07:35:32 -05:00

361 lines
14 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { createLocalAgentJwt } from "../../server/src/agent-auth-jwt";
import { idle, json, send, setup } from "./agent-chat.shared";
test.use({ trace: "retain-on-failure" });
test.setTimeout(120_000);
/**
* Agent chat session lifecycle coverage: first-open semantics, the feature
* flag, Stop + queued /new resets, and sidebar discovery. Shared fixtures
* live in ./agent-chat.shared.ts; project, attachment, and history flows run
* in agent-chat-projects.spec.ts.
*/
test("built chat initializes after service worker takeover and reload with a slow CPU", async ({
page,
context,
request,
}) => {
const f = await setup(request);
try {
const cdp = await context.newCDPSession(page);
try {
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
await page.goto(f.route);
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
// The failed CI traces stopped before React evaluated, while a service
// worker forwarded the Vite module graph. Keep this test on shipped assets
// and cover both first takeover and subsequent controlled navigations.
const scripts = await page.locator('script[type="module"][src]').evaluateAll(
(elements) => elements.map((element) => element.getAttribute("src")),
);
expect(scripts.length).toBeGreaterThan(0);
expect(scripts.every((src) => src?.startsWith("/assets/"))).toBe(true);
await page.evaluate(async () => { await navigator.serviceWorker.ready; });
for (let reload = 0; reload < 3; reload += 1) {
await page.reload();
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
expect(await page.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
}
expect(await json(await request.get(f.chatPath))).toBeNull();
} finally {
await cdp.detach();
}
} finally {
await f.restore();
}
});
test("chat first open is read-only; concurrent first sends and retries share one task", async ({
page,
context,
request,
}) => {
const f = await setup(request);
try {
await page.goto(f.route);
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
expect(await json(await request.get(f.chatPath))).toBeNull();
expect(
await json(
await request.get(`/api/companies/${f.company.id}/heartbeat-runs`),
),
).toHaveLength(0);
const other = await context.newPage();
await other.goto(f.route);
await Promise.all([send(page, "Same first message"), send(other, "Same first message")]);
const issue = await idle(request, f.chatPath, 2);
const initialComments = await json(await request.get(`/api/issues/${issue.id}/comments`));
expect(initialComments.filter((comment: any) => !comment.authorAgentId && comment.body === "Same first message")).toHaveLength(2);
const resolved = await Promise.all(
Array.from({ length: 4 }, () =>
request.post(f.chatPath, { data: {} }).then(json),
),
);
expect(new Set(resolved.map((row) => row.id))).toEqual(new Set([issue.id]));
const body = {
body: "Retry once",
clientRequestId: "00000000-0000-4000-8000-000000000001",
};
const replies = await Promise.all([
request
.post(`/api/issues/${issue.id}/comments`, { data: body })
.then(json),
request
.post(`/api/issues/${issue.id}/comments`, { data: body })
.then(json),
]);
expect(replies[0].id).toBe(replies[1].id);
await idle(request, f.chatPath, 3);
await page.reload();
await expect(
page.getByText("Reply generation 0: Retry once", { exact: true }),
).toBeVisible();
expect(
await json(await request.get(`/api/companies/${f.company.id}/issues`)),
).toHaveLength(0);
const dashboard = await json(
await request.get(`/api/companies/${f.company.id}/dashboard`),
);
expect(dashboard.tasks).toEqual({
open: 0,
inProgress: 0,
blocked: 0,
done: 0,
});
const count = (
await json(
await request.get(`/api/companies/${f.company.id}/heartbeat-runs`),
)
).length;
await page.goto(`/${f.company.issuePrefix}/issues/${issue.identifier}`);
await page.goto(f.route);
expect(
await json(
await request.get(`/api/companies/${f.company.id}/heartbeat-runs`),
),
).toHaveLength(count);
await other.close();
} finally {
await f.restore();
}
});
test("feature flag blocks new sends and resets while preserving existing history", async ({
page,
request,
}) => {
const f = await setup(request);
try {
await page.goto(f.route);
await send(page, "Visible history");
const issue = await idle(request, f.chatPath);
await json(
await request.patch("/api/instance/settings/experimental", {
data: { enableAgentChat: false },
}),
);
for (const body of ["blocked message", "/new"])
expect(
(
await request.post(`/api/issues/${issue.id}/comments`, {
data: { body },
})
).status(),
).toBe(404);
await page.reload();
await expect(page.getByText(/Agent Chat is disabled/)).toBeVisible();
expect(
(await json(await request.get(`/api/issues/${issue.id}/comments`))).some(
(c: any) => c.body.includes("Visible history"),
),
).toBe(true);
expect((await request.post(f.chatPath, { data: {} })).status()).toBe(404);
} finally {
await f.restore();
}
});
test("Stop then queued /new resets unpause the conversation without losing history", async ({
page,
request,
}) => {
const f = await setup(request);
try {
await page.goto(f.route);
await send(page, { action: "hold" });
await expect
.poll(async () => {
const chat = await json(await request.get(f.chatPath));
return (
chat &&
(
await json(await request.get(`/api/issues/${chat.id}/comments`))
).some(
(c: any) => c.body === "Provider is streaming and ready to stop.",
)
);
})
.toBe(true);
const issue = await json(await request.get(f.chatPath));
const active = (
await json(await request.get(`/api/issues/${issue.id}/live-runs`))
)[0];
await page.getByTestId("task-chat-composer-stop").click();
await expect
.poll(
async () =>
(await json(await request.get(`/api/heartbeat-runs/${active.id}`)))
.status,
)
.toBe("cancelled");
const staleToken = createLocalAgentJwt(
f.agent.id,
f.company.id,
"process",
active.id,
);
expect(staleToken).toBeTruthy();
const late = await request.post(`/api/issues/${issue.id}/comments`, {
headers: { Authorization: `Bearer ${staleToken}` },
data: { body: "Forbidden late response" },
});
expect([403, 409]).toContain(late.status());
const mutation = await request.post(
`/api/companies/${f.company.id}/projects`,
{
headers: { Authorization: `Bearer ${staleToken}` },
data: { name: "Cancelled project" },
},
);
expect([403, 409]).toContain(mutation.status());
expect(
await json(await request.get(`/api/companies/${f.company.id}/projects`)),
).toHaveLength(0);
await send(page, "/new");
await send(page, "/new");
await send(page, "Fresh followup");
await idle(request, f.chatPath, 2);
const fresh = await json(await request.get(f.chatPath));
expect(fresh.id).toBe(issue.id);
expect(fresh.conversationSessionGeneration).toBe(2);
await expect(
page.getByText("Reply generation 2: Fresh followup", { exact: true }),
).toBeVisible();
await page.reload();
await expect(page.getByText("New session", { exact: true })).toHaveCount(2);
await expect(
page.getByRole("separator", { name: "Run completed", exact: true }),
).toHaveCount(0);
const runs = await json(
await request.get(`/api/companies/${f.company.id}/heartbeat-runs`),
);
const detailed = await Promise.all(
runs.map((run: any) =>
request.get(`/api/heartbeat-runs/${run.id}`).then(json),
),
);
expect(
detailed.filter((run) => run.contextSnapshot?.conversationReset),
).toHaveLength(2);
expect(
detailed
.filter((run) => run.contextSnapshot?.conversationReset)
.every((run) => !run.sessionIdAfter),
).toBe(true);
} finally {
await f.restore();
}
});
test("secondary chat navigation preserves layout, unique conversations, history, and drafts", async ({
page,
request,
}) => {
const originalExperimental = await json(await request.get("/api/instance/settings/experimental"));
const f = await setup(request);
try {
await json(await request.patch("/api/instance/settings/experimental", {
data: { enableStreamlinedUi: true },
}));
await page.goto(`/${f.company.issuePrefix}/dashboard`);
await page.getByRole("link", { name: "Chat", exact: true }).click();
await expect(page.getByRole("heading", { name: "Who would you like to talk to?" })).toBeVisible();
const sidebar = page.getByRole("complementary", { name: "Chat", exact: true });
await expect(sidebar.getByText("No chats yet", { exact: true })).toBeVisible();
const landingBounds = await sidebar.boundingBox();
const compose = sidebar.getByRole("button", { name: "Add chat", exact: true });
const picker = page.getByRole("dialog", { name: "Chat with an agent", exact: true });
const chatListPath = `/api/companies/${f.company.id}/chats`;
const betaPath = `${chatListPath}/${f.agents[1].id}`;
await compose.click();
await expect(picker.getByRole("option")).toHaveCount(6);
await picker.getByRole("combobox").fill("Beta");
await expect(picker.getByRole("option", { name: /^Beta / })).toContainText("New chat");
await picker.getByRole("combobox").press("Enter");
await expect(picker).not.toBeVisible();
await expect(page.getByRole("link", { name: "Configure Beta", exact: true })).toBeVisible();
expect(await sidebar.boundingBox()).toEqual(landingBounds);
const beta = await json(await request.get(betaPath));
expect(beta.conversationAgentId).toBe(f.agents[1].id);
expect(await json(await request.get(chatListPath))).toHaveLength(1);
expect(await json(await request.get(`/api/companies/${f.company.id}/heartbeat-runs`))).toHaveLength(0);
// Explicitly adding an existing agent must reopen the same conversation.
await compose.click();
await expect(picker.getByRole("combobox")).toHaveValue("");
await picker.getByRole("combobox").fill("Beta");
await expect(picker.getByRole("option", { name: /^Beta / })).toContainText("Open chat");
await picker.getByRole("combobox").press("Enter");
await expect(picker).not.toBeVisible();
expect((await json(await request.get(betaPath))).id).toBe(beta.id);
expect(await json(await request.get(chatListPath))).toHaveLength(1);
await compose.click();
await picker.getByRole("combobox").fill("Alpha");
await picker.getByRole("combobox").press("Enter");
await expect(picker).not.toBeVisible();
await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible();
const nav = sidebar.getByRole("navigation", { name: "Agent conversations" });
const alphaLink = nav.getByRole("link", { name: /^Alpha / });
const betaLink = nav.getByRole("link", { name: /^Beta / });
await expect(nav.getByRole("link")).toHaveCount(2);
const editor = page.getByTestId("task-chat-composer-input").locator('[contenteditable="true"]');
await editor.fill("Unsent draft for Alpha");
await betaLink.click();
await expect(editor).toHaveText("");
await alphaLink.click();
await expect(editor).toContainText("Unsent draft for Alpha");
const search = sidebar.getByRole("textbox", { name: "Search agents", exact: true });
await search.fill("No matching agent");
await expect(sidebar.getByText("No agents found", { exact: true })).toBeVisible();
await search.fill("Beta");
await expect(nav.getByRole("link")).toHaveCount(1);
await expect(betaLink).toBeVisible();
await search.press("Escape");
await expect(nav.getByRole("link")).toHaveCount(2);
await page.reload();
await expect(editor).toContainText("Unsent draft for Alpha");
await expect(nav.getByRole("link")).toHaveCount(2);
await page.getByRole("link", { name: "Chat", exact: true }).click();
await expect(page.getByRole("heading", { name: "Who would you like to talk to?" })).toBeVisible();
expect(await sidebar.boundingBox()).toEqual(landingBounds);
await alphaLink.click();
await page.getByRole("link", { name: "Configure Alpha", exact: true }).click();
await expect(page).toHaveURL(/\/agents\/.*\/runtime/);
await json(await request.post(`/api/issues/${beta.id}/comments`, {
data: {
body: "Background activity",
clientRequestId: "00000000-0000-4000-8000-000000000099",
},
}));
await idle(request, betaPath);
await page.getByRole("link", { name: "Chat", exact: true }).click();
await compose.click();
await picker.getByRole("combobox").fill("Beta");
await picker.getByRole("combobox").press("Enter");
await expect(picker).not.toBeVisible();
await expect(page.getByRole("link", { name: "Configure Beta", exact: true })).toBeVisible();
await expect(page.getByText("Background activity", { exact: true })).toBeVisible();
expect((await json(await request.get(betaPath))).id).toBe(beta.id);
expect(await json(await request.get(chatListPath))).toHaveLength(2);
await json(await request.post(`/api/agents/${f.agents[1].id}/terminate`));
await page.goto(`/${f.company.issuePrefix}/chats`);
await expect(betaLink).toContainText("Terminated");
await betaLink.click();
await expect(page).toHaveURL(new RegExp(`/chats/${f.agents[1].id}$`));
await expect(page.getByText("Background activity", { exact: true })).toBeVisible();
await page.reload();
await expect(page.getByText("Background activity", { exact: true })).toBeVisible();
} finally {
await f.restore();
await json(await request.patch("/api/instance/settings/experimental", {
data: { enableStreamlinedUi: originalExperimental.enableStreamlinedUi },
}));
}
});