mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The board UI sidebar shows the person's recent tasks on every page, including the Mine inbox > - The sidebar refreshes those rows through `GET /companies/:id/issues/summaries`, which accepted at most five IDs > - `useRecentTasks` sent the chunks one after another, so a 25-task history cost five sequential requests > - On staging each request took 3–6 s, so the recent-task rows settled about 20 s after load and every request competed with the inbox rows > - This pull request raises the per-request cap to 50 and fetches any remaining chunks in parallel > - The benefit is one summaries request for a typical history instead of a serial chain ## Linked Issues or Issue Description **What existing behavior does this improve?** The refresh of the sidebar's recent tasks on page load. **Subsystem affected** Board UI (`ui/src/hooks/useRecentTasks.ts`) and the `/issues/summaries` route bound (`packages/shared`, `server/src/routes/issues.ts`). **Current behavior** A history of N recent tasks sends ceil(N/5) summaries requests in series. Measured on staging on 2026-11-09 for a cold Mine load: five requests of five IDs, started at 1.0 s, 4.3 s, 8.1 s, 12.3 s and 18.4 s, each taking 3.3–6.1 s. **Proposed behavior** Up to 50 recent tasks are one request. Longer histories send their chunks at the same time. **Reason and benefit** The staging server is saturated, so each extra serial round trip delays the sidebar and the inbox rows. One request removes four round trips from a typical load. ## What Changed - `ISSUE_SUMMARIES_MAX_IDS` is now 50 (was 5). The shared validator, the route's 400 message, the service guard and the OpenAPI description use the new bound. - `useRecentTasks` builds all chunks first and fetches them at most three at a time (`RECENT_TASK_SUMMARY_CONCURRENCY`). The recent list has no size cap, so this keeps a long history from becoming a burst. The cache key, omission handling and pruning are unchanged. A failed chunk still fails the batch, so nothing is pruned on a partial failure. - Tests: the route test accepts exactly `MAX` IDs and rejects `MAX + 1`; a hook test checks that at most three chunks are in flight; the API test uses the constant; `useRecentTasks` tests cover a 25-task history in one request and a 103-task history with three chunks in flight together, with and without a failed chunk. - New browser check `tests/perf/collections/recent-task-summaries.spec.ts`: a Mine load with a 12-task history sends summaries requests that each carry all 12 IDs, and the server answers 200 with all 12 tasks. ## Verification - `pnpm verify` (selected Vitest files) passes. - `server`: `npx vitest run src/__tests__/issue-summaries-routes.test.ts src/__tests__/issue-display-status.test.ts` passes (embedded PostgreSQL). - `ui`: `npx vitest run src/hooks/useRecentTasks.test.tsx src/api/issue-summaries.test.ts src/components/task-chat/TaskChatBlockerLinks.test.tsx src/components/CoordinatorSidebar.test.tsx src/components/SidebarRecentTasks.test.tsx` passes. - Browser check against a disposable local instance: `PAPERCLIP_COLLECTION_PERF_BASE_URL=http://127.0.0.1:<port> pnpm exec playwright test --config tests/perf/collections/playwright.config.ts recent-task-summaries.spec.ts`. It passes on this branch. With `master`'s hook and constant it fails: three requests of 5, 5 and 2 IDs. ## Risks - Low risk. The route still validates UUIDs, deduplicates, and applies the same read authorization per row. Blocker attention is computed with set-based queries, so 50 IDs is the same number of queries as 5. - During a rolling deploy, a new UI against an old server would get a 400 for a history above five tasks. The server serves the UI bundle, so both change together on a normal deploy. The sidebar keeps its stored snapshot when the refresh fails. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edits, browser). 1M-token context window. Extended thinking was off. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
8.6 KiB
TypeScript
184 lines
8.6 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { ISSUE_SUMMARIES_MAX_IDS, type Issue, type IssueSummary } from "@paperclipai/shared";
|
|
import { issuesApi } from "@/api/issues";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import {
|
|
RECENT_TASKS_UPDATED_EVENT,
|
|
getRecentTasksStorageKey,
|
|
mergeRecentTaskSnapshot,
|
|
migrateRecentTasks,
|
|
pruneRecentTasks,
|
|
readRecentTasks,
|
|
updateRecentTaskSnapshots,
|
|
type RecentTaskEntry,
|
|
} from "@/lib/recent-tasks";
|
|
|
|
type RecentTasksUpdatedDetail = {
|
|
storageKey: string;
|
|
entries: RecentTaskEntry[];
|
|
};
|
|
|
|
const RECENT_TASK_ORDER_DEBOUNCE_MS = 1_000;
|
|
/** Summary chunks in flight at once for a history longer than one request. */
|
|
export const RECENT_TASK_SUMMARY_CONCURRENCY = 3;
|
|
|
|
export function useRecentTasks({
|
|
companyId,
|
|
userId,
|
|
}: {
|
|
companyId: string | null | undefined;
|
|
userId: string | null | undefined;
|
|
}) {
|
|
const storageKey = useMemo(
|
|
() => companyId ? getRecentTasksStorageKey(companyId, userId) : null,
|
|
[companyId, userId],
|
|
);
|
|
const queryClient = useQueryClient();
|
|
const [snapshot, setSnapshot] = useState(() => ({
|
|
storageKey,
|
|
entries: storageKey && companyId ? readRecentTasks(storageKey, companyId) : [],
|
|
}));
|
|
// A scope change must not request the previous account's IDs for one render.
|
|
const entries = snapshot.storageKey === storageKey
|
|
? snapshot.entries
|
|
: storageKey && companyId ? readRecentTasks(storageKey, companyId) : [];
|
|
const setEntries = (next: RecentTaskEntry[]) => setSnapshot({ storageKey, entries: next });
|
|
|
|
useEffect(() => {
|
|
if (storageKey && companyId) migrateRecentTasks(storageKey, companyId);
|
|
setEntries(storageKey && companyId ? readRecentTasks(storageKey, companyId) : []);
|
|
}, [companyId, storageKey]);
|
|
|
|
useEffect(() => {
|
|
if (!storageKey || !companyId) return;
|
|
|
|
const sync = () => setEntries(readRecentTasks(storageKey, companyId));
|
|
const onStorage = (event: StorageEvent) => {
|
|
if (event.key === storageKey) sync();
|
|
};
|
|
const onRecentTasksUpdated = (event: Event) => {
|
|
const detail = (event as CustomEvent<RecentTasksUpdatedDetail>).detail;
|
|
if (detail?.storageKey === storageKey) setEntries(detail.entries);
|
|
};
|
|
|
|
window.addEventListener("storage", onStorage);
|
|
window.addEventListener(RECENT_TASKS_UPDATED_EVENT, onRecentTasksUpdated);
|
|
return () => {
|
|
window.removeEventListener("storage", onStorage);
|
|
window.removeEventListener(RECENT_TASKS_UPDATED_EVENT, onRecentTasksUpdated);
|
|
};
|
|
}, [companyId, storageKey]);
|
|
|
|
const queryIds = useMemo(() => entries.map((entry) => entry.id).sort(), [entries]);
|
|
const membership = JSON.stringify(queryIds);
|
|
const scope = JSON.stringify([storageKey, queryIds]);
|
|
const summaryKey = queryKeys.issues.summaryBatch(companyId ?? "__none__", userId, queryIds);
|
|
// A cached omission predates IDs that the user has just added back. Only a
|
|
// response completed during this visit to the batch may remove those IDs.
|
|
const batchVisit = useMemo(() => ({
|
|
initialUpdateCount: queryClient.getQueryState(summaryKey)?.dataUpdateCount ?? 0,
|
|
// The serialized scope includes the account, company, and sorted ID set.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}), [queryClient, scope]);
|
|
const summaryQuery = useQuery({
|
|
queryKey: summaryKey,
|
|
queryFn: async ({ signal }) => {
|
|
// One request covers a typical list. Longer lists fetch their chunks a
|
|
// few at a time rather than one after another or all at once.
|
|
const chunks: string[][] = [];
|
|
for (let offset = 0; offset < queryIds.length; offset += ISSUE_SUMMARIES_MAX_IDS) {
|
|
chunks.push(queryIds.slice(offset, offset + ISSUE_SUMMARIES_MAX_IDS));
|
|
}
|
|
const results: IssueSummary[][] = new Array(chunks.length);
|
|
let next = 0;
|
|
const worker = async () => {
|
|
while (next < chunks.length) {
|
|
const index = next++;
|
|
results[index] = await issuesApi.summaries(companyId!, chunks[index]!, { signal });
|
|
}
|
|
};
|
|
await Promise.all(Array.from({ length: Math.min(RECENT_TASK_SUMMARY_CONCURRENCY, chunks.length) }, worker));
|
|
return results.flat();
|
|
},
|
|
enabled: Boolean(companyId && queryIds.length),
|
|
staleTime: 30_000,
|
|
});
|
|
// Observe completion even when structural sharing preserves the data object.
|
|
// The successful-update counter itself is only available on the cache state.
|
|
const fetchingSummaries = summaryQuery.isFetching;
|
|
const updateCount = queryClient.getQueryState(summaryKey)?.dataUpdateCount ?? 0;
|
|
const summaries = summaryQuery.isSuccess ? summaryQuery.data : undefined;
|
|
const confirmed = new Map((summaries ?? []).filter((issue) => issue.companyId === companyId)
|
|
.map((issue) => [issue.id, issue]));
|
|
const refreshedEntries = entries.map((entry) => {
|
|
const issue = confirmed.get(entry.id);
|
|
return issue ? mergeRecentTaskSnapshot(entry, issue) : entry;
|
|
});
|
|
const processed = useRef<{ scope: string; revision: number } | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!storageKey || !companyId || !summaries) return;
|
|
if (processed.current?.scope === scope && processed.current.revision === updateCount) return;
|
|
processed.current = { scope, revision: updateCount };
|
|
const returnedIds = new Set(summaries.filter((issue) => issue.companyId === companyId).map((issue) => issue.id));
|
|
updateRecentTaskSnapshots(storageKey, companyId, summaries);
|
|
const omittedIds = new Set(queryIds.filter((id) => !returnedIds.has(id)));
|
|
if (omittedIds.size && updateCount <= batchVisit.initialUpdateCount) {
|
|
void summaryQuery.refetch({ cancelRefetch: false });
|
|
return;
|
|
}
|
|
// Never prune IDs added after this particular batch was requested.
|
|
pruneRecentTasks(storageKey, companyId, omittedIds);
|
|
// A storage notification must not republish the same successful revision.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [batchVisit, companyId, fetchingSummaries, scope, updateCount, summaries, storageKey]);
|
|
|
|
useEffect(() => {
|
|
if (!storageKey || !companyId || !summaries) return;
|
|
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;
|
|
if (!authorizedIds.has(issue.id) || issue.companyId !== companyId || issue.hiddenAt
|
|
|| typeof issue.title !== "string" || typeof issue.status !== "string"
|
|
|| !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 = summaryById.get(issue.id);
|
|
if (!summary || new Date(issue.updatedAt).getTime() <= new Date(summary.updatedAt).getTime()) return;
|
|
updateRecentTaskSnapshots(storageKey, companyId, [issue]);
|
|
};
|
|
for (const id of authorizedIds) mergeCached(queryClient.getQueryData(queryKeys.issues.detail(id)));
|
|
return queryClient.getQueryCache().subscribe((event) => {
|
|
if (event.type !== "updated" || event.action.type !== "success") return;
|
|
const key = event.query.queryKey;
|
|
if (key.length === 3 && key[0] === "issues" && key[1] === "detail"
|
|
&& event.query.state.status === "success") mergeCached(event.query.state.data);
|
|
});
|
|
}, [companyId, queryClient, summaries, storageKey]);
|
|
|
|
const [settledOrder, setSettledOrder] = useState(() => entries.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);
|
|
// Additions and removals are immediate. Only activity-driven moves wait for quiet.
|
|
if (JSON.stringify([...settledOrder].sort((a, b) => a.localeCompare(b))) !== membership) {
|
|
setSettledOrder(latestOrder);
|
|
return;
|
|
}
|
|
if (latestOrder.every((id, index) => id === settledOrder[index])) return;
|
|
const timeout = window.setTimeout(() => setSettledOrder(latestOrder), RECENT_TASK_ORDER_DEBOUNCE_MS);
|
|
return () => window.clearTimeout(timeout);
|
|
}, [activityRevision, membership, settledOrder, storageKey]);
|
|
|
|
const entryById = new Map(refreshedEntries.map((entry) => [entry.id, entry]));
|
|
const hasSameMembership = settledOrder.length === entries.length && settledOrder.every((id) => entryById.has(id));
|
|
|
|
return {
|
|
entries: hasSameMembership ? settledOrder.map((id) => entryById.get(id)!) : refreshedEntries,
|
|
storageKey,
|
|
};
|
|
}
|