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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-14 08:05:06 -05:00
1 parent 13368c5183
commit d351e08dee
7 files changed
+417 -40

No files matched your search

+57
View File
@@ -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.
@@ -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",
+4 -3
View File
@@ -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<RecentTaskEntry | null>(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;
+160
View File
@@ -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<string, unknown>).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 <div data-tab={name}>{entries.map((entry) => `${entry.title}:${entry.status}`).join(",")}</div>;
}
function client(snapshot?: ReturnType<typeof task>) {
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(<>
<QueryClientProvider client={staleClient}><RecentTasks name="stale" /></QueryClientProvider>
<QueryClientProvider client={freshClient}><RecentTasks name="fresh" /></QueryClientProvider>
</>));
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(
<QueryClientProvider client={client(cached)}><RecentTasks name="receiver" /></QueryClientProvider>,
));
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(
<QueryClientProvider client={client(task(20, "Current", "done"))}><RecentTasks name="current" /></QueryClientProvider>,
));
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(
<QueryClientProvider client={client()}><RecentTasks name="unavailable" /></QueryClientProvider>,
));
await flush();
expect(readRecentTasks(storageKey, "company-1")).toEqual([]);
expect(container.textContent).toBe("");
expect(getIssue).toHaveBeenCalledTimes(1);
});
});
+8 -9
View File
@@ -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);
+99 -2
View File
@@ -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([]);
});
});
+62 -26
View File
@@ -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<string>();
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<Issue, "id" | "companyId" | "title" | "identifier" | "status" | "updatedAt">;
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<Issue, "id" | "companyId" | "title" | "identifier" | "status" | "updatedAt" | "conversationAgentId">,
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<Pick<Issue, "id" | "companyId" | "title" | "identifier" | "status" | "updatedAt">>,
issues: ReadonlyArray<TaskSnapshot>,
) {
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);
}