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); }