mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-08 11:13:44 +02:00
## 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>
75 lines
5.6 KiB
TypeScript
75 lines
5.6 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { MessageCircle } from "lucide-react";
|
||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||
import { Button } from "@/components/ui/button";
|
||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||
import { useAgentChatNavigation, useOpenAgentChat } from "@/hooks/useAgentChatNavigation";
|
||
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, chats, session } = useAgentChatNavigation();
|
||
useEffect(() => setBreadcrumbs([{ label: "Chat" }]), [setBreadcrumbs]);
|
||
return <AgentChatsContent key={`${companyId}:${userId}`} companyId={companyId} userId={userId}
|
||
enabled={enabled} loaded={loaded} agents={agents} chats={chats} session={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>;
|
||
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 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}
|
||
onClick={async () => {
|
||
setOpeningId(agent.id); setOpenError(null);
|
||
try { await openChat(agent); } catch (error) { setOpenError(error instanceof Error ? error.message : "Couldn’t open chat. Try again."); } finally { setOpeningId(null); }
|
||
}} className="flex items-center gap-3 rounded-lg border border-border p-4 text-left hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
|
||
<AgentAvatar agent={agent} size={32} />
|
||
<span className="flex min-w-0 flex-col gap-1"><span className="truncate text-sm font-medium">{agent.name}</span><span className="text-xs text-muted-foreground">{openingId === agent.id ? "Opening chat…" : agent.title ?? agent.role}</span></span>
|
||
</button>)}
|
||
</div>}
|
||
{openError && <p role="alert" className="text-sm text-destructive">{openError}</p>}
|
||
{agents.data?.length === 0 && <p className="text-sm text-muted-foreground">Add an agent to start a conversation.</p>}
|
||
<Button variant="ghost" className="self-start" asChild><Link to="/agents/all">Browse all agents</Link></Button>
|
||
</div>;
|
||
}
|