mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
Restore chat contacts, agent pins, and scrollable recent navigation (#19685)
This commit is contained in:
1 parent
08f7ac03dc
commit
f5fb8ada3b
27 files changed
+571
-273
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user