Files
PaperClipAI/ui/src/components/SidebarRecentTasks.test.tsx
T
DottaandPaperclip 3e5a9f5afe perf(ui): load recent tasks with bounded summaries (#16851)
Replace passive recent-task detail reads with bounded, authorized summaries. Keep account isolation, stored history, task actions, and live recovery behavior.

Overlap access checks with shell settings and avoid duplicate session and health reads on the first healthy socket opening. Retain domain reconciliation.

Add focused coverage and local performance evidence. Isolate the existing cache capacity test from entry expiry on slow CI runners.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-19 12:13:28 -05:00

572 lines
20 KiB
TypeScript

// @vitest-environment jsdom
import { act, type ReactNode } 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 { TooltipProvider } from "@/components/ui/tooltip";
import { SidebarRecentTasks } from "./SidebarRecentTasks";
import {
getRecentTasksStorageKey,
readRecentTasks,
recordRecentTask,
pruneRecentTasks,
} from "@/lib/recent-tasks";
import { queryKeys } from "@/lib/queryKeys";
const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn() }));
const mockAgentsApi = vi.hoisted(() => ({ wakeup: vi.fn() }));
const mockIssuesApi = vi.hoisted(() => ({
get: vi.fn(),
summaries: vi.fn(),
update: vi.fn(),
archiveFromInbox: vi.fn(),
getTreeControlState: vi.fn(),
createTreeHold: vi.fn(),
releaseTreeHold: vi.fn(),
}));
vi.mock("@/api/agents", () => ({ agentsApi: mockAgentsApi }));
vi.mock("@/api/auth", () => ({ authApi: mockAuthApi }));
vi.mock("@/api/issues", () => ({ issuesApi: mockIssuesApi }));
vi.mock("@/lib/router", () => ({
NavLink: ({ children, to, className, ...props }: {
children: ReactNode;
to: string;
className?: string | ((state: { isActive: boolean }) => string);
}) => (
<a
href={to}
className={typeof className === "function" ? className({ isActive: false }) : className}
{...props}
>
{children}
</a>
),
}));
vi.mock("@/context/SidebarContext", () => ({
useSidebar: () => ({
collapsed: false,
peeking: false,
isMobile: false,
setSidebarOpen: vi.fn(),
}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("SidebarRecentTasks", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
window.localStorage.clear();
Object.values(mockAgentsApi).forEach((mock) => mock.mockReset());
Object.values(mockIssuesApi).forEach((mock) => mock.mockReset());
mockIssuesApi.summaries.mockImplementation(async (_company: string, ids: string[]) => {
const rows = await Promise.all(ids.map(id => mockIssuesApi.get.getMockImplementation()?.(id)));
return rows.filter(row => row && !row.hiddenAt);
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
mockAuthApi.getSession.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: { id: "user-1", name: "Board", email: "board@example.test", image: null },
});
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.clearAllMocks();
vi.useRealTimers();
});
async function render() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<SidebarRecentTasks companyId="company-1" liveIssueIds={new Set(["issue-1"])} />
</TooltipProvider>
</QueryClientProvider>,
);
});
await act(async () => {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
});
return queryClient;
}
async function openActions(taskTitle: string) {
const actions = container.querySelector<HTMLButtonElement>(
`button[aria-label="More actions for ${taskTitle}"]`,
);
await act(async () => {
actions?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
await Promise.resolve();
});
}
function menuItem(label: string) {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((item) => item.textContent?.trim() === label);
}
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
it("hides the section when there are no recent tasks", async () => {
await render();
expect(container.textContent).not.toContain("Recent Tasks");
expect(container.textContent).not.toContain("Open or create a task");
});
it("renders refreshed task text and live state without shifting for a status icon", async () => {
recordRecentTask({
id: "issue-1",
companyId: "company-1",
title: "Initial title",
identifier: "PAP-1",
status: "todo",
updatedAt: new Date(1),
}, "user-1");
mockIssuesApi.get.mockResolvedValue({
id: "issue-1",
companyId: "company-1",
title: "Refreshed title",
identifier: "PAP-1",
status: "in_progress",
hiddenAt: null,
updatedAt: new Date(2),
});
const queryClient = await render();
await act(async () => {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
});
const link = container.querySelector('a[href="/issues/issue-1"]');
expect(mockIssuesApi.get).not.toHaveBeenCalled();
expect(mockIssuesApi.summaries).toHaveBeenCalledTimes(1);
expect(queryClient.getQueryData(["issues", "detail", "issue-1"])).toBeUndefined();
expect(link?.textContent).toContain("Refreshed title");
expect(link?.textContent).toContain("1 live");
expect(link?.querySelector('[aria-label="In Progress"]')).toBeNull();
expect(link?.querySelector('[data-slot="recent-task-icon-spacer"]')).toBeNull();
expect(link?.querySelector('[data-slot="sidebar-nav-icon"]')).toBeNull();
expect(link?.firstElementChild?.textContent).toBe("Refreshed title");
const actions = container.querySelector<HTMLButtonElement>(
'button[aria-label="More actions for Refreshed title"]',
);
expect(actions).not.toBeNull();
expect(actions?.className).toContain("opacity-0");
});
it("keeps rows stable during alternating activity and applies the final order after quiet", async () => {
const tasks = [1, 2, 3].map((id) => ({
id: `issue-${id}`,
companyId: "company-1",
title: `Task ${id}`,
identifier: `PAP-${id}`,
status: "done" as const,
hiddenAt: null,
projectId: null,
updatedAt: new Date(id),
}));
tasks.forEach((task) => recordRecentTask(task, "user-1"));
mockIssuesApi.get.mockImplementation(async (id: string) => tasks.find((task) => task.id === id));
const queryClient = await render();
const order = () => Array.from(container.querySelectorAll("a")).map((link) => link.getAttribute("href"));
const originalOrder = ["/issues/issue-3", "/issues/issue-2", "/issues/issue-1"];
expect(order()).toEqual(originalOrder);
vi.useFakeTimers();
for (let index = 0; index < 6; index += 1) {
const task = tasks[index % 2]!;
await act(async () => {
queryClient.setQueryData(queryKeys.issues.detail(task.id), {
...task, title: `Updated ${task.id}`, updatedAt: new Date(100 + index),
});
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => { await vi.advanceTimersByTimeAsync(500); });
expect(order()).toEqual(originalOrder);
expect(container.textContent).toContain(`Updated ${task.id}`);
}
await act(async () => { await vi.advanceTimersByTimeAsync(499); });
expect(order()).toEqual(originalOrder);
await act(async () => { await vi.advanceTimersByTimeAsync(1); });
expect(order()).toEqual(["/issues/issue-2", "/issues/issue-1", "/issues/issue-3"]);
expect(mockIssuesApi.get).not.toHaveBeenCalled();
expect(mockIssuesApi.summaries).toHaveBeenCalledTimes(1);
queryClient.clear();
});
it("adds and removes tasks immediately while an activity reorder is pending", async () => {
const task = (id: string, recordedAt: number) => ({
id, companyId: "company-1", title: id, identifier: id,
status: "todo" as const, updatedAt: new Date(recordedAt),
});
recordRecentTask(task("first", 1), "user-1");
recordRecentTask(task("second", 2), "user-1");
mockIssuesApi.get.mockImplementation(async (id: string) => task(id, 0));
const queryClient = await render();
vi.useFakeTimers();
act(() => recordRecentTask(task("first", 3), "user-1"));
expect(container.querySelector("a")?.getAttribute("href")).toBe("/issues/second");
await act(async () => {
recordRecentTask(task("new", 4), "user-1");
await vi.advanceTimersByTimeAsync(0);
});
expect(container.querySelector("a")?.getAttribute("href")).toBe("/issues/new");
act(() => pruneRecentTasks(getRecentTasksStorageKey("company-1", "user-1"), "company-1", new Set(["second"])));
expect(container.querySelector('a[href="/issues/second"]')).toBeNull();
await act(async () => { await vi.advanceTimersByTimeAsync(2_000); });
expect(container.querySelector('a[href="/issues/second"]')).toBeNull();
queryClient.clear();
});
it("opens the compact task actions menu from the ellipsis button", async () => {
recordRecentTask({
id: "issue-1",
companyId: "company-1",
title: "Menu task",
identifier: "PAP-1",
status: "todo",
updatedAt: new Date(1),
}, "user-1");
mockIssuesApi.get.mockResolvedValue({
id: "issue-1",
companyId: "company-1",
title: "Menu task",
identifier: "PAP-1",
status: "todo",
hiddenAt: null,
updatedAt: new Date(1),
});
await render();
await openActions("Menu task");
const menu = document.body.querySelector('[data-slot="dropdown-menu-content"]');
expect(menu?.textContent).toContain("Rename");
expect(menu?.textContent).toContain("Archive");
expect(menu?.textContent).toContain("Pause/Restart");
});
it("keeps an idle Slack conversation in recent history and labels it Idle", async () => {
const issue = {
id: "issue-1", companyId: "company-1", title: "Slack conversation", identifier: "PAP-1",
status: "in_review" as const, externalConversationState: "waiting" as const,
updatedAt: new Date(1), hiddenAt: null,
};
recordRecentTask(issue, "user-1");
mockIssuesApi.get.mockResolvedValue(issue);
await render();
expect(container.textContent).toContain("Slack conversation");
expect(container.textContent).toContain("Idle");
});
it("archives a task from the inbox without hiding or removing the recent task", async () => {
const issue = {
id: "issue-1",
companyId: "company-1",
title: "Archive me",
identifier: "PAP-1",
status: "todo" as const,
hiddenAt: null,
updatedAt: new Date(1),
};
recordRecentTask(issue, "user-1");
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.archiveFromInbox.mockResolvedValue({
id: issue.id,
archivedAt: new Date(2),
});
await render();
await openActions("Archive me");
await act(async () => {
menuItem("Archive")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
expect(mockIssuesApi.archiveFromInbox).toHaveBeenCalledWith("issue-1");
expect(mockIssuesApi.update).not.toHaveBeenCalled();
expect(container.textContent).toContain("Recent Tasks");
expect(container.querySelector('a[href="/issues/issue-1"]')?.textContent).toContain(
"Archive me",
);
expect(readRecentTasks(
getRecentTasksStorageKey("company-1", "user-1"),
"company-1",
)).toHaveLength(1);
});
it("refreshes task activity after a rename", async () => {
const issue = {
id: "issue-1",
companyId: "company-1",
title: "Old title",
identifier: "PAP-1",
status: "todo" as const,
hiddenAt: null,
updatedAt: new Date(1),
};
const renamedIssue = { ...issue, title: "New title", updatedAt: new Date(2) };
recordRecentTask(issue, "user-1");
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.update.mockResolvedValue(renamedIssue);
const queryClient = await render();
const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries");
await openActions("Old title");
await act(async () => {
menuItem("Rename")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
const input = document.body.querySelector<HTMLInputElement>('input[aria-label="Task name"]');
expect(input).not.toBeNull();
await act(async () => {
setInputValue(input!, "New title");
await Promise.resolve();
});
await act(async () => {
input?.closest("form")?.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
expect(mockIssuesApi.update).toHaveBeenCalledWith("issue-1", { title: "New title" });
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: queryKeys.issues.activity("issue-1"),
});
});
it("pauses a running task or restarts its active pause hold", async () => {
const issue = {
id: "issue-1",
companyId: "company-1",
title: "Toggle work",
identifier: "PAP-1",
status: "in_progress" as const,
assigneeAgentId: "agent-1",
hiddenAt: null,
updatedAt: new Date(1),
};
recordRecentTask(issue, "user-1");
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.getTreeControlState
.mockResolvedValueOnce({ activePauseHold: null })
.mockResolvedValueOnce({
activePauseHold: {
holdId: "hold-1",
rootIssueId: "issue-1",
issueId: "issue-1",
isRoot: true,
mode: "pause",
reason: null,
releasePolicy: { strategy: "manual" },
},
});
mockIssuesApi.createTreeHold.mockResolvedValue({ hold: {}, preview: {} });
mockIssuesApi.releaseTreeHold.mockResolvedValue({});
mockAgentsApi.wakeup.mockResolvedValue({ id: "run-1" });
await render();
await openActions("Toggle work");
await act(async () => {
menuItem("Pause/Restart")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("issue-1", {
mode: "pause",
reason: "Paused from Recent Tasks.",
releasePolicy: { strategy: "manual" },
});
await openActions("Toggle work");
await act(async () => {
menuItem("Pause/Restart")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
expect(mockIssuesApi.releaseTreeHold).toHaveBeenCalledWith("issue-1", "hold-1", {
reason: "Restarted from Recent Tasks.",
});
expect(mockAgentsApi.wakeup).toHaveBeenCalledWith(
"agent-1",
{
source: "assignment",
triggerDetail: "manual",
reason: "recent_task_restart",
payload: { issueId: "issue-1" },
},
"company-1",
);
});
it("retries only the wake after a remount when restart releases the hold before wakeup fails", async () => {
const issue = {
id: "issue-1",
companyId: "company-1",
title: "Retry restart",
identifier: "PAP-1",
status: "in_progress" as const,
assigneeAgentId: "agent-1",
hiddenAt: null,
updatedAt: new Date(1),
};
recordRecentTask(issue, "user-1");
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.getTreeControlState
.mockResolvedValueOnce({
activePauseHold: {
holdId: "hold-1",
rootIssueId: "issue-1",
issueId: "issue-1",
isRoot: true,
mode: "pause",
reason: null,
releasePolicy: { strategy: "manual" },
},
})
.mockResolvedValueOnce({ activePauseHold: null });
mockIssuesApi.releaseTreeHold.mockResolvedValue({});
mockAgentsApi.wakeup
.mockRejectedValueOnce(new Error("Wake failed"))
.mockResolvedValueOnce({ id: "run-1" });
await render();
await openActions("Retry restart");
await act(async () => {
menuItem("Pause/Restart")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
await act(async () => root.unmount());
root = createRoot(container);
await render();
await openActions("Retry restart");
await act(async () => {
menuItem("Pause/Restart")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
expect(mockIssuesApi.releaseTreeHold).toHaveBeenCalledTimes(1);
expect(mockIssuesApi.createTreeHold).not.toHaveBeenCalled();
expect(mockAgentsApi.wakeup).toHaveBeenCalledTimes(2);
expect(mockAgentsApi.wakeup).toHaveBeenLastCalledWith(
"agent-1",
{
source: "assignment",
triggerDetail: "manual",
reason: "recent_task_restart_retry",
payload: { issueId: "issue-1" },
},
"company-1",
);
});
it("synchronizes recent tasks written by another tab", async () => {
mockIssuesApi.get.mockImplementation(() => new Promise(() => {}));
await render();
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
const entries = [{
id: "issue-2",
companyId: "company-1",
title: "Cross-tab task",
identifier: "PAP-2",
status: "todo" as const,
recordedAt: 2,
}];
await act(async () => {
window.localStorage.setItem(storageKey, JSON.stringify(entries));
window.dispatchEvent(new StorageEvent("storage", { key: storageKey }));
await Promise.resolve();
});
expect(container.querySelector('a[href="/issues/issue-2"]')?.textContent).toContain("Cross-tab task");
});
it("preserves pending restart wake retries from before the snapshot storage migration", async () => {
const issue = {
id: "issue-1", companyId: "company-1", title: "Retry after upgrade", identifier: "PAP-1",
status: "in_progress" as const, assigneeAgentId: "agent-1", hiddenAt: null, updatedAt: new Date(1),
};
window.localStorage.setItem("paperclip.recentTasks:company-1:user-1", JSON.stringify([{ ...issue, recordedAt: 1 }]));
window.localStorage.setItem("paperclip.recentTasks:company-1:user-1:restart-wake-retry", JSON.stringify([issue.id]));
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.getTreeControlState.mockResolvedValue({ activePauseHold: null });
mockAgentsApi.wakeup.mockResolvedValue({ id: "run-1" });
await render();
await openActions("Retry after upgrade");
await act(async () => {
menuItem("Pause/Restart")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
expect(mockIssuesApi.createTreeHold).not.toHaveBeenCalled();
expect(mockIssuesApi.releaseTreeHold).not.toHaveBeenCalled();
expect(mockAgentsApi.wakeup).toHaveBeenCalledWith("agent-1", expect.objectContaining({
reason: "recent_task_restart_retry",
}), "company-1");
expect(window.localStorage.getItem("paperclip.recentTasks:company-1:user-1:restart-wake-retry")).toBeNull();
});
it("prunes tasks that become hidden", async () => {
recordRecentTask({
id: "issue-hidden",
companyId: "company-1",
title: "Hidden task",
identifier: "PAP-3",
status: "todo",
updatedAt: new Date(3),
}, "user-1");
mockIssuesApi.get.mockResolvedValue({
id: "issue-hidden",
companyId: "company-1",
title: "Hidden task",
identifier: "PAP-3",
status: "todo",
hiddenAt: new Date(),
updatedAt: new Date(4),
});
await render();
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
expect(container.querySelector('a[href="/issues/issue-hidden"]')).toBeNull();
expect(readRecentTasks(
getRecentTasksStorageKey("company-1", "user-1"),
"company-1",
)).toEqual([]);
});
});