From 26900655b46d92f2be28b8dc5f29d581eba2812e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:49:02 -0700 Subject: [PATCH 01/10] chore(deps): bump lucide-react from 1.38.0 to 1.45.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 1.38.0 to 1.45.0.
Release notes

Sourced from lucide-react's releases.

Version 1.45.0

What's Changed

New Contributors

Full Changelog: https://github.com/lucide-icons/lucide/compare/1.44.0...1.45.0

Version 1.44.0

What's Changed

New Contributors

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=lucide-react&package-manager=npm_and_yarn&previous-version=1.38.0&new-version=1.45.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 10 +++++----- ui/package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d97f5c2029..3d48251ab0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1179,8 +1179,8 @@ importers: specifier: 0.48.0 version: 0.48.0(typescript@7.0.2) lucide-react: - specifier: ^1.38.0 - version: 1.38.0(react@19.2.8) + specifier: ^1.45.0 + version: 1.45.0(react@19.2.8) mermaid: specifier: ^11.17.2 version: 11.17.2 @@ -7281,8 +7281,8 @@ packages: lru_map@0.4.1: resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==} - lucide-react@1.38.0: - resolution: {integrity: sha512-xZCyBd/wiVUDactoCc+42TjL0aB7EBOXsuX+tjz+W/sGzw2KhHpL1NOH3FIaVUcpimvUBpIYfz34Ofj9S5JEzQ==} + lucide-react@1.45.0: + resolution: {integrity: sha512-yH1ubCAduho9UR7oJhRXIQXogksRILBiTuZC4/bQIGeB9JOkxMlSuEHyyZpo1Z3S0yWJO2KTSUZbjiNvVxeOUw==} peerDependencies: react: ^19.2.8 @@ -15060,7 +15060,7 @@ snapshots: lru_map@0.4.1: {} - lucide-react@1.38.0(react@19.2.8): + lucide-react@1.45.0(react@19.2.8): dependencies: react: 19.2.8 diff --git a/ui/package.json b/ui/package.json index 1b380b7432..15533b4a89 100644 --- a/ui/package.json +++ b/ui/package.json @@ -59,7 +59,7 @@ "cmdk": "^1.1.1", "i18next": "^26.4.0", "lexical": "0.48.0", - "lucide-react": "^1.38.0", + "lucide-react": "^1.45.0", "mermaid": "^11.17.2", "motion": "^13.1.1", "radix-ui": "^1.6.7", From 33a00d2f1e807055fc017105d9ee7e62c3c11544 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:01:45 -0500 Subject: [PATCH 02/10] fix(ui): reopen last visited agent chat (#14848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- doc/SPEC-implementation.md | 2 + tests/e2e/agent-chat-sessions.spec.ts | 5 +- ui/src/lib/recent-agent-chats.test.ts | 26 ++++- ui/src/lib/recent-agent-chats.ts | 25 +++++ ui/src/pages/AgentChat.tsx | 6 +- ui/src/pages/AgentChats.test.tsx | 150 ++++++++++++++++++++++++++ ui/src/pages/AgentChats.tsx | 40 +++++-- 7 files changed, 242 insertions(+), 12 deletions(-) create mode 100644 ui/src/pages/AgentChats.test.tsx diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 580aa360aa..41b5de6d29 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 f087e1ad77..185d78548a 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 1e3dfdbfff..ca6a6e1aa2 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 a49f36bb29..3e483bdf81 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 84279b7333..6e8dc81d55 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 0000000000..a65f8fa646 --- /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 5ec7eb6411..0cb30cd4a1 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 => + + { + event.preventDefault(); + copyButton.current?.focus(); + }} + onFocusOutside={(event) => { + // The legacy clipboard fallback briefly focuses a textarea in + // document.body. Keep the preview open so its result stays visible. + if (copying.current) event.preventDefault(); + }} + > +
+
+

{title}

+

{description}

+
+ +
+ +
+
+ {status === "failed" ? ( +