mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 21:59:38 +01:00
## 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>
361 lines
14 KiB
TypeScript
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 },
|
|
}));
|
|
}
|
|
});
|