fix(ui): reopen last visited agent chat (#14848)

## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - Agent Chat keeps one conversation for each agent and board user.
> - The Chat sidebar entry opens the agent chooser each time.
> - A user must then find and reopen the chat they just used.
> - The browser already records recent agent chat visits by company and
user.
> - This pull request uses that record to reopen the last available
chat.
> - The chooser still serves users who have no available saved chat.

## Linked Issues or Issue Description

Related: #14706 added the secondary Agent Chat navigation.

**What happened?**

The Chat sidebar entry opened the agent chooser, even after a user
opened an agent chat.

**Expected behavior**

The Chat entry should reopen the last agent chat visited by the current
user in the current company.

**Steps to reproduce**

1. Enable Agent Chat and open a chat with an agent.
2. Open another page.
3. Select Chat in the sidebar.
4. Observe the agent chooser instead of the chat.

**Paperclip version or commit**

Reproduced on master at `0829d94af`.

**Deployment mode**

Local development, browser UI. The change also uses the same browser
storage path in authenticated mode.

## What Changed

- Use the existing recent chat record when the Chat landing route opens.
- Check saved agents against the current roster and chat history before
redirecting.
- Keep the chooser when no saved chat is available, and show a retry
state for load errors.
- Add route tests and update the Agent Chat implementation spec.

## Verification

- `pnpm exec vitest run ui/src/pages/AgentChats.test.tsx
ui/src/lib/recent-agent-chats.test.ts` — 16 tests passed.
- `pnpm check:token-gates` — passed.
- `pnpm exec playwright test --config tests/e2e/playwright.config.ts
tests/e2e/agent-chat-sessions.spec.ts --grep 'secondary chat navigation
preserves layout'` — passed.
- `pnpm --filter @paperclipai/ui typecheck` — passed on the final
commit.
- `pnpm -r typecheck` and `pnpm build` — passed earlier in this branch;
latest-head CI completed all 47 jobs successfully.
- `pnpm test:run` reported an unrelated native runtime test failure
before it was stopped. That test and an unrelated external object
refresh test passed in isolation. CI runs the same suites on the PR.
- To check in the UI: open an agent chat, leave it, and select Chat. The
same chat should open. Clear the recent chat record or use another
company to see the chooser.

## Risks

- The recent order is stored in the browser. Clearing browser storage
returns the user to the chooser.
- An existing chat ID is stored with its visit. If the chat is removed,
the landing route skips that visit when history loads. Cross-tab storage
removal clears the identity; failed writes retain an in-tab fallback.
- The landing route waits for the agent roster and validates saved issue
IDs against chat history when available. If history fails, an active
agent chat can still open; roster or session failures show a retry
action.
- No database or API contract changes are required.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-6 family. The runtime did not expose an exact API
model ID or context window. It used reasoning, repository tools, shell
commands, and code execution.

## 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
#` / `Refs #` 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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-01 11:01:45 -05:00
1 parent 26900655b4
commit 33a00d2f1e
7 files changed
+242 -12

No files matched your search

+2
View File
@@ -1661,6 +1661,8 @@ for persistence, migration, rendering, and integration contracts.
`GET /api/companies/:companyId/chats/:agentRef` reads an existing conversation or null. `POST` atomically resolves its issue when adding a chat or on first send/upload. `GET /api/companies/:companyId/chats` lists only the current board user’s conversations in that company, subject to ordinary issue read access. The Chat navigation opens a searchable secondary sidebar with agent avatars and a picker for starting or reopening the same per-agent conversation. Existing issue comment, attachment, document, interaction, and run APIs apply thereafter. User chat comments require an idempotent UUID `clientRequestId`. Conversation delivery preserves comment order through the existing issue execution queue; the durable comment outbox repairs the commit-to-enqueue crash window.
The Chat navigation entry reopens the last agent conversation visited by the current user in the current company. The browser keeps this recent order and any existing conversation ID locally; unavailable agents and removed conversations are skipped. An agent chat that has no issue yet can still reopen from the agent roster. The agent chooser remains the landing view when no saved chat is available.
The server owns conversation state: `waiting` plus `in_review` denotes a healthy idle conversation, and `active` denotes an unanswered or executing turn. Successful replies settle a turn; they do not finish the issue. Idle containers are excluded from execution-work counts, ordinary task lists, timer work, and recovery invocations. Failed/unanswered turns retain normal handling. Child completion never wakes or completes the conversation. Search and direct task access preserve history.
Standalone `/new` is an ordered queue command with no model response. It advances a durable session generation and boundary comment, resets only this issue's provider context, and preserves the issue ID and history. Generation checks reject stale context writes and replies. Fresh replay excludes earlier messages and summaries. The shared transcript renders a session divider.
+3 -2
View File
@@ -322,9 +322,9 @@ test("secondary chat navigation preserves layout, unique conversations, history,
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();
await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible();
await expect(editor).toContainText("Unsent draft for Alpha");
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`, {
@@ -335,6 +335,7 @@ test("secondary chat navigation preserves layout, unique conversations, history,
}));
await idle(request, betaPath);
await page.getByRole("link", { name: "Chat", exact: true }).click();
await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible();
await compose.click();
await picker.getByRole("combobox").fill("Beta");
await picker.getByRole("combobox").press("Enter");
+25 -1
View File
@@ -1,9 +1,10 @@
// @vitest-environment jsdom
import { clearLegacyChatMessageRequests } from "./chat-message-request";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
orderChatAgents,
parseRecentAgentChats,
recordedAgentChatIssueId,
recordAgentChatVisit,
} from "./recent-agent-chats";
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
@@ -57,6 +58,29 @@ describe("agent chat navigation and session markers", () => {
JSON.parse(localStorage.getItem("paperclip.recentAgentChats:a:user2")!),
).toEqual(["agent4"]);
});
it("remembers an existing conversation and clears its identity for a new empty chat", () => {
recordAgentChatVisit("company-c", "user1", "agent1", "issue-1");
expect(recordedAgentChatIssueId("company-c", "user1", "agent1")).toBe("issue-1");
expect(recordedAgentChatIssueId("company-c", "user2", "agent1")).toBeNull();
recordAgentChatVisit("company-c", "user1", "agent1", null);
expect(recordedAgentChatIssueId("company-c", "user1", "agent1")).toBeNull();
});
it("recognizes a conversation cleared in another tab while retaining failed-write fallback", () => {
const storageKey = "paperclip.recentAgentChatIssue:company-d:user1:agent1";
recordAgentChatVisit("company-d", "user1", "agent1", "issue-1");
localStorage.removeItem(storageKey);
expect(recordedAgentChatIssueId("company-d", "user1", "agent1")).toBeNull();
const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage unavailable");
});
try {
recordAgentChatVisit("company-d", "user1", "agent1", "issue-2");
expect(recordedAgentChatIssueId("company-d", "user1", "agent1")).toBe("issue-2");
} finally {
setItem.mockRestore();
}
});
it("removes legacy plaintext retry records", () => {
const scope = "company:user:agent";
const key = `paperclip:agent-chat-pending:${scope}`;
+25
View File
@@ -4,6 +4,9 @@ const eventName = "paperclip:recent-agent-chats";
const key = (company: string, user?: string | null) =>
`paperclip.recentAgentChats:${company}:${user ?? "__local_board__"}`;
const memory = new Map<string, string>();
const issueKey = (company: string, user: string | null | undefined, agentId: string) =>
`paperclip.recentAgentChatIssue:${company}:${user ?? "__local_board__"}:${agentId}`;
const rememberedIssues = new Map<string, string>();
function read(storageKey: string): string {
try {
return (
@@ -29,6 +32,7 @@ export function recordAgentChatVisit(
company: string,
user: string | null | undefined,
agentId: string,
issueId?: string | null,
) {
const storageKey = key(company, user);
const value = JSON.stringify(
@@ -43,8 +47,29 @@ export function recordAgentChatVisit(
} catch {
/* In-tab navigation still works without storage. */
}
if (issueId !== undefined) {
const storageIssueKey = issueKey(company, user, agentId);
try {
if (issueId) window.localStorage.setItem(storageIssueKey, issueId);
else window.localStorage.removeItem(storageIssueKey);
// Storage is authoritative when available, including when another tab
// removes this key. Only retain a fallback after a failed write.
rememberedIssues.delete(storageIssueKey);
} catch {
if (issueId) rememberedIssues.set(storageIssueKey, issueId);
else rememberedIssues.delete(storageIssueKey);
}
}
window.dispatchEvent(new Event(eventName));
}
export function recordedAgentChatIssueId(company: string, user: string | null | undefined, agentId: string): string | null {
const storageIssueKey = issueKey(company, user, agentId);
try {
return window.localStorage.getItem(storageIssueKey) ?? rememberedIssues.get(storageIssueKey) ?? null;
} catch {
return rememberedIssues.get(storageIssueKey) ?? null;
}
}
function subscribe(callback: () => void) {
window.addEventListener(eventName, callback);
window.addEventListener("storage", callback);
+3 -3
View File
@@ -48,9 +48,9 @@ export function AgentChat() {
creating.current = null;
}, [selectedCompanyId, userId, agent?.id]);
useEffect(() => {
if (enabled && agent && session.isFetched)
recordAgentChatVisit(agent.companyId, userId, agent.id);
}, [enabled, agent?.id, agent?.companyId, userId, session.isFetched]);
if (enabled && agent && session.isSuccess && chat.isSuccess)
recordAgentChatVisit(agent.companyId, userId, agent.id, chat.data?.id ?? null);
}, [enabled, agent?.id, agent?.companyId, userId, session.isSuccess, chat.isSuccess, chat.data?.id]);
const ensureIssue = useCallback(async () => {
if (!agent || !selectedCompanyId) throw new Error("Agent not found");
if (chat.data) return chat.data;
+150
View File
@@ -0,0 +1,150 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Agent } from "@paperclipai/shared";
import { recordAgentChatVisit } from "@/lib/recent-agent-chats";
import { AgentChats } from "./AgentChats";
const state = vi.hoisted(() => ({
companyId: "company-a",
userId: "user-a",
agents: [] as Agent[],
chats: [] as { id: string; conversationAgentId: string }[],
chatsFetched: true,
chatsError: null as Error | null,
sessionError: null as Error | null,
navigate: vi.fn(),
}));
vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }) }));
vi.mock("@/hooks/useAgentChatNavigation", () => ({
useAgentChatNavigation: () => ({
companyId: state.companyId,
userId: state.userId,
enabled: true,
loaded: true,
agents: { data: state.agents, isFetched: true, isPending: false, error: null },
chats: { data: state.chats, isFetched: state.chatsFetched, isSuccess: state.chatsFetched && !state.chatsError, error: state.chatsError },
session: { isFetched: !state.sessionError, isPending: false, error: state.sessionError },
}),
useOpenAgentChat: () => vi.fn(),
}));
vi.mock("@/lib/router", () => ({
useNavigate: () => state.navigate,
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
}));
vi.mock("@/components/AgentAvatar", () => ({ AgentAvatar: () => null }));
let root: Root;
let container: HTMLDivElement;
const agent = (id: string, companyId = "company-a") => ({
id, companyId, name: id, urlKey: `${id}-slug`, role: "engineer", status: "idle",
} as Agent);
async function render() {
await act(async () => root.render(<AgentChats />));
}
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
state.companyId = "company-a";
state.userId = "user-a";
state.agents = [agent("alice"), agent("bob")];
state.chats = [];
state.chatsFetched = true;
state.chatsError = null;
state.sessionError = null;
state.navigate.mockReset();
for (const id of ["alice", "bob", "removed", "retired-id"])
recordAgentChatVisit("company-a", "user-a", id, null);
localStorage.setItem("paperclip.recentAgentChats:company-a:user-a", "[]");
localStorage.setItem("paperclip.recentAgentChats:company-b:user-a", "[]");
localStorage.setItem("paperclip.recentAgentChats:company-a:user-b", "[]");
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
describe("Chat landing", () => {
it("opens the most recently visited agent and replaces the landing history entry", async () => {
recordAgentChatVisit("company-a", "user-a", "alice");
recordAgentChatVisit("company-a", "user-a", "bob");
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/bob-slug", { replace: true });
expect(container.textContent).toContain("Opening chat");
});
it("skips unavailable saved agents and preserves the company and user scope", async () => {
recordAgentChatVisit("company-a", "user-a", "alice");
recordAgentChatVisit("company-a", "user-a", "removed");
recordAgentChatVisit("company-b", "user-a", "other-company-agent");
recordAgentChatVisit("company-a", "user-b", "other-user-agent");
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/alice-slug", { replace: true });
});
it("reopens historical chats for agents missing from the active roster", async () => {
recordAgentChatVisit("company-a", "user-a", "retired-id");
state.chats = [{ id: "chat-retired", conversationAgentId: "retired-id" }];
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/retired-id", { replace: true });
});
it("waits for history before skipping a recent historical chat", async () => {
recordAgentChatVisit("company-a", "user-a", "alice");
recordAgentChatVisit("company-a", "user-a", "retired-id");
state.chatsFetched = false;
await render();
expect(state.navigate).not.toHaveBeenCalled();
state.chatsFetched = true;
state.chats = [{ id: "chat-retired", conversationAgentId: "retired-id" }];
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/retired-id", { replace: true });
});
it("shows the chooser when no saved chat remains available", async () => {
recordAgentChatVisit("company-a", "user-a", "removed");
await render();
expect(state.navigate).not.toHaveBeenCalled();
expect(container.textContent).toContain("Who would you like to talk to?");
});
it("skips a saved conversation that is no longer accessible even when its agent remains available", async () => {
recordAgentChatVisit("company-a", "user-a", "alice");
recordAgentChatVisit("company-a", "user-a", "bob", "deleted-chat");
state.chats = [{ id: "chat-alice", conversationAgentId: "alice" }];
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/alice-slug", { replace: true });
});
it("reopens an active agent when history fails", async () => {
recordAgentChatVisit("company-a", "user-a", "alice", "chat-alice");
state.chatsFetched = false;
state.chatsError = new Error("History unavailable");
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/alice-slug", { replace: true });
});
it("reopens a visited empty chat without waiting for history", async () => {
recordAgentChatVisit("company-a", "user-a", "alice", null);
state.chatsFetched = false;
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/alice-slug", { replace: true });
});
it("shows a retry state when identity fails instead of waiting for disabled history", async () => {
recordAgentChatVisit("company-a", "user-a", "alice");
state.sessionError = new Error("Session unavailable");
state.chatsFetched = false;
await render();
expect(state.navigate).not.toHaveBeenCalled();
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Couldn’t load your chats");
});
});
+34 -6
View File
@@ -4,30 +4,58 @@ import { AgentAvatar } from "@/components/AgentAvatar";
import { Button } from "@/components/ui/button";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useAgentChatNavigation, useOpenAgentChat } from "@/hooks/useAgentChatNavigation";
import { Link } from "@/lib/router";
import { recordedAgentChatIssueId, useRecentAgentChats } from "@/lib/recent-agent-chats";
import { Link, useNavigate } from "@/lib/router";
import { agentRouteRef } from "@/lib/utils";
export function AgentChats() {
const { setBreadcrumbs } = useBreadcrumbs();
const { companyId, userId, enabled, loaded, agents, session } = useAgentChatNavigation();
const { companyId, userId, enabled, loaded, agents, chats, session } = useAgentChatNavigation();
useEffect(() => setBreadcrumbs([{ label: "Chat" }]), [setBreadcrumbs]);
return <AgentChatsContent key={`${companyId}:${userId}`} companyId={companyId} userId={userId}
enabled={enabled} loaded={loaded} agents={agents} session={session} />;
enabled={enabled} loaded={loaded} agents={agents} chats={chats} session={session} />;
}
function AgentChatsContent({ companyId, userId, enabled, loaded, agents, session }: Pick<ReturnType<typeof useAgentChatNavigation>, "companyId" | "userId" | "enabled" | "loaded" | "agents" | "session">) {
function AgentChatsContent({ companyId, userId, enabled, loaded, agents, chats, session }: Pick<ReturnType<typeof useAgentChatNavigation>, "companyId" | "userId" | "enabled" | "loaded" | "agents" | "chats" | "session">) {
const openChat = useOpenAgentChat(companyId, userId);
const navigate = useNavigate();
const recentIds = useRecentAgentChats(companyId ?? "", userId);
const [openingId, setOpeningId] = useState<string | null>(null);
const [openError, setOpenError] = useState<string | null>(null);
const blockingError = agents.error ?? session.error;
const firstId = recentIds[0];
const firstAgent = agents.data?.find(agent => agent.id === firstId);
// An empty chat with an active agent can reopen from the roster alone. A
// missing agent or a saved issue needs history to validate that visit.
const needsHistory = !!firstId && (!firstAgent || !!recordedAgentChatIssueId(companyId ?? "", userId, firstId));
const resolvingRecent = recentIds.length > 0 && !blockingError && (!agents.isFetched || !session.isFetched || (needsHistory && !chats.isFetched && !chats.error));
const recentChatPath = loaded && enabled && companyId && !blockingError && !resolvingRecent
? recentIds.map(id => {
const agent = agents.data?.find(item => item.id === id);
const historyIssue = chats.data?.find(chat => chat.conversationAgentId === id);
const savedIssueId = recordedAgentChatIssueId(companyId, userId, id);
// A deleted or inaccessible issue must not silently reopen as a new chat.
if (savedIssueId && chats.isSuccess && historyIssue?.id !== savedIssueId) return null;
if (agent) return `/chats/${encodeURIComponent(agentRouteRef(agent))}`;
if (historyIssue) return `/chats/${encodeURIComponent(id)}`;
return null;
}).find((path): path is string => path !== null)
: undefined;
useEffect(() => {
if (recentChatPath) navigate(recentChatPath, { replace: true });
}, [navigate, recentChatPath]);
if (!loaded) return <p role="status" className="text-sm text-muted-foreground">Loading chat…</p>;
if (!enabled) return <p className="text-sm text-muted-foreground">Agent Chat is disabled. Enable it in Experimental settings.</p>;
const error = agents.error ?? session.error;
if (!companyId) return <p className="text-sm text-muted-foreground">Select a company to start a conversation.</p>;
if (resolvingRecent || recentChatPath) return <p role="status" className="text-sm text-muted-foreground">Opening chat…</p>;
const error = blockingError ?? (recentIds.length > 0 ? chats.error : null);
return <div className="mx-auto flex h-full max-w-xl flex-col justify-center gap-6 px-4 py-12">
<div className="flex flex-col gap-3">
<MessageCircle className="size-6 text-muted-foreground" />
<h1 className="text-xl font-semibold">Who would you like to talk to?</h1>
<p className="text-sm leading-relaxed text-muted-foreground">Ask a question, think through an idea, or plan the next step with your team.</p>
</div>
{error ? <div role="alert" className="flex flex-col items-start gap-3"><p className="text-sm">Couldn’t load your agents.</p><Button variant="outline" onClick={() => { void agents.refetch(); void session.refetch(); }}>Try again</Button></div>
{error ? <div role="alert" className="flex flex-col items-start gap-3"><p className="text-sm">Couldn’t load your chats.</p><Button variant="outline" onClick={() => { void agents.refetch(); void chats.refetch(); void session.refetch(); }}>Try again</Button></div>
: agents.isPending || session.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading agents…</p>
: <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{(agents.data ?? []).slice(0, 6).map(agent => <button key={agent.id} type="button" disabled={openingId !== null}