diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 580aa360aaf..41b5de6d298 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -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. diff --git a/tests/e2e/agent-chat-sessions.spec.ts b/tests/e2e/agent-chat-sessions.spec.ts index f087e1ad77e..185d78548ac 100644 --- a/tests/e2e/agent-chat-sessions.spec.ts +++ b/tests/e2e/agent-chat-sessions.spec.ts @@ -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"); diff --git a/ui/src/lib/recent-agent-chats.test.ts b/ui/src/lib/recent-agent-chats.test.ts index 1e3dfdbfff3..ca6a6e1aa2f 100644 --- a/ui/src/lib/recent-agent-chats.test.ts +++ b/ui/src/lib/recent-agent-chats.test.ts @@ -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}`; diff --git a/ui/src/lib/recent-agent-chats.ts b/ui/src/lib/recent-agent-chats.ts index a49f36bb29c..3e483bdf813 100644 --- a/ui/src/lib/recent-agent-chats.ts +++ b/ui/src/lib/recent-agent-chats.ts @@ -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(); +const issueKey = (company: string, user: string | null | undefined, agentId: string) => + `paperclip.recentAgentChatIssue:${company}:${user ?? "__local_board__"}:${agentId}`; +const rememberedIssues = new Map(); 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); diff --git a/ui/src/pages/AgentChat.tsx b/ui/src/pages/AgentChat.tsx index 84279b7333c..6e8dc81d559 100644 --- a/ui/src/pages/AgentChat.tsx +++ b/ui/src/pages/AgentChat.tsx @@ -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; diff --git a/ui/src/pages/AgentChats.test.tsx b/ui/src/pages/AgentChats.test.tsx new file mode 100644 index 00000000000..a65f8fa646f --- /dev/null +++ b/ui/src/pages/AgentChats.test.tsx @@ -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 }) => {children}, +})); +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()); +} + +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"); + }); +}); diff --git a/ui/src/pages/AgentChats.tsx b/ui/src/pages/AgentChats.tsx index 5ec7eb6411f..0cb30cd4a10 100644 --- a/ui/src/pages/AgentChats.tsx +++ b/ui/src/pages/AgentChats.tsx @@ -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 ; + enabled={enabled} loaded={loaded} agents={agents} chats={chats} session={session} />; } -function AgentChatsContent({ companyId, userId, enabled, loaded, agents, session }: Pick, "companyId" | "userId" | "enabled" | "loaded" | "agents" | "session">) { +function AgentChatsContent({ companyId, userId, enabled, loaded, agents, chats, session }: Pick, "companyId" | "userId" | "enabled" | "loaded" | "agents" | "chats" | "session">) { const openChat = useOpenAgentChat(companyId, userId); + const navigate = useNavigate(); + const recentIds = useRecentAgentChats(companyId ?? "", userId); const [openingId, setOpeningId] = useState(null); const [openError, setOpenError] = useState(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

Loading chat…

; if (!enabled) return

Agent Chat is disabled. Enable it in Experimental settings.

; - const error = agents.error ?? session.error; + if (!companyId) return

Select a company to start a conversation.

; + if (resolvingRecent || recentChatPath) return

Opening chat…

; + const error = blockingError ?? (recentIds.length > 0 ? chats.error : null); return

Who would you like to talk to?

Ask a question, think through an idea, or plan the next step with your team.

- {error ?

Couldn’t load your agents.

+ {error ?

Couldn’t load your chats.

: agents.isPending || session.isPending ?

Loading agents…

:
{(agents.data ?? []).slice(0, 6).map(agent =>