From d351e08deee1b49d3467a950d1a3f01131943441 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:05:06 -0500 Subject: [PATCH] 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 --- doc/plans/2026-09-14-recent-tasks-sync.md | 57 +++++++ ui/src/components/SidebarRecentTasks.test.tsx | 27 +++ ui/src/components/SidebarRecentTasks.tsx | 7 +- ui/src/hooks/useRecentTasks.test.tsx | 160 ++++++++++++++++++ ui/src/hooks/useRecentTasks.ts | 17 +- ui/src/lib/recent-tasks.test.ts | 101 ++++++++++- ui/src/lib/recent-tasks.ts | 88 +++++++--- 7 files changed, 417 insertions(+), 40 deletions(-) create mode 100644 doc/plans/2026-09-14-recent-tasks-sync.md create mode 100644 ui/src/hooks/useRecentTasks.test.tsx diff --git a/doc/plans/2026-09-14-recent-tasks-sync.md b/doc/plans/2026-09-14-recent-tasks-sync.md new file mode 100644 index 0000000000..a9f60c6eb4 --- /dev/null +++ b/doc/plans/2026-09-14-recent-tasks-sync.md @@ -0,0 +1,57 @@ +# Stop Recent Tasks storage feedback between tabs + +## Problem and evidence + +Recent Tasks stores five task snapshots in localStorage. Each open tab fetches +the task details and subscribes to storage changes. The persistence effect also +depends on the stored entries. When two query caches disagree about a task, +each storage event makes the receiving tab write its own cached title and status +back to storage. This repeats without new server data. + +A live affected tab received 1,084 storage events and 673 same-tab notifications +in three seconds. Detaching only its Recent Tasks listeners reduced its renderer +CPU from about 90% to about 2%. Restoring the listeners restored the high load. +The stored status repeatedly alternated between two values. + +## Design + +- Store `snapshotUpdatedAt` from the server's task `updatedAt` separately from + `recordedAt`. Comments can advance activity without changing the task version. +- Accept only a strictly newer, finite snapshot version. Keep the stored + snapshot for equal versions. Use the same merge rule for persistence and the + rendered sidebar so stale query data cannot mask a newer stored title/status. +- Run the persistence effect when query results or task membership change. + Receiving metadata or activity from storage must not publish cached data again. +- Skip storage writes and same-tab notifications when the normalized list is + unchanged. Keep the five-task limit and the existing activity-order debounce. +- Use a versioned storage namespace. Existing tabs running the old code can + otherwise undo the version guard. Read the legacy list for initial display, + then persist it once in an effect. Preserve an empty migrated list too. Leave + the independent restart-wake retry key unchanged across the upgrade. + +The server and API contracts do not change. The list remains scoped to company +and user. This is a local browser cache, not a transactional activity ledger: +simultaneous read-modify-write operations can still race. Avoiding feedback is +essential even when such a race delivers an older snapshot to a newer cache. +Tabs running old code keep their own legacy list until reloaded. Reload every +affected tab after deployment to stop feedback among the old tabs themselves. + +## Verification + +Regression tests alternate old and new snapshots, mount independent query +caches, and assert that rendering and writes settle. They cover comment activity, +equal and invalid versions, legacy migration, unchanged writes, subsequent query +updates, unavailable tasks, and pending restart retries. Existing sidebar tests +cover rename, archive, task membership, and debounced ordering. + +Browser acceptance uses the actual sidebar in two tabs with controlled API and +router fixtures. Check that task names agree after a rename, persist across +reload, and produce no continued storage traffic at rest. Run the focused tests, +token gates, repository typecheck, full Vitest suite, and build before merge. + +The browser acceptance run passed. Both tabs displayed the renamed task, and the +stale tab retained it after reload. Each tab then had zero storage writes, +storage events, same-tab notifications, React commits, and detail requests during +a 15-second idle sample. Both consoles had no errors or warnings. This exercised +the real sidebar and browser storage with fixture API data, not a live server +deployment. diff --git a/ui/src/components/SidebarRecentTasks.test.tsx b/ui/src/components/SidebarRecentTasks.test.tsx index 30f6d654bb..0dd414676e 100644 --- a/ui/src/components/SidebarRecentTasks.test.tsx +++ b/ui/src/components/SidebarRecentTasks.test.tsx @@ -491,6 +491,33 @@ describe("SidebarRecentTasks", () => { 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", diff --git a/ui/src/components/SidebarRecentTasks.tsx b/ui/src/components/SidebarRecentTasks.tsx index 3bcaf90380..4db311d845 100644 --- a/ui/src/components/SidebarRecentTasks.tsx +++ b/ui/src/components/SidebarRecentTasks.tsx @@ -35,8 +35,9 @@ const RECENT_TASK_MENU_ITEM_CLASS = "h-(--profile-popover-row-height) gap-(--profile-popover-row-gap) rounded-lg px-2.5 py-0 text-(length:--text-compact) font-medium leading-(--profile-popover-label-line-height) focus:bg-accent/50 focus:text-foreground"; const RESTART_WAKE_RETRY_STORAGE_SUFFIX = ":restart-wake-retry"; -function restartWakeRetryStorageKey(storageKey: string | null) { - return storageKey ? `${storageKey}${RESTART_WAKE_RETRY_STORAGE_SUFFIX}` : null; +function restartWakeRetryStorageKey(companyId: string, userId: string | null) { + // Action retry state must survive changes to the recent-task snapshot format. + return `paperclip.recentTasks:${companyId}:${userId ?? "__local_board__"}${RESTART_WAKE_RETRY_STORAGE_SUFFIX}`; } function readRestartWakeRetryIssueIds(storageKey: string | null) { @@ -112,7 +113,7 @@ function RecentTasksList({ const [renameEntry, setRenameEntry] = useState(null); const [renameValue, setRenameValue] = useState(""); const [pendingAction, setPendingAction] = useState<"rename" | "archive" | "pause" | null>(null); - const restartRetryStorageKey = restartWakeRetryStorageKey(storageKey); + const restartRetryStorageKey = restartWakeRetryStorageKey(companyId, userId); if (entries.length === 0) return null; diff --git a/ui/src/hooks/useRecentTasks.test.tsx b/ui/src/hooks/useRecentTasks.test.tsx new file mode 100644 index 0000000000..987fedc346 --- /dev/null +++ b/ui/src/hooks/useRecentTasks.test.tsx @@ -0,0 +1,160 @@ +// @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 { ApiError } from "@/api/client"; +import { queryKeys } from "@/lib/queryKeys"; +import { + getRecentTasksStorageKey, + readRecentTasks, + recordRecentTask, + RECENT_TASKS_UPDATED_EVENT, +} from "@/lib/recent-tasks"; +import { useRecentTasks } from "./useRecentTasks"; + +const { getIssue } = vi.hoisted(() => ({ getIssue: vi.fn() })); +vi.mock("@/api/issues", () => ({ issuesApi: { get: getIssue } })); + +(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; + +const task = (updatedAt: number, title: string, status: "todo" | "done" = "todo") => ({ + id: "issue-1", companyId: "company-1", identifier: "TASK-1", title, status, + updatedAt: new Date(updatedAt), hiddenAt: null, +}); +const storageKey = getRecentTasksStorageKey("company-1", "user-1"); +// Node 24 uses jsdom Storage; newer Node versions use the setup's storage shim. +// jsdom's Storage proxy does not support spying on instance methods. +const storageMethods = window.localStorage instanceof window.Storage ? window.Storage.prototype : window.localStorage; + +describe("recent task synchronization", () => { + let container: HTMLDivElement; + let root: Root; + let clients: QueryClient[]; + let renders: number; + + function RecentTasks({ name }: { name: string }) { + const { entries } = useRecentTasks({ companyId: "company-1", userId: "user-1" }); + renders += 1; + // Fail promptly if a regression restores the cross-cache render loop. + if (renders > 100) throw new Error("Recent Tasks did not settle"); + return
{entries.map((entry) => `${entry.title}:${entry.status}`).join(",")}
; + } + + function client(snapshot?: ReturnType) { + const value = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + if (snapshot) value.setQueryData(queryKeys.issues.detail(snapshot.id), snapshot); + clients.push(value); + return value; + } + + async function flush() { + await act(async () => { + for (let index = 0; index < 5; index += 1) await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + + beforeEach(() => { + localStorage.clear(); + getIssue.mockReset(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + clients = []; + renders = 0; + }); + + afterEach(async () => { + await act(async () => root.unmount()); + clients.forEach((value) => value.clear()); + container.remove(); + vi.restoreAllMocks(); + }); + + it("settles independent stale and fresh query caches and displays the newer snapshot in both", async () => { + const older = task(10, "Old title"); + const newer = task(20, "New title", "done"); + recordRecentTask(older, "user-1"); + const staleClient = client(older); + const freshClient = client(newer); + const setItem = vi.spyOn(storageMethods, "setItem"); + await act(async () => root.render(<> + + + )); + await flush(); + expect(container.querySelector('[data-tab="stale"]')?.textContent).toBe("New title:done"); + expect(container.querySelector('[data-tab="fresh"]')?.textContent).toBe("New title:done"); + expect(setItem).toHaveBeenCalledTimes(1); + expect(getIssue).not.toHaveBeenCalled(); + const settledRenders = renders; + await flush(); + expect(renders).toBe(settledRenders); + + // A real query update still propagates after the two caches settle. + await act(async () => { + freshClient.setQueryData(queryKeys.issues.detail(newer.id), task(30, "Renamed", "done")); + }); + await flush(); + expect(container.querySelector('[data-tab="stale"]')?.textContent).toBe("Renamed:done"); + expect(readRecentTasks(storageKey, "company-1")[0]?.snapshotUpdatedAt).toBe(30); + expect(setItem).toHaveBeenCalledTimes(2); + }); + + it("receives storage updates without republishing cached data", async () => { + const cached = task(20, "Cached", "done"); + recordRecentTask(cached, "user-1"); + await act(async () => root.render( + , + )); + await flush(); + // Simulate a concurrent write from another tab with an older snapshot. + const incoming = { ...readRecentTasks(storageKey, "company-1")[0], title: "Older", snapshotUpdatedAt: 10 }; + localStorage.setItem(storageKey, JSON.stringify([incoming])); + const setItem = vi.spyOn(storageMethods, "setItem"); + const publish = vi.fn(); + window.addEventListener(RECENT_TASKS_UPDATED_EVENT, publish); + try { + await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: storageKey })); }); + await flush(); + expect(container.textContent).toBe("Cached:done"); + expect(setItem).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + } finally { + window.removeEventListener(RECENT_TASKS_UPDATED_EVENT, publish); + } + }); + + it("ignores writes from the old application after migration", async () => { + const legacyKey = "paperclip.recentTasks:company-1:user-1"; + localStorage.setItem(legacyKey, JSON.stringify([{ ...task(10, "Legacy"), recordedAt: 100 }])); + await act(async () => root.render( + , + )); + await flush(); + expect(container.textContent).toBe("Current:done"); + const settledRenders = renders; + await act(async () => { + localStorage.setItem(legacyKey, "[]"); + window.dispatchEvent(new StorageEvent("storage", { key: legacyKey })); + window.dispatchEvent(new CustomEvent(RECENT_TASKS_UPDATED_EVENT, { detail: { storageKey: legacyKey, entries: [] } })); + }); + await flush(); + expect(renders).toBe(settledRenders); + expect(container.textContent).toBe("Current:done"); + expect(readRecentTasks(storageKey, "company-1")[0]?.recordedAt).toBe(100); + }); + + it.each([403, 404])("still removes tasks whose query returns %s", async (status) => { + recordRecentTask(task(10, "Unavailable"), "user-1"); + getIssue.mockRejectedValue(new ApiError("Unavailable", status, null)); + await act(async () => root.render( + , + )); + await flush(); + expect(readRecentTasks(storageKey, "company-1")).toEqual([]); + expect(container.textContent).toBe(""); + expect(getIssue).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/src/hooks/useRecentTasks.ts b/ui/src/hooks/useRecentTasks.ts index 01d0a6e28e..e50b1c464e 100644 --- a/ui/src/hooks/useRecentTasks.ts +++ b/ui/src/hooks/useRecentTasks.ts @@ -7,6 +7,8 @@ import { queryKeys } from "@/lib/queryKeys"; import { RECENT_TASKS_UPDATED_EVENT, getRecentTasksStorageKey, + mergeRecentTaskSnapshot, + migrateRecentTasks, pruneRecentTasks, readRecentTasks, updateRecentTaskSnapshots, @@ -36,6 +38,7 @@ export function useRecentTasks({ )); useEffect(() => { + if (storageKey && companyId) migrateRecentTasks(storageKey, companyId); setEntries(storageKey && companyId ? readRecentTasks(storageKey, companyId) : []); }, [companyId, storageKey]); @@ -61,6 +64,7 @@ export function useRecentTasks({ // Keep query observers in a fixed order when activity changes the display order. const queryEntries = [...entries].sort((left, right) => left.id.localeCompare(right.id)); + const membership = JSON.stringify(queryEntries.map((entry) => entry.id)); const detailQueries = useQueries({ queries: queryEntries.map((entry) => ({ queryKey: queryKeys.issues.detail(entry.id), @@ -74,12 +78,7 @@ export function useRecentTasks({ const refreshedEntries = entries.map((entry) => { const issue = issueById.get(entry.id); if (!issue || issue.companyId !== companyId || issue.hiddenAt) return entry; - return { - ...entry, - title: issue.title, - identifier: issue.identifier, - status: issue.status, - }; + return mergeRecentTaskSnapshot(entry, issue); }); const queryRevision = detailQueries .map((query) => `${query.dataUpdatedAt}:${query.errorUpdatedAt}:${query.status}`) @@ -106,12 +105,12 @@ export function useRecentTasks({ updateRecentTaskSnapshots(storageKey, companyId, resolvedIssues); pruneRecentTasks(storageKey, companyId, removeIds); - // queryRevision is the stable notification boundary for the useQueries result array. + // Publish only when queries or membership change. A storage notification must + // not feed the same cached query results back into storage in every tab. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [companyId, entries, queryRevision, storageKey]); + }, [companyId, membership, queryRevision, storageKey]); const [settledOrder, setSettledOrder] = useState(() => entries.map((entry) => entry.id)); - const membership = JSON.stringify(queryEntries.map((entry) => entry.id)); const activityRevision = JSON.stringify(entries.map((entry) => [entry.id, entry.recordedAt])); useEffect(() => { const latestOrder = (JSON.parse(activityRevision) as Array<[string, number]>).map(([id]) => id); diff --git a/ui/src/lib/recent-tasks.test.ts b/ui/src/lib/recent-tasks.test.ts index 05466a5f63..f865371558 100644 --- a/ui/src/lib/recent-tasks.test.ts +++ b/ui/src/lib/recent-tasks.test.ts @@ -5,10 +5,12 @@ import { RECENT_TASKS_LIMIT, RECENT_TASKS_UPDATED_EVENT, getRecentTasksStorageKey, + migrateRecentTasks, pruneRecentTasks, readRecentTasks, recordRecentTask, updateRecentTaskSnapshots, + writeRecentTasks, } from "./recent-tasks"; const issue = (id: string, companyId = "company-1") => ({ @@ -19,6 +21,8 @@ const issue = (id: string, companyId = "company-1") => ({ status: "todo" as const, updatedAt: new Date(0), }); +// Support both jsdom Storage (Node 24) and the setup's storage shim (newer Node). +const storageMethods = window.localStorage instanceof window.Storage ? window.Storage.prototype : window.localStorage; describe("recent task persistence", () => { beforeEach(() => window.localStorage.clear()); @@ -85,8 +89,8 @@ describe("recent task persistence", () => { it("does not demote recent comment activity when older task details arrive", () => { const storageKey = getRecentTasksStorageKey("company-1", "user-1"); - recordRecentTask(issue("1"), "user-1", 100); - recordRecentTask(issue("2"), "user-1", 90); + recordRecentTask({ ...issue("1"), updatedAt: new Date(50) }, "user-1", 100); + recordRecentTask({ ...issue("2"), updatedAt: new Date(80) }, "user-1", 90); const listener = vi.fn(); window.addEventListener(RECENT_TASKS_UPDATED_EVENT, listener); @@ -118,7 +122,100 @@ describe("recent task persistence", () => { window.localStorage.setItem(storageKey, JSON.stringify([ issue("other", "company-2"), { nonsense: true }, + { ...issue("invalid-version"), recordedAt: 1, snapshotUpdatedAt: "invalid" }, ])); expect(readRecentTasks(storageKey, "company-1")).toEqual([]); }); + + it("settles conflicting tab snapshots without further writes or notifications", () => { + const storageKey = getRecentTasksStorageKey("company-1", "user-1"); + const older = { ...issue("1"), title: "Old title", updatedAt: new Date(10) }; + const newer = { ...issue("1"), title: "New title", status: "done" as const, updatedAt: new Date(20) }; + recordRecentTask(older, "user-1"); + updateRecentTaskSnapshots(storageKey, "company-1", [newer]); + const setItem = vi.spyOn(storageMethods, "setItem"); + const listener = vi.fn(); + window.addEventListener(RECENT_TASKS_UPDATED_EVENT, listener); + try { + for (let index = 0; index < 20; index += 1) { + updateRecentTaskSnapshots(storageKey, "company-1", [older]); + recordRecentTask(older, "user-1"); + updateRecentTaskSnapshots(storageKey, "company-1", [newer]); + } + expect(readRecentTasks(storageKey, "company-1")[0]).toMatchObject({ + title: "New title", status: "done", snapshotUpdatedAt: 20, recordedAt: 20, + }); + expect(setItem).not.toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); + } finally { + setItem.mockRestore(); + window.removeEventListener(RECENT_TASKS_UPDATED_EVENT, listener); + } + }); + + it("promotes a new comment without overwriting newer task details", () => { + const storageKey = getRecentTasksStorageKey("company-1", "user-1"); + recordRecentTask({ ...issue("1"), title: "Done", status: "done", updatedAt: new Date(20) }, "user-1"); + recordRecentTask({ ...issue("1"), updatedAt: new Date(10) }, "user-1", 30); + expect(readRecentTasks(storageKey, "company-1")[0]).toMatchObject({ + title: "Done", status: "done", snapshotUpdatedAt: 20, recordedAt: 30, + }); + }); + + it("retains the current snapshot for equal or invalid versions", () => { + const storageKey = getRecentTasksStorageKey("company-1", "user-1"); + recordRecentTask(issue("1"), "user-1"); + for (const updatedAt of [new Date(0), new Date("invalid")]) { + updateRecentTaskSnapshots(storageKey, "company-1", [{ ...issue("1"), title: "Conflict", updatedAt }]); + } + expect(readRecentTasks(storageKey, "company-1")[0]).toMatchObject({ title: "Task 1", snapshotUpdatedAt: 0 }); + }); + + it("does not write or publish an unchanged normalized list", () => { + const storageKey = getRecentTasksStorageKey("company-1", "user-1"); + recordRecentTask(issue("1"), "user-1", 1); + recordRecentTask(issue("2"), "user-1", 2); + const entries = readRecentTasks(storageKey, "company-1"); + const setItem = vi.spyOn(storageMethods, "setItem"); + const listener = vi.fn(); + window.addEventListener(RECENT_TASKS_UPDATED_EVENT, listener); + try { + writeRecentTasks(storageKey, [...entries].reverse()); + expect(setItem).not.toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); + } finally { + setItem.mockRestore(); + window.removeEventListener(RECENT_TASKS_UPDATED_EVENT, listener); + } + }); + + it("migrates legacy tasks once and isolates subsequent old-tab writes", () => { + const legacyKey = "paperclip.recentTasks:company-1:user-1"; + const storageKey = getRecentTasksStorageKey("company-1", "user-1"); + const legacy = [{ ...issue("1"), recordedAt: 100 }]; + window.localStorage.setItem(legacyKey, JSON.stringify(legacy)); + expect(readRecentTasks(storageKey, "company-1")[0]?.id).toBe("1"); + expect(window.localStorage.getItem(storageKey)).toBeNull(); + migrateRecentTasks(storageKey, "company-1"); + expect(window.localStorage.getItem(legacyKey)).toBe(JSON.stringify(legacy)); + // The first versioned refresh can update legacy metadata, even after a comment. + updateRecentTaskSnapshots(storageKey, "company-1", [{ ...issue("1"), title: "Fresh", updatedAt: new Date(20) }]); + expect(readRecentTasks(storageKey, "company-1")[0]).toMatchObject({ + title: "Fresh", snapshotUpdatedAt: 20, recordedAt: 100, + }); + window.localStorage.setItem(legacyKey, JSON.stringify([{ ...issue("old-tab"), recordedAt: 200 }])); + migrateRecentTasks(storageKey, "company-1"); + expect(readRecentTasks(storageKey, "company-1")[0]?.title).toBe("Fresh"); + pruneRecentTasks(storageKey, "company-1", new Set(["1"])); + migrateRecentTasks(storageKey, "company-1"); + expect(readRecentTasks(storageKey, "company-1")).toEqual([]); + }); + + it("does not import legacy tasks that appear after an empty migration", () => { + const storageKey = getRecentTasksStorageKey("company-1", "user-1"); + migrateRecentTasks(storageKey, "company-1"); + window.localStorage.setItem("paperclip.recentTasks:company-1:user-1", JSON.stringify([{ ...issue("1"), recordedAt: 1 }])); + migrateRecentTasks(storageKey, "company-1"); + expect(readRecentTasks(storageKey, "company-1")).toEqual([]); + }); }); diff --git a/ui/src/lib/recent-tasks.ts b/ui/src/lib/recent-tasks.ts index 12ddab4e07..9e7ca221dc 100644 --- a/ui/src/lib/recent-tasks.ts +++ b/ui/src/lib/recent-tasks.ts @@ -2,6 +2,8 @@ import type { Issue, IssueStatus } from "@paperclipai/shared"; export const RECENT_TASKS_LIMIT = 5; export const RECENT_TASKS_UPDATED_EVENT = "paperclip:recent-tasks-updated"; +const STORAGE_PREFIX = "paperclip.recentTasks.v2:"; +const LEGACY_STORAGE_PREFIX = "paperclip.recentTasks:"; export interface RecentTaskEntry { id: string; @@ -10,6 +12,9 @@ export interface RecentTaskEntry { identifier: string | null; status: IssueStatus; recordedAt: number; + // Server version of the title/status snapshot, independent of comment activity. + // Legacy entries have no version until a detail query refreshes them. + snapshotUpdatedAt?: number; } interface RecentTasksUpdatedDetail { @@ -18,7 +23,8 @@ interface RecentTasksUpdatedDetail { } export function getRecentTasksStorageKey(companyId: string, userId: string | null | undefined) { - return `paperclip.recentTasks:${companyId}:${userId ?? "__local_board__"}`; + // Old tabs can still publish stale snapshots. Keep their writes out of v2. + return `${STORAGE_PREFIX}${companyId}:${userId ?? "__local_board__"}`; } function isRecentTaskEntry(value: unknown, companyId: string): value is RecentTaskEntry { @@ -31,13 +37,20 @@ function isRecentTaskEntry(value: unknown, companyId: string): value is RecentTa && (entry.identifier === null || typeof entry.identifier === "string") && typeof entry.status === "string" && typeof entry.recordedAt === "number" - && Number.isFinite(entry.recordedAt); + && Number.isFinite(entry.recordedAt) + && (entry.snapshotUpdatedAt === undefined || ( + typeof entry.snapshotUpdatedAt === "number" && Number.isFinite(entry.snapshotUpdatedAt) + )); } export function readRecentTasks(storageKey: string, companyId: string): RecentTaskEntry[] { if (typeof window === "undefined") return []; try { - const parsed = JSON.parse(window.localStorage.getItem(storageKey) ?? "[]") as unknown; + const legacyKey = storageKey.startsWith(STORAGE_PREFIX) + ? LEGACY_STORAGE_PREFIX + storageKey.slice(STORAGE_PREFIX.length) + : storageKey; + const raw = window.localStorage.getItem(storageKey) ?? window.localStorage.getItem(legacyKey); + const parsed = JSON.parse(raw ?? "[]") as unknown; if (!Array.isArray(parsed)) return []; return normalizeRecentTasks( parsed.filter((entry): entry is RecentTaskEntry => isRecentTaskEntry(entry, companyId)), @@ -47,6 +60,17 @@ export function readRecentTasks(storageKey: string, companyId: string): RecentTa } } +export function migrateRecentTasks(storageKey: string, companyId: string) { + if (typeof window === "undefined") return; + try { + if (window.localStorage.getItem(storageKey) !== null) return; + } catch { + return; + } + // Persist even an empty list so an old tab cannot seed it again later. + writeRecentTasks(storageKey, readRecentTasks(storageKey, companyId)); +} + function normalizeRecentTasks(entries: RecentTaskEntry[]) { const seen = new Set(); return [...entries] @@ -70,13 +94,33 @@ export function writeRecentTasks(storageKey: string, entries: RecentTaskEntry[]) if (typeof window === "undefined") return; const bounded = normalizeRecentTasks(entries); try { - window.localStorage.setItem(storageKey, JSON.stringify(bounded)); + const serialized = JSON.stringify(bounded); + if (window.localStorage.getItem(storageKey) === serialized) return; + window.localStorage.setItem(storageKey, serialized); } catch { // The in-tab event still keeps mounted navigation current for this session. } publishRecentTasks(storageKey, bounded); } +type TaskSnapshot = Pick; + +export function mergeRecentTaskSnapshot(entry: RecentTaskEntry, issue: TaskSnapshot): RecentTaskEntry { + if (entry.id !== issue.id || entry.companyId !== issue.companyId) return entry; + const snapshotUpdatedAt = new Date(issue.updatedAt).getTime(); + if (!Number.isFinite(snapshotUpdatedAt)) return entry; + // Equal versions retain the persisted snapshot, so conflicting caches settle. + if (entry.snapshotUpdatedAt !== undefined && snapshotUpdatedAt <= entry.snapshotUpdatedAt) return entry; + return { + ...entry, + title: issue.title, + identifier: issue.identifier, + status: issue.status, + snapshotUpdatedAt, + recordedAt: Math.max(entry.recordedAt, snapshotUpdatedAt), + }; +} + export function recordRecentTask( issue: Pick, userId: string | null | undefined, @@ -89,14 +133,20 @@ export function recordRecentTask( const activityAt = Number.isFinite(recordedAt) ? recordedAt : existing?.recordedAt ?? Date.now(); - const entry: RecentTaskEntry = { + const snapshotUpdatedAt = new Date(issue.updatedAt).getTime(); + const snapshot: RecentTaskEntry = existing ? mergeRecentTaskSnapshot(existing, issue) : { id: issue.id, companyId: issue.companyId, title: issue.title, identifier: issue.identifier, status: issue.status, - // A stale detail query must not undo a newer comment or activity update. - recordedAt: Math.max(activityAt, existing?.recordedAt ?? activityAt), + recordedAt: activityAt, + ...(Number.isFinite(snapshotUpdatedAt) ? { snapshotUpdatedAt } : {}), + }; + const entry: RecentTaskEntry = { + ...snapshot, + // A comment can promote activity even when its task details are stale. + recordedAt: Math.max(activityAt, snapshot.recordedAt), }; if ( existing @@ -104,6 +154,7 @@ export function recordRecentTask( && existing.identifier === entry.identifier && existing.status === entry.status && existing.recordedAt === entry.recordedAt + && existing.snapshotUpdatedAt === entry.snapshotUpdatedAt ) return; writeRecentTasks( @@ -128,7 +179,7 @@ export function pruneRecentTasks( export function updateRecentTaskSnapshots( storageKey: string, companyId: string, - issues: ReadonlyArray>, + issues: ReadonlyArray, ) { const issueById = new Map(issues.map((issue) => [issue.id, issue])); const current = readRecentTasks(storageKey, companyId); @@ -136,24 +187,9 @@ export function updateRecentTaskSnapshots( const next = current.map((entry) => { const issue = issueById.get(entry.id); if (!issue || issue.companyId !== companyId) return entry; - const activityAt = new Date(issue.updatedAt).getTime(); - const nextRecordedAt = Number.isFinite(activityAt) - ? Math.max(activityAt, entry.recordedAt) - : entry.recordedAt; - if ( - issue.title === entry.title - && issue.identifier === entry.identifier - && issue.status === entry.status - && nextRecordedAt === entry.recordedAt - ) return entry; - changed = true; - return { - ...entry, - title: issue.title, - identifier: issue.identifier, - status: issue.status, - recordedAt: nextRecordedAt, - }; + const nextEntry = mergeRecentTaskSnapshot(entry, issue); + if (nextEntry !== entry) changed = true; + return nextEntry; }); if (changed) writeRecentTasks(storageKey, next); }