mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - The recent tasks sidebar helps operators return to active and
completed work.
> - Task detail refreshes update the activity timestamps used to sort
that list.
> - Older responses can undo newer activity. Concurrent updates can move
rows repeatedly.
> - This pull request preserves the newest observed activity and
debounces row moves.
> - Operators can select a stable row while task text and live state
stay current.
## Linked Issues or Issue Description
**What happened?**
Recent task rows can repeatedly swap positions when two tasks receive
updates. Older detail responses can also move a task below another task
after a newer comment promoted it.
**Expected behavior**
Rows remain stable during an update burst. The final activity order
appears after one second of quiet. Old responses never reduce the
recorded activity time.
**Steps to reproduce**
1. Enable the streamlined UI and open multiple tasks.
2. Send alternating updates to two tasks in the recent list.
3. Observe row order while those updates arrive and while older detail
data refreshes.
**Paperclip version or commit**
Base commit: 1cc45086d.
**Deployment mode**
Board UI. The behavior is independent of the server deployment mode.
Related work: #12854 and #12746 introduced the current navigation.
#12314 is an earlier experimental sidebar proposal; this fix targets the
implementation already on master.
## What Changed
- Keep stored activity timestamps at their newest observed value in both
recording paths.
- Debounce activity-driven row moves for one second. Keep additions and
removals immediate.
- Keep detail query observers in a fixed order and resolve task text by
task ID.
- Add regression coverage for alternating updates, stale data,
additions, and removals.
- Document recent task ordering in the development guide.
## Verification
- Passed: 17 focused recent task tests.
- Passed: `pnpm check:token-gates`.
- Passed: `pnpm -r typecheck` and `pnpm build`.
- Passed: Greptile 5/5 on commit
`8861961de9aaa7e9a8184826bc5ce367450f9e8a`, with no review threads or
actionable findings.
- Passed: all 31 remote checks, including all general and serialized
test shards, browser tests, release verification, and build. The opt-in
Storybook visual check was skipped by policy.
- One unrelated concurrent artifact-document test failed on the first CI
attempt. Its 11-test suite passed locally, and the single CI retry
passed without code changes.
- The duplicate full local `pnpm test:run` was started; the completed CI
suite provides the full-suite result.
- Regression tests use fake timers to check that rows remain fixed
during alternating activity, text refreshes immediately, and the final
order appears after one second of quiet.
## Risks
- Continuous activity intentionally delays row moves until updates
settle. New and removed tasks still appear immediately.
- Storage format and server contracts stay compatible. No migration is
needed.
## Model Used
OpenAI GPT-6 through Codex, with reasoning, repository inspection, code
editing, and test execution. The exact deployment model ID and context
window size are not exposed in this session.
## 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>
526 lines
18 KiB
TypeScript
526 lines
18 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("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([]);
|
|
});
|
|
});
|