Files
PaperClipAI/ui/src/components/SidebarRecentTasks.test.tsx
T
DottaandPaperclip d351e08dee fix(ui): stop Recent Tasks storage feedback across tabs (#13402)
Version recent-task snapshots separately from activity, reject stale cache updates, and publish only on query or membership changes. Migrate history into an isolated storage namespace while preserving restart retries.

Add regression coverage for conflicting caches, migration, comments, unchanged writes, and storage implementations on Node 24 and Node 26.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-14 08:05:06 -05:00

553 lines
19 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(),
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());
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).toHaveBeenCalledWith("issue-1");
expect(queryClient.getQueryData(["issues", "detail", "issue-1"])).toMatchObject({
title: "Refreshed title",
});
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,
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).toHaveBeenCalledTimes(3);
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("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([]);
});
});