mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
fix(ui): make every agent reachable from Chats (#13420)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Experimental Agent Chat gives each person a persistent conversation
with each agent.
> - The sidebar only showed starred or previously visited agents.
> - A person could not start a conversation with an agent absent from
that list.
> - This pull request adds a searchable agent picker and a default
sidebar entry.
> - People can now reach every company agent while keeping their
frequent conversations close.
## Linked Issues or Issue Description
Refs #13283, which introduced the existing task-backed agent chat
surface. This change adds discovery to that surface after maintainer
review of the Storybook design.
**What happened?**
With Agent Chat enabled, a person with no starred or recent agents had
no agent shortcut. The sidebar also lacked a direct way to start a chat
with another agent.
**Expected behavior**
Show the first-created company agent by default. Let the person search
all company agents and open a persistent conversation.
**Steps to reproduce**
1. Enable Agent Chat in a company with agents.
2. Use an account with no starred agents or recent chats.
3. Look for a chat entry in the sidebar, or try to chat with an agent
absent from its shortcuts.
**Paperclip version or commit**
The existing behavior is present on master at 728f7185f.
## What Changed
- Add a compact picker that searches every company agent by name or
role. Include loading, retry, empty, and no-match states.
- Keep the first-created agent in Chats alongside stars and up to four
other recent conversations.
- Align the compose button with stars. Show it on hover or keyboard
focus, and keep it visible on touch devices.
- Use the existing company-aware chat route. Close the mobile sidebar on
selection and reset the picker when the company or user changes.
- Connect the approved Storybook scenarios to the production sidebar and
picker. Update product and design-guide documentation.
## Verification
- Ten focused tests pass for ordering, duplicate names, role search,
keyboard selection, search reset, retry states, full-roster navigation,
mobile dismissal, and company/user changes.
- The agent-chat sidebar end-to-end test passes against an isolated
PostgreSQL container. It verifies the first-use default, picker
navigation, lazy creation, stars, draft preservation, and reopening
existing history. The old four-row and See all link expectations now
match the approved behavior.
- Browser review of the production components in Storybook: open the
sidebar picker, search for an agent absent from the shortcuts, select
with Enter, and send a message through the existing composer.
- `pnpm build`, `pnpm -r typecheck`, `pnpm check:token-gates`, and `pnpm
build-storybook` pass on the rebased branch. The full local test run
reached 12,020 passing server tests, then stopped because an unrelated
environment-custom-images suite could not allocate PostgreSQL shared
memory on this Mac. All corresponding CI test shards pass on isolated
hosts.
- All CI checks pass on commit
`c324568324a6a8b65adfc6447ef19bcc710c61df`. Greptile reviewed that
commit at 5/5 with no open findings.
- Reviewer path: enable Agent Chat, open the Chats compose button,
search by name or role, and select an agent. Existing conversation
history must remain available.
## Risks
- Sidebar ordering changes: the earliest-created agent remains visible
even without a visit or star.
- The hover control remains available to keyboard and touch users.
- This change uses the existing feature gate, APIs, membership
preferences, and persistent conversation behavior. It adds no migration
or server contract.
- Storybook uses fixture responses. Its first-use landing page is a
review fixture and is not added as a product route.
## Model Used
OpenAI GPT-6 Astra (`gpt-6-astra`) through Codex, with reasoning, code
execution, and browser tools. The session does not expose its 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
#` / `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:
1 parent
5054c9ef9b
commit
5282cabde8
17 files changed
+587
-110
No files matched your search
@@ -199,3 +199,13 @@ An optional announcement card shares product news with board users on opening
|
||||
or returning to Paperclip. Dismissals persist per user across companies and
|
||||
browsers within an instance. Operators can disable fetching independently of
|
||||
telemetry. See [Announcements](ANNOUNCEMENTS.md).
|
||||
|
||||
### Agent chat discovery
|
||||
|
||||
With Agent Chat enabled, the Chats sidebar always includes the company's
|
||||
earliest-created agent, plus personal starred agents and up to four other recent
|
||||
conversations. First use has the same compact rows as returning use. The compose
|
||||
icon shares a column with stars and appears on hover or keyboard focus (always on
|
||||
touch). It opens a company-wide name/role search, independent of sidebar membership.
|
||||
Selecting an agent opens their persistent conversation; it does not reset history
|
||||
or create a task until the existing first-write flow requires one.
|
||||
@@ -554,19 +554,30 @@ for (const bad of [
|
||||
}
|
||||
});
|
||||
|
||||
test("sidebar stars, recent agents, configuration links, and drafts survive switching", async ({
|
||||
test("sidebar discovery, stars, recent agents, configuration links, and drafts survive switching", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const f = await setup(request);
|
||||
try {
|
||||
await page.goto(`/${f.company.issuePrefix}/dashboard`);
|
||||
const nav = page.getByRole("navigation");
|
||||
const chatLinks = nav.locator('a[href*="/chats/"]');
|
||||
await expect(chatLinks).toHaveText(["Alpha"]);
|
||||
const compose = nav.getByRole("button", { name: "Chat with an agent", exact: true });
|
||||
await compose.click();
|
||||
const picker = page.getByRole("dialog", { name: "Chat with an agent", exact: true });
|
||||
await expect(picker.getByRole("option")).toHaveCount(6);
|
||||
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();
|
||||
expect(await json(await request.get(`/api/companies/${f.company.id}/chats/${f.agents[1].id}`))).toBeNull();
|
||||
for (const agent of f.agents) {
|
||||
await page.goto(`/${f.company.issuePrefix}/chats/${agent.id}`);
|
||||
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
|
||||
}
|
||||
const nav = page.getByRole("navigation");
|
||||
await expect(nav.locator('a[href*="/chats/"]')).toHaveCount(4);
|
||||
await expect(nav.locator('a[href*="/chats/"]').first()).toHaveText("Zeta");
|
||||
await expect(chatLinks).toHaveText(["Alpha", "Zeta", "Epsilon", "Delta", "Gamma"]);
|
||||
const star = page.getByRole("button", { name: "Star Zeta", exact: true });
|
||||
await page.getByTestId("task-chat-composer-input").click();
|
||||
await expect(star).toHaveCSS("opacity", "0");
|
||||
@@ -618,12 +629,13 @@ test("sidebar stars, recent agents, configuration links, and drafts survive swit
|
||||
),
|
||||
),
|
||||
).toEqual(recent);
|
||||
await page
|
||||
.getByRole("link", { name: "See all agents", exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/${f.company.issuePrefix}/agents/all$`),
|
||||
);
|
||||
await compose.click();
|
||||
await expect(picker.getByRole("combobox")).toHaveValue("");
|
||||
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();
|
||||
} finally {
|
||||
await f.restore();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// @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 { AgentChatPicker, type AgentChatPickerProps } from "./AgentChatPicker";
|
||||
|
||||
const agents = [
|
||||
{ id: "first", name: "Alex", title: "Engineering Lead", role: "engineer", icon: "code", status: "idle" },
|
||||
{ id: "second", name: "Alex", title: "Product Designer", role: "designer", icon: "palette", status: "paused" },
|
||||
] as Agent[];
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let props: AgentChatPickerProps;
|
||||
async function render(overrides: Partial<AgentChatPickerProps> = {}) {
|
||||
props = { ...props, ...overrides };
|
||||
await act(async () => { root.render(<AgentChatPicker {...props} />); });
|
||||
}
|
||||
async function search(value: string) {
|
||||
const input = document.querySelector<HTMLInputElement>("[role=combobox]")!;
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
const options = () => [...document.querySelectorAll<HTMLElement>("[role=option]")];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} });
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
props = { agents, open: true, onOpenChange: vi.fn(), onSelect: vi.fn() };
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("AgentChatPicker", () => {
|
||||
it("searches by role and selects the correct same-name agent with Enter", async () => {
|
||||
await render();
|
||||
expect(options()).toHaveLength(2);
|
||||
await search("designer");
|
||||
expect(options()).toHaveLength(1);
|
||||
expect(options()[0].textContent).toContain("Paused");
|
||||
await act(async () => {
|
||||
document.querySelector("[role=combobox]")!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
expect(props.onSelect).toHaveBeenCalledWith(agents[1]);
|
||||
expect(props.onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("recovers from no results and resets search when reopened", async () => {
|
||||
await render();
|
||||
await search("accountant");
|
||||
expect(options()).toHaveLength(0);
|
||||
expect(document.body.textContent).toContain("No agents match");
|
||||
const clear = [...document.querySelectorAll("button")].find((b) => b.textContent === "Clear search")!;
|
||||
await act(async () => clear.click());
|
||||
expect(options()).toHaveLength(2);
|
||||
await search("designer");
|
||||
await render({ open: false });
|
||||
await render({ open: true });
|
||||
expect(document.querySelector<HTMLInputElement>("[role=combobox]")!.value).toBe("");
|
||||
expect(options()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows loading, a retryable error, and an empty roster without stale choices", async () => {
|
||||
await render({ loading: true });
|
||||
expect(document.querySelector("[role=status]")?.textContent).toContain("Loading agents");
|
||||
expect(options()).toHaveLength(0);
|
||||
const retry = vi.fn();
|
||||
await render({ loading: false, error: new Error("Unavailable"), onRetry: retry });
|
||||
expect(document.querySelector("[role=alert]")?.textContent).toContain("Couldn’t load agents");
|
||||
expect(options()).toHaveLength(0);
|
||||
await act(async () => [...document.querySelectorAll("button")].find((b) => b.textContent === "Retry")!.click());
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
await render({ error: null, agents: [] });
|
||||
expect(document.body.textContent).toContain("Create an agent from the Agents page");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from "react";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { AgentIcon } from "@/components/AgentIconPicker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
|
||||
|
||||
export interface AgentChatPickerProps {
|
||||
agents: Agent[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (agent: Agent) => void;
|
||||
loading?: boolean;
|
||||
error?: Error | null;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function AgentChatPicker({ open, onOpenChange, ...props }: AgentChatPickerProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent aria-describedby={undefined} className="gap-0 overflow-hidden p-0 sm:max-w-md">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<DialogTitle>Chat with an agent</DialogTitle>
|
||||
</div>
|
||||
{/* The dialog unmounts its content on close, so each search starts empty. */}
|
||||
<AgentChatPickerResults {...props} onSelect={(agent) => {
|
||||
onOpenChange(false);
|
||||
props.onSelect(agent);
|
||||
}} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentChatPickerResults({ agents, onSelect, loading, error, onRetry }: Omit<AgentChatPickerProps, "open" | "onOpenChange">) {
|
||||
const [search, setSearch] = useState("");
|
||||
return (
|
||||
<Command>
|
||||
<CommandInput
|
||||
aria-label="Search agents by name or role"
|
||||
placeholder="Search by name or role…"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
{error ? (
|
||||
<div role="alert" className="flex flex-col items-start gap-2 p-4 text-sm">
|
||||
<p>Couldn’t load agents. Try again.</p>
|
||||
{onRetry && <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<p role="status" className="p-4 text-sm text-muted-foreground">Loading agents…</p>
|
||||
) : (
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<div className="flex flex-col items-center gap-2 px-4">
|
||||
<span>{agents.length ? `No agents match “${search}”` : "No agents yet."}</span>
|
||||
{agents.length ? <>
|
||||
<span className="text-xs text-muted-foreground">Try another name or role.</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSearch("")}>Clear search</Button>
|
||||
</> : <span className="text-xs text-muted-foreground">Create an agent from the Agents page to start chatting.</span>}
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{agents.map((agent) => (
|
||||
<CommandItem
|
||||
key={agent.id}
|
||||
value={agent.id}
|
||||
keywords={[agent.name, agent.title ?? "", agent.role]}
|
||||
onSelect={() => onSelect(agent)}
|
||||
className="gap-3 px-3 py-3"
|
||||
>
|
||||
<AgentIcon icon={agent.icon} className="size-4 shrink-0" />
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate font-medium">{agent.name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{agent.title ?? agent.role}</span>
|
||||
</span>
|
||||
{agent.status === "paused" && <span className="text-xs text-(--status-agent-paused)">Paused</span>}
|
||||
{agent.status === "terminated" && <span className="text-xs text-muted-foreground">Terminated</span>}
|
||||
{agent.status === "pending_approval" && <span className="text-xs text-muted-foreground">Awaiting approval</span>}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
)}
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { Star, Users } from "lucide-react";
|
||||
import { SidebarSection } from "@/components/SidebarSection";
|
||||
import { Star, SquarePen } from "lucide-react";
|
||||
import { SidebarNavItem } from "@/components/SidebarNavItem";
|
||||
import { AgentIcon } from "@/components/AgentIconPicker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { agentRouteRef } from "@/lib/utils";
|
||||
import { agentRouteRef, cn } from "@/lib/utils";
|
||||
import { orderChatAgents } from "@/lib/recent-agent-chats";
|
||||
export function AgentChatSidebar({
|
||||
activeId,
|
||||
starredIds,
|
||||
recentIds,
|
||||
onToggleStar,
|
||||
onOpenChat,
|
||||
agents,
|
||||
href = (id: string) =>
|
||||
`/chats/${encodeURIComponent(agentRouteRef(agents.find((agent) => agent.id === id)!))}`,
|
||||
@@ -24,9 +22,9 @@ export function AgentChatSidebar({
|
||||
starredIds: string[];
|
||||
recentIds: string[];
|
||||
onToggleStar: (id: string) => void;
|
||||
onOpenChat: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const { collapsed, peeking, isMobile, setSidebarOpen } = useSidebar();
|
||||
const { collapsed, peeking } = useSidebar();
|
||||
const rail = collapsed && !peeking;
|
||||
const ordered = orderChatAgents(agents, starredIds, recentIds);
|
||||
const row = (agent: Agent) => {
|
||||
@@ -54,11 +52,11 @@ export function AgentChatSidebar({
|
||||
event.stopPropagation();
|
||||
onToggleStar(agent.id);
|
||||
}}
|
||||
className="absolute right-2 top-(--pct-50) -translate-y-(--pct-50) text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/agent-chat:opacity-100 focus-visible:opacity-100"
|
||||
className={cn("absolute right-2 top-(--pct-50) -translate-y-(--pct-50) text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/agent-chat:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100", pinned && "opacity-100")}
|
||||
>
|
||||
<Star
|
||||
aria-hidden="true"
|
||||
className={pinned ? "fill-current" : undefined}
|
||||
className={cn("size-3.5", pinned && "fill-current")}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
@@ -66,24 +64,22 @@ export function AgentChatSidebar({
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<SidebarSection
|
||||
label="Agents"
|
||||
collapsible={{ open, onOpenChange: setOpen }}
|
||||
>
|
||||
{ordered.map((agent) => row(agent))}
|
||||
<Link
|
||||
to="/agents/all"
|
||||
className="flex items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 text-(length:--text-compact) font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="See all agents"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
<section aria-label="Chats" className="group/chats flex flex-col gap-0.5">
|
||||
<div className="relative flex min-h-9 items-center px-4 py-1.5">
|
||||
<span className={cn("font-mono text-(length:--text-nano) font-medium uppercase tracking-widest text-muted-foreground/60", rail && "sr-only")}>Chats</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Chat with an agent"
|
||||
title="Chat with an agent"
|
||||
onClick={onOpenChat}
|
||||
className="absolute right-2 top-(--pct-50) -translate-y-(--pct-50) text-muted-foreground opacity-0 group-hover/chats:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100"
|
||||
>
|
||||
<Users className="h-4 w-4 shrink-0" />
|
||||
{!rail && <span>See all agents</span>}
|
||||
</Link>
|
||||
</SidebarSection>
|
||||
</>
|
||||
<SquarePen aria-hidden="true" className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{ordered.map(row)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { SidebarAgentChats } from "./SidebarAgentChats";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
const state = vi.hoisted(() => ({ companyId: "company-a", navigate: vi.fn(), closeSidebar: vi.fn(), list: vi.fn() }));
|
||||
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: state.companyId }) }));
|
||||
vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: true, setSidebarOpen: state.closeSidebar }) }));
|
||||
vi.mock("@/lib/router", () => ({ useLocation: () => ({ pathname: "/A/dashboard" }), useNavigate: () => state.navigate }));
|
||||
vi.mock("@/api/agents", () => ({ agentsApi: { list: state.list } }));
|
||||
vi.mock("@/api/auth", () => ({ authApi: { getSession: () => ({ user: { id: "user-a" } }) } }));
|
||||
vi.mock("@/hooks/useResourceMemberships", () => ({
|
||||
useResourceMemberships: () => ({ data: { starredAgentIds: [] } }),
|
||||
useResourceMembershipMutation: () => ({ mutate: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./AgentChatSidebar", () => ({ AgentChatSidebar: ({ onOpenChat }: { onOpenChat: () => void }) => <button onClick={onOpenChat}>Chat with an agent</button> }));
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let client: QueryClient;
|
||||
const agent = (id: string, companyId: string) => ({ id, companyId, name: id, urlKey: id, role: "engineer", title: "Engineer", icon: null, status: "idle" } as Agent);
|
||||
async function render() {
|
||||
await act(async () => { root.render(<QueryClientProvider client={client}><SidebarAgentChats /></QueryClientProvider>); });
|
||||
}
|
||||
async function openPicker() {
|
||||
await act(async () => container.querySelector("button")!.click());
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} });
|
||||
state.companyId = "company-a";
|
||||
state.navigate.mockClear();
|
||||
state.closeSidebar.mockClear();
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-a" } });
|
||||
client.setQueryData(queryKeys.agents.list("company-a"), [agent("alice", "company-a"), agent("never-visited", "company-a")]);
|
||||
client.setQueryData(queryKeys.agents.list("company-b"), [agent("bob", "company-b")]);
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
client.clear();
|
||||
container.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("SidebarAgentChats", () => {
|
||||
it("opens an unvisited agent from the full roster and closes the mobile sidebar", async () => {
|
||||
await render();
|
||||
await openPicker();
|
||||
const options = [...document.querySelectorAll<HTMLElement>("[role=option]")];
|
||||
expect(options).toHaveLength(2);
|
||||
await act(async () => options.find((item) => item.textContent?.includes("never-visited"))!.click());
|
||||
expect(state.navigate).toHaveBeenCalledWith("/chats/never-visited");
|
||||
expect(state.closeSidebar).toHaveBeenCalledWith(false);
|
||||
expect(document.querySelector("[role=dialog]")).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the open picker across company and user changes", async () => {
|
||||
await render();
|
||||
await openPicker();
|
||||
state.companyId = "company-b";
|
||||
await render();
|
||||
expect(document.querySelector("[role=dialog]")).toBeNull();
|
||||
await openPicker();
|
||||
const options = [...document.querySelectorAll("[role=option]")];
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0].textContent).toContain("bob");
|
||||
await act(async () => {
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-b" } });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(document.querySelector("[role=dialog]")).toBeNull();
|
||||
state.companyId = "company-a";
|
||||
await render();
|
||||
expect(document.querySelector("[role=dialog]")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { authApi } from "@/api/auth";
|
||||
@@ -8,45 +9,75 @@ import {
|
||||
} from "@/hooks/useResourceMemberships";
|
||||
import { useRecentAgentChats } from "@/lib/recent-agent-chats";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useLocation } from "@/lib/router";
|
||||
import { useLocation, useNavigate } from "@/lib/router";
|
||||
import { agentRouteRef } from "@/lib/utils";
|
||||
import { AgentChatSidebar } from "./AgentChatSidebar";
|
||||
import { AgentChatPicker } from "./AgentChatPicker";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
|
||||
export function SidebarAgentChats() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { data: agents = [] } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const userId = session?.user?.id ?? session?.session?.userId;
|
||||
const recentIds = useRecentAgentChats(selectedCompanyId ?? "", userId);
|
||||
const memberships = useResourceMemberships(selectedCompanyId);
|
||||
const mutation = useResourceMembershipMutation(selectedCompanyId);
|
||||
const stars = memberships.data?.starredAgentIds ?? [];
|
||||
const location = useLocation();
|
||||
const activeRef = location.pathname.match(/\/chats\/([^/]+)/)?.[1];
|
||||
const active = agents.find(
|
||||
(agent) => agent.id === activeRef || agentRouteRef(agent) === activeRef,
|
||||
);
|
||||
return (
|
||||
<AgentChatSidebar
|
||||
agents={agents}
|
||||
activeId={active?.id ?? ""}
|
||||
starredIds={stars}
|
||||
recentIds={recentIds}
|
||||
onToggleStar={(id) => {
|
||||
mutation.mutate({
|
||||
resourceType: "agent",
|
||||
resourceId: id,
|
||||
resourceName:
|
||||
agents.find((agent) => agent.id === id)?.name ?? "Agent",
|
||||
starred: !stars.includes(id),
|
||||
});
|
||||
}}
|
||||
<CompanyAgentChats
|
||||
key={`${selectedCompanyId}:${userId ?? "local-board"}`}
|
||||
companyId={selectedCompanyId}
|
||||
userId={userId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// A scope change unmounts the picker, including its open state and search.
|
||||
function CompanyAgentChats({ companyId, userId }: { companyId: string | null; userId?: string }) {
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(companyId!),
|
||||
queryFn: () => agentsApi.list(companyId!),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
const agents = agentsQuery.data ?? [];
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const recentIds = useRecentAgentChats(companyId ?? "", userId);
|
||||
const memberships = useResourceMemberships(companyId);
|
||||
const mutation = useResourceMembershipMutation(companyId);
|
||||
const stars = memberships.data?.starredAgentIds ?? [];
|
||||
const location = useLocation();
|
||||
const activeRef = location.pathname.match(/\/chats\/([^/]+)/)?.[1];
|
||||
const active = agents.find((agent) => agent.id === activeRef || agentRouteRef(agent) === activeRef);
|
||||
return (
|
||||
<>
|
||||
<AgentChatSidebar
|
||||
agents={agents}
|
||||
onOpenChat={() => setPickerOpen(true)}
|
||||
activeId={active?.id ?? ""}
|
||||
starredIds={stars}
|
||||
recentIds={recentIds}
|
||||
onToggleStar={(id) => {
|
||||
mutation.mutate({
|
||||
resourceType: "agent",
|
||||
resourceId: id,
|
||||
resourceName: agents.find((agent) => agent.id === id)?.name ?? "Agent",
|
||||
starred: !stars.includes(id),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<AgentChatPicker
|
||||
agents={agents}
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
loading={agentsQuery.isPending}
|
||||
error={agentsQuery.error}
|
||||
onRetry={() => { void agentsQuery.refetch(); }}
|
||||
onSelect={(agent) => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
navigate(`/chats/${encodeURIComponent(agentRouteRef(agent))}`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ describe("agent chat navigation and session markers", () => {
|
||||
"Five",
|
||||
"Six",
|
||||
"Seven",
|
||||
].map((name, i) => ({ id: String(i), name }));
|
||||
].map((name, i) => ({ id: String(i), name, createdAt: new Date(i * 1000) }));
|
||||
expect(
|
||||
orderChatAgents(agents, ["0", "1"], ["0", "6", "5", "4", "3", "2"]).map(
|
||||
(agent) => agent.name,
|
||||
@@ -28,6 +28,18 @@ describe("agent chat navigation and session markers", () => {
|
||||
expect(parseRecentAgentChats('["a","a",null,1,"b"]')).toEqual(["a", "b"]);
|
||||
expect(parseRecentAgentChats("broken")).toEqual([]);
|
||||
});
|
||||
it("always includes the first-created agent, with deterministic ties and no duplicates", () => {
|
||||
const agents = [
|
||||
{ id: "new", name: "New", createdAt: new Date("2026-09-12") },
|
||||
{ id: "first", name: "First", createdAt: new Date("2026-09-01") },
|
||||
{ id: "tie", name: "Tie", createdAt: new Date("2026-09-01") },
|
||||
];
|
||||
expect(orderChatAgents(agents, [], []).map((a) => a.id)).toEqual(["first"]);
|
||||
expect(orderChatAgents(agents, ["new"], ["first", "new", "tie", "tie", "missing"]).map((a) => a.id)).toEqual(["new", "first", "tie"]);
|
||||
expect(orderChatAgents(agents, ["first"], ["first"]).map((a) => a.id)).toEqual(["first"]);
|
||||
expect(orderChatAgents([], ["missing"], ["missing"])).toEqual([]);
|
||||
expect(orderChatAgents(agents.slice(0, 1), [], []).map((a) => a.id)).toEqual(["new"]);
|
||||
});
|
||||
it("keeps visits personal and company scoped and moves only the visited agent", () => {
|
||||
localStorage.clear();
|
||||
recordAgentChatVisit("a", "user1", "agent1");
|
||||
|
||||
@@ -62,17 +62,21 @@ export function useRecentAgentChats(company: string, user?: string | null) {
|
||||
useSyncExternalStore(subscribe, getSnapshot, () => "[]"),
|
||||
);
|
||||
}
|
||||
export function orderChatAgents<T extends Pick<Agent, "id" | "name">>(
|
||||
export function orderChatAgents<T extends Pick<Agent, "id" | "name" | "createdAt">>(
|
||||
agents: T[],
|
||||
stars: string[],
|
||||
recent: string[],
|
||||
) {
|
||||
const firstAgent = [...agents].sort((a, b) =>
|
||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() || a.id.localeCompare(b.id),
|
||||
)[0];
|
||||
return [
|
||||
...agents
|
||||
.filter((agent) => stars.includes(agent.id))
|
||||
.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)),
|
||||
...recent
|
||||
.filter((id) => !stars.includes(id))
|
||||
...(firstAgent && !stars.includes(firstAgent.id) ? [firstAgent] : []),
|
||||
...[...new Set(recent)]
|
||||
.filter((id) => !stars.includes(id) && id !== firstAgent?.id)
|
||||
.flatMap((id) => agents.filter((agent) => agent.id === id))
|
||||
.slice(0, 4),
|
||||
];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentChatPicker } from "@/components/AgentChatPicker";
|
||||
import { TaskChatProjectCreatedCard } from "@/components/task-chat/TaskChatProjectCreatedCard";
|
||||
import { AnnouncementCard } from "@/components/AnnouncementCard";
|
||||
import { announcementPreview, announcementAnimationPreview, announcementAnimationPreviewSrc } from "@/lib/announcement-preview";
|
||||
@@ -480,6 +481,17 @@ function TaskExecutionControlsExample() {
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AgentChatPickerExample() {
|
||||
const [state, setState] = useState<"closed" | "empty" | "loading" | "error">("closed");
|
||||
return <div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => setState("empty")}>Empty picker</Button>
|
||||
<Button variant="outline" onClick={() => setState("loading")}>Loading picker</Button>
|
||||
<Button variant="outline" onClick={() => setState("error")}>Failed picker</Button>
|
||||
<AgentChatPicker agents={[]} open={state !== "closed"} onOpenChange={(open) => { if (!open) setState("closed"); }} onSelect={() => {}}
|
||||
loading={state === "loading"} error={state === "error" ? new Error("Unavailable") : null} onRetry={() => setState("empty")} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function DesignGuide() {
|
||||
const [status, setStatus] = useState("todo");
|
||||
const [priority, setPriority] = useState("medium");
|
||||
@@ -1666,11 +1678,14 @@ export function DesignGuide() {
|
||||
{/* NAVIGATION PATTERNS */}
|
||||
{/* ============================================================ */}
|
||||
<Section title="Navigation Patterns">
|
||||
<SubSection title="Agent chat picker">
|
||||
<AgentChatPickerExample />
|
||||
</SubSection>
|
||||
<SubSection title="Sidebar nav items">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Layout accepts sidebarSections to compose additional SidebarSection groups inside the shared sidebar.
|
||||
Use SidebarNavItem for each row, with sibling action buttons for starring or menus.
|
||||
Starred agent conversations precede recent conversations without a divider. Stars appear on hover or keyboard focus. Task breadcrumbs support leading identity and trailing actions beside the label, including single-item task headers; see the Agent chat Storybook.
|
||||
The Chats section shows starred agents, the earliest-created agent when unstarred, then four recent agents without duplicates. Compose and star controls share a vertical column. Compose appears on hover or keyboard focus and remains visible on touch; starred icons remain visible. The picker searches all company agents by name or role without a subtitle, count, continuation labels, or footer. Task breadcrumbs support leading identity and trailing actions beside the label, including single-item task headers; see the Agent chat Storybook.
|
||||
</p>
|
||||
<Card className="block w-60 p-3 space-y-0.5">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium bg-accent text-accent-foreground">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useLayoutEffect, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useState, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { agentRouteRef } from "@/lib/utils";
|
||||
import { recordAgentChatVisit } from "@/lib/recent-agent-chats";
|
||||
@@ -9,7 +9,7 @@ import { Agents, AGENT_FILTER_TABS } from "@/pages/Agents";
|
||||
import { Layout } from "@/components/Layout";
|
||||
import { usePanel } from "@/context/PanelContext";
|
||||
import { PluginLauncherProvider } from "@/plugins/launchers";
|
||||
import { Routes, Route, useNavigate, useLocation } from "@/lib/router";
|
||||
import { Routes, Route, useNavigate } from "@/lib/router";
|
||||
import type {
|
||||
IssueChatComment,
|
||||
IssueChatLinkedRun,
|
||||
@@ -27,11 +27,12 @@ import {
|
||||
storybookIssueDocuments,
|
||||
} from "../../fixtures/paperclipData";
|
||||
import { chatAgents, chatIdentifier } from "./AgentChatSidebar";
|
||||
import { ChatEntryReviewProvider, ChatEntrySidebar, ChatEntryLanding, reviewRoster, type EntryScenario } from "../chat-entry/ChatEntryReview";
|
||||
|
||||
const agent = storybookAgents.find((agent) => agent.id === "agent-codex")!;
|
||||
const issue = {
|
||||
...storybookIssues[0],
|
||||
id: "agent-chat-shared-task",
|
||||
id: "00000000-0000-4000-8000-000000000001",
|
||||
identifier: "PAP-241",
|
||||
title: "Chat with CodexCoder",
|
||||
description: "",
|
||||
@@ -47,6 +48,10 @@ const issue = {
|
||||
labelIds: [],
|
||||
currentExecutionWorkspace: null,
|
||||
};
|
||||
function chatIssueId(agentId: string) {
|
||||
const index = reviewRoster("large-team").findIndex((item) => item.id === agentId);
|
||||
return agentId === agent.id ? issue.id : `00000000-0000-4000-8000-${String(index + 2).padStart(12, "0")}`;
|
||||
}
|
||||
const child = {
|
||||
...storybookIssues[0],
|
||||
id: "agent-chat-child",
|
||||
@@ -202,6 +207,7 @@ export interface AgentChatPrototypeProps {
|
||||
scenario?: Scenario;
|
||||
contextInitiallyOpen?: boolean;
|
||||
taskComparison?: boolean;
|
||||
entryScenario?: EntryScenario;
|
||||
}
|
||||
|
||||
/** Production pages with an in-memory API. No alternate chat controller. */
|
||||
@@ -209,10 +215,11 @@ export function AgentChatPrototype({
|
||||
scenario = "returning",
|
||||
contextInitiallyOpen = true,
|
||||
taskComparison = false,
|
||||
entryScenario,
|
||||
}: AgentChatPrototypeProps) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const initialRouteSet = useRef(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { setPanelVisible } = usePanel();
|
||||
useEffect(() => {
|
||||
@@ -220,6 +227,9 @@ export function AgentChatPrototype({
|
||||
}, [contextInitiallyOpen, setPanelVisible]);
|
||||
useLayoutEffect(() => {
|
||||
const originalFetch = window.fetch;
|
||||
const recentKey = `paperclip.recentAgentChats:${issue.companyId}:user-board`;
|
||||
const previousRecents = localStorage.getItem(recentKey);
|
||||
if (entryScenario) localStorage.setItem(recentKey, "[]");
|
||||
const chats = new Map<
|
||||
string,
|
||||
typeof issue & {
|
||||
@@ -233,7 +243,7 @@ export function AgentChatPrototype({
|
||||
projectMemberships: {},
|
||||
agentMemberships: {},
|
||||
starredProjectIds: [],
|
||||
starredAgentIds: ["agent-cto"],
|
||||
starredAgentIds: entryScenario === "first-use" ? [] : ["agent-cto"],
|
||||
starredDocumentIds: [],
|
||||
projectStarredAt: {},
|
||||
agentStarredAt: {},
|
||||
@@ -242,7 +252,7 @@ export function AgentChatPrototype({
|
||||
};
|
||||
let failSend = scenario === "error";
|
||||
let active = scenario === "working";
|
||||
const fixtureAgents = chatAgents.map((a) => ({
|
||||
const fixtureAgents = (entryScenario ? reviewRoster(entryScenario) : chatAgents).map((a) => ({
|
||||
...a,
|
||||
status:
|
||||
scenario === "paused" && a.id === agent.id
|
||||
@@ -252,15 +262,15 @@ export function AgentChatPrototype({
|
||||
for (const a of fixtureAgents) {
|
||||
const task = {
|
||||
...issue,
|
||||
id: a.id === agent.id ? issue.id : `chat-task-${a.id}`,
|
||||
identifier: chatIdentifier(a.id),
|
||||
id: chatIssueId(a.id),
|
||||
identifier: entryScenario ? `PAP-${400 + fixtureAgents.findIndex((item) => item.id === a.id)}` : chatIdentifier(a.id),
|
||||
assigneeAgentId: a.id,
|
||||
title: `Chat with ${a.name}`,
|
||||
conversationAgentId: taskComparison ? null : a.id,
|
||||
conversationUserId: taskComparison ? null : "user-board",
|
||||
conversationState: "waiting" as const,
|
||||
};
|
||||
if (scenario !== "empty" || a.id !== agent.id) chats.set(a.id, task);
|
||||
if (entryScenario ? entryScenario !== "first-use" && a.id === agent.id : scenario !== "empty" || a.id !== agent.id) chats.set(a.id, task);
|
||||
let history =
|
||||
a.id === agent.id && scenario !== "empty"
|
||||
? scenario === "working"
|
||||
@@ -313,7 +323,7 @@ export function AgentChatPrototype({
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
for (const id of ["chat-design", "agent-qa", "agent-codex"])
|
||||
for (const id of entryScenario === "first-use" ? [] : ["chat-design", "agent-qa", "agent-codex"])
|
||||
recordAgentChatVisit(issue.companyId, "user-board", id);
|
||||
window.fetch = async (input, init) => {
|
||||
const url = new URL(
|
||||
@@ -346,7 +356,10 @@ export function AgentChatPrototype({
|
||||
conversationAgentId: a.id,
|
||||
conversationUserId: "user-board",
|
||||
conversationState: "waiting",
|
||||
id: issue.id,
|
||||
id: chatIssueId(a.id),
|
||||
identifier: entryScenario ? `PAP-${400 + fixtureAgents.findIndex((item) => item.id === a.id)}` : chatIdentifier(a.id),
|
||||
assigneeAgentId: a.id,
|
||||
title: `Chat with ${a.name}`,
|
||||
});
|
||||
return Response.json(chats.get(a.id) ?? null);
|
||||
}
|
||||
@@ -362,7 +375,9 @@ export function AgentChatPrototype({
|
||||
members.starredAgentIds = body.starred
|
||||
? [...new Set([...members.starredAgentIds, id])]
|
||||
: members.starredAgentIds.filter((i) => i !== id);
|
||||
return Response.json(members);
|
||||
return Response.json({
|
||||
resourceId: id, state: "joined", starredAt: body.starred ? new Date().toISOString() : null,
|
||||
});
|
||||
}
|
||||
if (method === "POST" && path.endsWith("/comments")) {
|
||||
if (failSend) {
|
||||
@@ -525,21 +540,28 @@ export function AgentChatPrototype({
|
||||
setReady(true);
|
||||
return () => {
|
||||
window.fetch = originalFetch;
|
||||
if (entryScenario) {
|
||||
if (previousRecents === null) localStorage.removeItem(recentKey);
|
||||
else localStorage.setItem(recentKey, previousRecents);
|
||||
window.dispatchEvent(new Event("paperclip:recent-agent-chats"));
|
||||
}
|
||||
queryClient.clear();
|
||||
};
|
||||
}, [scenario, taskComparison, queryClient]);
|
||||
}, [scenario, taskComparison, entryScenario, queryClient]);
|
||||
useEffect(() => {
|
||||
if (ready && location.pathname.endsWith("/storybook"))
|
||||
if (ready && !initialRouteSet.current) {
|
||||
initialRouteSet.current = true;
|
||||
navigate(
|
||||
`/PAP/${taskComparison ? `issues/${issue.id}` : "chats/agent-codex"}`,
|
||||
`/PAP/${entryScenario === "first-use" ? "chats" : entryScenario === "paused" ? "chats/operations" : taskComparison ? `issues/${issue.id}` : "chats/agent-codex"}`,
|
||||
{ replace: true },
|
||||
);
|
||||
}, [ready, navigate, location.pathname, taskComparison]);
|
||||
}
|
||||
}, [ready, navigate, taskComparison, entryScenario]);
|
||||
if (!ready) return null;
|
||||
return (
|
||||
<PluginLauncherProvider>
|
||||
const routes = (
|
||||
<Routes>
|
||||
<Route path="/:companyPrefix" element={<Layout />}>
|
||||
<Route path="/:companyPrefix" element={<Layout sidebarSections={entryScenario ? <ChatEntrySidebar /> : undefined} />}>
|
||||
<Route path="chats" element={<ChatEntryLanding />} />
|
||||
<Route path="chats/:agentRef" element={<AgentChat />} />
|
||||
<Route path="issues/:issueId" element={<IssueDetail />} />
|
||||
<Route path="agents" element={<Agents />} />
|
||||
@@ -550,6 +572,8 @@ export function AgentChatPrototype({
|
||||
<Route path="agents/:agentId" element={<AgentDetail />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</PluginLauncherProvider>
|
||||
);
|
||||
return <PluginLauncherProvider>{entryScenario
|
||||
? <ChatEntryReviewProvider scenario={entryScenario}>{routes}</ChatEntryReviewProvider>
|
||||
: routes}</PluginLauncherProvider>;
|
||||
}
|
||||
@@ -30,20 +30,3 @@ export const chatIdentifier = (id: string) =>
|
||||
: `PAP-${249 + chatAgents.findIndex((agent) => agent.id === id)}`;
|
||||
export const chatHref = (id: string) =>
|
||||
`/issues/${chatIdentifier(id)}?chatAgent=${encodeURIComponent(id)}`;
|
||||
|
||||
import { AgentChatSidebar as ProductionAgentChatSidebar } from "@/components/AgentChatSidebar";
|
||||
export function AgentChatSidebar(props: {
|
||||
activeId: string;
|
||||
starredIds: string[];
|
||||
recentIds: string[];
|
||||
onToggleStar: (id: string) => void;
|
||||
agents?: typeof chatAgents;
|
||||
}) {
|
||||
return (
|
||||
<ProductionAgentChatSidebar
|
||||
{...props}
|
||||
agents={props.agents ?? chatAgents}
|
||||
href={chatHref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
These stories mount `AgentChat`, `TaskDetailSurface`, `Layout`, and their actual task transcript, composer, and side panel. They provide in-memory API responses; they contain no alternate chat controller or renderer. Sending appends a fixture comment, `/new` adds a shared session marker, and switching agents preserves each fixture history during the mounted story. Unsupported mutations fail explicitly.
|
||||
|
||||
The sidebar uses production resource memberships and company/user-scoped recent conversation visits. Starred agents sort alphabetically, followed by four recent unstarred agents. Stars appear on hover/focus. The gear and See all agents render the real agent configuration and roster pages. Roster Chat actions open the corresponding conversation.
|
||||
The sidebar uses production resource memberships and company/user-scoped recent conversation visits. Starred agents sort alphabetically, followed by the earliest-created agent when unstarred, then four other recent agents. Compose and star icons share a column. Compose appears on hover/focus and remains visible on touch. The picker searches all company agents. The gear opens the real agent configuration page. Roster Chat actions open the corresponding conversation.
|
||||
|
||||
Scenarios cover returning, first conversation, working, paused, failed send, long history, collapsed panel, light theme, ordinary task comparison, `/new`, and disabled experiment. Production API/runtime behavior is verified by server database/route tests; fixture replies do not represent live provider execution.
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { ArrowRight, MessageSquare, Search } from "lucide-react";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { AgentIcon } from "@/components/AgentIconPicker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AgentChatPicker } from "@/components/AgentChatPicker";
|
||||
export { SidebarAgentChats as ChatEntrySidebar } from "@/components/SidebarAgentChats";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { agentRouteRef } from "@/lib/utils";
|
||||
import { chatAgents } from "../agent-chat/AgentChatSidebar";
|
||||
|
||||
export type EntryScenario = "first-use" | "returning" | "picker" | "search" | "no-results" | "paused" | "large-team";
|
||||
|
||||
const titles: Record<string, string> = {
|
||||
"chat-design": "Product Designer", "chat-research": "Research Analyst", "chat-ops": "Operations Lead",
|
||||
};
|
||||
export const entryAgents: Agent[] = chatAgents.map((agent) => ({
|
||||
...agent,
|
||||
title: titles[agent.id] ?? agent.title,
|
||||
status: agent.id === "chat-ops" ? "paused" : "idle",
|
||||
}));
|
||||
const extraRoles = ["Content Strategist", "Customer Researcher", "Data Analyst", "Support Specialist", "Growth Marketer", "Security Engineer"];
|
||||
export function reviewRoster(scenario?: EntryScenario): Agent[] {
|
||||
return scenario === "large-team" ? [...entryAgents, ...extraRoles.map((title, index) => ({
|
||||
...entryAgents[0], id: `extra-${index}`, urlKey: `extra-${index}`, name: title, title,
|
||||
}))] : entryAgents;
|
||||
}
|
||||
|
||||
interface ReviewState {
|
||||
agents: Agent[];
|
||||
openPicker: () => void;
|
||||
choose: (agent: Agent) => void;
|
||||
}
|
||||
const ReviewContext = createContext<ReviewState | null>(null);
|
||||
function useReview() {
|
||||
const value = useContext(ReviewContext);
|
||||
if (!value) throw new Error("Chat entry review provider is required");
|
||||
return value;
|
||||
}
|
||||
|
||||
export function ChatEntryReviewProvider({ scenario, children }: { scenario: EntryScenario; children: ReactNode }) {
|
||||
const [open, setOpen] = useState(["picker", "search", "no-results", "large-team"].includes(scenario));
|
||||
const agents = reviewRoster(scenario);
|
||||
const navigate = useNavigate();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
function choose(agent: Agent) {
|
||||
setOpen(false);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
navigate(`/chats/${encodeURIComponent(agentRouteRef(agent))}`);
|
||||
}
|
||||
return <ReviewContext.Provider value={{ agents, openPicker: () => setOpen(true), choose }}>
|
||||
{children}
|
||||
<AgentChatPicker agents={agents} open={open} onOpenChange={setOpen} onSelect={choose} />
|
||||
</ReviewContext.Provider>;
|
||||
}
|
||||
|
||||
export function ChatEntryLanding() {
|
||||
const { agents, choose, openPicker } = useReview();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
useEffect(() => setBreadcrumbs([{ label: "Chats" }]), [setBreadcrumbs]);
|
||||
const suggestions = ["agent-cto", "agent-codex", "chat-design"].flatMap((id) => agents.filter((a) => a.id === id));
|
||||
return <div className="mx-auto flex w-full max-w-xl flex-col gap-6 px-6 py-16">
|
||||
<div className="flex flex-col gap-3">
|
||||
<MessageSquare className="size-6 text-muted-foreground" />
|
||||
<h1 className="text-xl font-semibold">Who would you like to talk to?</h1>
|
||||
<p className="text-sm text-muted-foreground">Think through an idea, ask a question, or plan the next step with someone on your team.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{suggestions.map((agent) => <Button key={agent.id} variant="ghost" className="h-auto justify-start gap-3 px-3 py-3" onClick={() => choose(agent)}>
|
||||
<AgentIcon icon={agent.icon} className="size-5" />
|
||||
<span className="flex flex-1 flex-col items-start gap-1"><span>{agent.name}</span><span className="text-xs font-normal text-muted-foreground">{agent.title}</span></span>
|
||||
<ArrowRight className="size-4 text-muted-foreground" />
|
||||
</Button>)}
|
||||
</div>
|
||||
<Button variant="outline" className="self-start" onClick={openPicker}><Search className="size-4" />Browse all {agents.length} agents</Button>
|
||||
<p className="text-xs text-muted-foreground">Your conversations will appear in Chats. Star the people you talk to most.</p>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Agent chat discovery
|
||||
|
||||
Open **Design explorations → Chat entry**. The sidebar, picker, conversation,
|
||||
and composer are production components backed by the agent-chat in-memory API.
|
||||
Only the first-use landing page is a review fixture. No real agents run.
|
||||
|
||||
The earliest-created agent always stays in Chats, even after unstarring. Stars
|
||||
precede that default and four recent agents. Compose and star controls align;
|
||||
compose appears on hover/focus and remains visible on touch. The picker searches
|
||||
the whole company by name or role, with no subtitle, count, continuation labels,
|
||||
or footer. Each agent keeps a distinct fixture conversation across switches.
|
||||
|
||||
Stories cover first use, returning chats, picker, role search, search recovery,
|
||||
paused-agent discovery, larger roster, light theme, and mobile. Try selecting
|
||||
Design Lead, sending a message, switching to CodexCoder, and returning. Star a
|
||||
conversation to keep it above recents. Story-scoped recent visits are restored
|
||||
on unmount.
|
||||
|
||||
Run `pnpm storybook` from the repository root.
|
||||
@@ -9,7 +9,7 @@ const meta = {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Uses the actual production Layout (Sidebar, BreadcrumbBar, PropertiesPanel), TaskChatThread (TaskChatComposer, harness activity, thinking/tool disclosures, responses), and TaskSidePanel (plans, artifacts, subtasks). Agent shortcuts compose the existing sidebar rows and sections: starred agents first, then recent conversations, plus a See all link to the existing Agents page and a gear link to the existing agent configuration page. The task surfaces differ only in breadcrumb/title and initially open panel tabs. All data is fixture data; sends append locally and unsupported mutations fail explicitly.",
|
||||
"Uses the actual production Layout (Sidebar, BreadcrumbBar, PropertiesPanel), TaskChatThread (TaskChatComposer, harness activity, thinking/tool disclosures, responses), and TaskSidePanel (plans, artifacts, subtasks). Agent shortcuts compose the existing sidebar rows and sections: starred agents first, the first-created agent as a default, then recent conversations. The compose button opens a searchable picker for every agent, and the gear link opens agent configuration. The task surfaces differ only in breadcrumb/title and initially open panel tabs. All data is fixture data; sends append locally and unsupported mutations fail explicitly.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { userEvent, within } from "storybook/test";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AgentChatPrototype } from "../prototypes/agent-chat/AgentChatPrototype";
|
||||
|
||||
const meta = {
|
||||
title: "Design explorations/Chat entry",
|
||||
component: AgentChatPrototype,
|
||||
parameters: { layout: "fullscreen", docs: { description: { component:
|
||||
"Review-only proposal: Chats contains starred and recent conversations, with the first-created agent always present as a default entry. First use uses the same compact rows as returning chats. The aligned compose icon appears on hover or keyboard focus (and stays visible on touch). The picker shows search and agent results with roles and paused status. The sidebar, picker, layout, transcript, and composer are production components. Only the first-use landing page and data are Storybook fixtures. Selecting and sending use in-memory fixtures; no real agents run."
|
||||
} } },
|
||||
args: { entryScenario: "returning", scenario: "returning", contextInitiallyOpen: false },
|
||||
render: (args) => <AgentChatPrototype key={JSON.stringify(args)} {...args} />,
|
||||
} satisfies Meta<typeof AgentChatPrototype>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const FirstUse: Story = { name: "01 · First use · Start here", args: { entryScenario: "first-use", scenario: "empty" } };
|
||||
export const Returning: Story = { name: "02 · Returning · Starred and recent chats" };
|
||||
export const AgentPicker: Story = { name: "03 · Chat with any agent", args: { entryScenario: "picker" } };
|
||||
export const SearchByRole: Story = { name: "04 · Find someone by role", args: { entryScenario: "search" }, play: async ({ canvasElement }) => {
|
||||
const input = await within(canvasElement.ownerDocument.body).findByRole("combobox", { name: "Search agents by name or role" });
|
||||
await userEvent.type(input, "design");
|
||||
} };
|
||||
export const NoResults: Story = { name: "05 · No matches · Recover search", args: { entryScenario: "no-results" }, play: async ({ canvasElement }) => {
|
||||
const input = await within(canvasElement.ownerDocument.body).findByRole("combobox", { name: "Search agents by name or role" });
|
||||
await userEvent.type(input, "accountant");
|
||||
} };
|
||||
export const PausedAgent: Story = { name: "06 · Paused agent · Still discoverable", args: { entryScenario: "picker" }, play: async ({ canvasElement }) => {
|
||||
const input = await within(canvasElement.ownerDocument.body).findByRole("combobox", { name: "Search agents by name or role" });
|
||||
await userEvent.type(input, "operations");
|
||||
} };
|
||||
export const LargerTeam: Story = { name: "07 · Larger team · All agents stay reachable", args: { entryScenario: "large-team" } };
|
||||
export const Light: Story = { name: "08 · Light theme", globals: { theme: "light" }, args: { entryScenario: "picker" } };
|
||||
export const Mobile: Story = { name: "09 · Mobile · Choose an agent", globals: { viewport: { value: "mobile", isRotated: false } }, args: { entryScenario: "picker" } };
|
||||
Reference in new issue
Block a user