Restore chat contacts, agent pins, and scrollable recent navigation (#19685)

This commit is contained in:
scotttong authored and GitHub committed 2026-11-06 19:17:25 -08:00
1 parent 08f7ac03dc
commit f5fb8ada3b
27 files changed
+571 -273

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import { usePinnedNavigation } from "./usePinnedNavigation";
it("reorders hundreds of pins without nested scans and preserves hidden-company slots", async () => {
const key = "paperclip.coordinator.pinned-nav.v1:pin-review";
const visible = Array.from({ length: 500 }, (_, index) => `agent-chat:company:agent-${index}`);
const hidden = "agent-chat:other-company:hidden";
localStorage.setItem(key, JSON.stringify([...visible.slice(0, 250), hidden, ...visible.slice(250)]));
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
let navigation!: ReturnType<typeof usePinnedNavigation>;
function Harness() { navigation = usePinnedNavigation("pin-review"); return null; }
const original = Array.prototype.includes;
let scanned = 0;
const includes = vi.spyOn(Array.prototype, "includes").mockImplementation(function (this: unknown[], value: unknown, from?: number) {
if (this.length >= 500 && typeof value === "string" && value.startsWith("agent-chat:")) {
scanned += this.length;
if (scanned > visible.length * 3) throw new Error("Pin reordering scans growing lists repeatedly");
}
return original.call(this, value, from);
});
try {
await act(async () => root.render(<Harness />));
const before = navigation.pins;
await act(async () => root.render(<Harness />));
expect(navigation.pins).toBe(before);
await act(async () => navigation.reorder([...visible].reverse()));
const saved = JSON.parse(localStorage.getItem(key)!);
expect(saved[250]).toBe(hidden);
expect(saved.filter((entry: string) => entry !== hidden)).toEqual([...visible].reverse());
expect(scanned).toBeLessThanOrEqual(visible.length * 3);
} finally {
includes.mockRestore();
await act(async () => root.unmount());
host.remove(); localStorage.removeItem(key);
}
});
+21 -6
View File
@@ -1,4 +1,4 @@
import { useCallback, useState, useSyncExternalStore } from "react";
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
const UPDATED = "paperclip:pinned-navigation-updated";
const EMPTY = "[]";
@@ -27,19 +27,34 @@ export function usePinnedNavigation(userId: string | null | undefined) {
return () => { window.removeEventListener("storage", storage); window.removeEventListener(UPDATED, updated); };
}, [key]);
const snapshot = useSyncExternalStore(subscribe, () => read(key), () => EMPTY);
const pins = useMemo(() => parse(snapshot), [snapshot]);
function toggle(to: string) {
function save(next: string[]) {
if (!key) return;
const current = parse(read(key));
const next = current.includes(to) ? current.filter(entry => entry !== to) : [...current, to];
try {
localStorage.setItem(key, JSON.stringify(next));
setFailure(null);
window.dispatchEvent(new CustomEvent(UPDATED, { detail: key }));
} catch {
setFailure({ key, message: "Couldn’t save pinned pages. Allow browser storage and try again." });
setFailure({ key, message: "Couldn’t save pinned items. Allow browser storage and try again." });
}
}
return { pins: parse(snapshot), toggle, canPin: !!key, error: failure?.key === key ? failure.message : null };
function toggle(to: string) {
if (!key) return;
const current = parse(read(key));
save(current.includes(to) ? current.filter(entry => entry !== to) : [...current, to]);
}
function reorder(visibleOrder: string[]) {
if (!key) return;
const current = parse(read(key));
const currentKeys = new Set(current);
const ordered = [...new Set(visibleOrder)].filter(entry => currentKeys.has(entry));
const visible = new Set(ordered);
let index = 0;
save(current.map(entry => visible.has(entry) ? ordered[index++] : entry));
}
return { pins, toggle, reorder, canPin: !!key, error: failure?.key === key ? failure.message : null };
}
+17
View File
@@ -182,6 +182,23 @@ describe("recent task synchronization", () => {
expect(summaries).not.toHaveBeenCalled();
});
it.each([false, true])("refreshes long histories in bounded requests without pruning on a partial failure (%s)", async fails => {
const tasks = Array.from({ length: 13 }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${i}` }));
tasks.forEach(issue => recordRecentTask(issue, "user-1"));
summaries.mockImplementation(async (_company, ids: string[]) => {
expect(ids.length).toBeLessThanOrEqual(5);
if (fails && summaries.mock.calls.length === 2) throw new ApiError("Unavailable", 500, null);
return tasks.filter(issue => ids.includes(issue.id)).map(issue => ({ ...issue, title: `Refreshed ${issue.title}`, updatedAt: new Date(100) }));
});
await act(async () => root.render(<QueryClientProvider client={client()}><RecentTasks name="long-history" /></QueryClientProvider>));
await flush();
expect(summaries).toHaveBeenCalledTimes(fails ? 2 : 3);
const entries = readRecentTasks(storageKey, "company-1");
expect(entries).toHaveLength(13);
expect(entries.every(entry => entry.title.startsWith("Refreshed"))).toBe(!fails);
expect(getIssue).not.toHaveBeenCalled();
});
it("prunes successful omissions without resurrecting cached full details", async () => {
const missing = task(10, "Removed");
recordRecentTask(missing, "user-1");
+11 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { ISSUE_SUMMARIES_MAX_IDS, type Issue, type IssueSummary } from "@paperclipai/shared";
import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
import {
@@ -81,7 +81,13 @@ export function useRecentTasks({
}), [queryClient, scope]);
const summaryQuery = useQuery({
queryKey: summaryKey,
queryFn: ({ signal }) => issuesApi.summaries(companyId!, queryIds, { signal }),
queryFn: async ({ signal }) => {
const summaries: IssueSummary[] = [];
for (let offset = 0; offset < queryIds.length; offset += ISSUE_SUMMARIES_MAX_IDS) {
summaries.push(...await issuesApi.summaries(companyId!, queryIds.slice(offset, offset + ISSUE_SUMMARIES_MAX_IDS), { signal }));
}
return summaries;
},
enabled: Boolean(companyId && queryIds.length),
staleTime: 30_000,
});
@@ -117,7 +123,8 @@ export function useRecentTasks({
useEffect(() => {
if (!storageKey || !companyId || !summaries) return;
const authorizedIds = new Set(summaries.filter((issue) => issue.companyId === companyId).map((issue) => issue.id));
const summaryById = new Map(summaries.filter((issue) => issue.companyId === companyId).map((issue) => [issue.id, issue]));
const authorizedIds = new Set(summaryById.keys());
const mergeCached = (value: unknown) => {
if (!value || typeof value !== "object") return;
const issue = value as Issue;
@@ -126,7 +133,7 @@ export function useRecentTasks({
|| !issue.updatedAt || !Object.hasOwn(issue, "projectId")) return;
// At equal task versions, the summary's independently computed readiness
// is newer than an already-cached full detail response.
const summary = summaries.find((item) => item.id === issue.id);
const summary = summaryById.get(issue.id);
if (!summary || new Date(issue.updatedAt).getTime() <= new Date(summary.updatedAt).getTime()) return;
updateRecentTaskSnapshots(storageKey, companyId, [issue]);
};