feat(ui): retain approved reads across outage reloads (#18825)

## Thinking Path

> - Paperclip lets people manage agent work through the board.
> - The board already retains loaded reads during a temporary server
failure.
> - Reloading the page removes that in-memory data.
> - A deploy can then leave a returning user without the shell or recent
tasks.
> - This pull request saves an explicit, limited set of reads in the
browser.
> - Cached identity supports display while fresh session checks protect
writes.

## Linked Issues or Issue Description

**What happened?**
A reload during an API outage loses the board's loaded reads. An
automatic worker update can also reload a hidden tab while a write is
pending.

**Expected behavior**
Show the cached shell and recent tasks during a temporary outage. Limit
saved content by user, company, age and build. Clear it on sign-out.
Wait for a fresh session before sending account-bound writes. Defer
automatic reloads while work is pending.

**Steps to reproduce**
Open a task and settings while online. Return 503 from the API and
reload. Restore the API. Sign out and confirm that saved query records
are gone. Discover a worker update while offline or during an autosave
and confirm that it waits.

Stacked on Refs #18822. Related durable-write foundation: Refs #17960.
The outage browser suite follows in a separate test PR.

## What Changed

- Persist an exact read allowlist through TanStack Query dehydration and
hydration. Save shell preferences, company/sidebar data, agent/project
lists, plugin UI metadata, the first inbox page, and 20 recent tasks.
Task UUID and identifier reads share one recent-task slot.
- Namespace records by user and company. Retain eligible reads across
normal memory garbage collection, expire each read after 24 hours, and
discard them after a build change. Unstamped builds disable hydration
and persistence while retaining sign-out cleanup. Use the existing
instance-wide plugin contribution query in the user's root record.
- Exclude session credentials and bootstrap data, embedded plan
documents, comments, preview work products, provider credentials and
environment values, browser capabilities, pairing data, other queries,
and mutations. Restore only display identity from session data. Preserve
safe agent provider/model display fields and configuration object
shapes. Apply the projection on save and restore.
- Clear saved reads on sign-out, cross-tab account changes, and
definitive session denial. Keep revocation working on plain HTTP without
crypto.randomUUID. Use per-user revocation revisions to reject delayed
writes and stale records. Preserve a newer account. Remove every
UUID/identifier projection and nested inbox copy of a denied task.
- Detach shared GETs when the account changes or signs out. Load
complete, freshly authorized agents before opening bulk settings from a
display-only roster. Retire a pending bulk-editor result or error when
row, select-all or clear changes the selection, including a
clear/reselect of the same agent.
- Require a fresh server lookup before account-bound writes, pending
task creation, or mounted autosaves resume. This includes automatic task
read receipts, inbox archives, workspace preferences and primary-agent
choices, with optimistic archive changes gated too. Cancel the old write
scope when the verified user changes. Release completed archives through
account-independent cleanup without restoring old cached rows; an old
operation cannot clear a newer hide flag. Retry unconfirmed read
receipts after a fresh session succeeds for the original account, and
remember confirmed receipts across refreshes. Track pending reads per
task so opening another task sends its receipt immediately; revisiting
reuses an outstanding receipt and its original account scope.
- Defer a hidden-tab worker reload until connectivity, mutations,
durable sends, and autosaves are safe. Synchronously count each
unresolved operation across online rate-limit retries. Release a
cancelled target’s unused dirty token when an editor changes tasks; keep
stored drafts, admitted saves and queued retries. Cover autosave
debounce, newer edits, navigation and undo.

## Verification

- Current head `fe4f682c879` is merge-ready: all 54 check runs completed
(52 successful, one skipped, one neutral), the legacy Snyk status is
successful, and both `ci / verify` and `ci / e2e` pass in run
`41752927822`. Greptile Apex reviewed this exact head at 5/5 with zero
unresolved threads; the branch has no merge conflicts. All three PRs now
meet the merge-ready bar; the stack remains unmerged pending operator
approval.
- Greptile Apex reviewed exact current head `fe4f682c879` at 5/5 with no
actionable findings and zero unresolved threads (summary updated
2026-11-04 at 01:24:40 UTC). Both settings-account and pending-selection
findings are fixed, replied to and resolved. The branch has no merge
conflicts. Current-head CI now passes in run `41752927822`.
- Seventeen new cases cover settings restored from actual saved query
records, matching/changed/signed-out identities, denied verification,
late crown responses without overwriting newer reads, and bulk-selection
changes during successful/failed reads. Sixteen fail the preceding
source and one preserves denial compatibility. Both settings paths reuse
the existing account-bound mutation guard; its optional explicit
QueryClient supports the workspace hook’s existing detached-host mode.
Primary-agent optimistic updates begin only after verification and
cleanup checks ownership and the cache revision.
- Earlier CI run `41749456480` on prior head `2cf7516f18c` has one real
server test failure: the owned-runtime case in
`idle-completed-history.test.ts` could not reach its HTTP child on port
34757 within 10 seconds (99 refused probes). Its other 1,829 cases pass
and three skip; the only other unsuccessful job is the downstream
`verify` gate. The exact selected case passes locally, and the entire
75-case file passes in 72.19 seconds on the exact `2cf7516f18c` source
tree with isolated PostgreSQL and a real child process on macOS
arm64/Node 25.6.1. The Linux CI startup cause remains unknown; these
local results are not a fix or CI acceptance. One failed-jobs retry with
debug logging was accepted on that unchanged head, then superseded by
the later settings fixes. Current-head CI remains required; no source
change was made for this failure.
- Current cache head `fe4f682c879`, based on read head `09d1db29dea` and
master `683fa4725e9`, passes all 1,198 focused tests across twenty-eight
files, UI typecheck, token gates and the enforcing query-error guard.
Dependent browser head `228c1b68d30` contains this exact application
source and passes standalone strict checking of all three browser specs.
Current-head CI now passes in run `41752927822`.
- Three new navigation regressions fail the preceding application source
and pass with the fix. They open three cached tasks before any receipt
resolves, leave the page before success or failure, revisit an
outstanding read without duplicating it, and recover an older failed
receipt after a fresh session succeeds while a later task remains
pending.
- The fixture repair passes all seven deadline integration cases and the
complete server typecheck, including the server test layer. The old
assertion deterministically fails at 23:59:06.888 UTC; its
one-second-old run also fails the first-second-of-day probe. The
corrected fixture passes noon and both midnight probes. The permanent
three-case coverage uses seeded 2099 clocks.
- Nine new cases cover archive success/failure after sign-out and
another account takes over, both mounted and unmounted; cleanup
ownership when a newer archive uses the same task; session-check 429
exhaustion followed by matching, changed or signed-out identity; and a
failed read receipt followed by a new session read. They verify no
old-cache restoration, toast or navigation, retained newer hide flags,
no failed-check loop and no duplicate confirmed receipt. Seven fail the
previous implementation; two compatibility cases retain account-change
suppression. Initial test error-constructor and query-notification
timing assumptions were corrected before the passing checks.
- The inherited skills/app error-priority fix passes all 60 affected
tests and UI typecheck on both application branches. Its twelve new
regression cases fail the preceding read implementation. The
persisted-cache suite includes 48 cases.
- Six new regressions fail against the preceding application source and
pass with the fix. Actual worker-update and mutation-cache listeners
cannot reload a hidden tab between online 429 retries; the original
create key survives, and the update reloads once after a receipt. Real
self-hosted and Cloud sign-out work without crypto.randomUUID, with
stamped and unstamped builds. Saved reads are removed and account write
scopes are invalidated. An initial test-fixture type annotation error
was corrected before the passing typecheck.
- The 48 cache tests cover actual five-minute memory GC, the 20-task
bound across saved and live reads, original 24-hour expiry, denial
before session verification, unstamped cleanup, cross-tab revocation and
delayed flushes. Private GETs cannot cross account transitions. Bulk
edits require complete current agent data. Undo releases the update
blocker while preserving the Saved indicator.
- All 23 built-UI outage/write/loading cases pass in 4.5 minutes on
exact integrated head `228c1b68d30`, containing current cache revision
`fe4f682c879`: ten outage, six durable-write and seven loading cases.
The run uses bundled Chromium, the built UI, a fresh local-trusted
server and isolated embedded PostgreSQL on macOS arm64/Node 25.6.1. All
three specs pass standalone strict TypeScript checking. Owned temporary
server and database stopped.
- Earlier browser runs exposed and fixed cached-header, native Storage
cleanup and replay/session defects. A prior run passed 21/22 because
real WebSockets contradicted the simulated HTTP outage; the fixture now
closes existing socket pairs and holds new sockets through the same
outage deadline. An older CI run exposed a disposed response in the
Inbox route callback; pre-navigation snapshots remove that lifetime, and
the current full run includes that repair. Earlier failures and their
diagnosis remain in the discussion. The current full run passes. Broader
historical workspace failures remain documented in Refs #18822; this
does not claim a clean full workspace suite.
- Current-head CI passes on `fe4f682c879`. Controlled local outages do
not complete hosted authentication or a real Cloud restart/deploy
acceptance.

Commands for the current cache change and inherited read fix (the
current focused run also includes src/lib/inboxArchiveCache.test.ts and
src/pages/Inbox.test.ts):

```sh
pnpm --filter @paperclipai/ui exec vitest run src/pages/CompanySkills.test.tsx src/pages/apps/AppNotConnected.test.tsx
pnpm --filter @paperclipai/ui exec vitest run src/hooks/useDurableSubmit.test.tsx src/hooks/useDurableAutosave.test.tsx src/hooks/useSignOut.test.tsx src/lib/persisted-query-cache.test.ts src/lib/service-worker-updates.test.ts src/lib/service-worker-cache-policy.test.ts src/lib/pending-send.test.ts src/lib/issue-execution-policy.test.ts src/components/InlineEditor.test.tsx src/components/MarkdownEditor.test.tsx src/components/NewIssueDialog.test.tsx
pnpm --filter @paperclipai/ui exec vitest run src/components/agents/AgentBulkRoster.test.tsx src/components/agents/AgentBulkSettingsSheet.test.tsx src/hooks/useAccountBoundMutation.test.tsx src/pages/IssueDetail.test.tsx src/components/TaskChatThread.test.tsx src/components/TaskChatThread.task-creation-order.test.tsx src/api/client.test.ts src/api/agents.test.ts
pnpm --filter @paperclipai/ui exec vitest run src/hooks/useCoordinatorWorkspaceEnabled.test.tsx src/context/PrimaryAgentProvider.test.tsx src/components/CoordinatorWorkspaceSetting.test.tsx src/components/primary-agent/PrimaryAgentPresentation.test.tsx src/components/MobileBottomNav.coordinator.test.tsx src/components/CoordinatorSidebar.test.tsx src/pages/Issues.coordinator-routing.test.tsx
pnpm --filter @paperclipai/ui typecheck
pnpm check:token-gates
node scripts/check-query-error-rendering.mjs --enforce --summary
```

## Risks

This deliberately saves task content on the local browser for up to 24
hours. The allowlist excludes credentials and ephemeral capabilities.
Local storage can be disabled or full; persistence then fails without
blocking the app. Restored data can be stale and never grants server
access. A user or access change clears cached account reads and
invalidates pending writes. The browser outage suite and real hosted
deploy acceptance remain release requirements. There are no migrations
or new dependencies.

## Model Used

OpenAI `gpt-6.1-sol` through Codex, with xhigh reasoning effort, tool
use, and code execution. The context window size is not exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used
- [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 described the issue in-PR following the bug report template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] The focused local tests for this change pass
- [x] I have added tests and updated relevant documentation
- [x] I have considered and documented risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Bender (Fable) <bender@agents.paperclip.ing>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-11-04 15:40:30 -08:00
1 parent 35d086213e
commit 2ccc7d37ef
41 files changed
+2637 -137

No files matched your search

+12
View File
@@ -4555,3 +4555,15 @@ secret handling, initialization order and migration interfaces.
Read surfaces use `useQueryView` and `QueryErrorState`. Transient refetch failures keep loaded data visible. An initial outage keeps a quiet placeholder until recovery. Authorization, missing resources and validation failures still show readable errors. Exhausted first reads on a reachable server offer Retry, including task filters and hierarchy reads. Health probes and the socket own app-wide recovery; a successful component load may be an older in-flight response or a local no-op. Sensitive manual reads use `useResilientFetch`; browser viewer URLs and pairing previews remain in component memory and never enter a shared cache.
Run `node scripts/check-query-error-rendering.mjs`. The guard now enforces across `ui/src`, including multiline expressions, and CI runs it as a policy check. Its small allowlist covers standalone preview/performance fixtures, transcript protocol result flags, and the existing account-resolution and Copilot discovery retry owners. These are not ordinary read-error renders. Additions require a specific reason; do not allowlist a page to hide an outage regression.
The browser saves a small read cache in `paperclip:query-cache:v1:*` local storage records. It contains session display identity, health and shell preferences, company and sidebar data, agent/project lists, plugin UI contribution metadata, the first inbox page (including up to 100 rows per legacy Inbox query), and up to 20 recently opened tasks. Records belong to one user and company, expire 24 hours after each read, and are discarded when the build changes. Normal in-memory query garbage collection does not erase saved recent tasks or extend their expiry. Builds without a source commit stamp skip read-cache hydration and persistence, but still revoke and clear saved reads on sign-out. Plugin contributions use the existing instance-wide query inside the user's root record. Session tokens, bootstrap payloads, documents, comments, browser viewer capabilities, pairing data, other queries and mutations are excluded. Task content in this allowlist remains on the browser until expiry or sign-out; use the normal sign-out action to clear it across tabs. Account changes also detach pending shared GETs so a new user cannot join the previous user’s private reads. Editors that need fields excluded from the display cache, including bulk agent settings, fetch complete current data before opening.
Restored identity permits cached display only. Restored inline task drafts compare their saved base with a fresh field read before each replay; conflicting edits stay in the editor for review. Account-bound writes wait for a fresh server session and are cancelled if its user changes. A definitive session denial removes saved account reads. Service-worker updates reload hidden tabs only after connectivity is online and mutations, durable sends and autosaves have settled. Durable queues synchronously report each unresolved operation, including the intervals between online rate-limit retries. Sign-out revocation also works on plain-HTTP installs without `crypto.randomUUID`.
Embedded plan documents and preview work products are stripped from saved payloads. A denied task clears its UUID and identifier projections. Sign-out revocation markers contain no task content and remain after sign-out to reject stale records and delayed writes from older tabs. Session responses started before revocation cannot restore those reads, even before the storage event reaches the other tab.
Cached agents retain only safe adapter display fields and an empty runtime configuration. Their configuration objects remain present for existing display consumers; provider credentials and environment values are excluded.
A restored task shows its header and properties while uncached history is still loading. History is not persisted, and its normal placeholder remains until reads recover. The header stays visible across recovery.
Query-cache cleanup snapshots storage keys before deletion because native storage can reorder them as records are removed. Flush cleanup affects only its owning account and yields if another account becomes active.
+3
View File
@@ -234,6 +234,9 @@ test("a seeded Classic loading title uses the resolved title typography", async
await page.getByRole("link").filter({ hasText: fixture.title }).first().click();
await expect(page.getByTestId("issue-detail-header").getByRole("heading", { name: fixture.title, exact: true })).toBeVisible();
await page.route("**/api/issues/*", async route => { await coreReady; await route.continue(); });
// Keep the recent-task title seed, but exercise the loading frame without
// a persisted full task that would correctly reveal the resolved header.
await page.addInitScript(() => Object.keys(localStorage).filter(key => key.startsWith("paperclip:query-cache:v1:")).forEach(key => localStorage.removeItem(key)));
await page.reload({ waitUntil: "domcontentloaded" });
const loadingTitle = page.getByRole("heading", { name: fixture.title, exact: true });
await expect(loadingTitle).toBeVisible();
+5 -5
View File
@@ -39,7 +39,7 @@ import type {
AgentApiKeyScope,
} from "@paperclipai/shared";
import { isUuidLike, normalizeAgentUrlKey } from "@paperclipai/shared";
import { ApiError, api } from "./client";
import { ApiError, api, type RequestOptions } from "./client";
export interface AgentKey {
id: string;
@@ -127,9 +127,9 @@ export const agentsApi = {
org: (companyId: string) => api.get<OrgNode[]>(`/companies/${companyId}/org`),
listConfigurations: (companyId: string) =>
api.get<Record<string, unknown>[]>(`/companies/${companyId}/agent-configurations`),
get: async (id: string, companyId?: string) => {
get: async (id: string, companyId?: string, options?: RequestOptions) => {
try {
return await api.get<AgentDetail>(agentPath(id, companyId));
return await api.get<AgentDetail>(agentPath(id, companyId), options);
} catch (error) {
// Backward-compat fallback: if backend shortname lookup reports ambiguity,
// resolve using company agent list while ignoring terminated agents.
@@ -145,12 +145,12 @@ export const agentsApi = {
const urlKey = normalizeAgentUrlKey(id);
if (!urlKey) throw error;
const agents = await api.get<Agent[]>(`/companies/${companyId}/agents`);
const agents = await api.get<Agent[]>(`/companies/${companyId}/agents`, options);
const matches = agents.filter(
(agent) => agent.status !== "terminated" && normalizeAgentUrlKey(agent.urlKey) === urlKey,
);
if (matches.length !== 1) throw error;
return api.get<AgentDetail>(agentPath(matches[0]!.id, companyId));
return api.get<AgentDetail>(agentPath(matches[0]!.id, companyId), options);
}
},
getConfiguration: (id: string, companyId?: string) =>
+5
View File
@@ -262,6 +262,11 @@ export function detachInflightGet(path: string): void {
inflightGets.delete(path);
}
/** End the sharing scope when identity changes; existing callers still settle. */
export function detachInflightGets(): void {
inflightGets.clear();
}
function isRequestOptions(value: unknown): value is RequestOptions {
return typeof value === "object" && value !== null && "signal" in value;
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { companiesApi } from "./companies";
import { ApiError } from "./client";
import { queryKeys } from "../lib/queryKeys";
import { useBootstrapQuery } from "./bootstrap-query";
import { hasUnverifiedSession } from "../lib/cached-session";
export type CompanyListResult = { companies: Company[]; unauthorized: boolean };
@@ -140,7 +141,7 @@ export async function resolveAccountUserId(queryClient: QueryClient): Promise<st
// breath. The entry is still `success`, still holding the *old* user, until a
// refetch lands; trusting it would key the new account's list to the old
// account's id, which is the misattribution this whole file exists to prevent.
if (state?.status === "success" && !state.isInvalidated) {
if (state?.status === "success" && !state.isInvalidated && !hasUnverifiedSession(queryClient)) {
return state.data?.user.id ?? null;
}
const session = await queryClient.fetchQuery({ ...sessionQueryOptions, staleTime: 0 });
+107
View File
@@ -4,6 +4,9 @@ import { act, forwardRef, useImperativeHandle, useRef, type ReactNode } from "re
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { hasPendingWrites } from "../lib/reload-safety";
import { markCachedSession } from "../lib/cached-session";
import { startPersistedQueryCache } from "../lib/persisted-query-cache";
import { queryKeys } from "../lib/queryKeys";
const mockSession = vi.hoisted(() => ({ userId: "user-1", pending: null as Promise<{ user: { id: string } } | null> | null }));
@@ -217,6 +220,56 @@ describe("InlineEditor", () => {
act(() => root.unmount());
});
it.each([false, true])("checks a restored draft against a fresh field after session verification (multiline=%s)", async (multiline) => {
const key = "paperclip:inline-draft:v2:user-1:cached-field";
localStorage.setItem(key, JSON.stringify({ version: 1, draft: "My draft B", base: "Cached A" }));
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
const cache = startPersistedQueryCache(client, { storage: null, buildId: "test", eventTarget: null });
markCachedSession(client);
let releaseSession!: () => void;
mockSession.pending = new Promise(resolve => { releaseSession = () => resolve({ user: { id: "user-1" } }); });
let releaseRead!: (value: string) => void;
const readCurrentValue = vi.fn<() => Promise<string>>().mockImplementationOnce(() => new Promise(resolve => { releaseRead = resolve; })).mockResolvedValue("New server C");
const onSave = vi.fn().mockResolvedValue(undefined);
const root = createRoot(container);
const render = (value: string) => root.render(<QueryClientProvider client={client}>
<InlineEditor value={value} multiline={multiline} draftKey="cached-field" onSave={onSave} readCurrentValue={readCurrentValue} />
</QueryClientProvider>);
try {
await act(async () => render("Cached A"));
expect(onSave).not.toHaveBeenCalled();
expect(readCurrentValue).not.toHaveBeenCalled();
await act(async () => releaseSession());
expect(readCurrentValue).toHaveBeenCalledOnce();
expect(onSave).not.toHaveBeenCalled();
await act(async () => releaseRead("New server C"));
await act(async () => render("New server C"));
expect(container.querySelector("textarea")?.value).toBe("My draft B");
expect(localStorage.getItem(key)).toContain("My draft B");
expect(onSave).not.toHaveBeenCalled();
if (!multiline) {
expect(container.querySelector("textarea")?.getAttribute("aria-invalid")).toBe("true");
expect(container.querySelector("textarea")?.getAttribute("aria-description")).toContain("This changed since you loaded it");
await act(async () => container.querySelector("textarea")!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })));
expect(readCurrentValue).toHaveBeenCalledTimes(2);
expect(onSave).not.toHaveBeenCalled();
}
} finally { act(() => root.unmount()); cache.dispose(); client.clear(); mockSession.pending = null; }
});
it("replays a restored draft after a fresh field confirms its base", async () => {
localStorage.setItem("paperclip:inline-draft:v2:user-1:confirmed-field", JSON.stringify({ version: 1, draft: "Confirmed draft", base: "Original" }));
const readCurrentValue = vi.fn().mockResolvedValue("Original");
const onSave = vi.fn().mockResolvedValue(undefined);
const root = createRoot(container);
try {
await act(async () => root.render(signedIn("user-1", <InlineEditor value="Original" draftKey="confirmed-field" onSave={onSave} readCurrentValue={readCurrentValue} />)));
expect(readCurrentValue).toHaveBeenCalledOnce();
expect(onSave).toHaveBeenCalledWith("Confirmed draft");
} finally { act(() => root.unmount()); }
});
it("restores an unsaved edit and saves it when the server value has not moved", async () => {
localStorage.setItem("paperclip:inline-draft:v2:user-1:issue-1:description", JSON.stringify({ version: 1, draft: "Typed before reload", base: "Original" }));
const onSave = vi.fn().mockResolvedValue(undefined);
@@ -255,6 +308,36 @@ describe("InlineEditor", () => {
}
});
it("releases a cancelled description debounce when switching cached tasks and keeps its stored draft", async () => {
vi.useFakeTimers();
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
const saveA = vi.fn().mockResolvedValue(undefined);
const saveB = vi.fn().mockResolvedValue(undefined);
const root = createRoot(container);
const renderTask = (id: string, onSave: typeof saveA) => root.render(
<QueryClientProvider client={client}>
<InlineEditor value={`Original ${id.toUpperCase()}`} multiline draftKey={`task-${id}:description`} onSave={onSave} />
</QueryClientProvider>,
);
try {
await act(async () => renderTask("a", saveA));
act(() => enterMultilineEdit(container));
act(() => {
const textarea = container.querySelector<HTMLTextAreaElement>('[data-testid="multiline-md-mock"]')!;
textarea.focus();
setNativeTextareaValue(textarea, "A draft before navigation");
});
expect(hasPendingWrites()).toBe(true);
await act(async () => renderTask("b", saveB));
expect(hasPendingWrites()).toBe(false);
await act(async () => { await vi.advanceTimersByTimeAsync(1_000); });
expect(saveA).not.toHaveBeenCalled();
expect(saveB).not.toHaveBeenCalled();
expect(localStorage.getItem("paperclip:inline-draft:v2:user-1:task-a:description")).toContain("A draft before navigation");
} finally { act(() => root.unmount()); client.clear(); localStorage.clear(); vi.useRealTimers(); }
});
it("restores an unsaved edit without overwriting a newer server value", async () => {
localStorage.setItem("paperclip:inline-draft:v2:user-1:issue-2:description", JSON.stringify({ version: 1, draft: "My old edit", base: "Original" }));
const onSave = vi.fn().mockResolvedValue(undefined);
@@ -419,6 +502,30 @@ describe("InlineEditor", () => {
});
});
it("releases update blocking when a newer multiline edit is undone after an older save completes", async () => {
vi.useFakeTimers();
const root = createRoot(container);
let complete!: () => void;
const onSave = vi.fn().mockImplementationOnce(() => new Promise<void>(resolve => { complete = resolve; }));
try {
await act(async () => root.render(<InlineEditor value="Original" multiline defaultEditing onSave={onSave} />));
const textarea = container.querySelector<HTMLTextAreaElement>("textarea")!;
act(() => { textarea.focus(); setNativeTextareaValue(textarea, "First edit"); });
await act(async () => { await vi.advanceTimersByTimeAsync(1000); });
expect(onSave).toHaveBeenCalledWith("First edit");
act(() => setNativeTextareaValue(textarea, "Newer edit"));
await act(async () => { complete(); await Promise.resolve(); });
expect(container.textContent).toContain("Saved");
expect(hasPendingWrites()).toBe(true);
act(() => setNativeTextareaValue(textarea, "Original"));
expect(container.textContent).toContain("Saved");
expect(hasPendingWrites()).toBe(false);
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
expect(onSave).toHaveBeenCalledTimes(1);
expect(container.textContent).not.toContain("Saved");
} finally { act(() => root.unmount()); vi.useRealTimers(); }
});
it("multiline nullable clear uses autosave path (shows Saved after blur)", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const root = createRoot(container);
+37 -12
View File
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback, useId } from "react";
import { cn } from "../lib/utils";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { ApiError } from "../api/client";
import { useDurableAutosave } from "../hooks/useDurableAutosave";
import { useDraftOwner } from "../hooks/useDraftOwner";
import { loadStructuredDraft, saveStructuredDraft } from "../lib/composer-draft";
@@ -40,6 +41,8 @@ interface InlineEditorProps {
* outage does not lose them. A stored edit is restored and saved on mount.
*/
draftKey?: string;
/** Fresh field read used before replaying a restored draft; cached values are display only. */
readCurrentValue?: () => Promise<string>;
}
/** Shared padding so display and edit modes occupy the exact same box. */
@@ -88,6 +91,7 @@ function OwnedInlineEditor({
defaultEditing = false,
onEditingChange,
draftKey,
readCurrentValue,
draftOwner,
}: InlineEditorProps & { draftOwner: string | undefined }) {
const [editing, setEditing] = useState(false);
@@ -106,6 +110,10 @@ function OwnedInlineEditor({
const [draft, setDraft] = useState(value);
const unownedDraft = useRef<StoredInlineDraft | null>(null);
const lastPropValueRef = useRef(value);
const lastDraftKeyRef = useRef(draftKey);
const restoredBaseRef = useRef<StoredInlineDraft | null>(null);
const readCurrentValueRef = useRef(readCurrentValue);
readCurrentValueRef.current = readCurrentValue;
const inputRef = useRef<HTMLTextAreaElement>(null);
const markdownRef = useRef<MarkdownEditorRef>(null);
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -120,7 +128,19 @@ function OwnedInlineEditor({
await onSave(next);
},
});
const { markDirty, reset, run: runAutosave } = autosave;
const { markDirty, clearDirty, reset, run: runAutosave } = autosave;
const runInlineAutosave = useCallback((next: string) => {
const restored = restoredBaseRef.current;
const readCurrent = readCurrentValueRef.current;
return runAutosave(next, {
beforeSave: restored && restored.draft.trim() === next && readCurrent ? async () => {
const current = await readCurrent();
if (current !== restored.base && current.trim() !== next) {
throw new ApiError("This changed since you loaded it. Your draft is kept for review.", 409, {});
}
} : undefined,
});
}, [runAutosave]);
const autosaveState = autosave.status;
const autosaveStateRef = useRef(autosaveState);
autosaveStateRef.current = autosaveState;
@@ -147,6 +167,7 @@ function OwnedInlineEditor({
}, [clearStoredDraft, storageKey, value]);
const changeDraft = useCallback((next: string) => {
restoredBaseRef.current = null;
setDraft(next);
if (draftOwner === undefined) unownedDraft.current = { version: 1, draft: next, base: value };
if (!storageKey) return;
@@ -156,14 +177,15 @@ function OwnedInlineEditor({
useEffect(() => {
const previousValue = lastPropValueRef.current;
const sameTarget = lastDraftKeyRef.current === draftKey;
lastPropValueRef.current = value;
lastDraftKeyRef.current = draftKey;
if (sameTarget && previousValue === value) return;
setDraft((currentDraft) => {
if (multiline && multilineFocused && currentDraft !== previousValue) {
return currentDraft;
}
if (sameTarget && (editing || multilineEditing) && currentDraft !== previousValue) return currentDraft;
return value;
});
}, [value, multiline, multilineFocused]);
}, [value, draftKey, editing, multilineEditing]);
// An edit that never saved (reload, crash, outage) comes back. It saves on
// its own only when the server value is still the one it was based on;
@@ -172,6 +194,7 @@ function OwnedInlineEditor({
useEffect(() => {
if (!storageKey || restoredDraftRef.current === storageKey) return;
restoredDraftRef.current = storageKey;
restoredBaseRef.current = null;
if (unownedDraft.current) {
if (draft.trim() !== value.trim()) saveStructuredDraft(storageKey, { ...unownedDraft.current, draft });
else clearStoredDraft();
@@ -184,6 +207,7 @@ function OwnedInlineEditor({
clearStoredDraft();
return;
}
restoredBaseRef.current = stored;
setDraft(stored.draft);
if (multiline) {
setMultilineEditing(true);
@@ -191,7 +215,7 @@ function OwnedInlineEditor({
} else {
setEditing(true);
}
if (stored.base === value) void runAutosave(stored.draft.trim());
if (stored.base === value) void runInlineAutosave(stored.draft.trim());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storageKey]);
@@ -284,10 +308,10 @@ function OwnedInlineEditor({
if (!multiline) setEditing(false);
return;
}
const result = await runAutosave(valueToSave);
const result = await runInlineAutosave(valueToSave);
// A single-line edit stays open until it is saved, so its text is not lost.
if (!multiline && result.kind === "saved") setEditing(false);
}, [clearStoredDraft, draft, draftKey, multiline, nullable, runAutosave, value]);
}, [clearStoredDraft, draft, draftKey, multiline, nullable, runInlineAutosave, value]);
/** Multiline blur/submit: show autosave indicator when persisting */
const finalizeMultilineBlurOrSubmit = useCallback(() => {
@@ -348,9 +372,8 @@ function OwnedInlineEditor({
const trimmed = draft.trim();
// Nullable: empty draft can still be a real edit (clearing); only skip debounce when unchanged or empty is invalid.
if (trimmed === value || (!trimmed && !nullable)) {
if (autosaveStateRef.current !== "saved") {
reset();
}
if (autosaveStateRef.current === "saved") clearDirty();
else reset();
return;
}
markDirty();
@@ -366,7 +389,7 @@ function OwnedInlineEditor({
clearTimeout(autosaveDebounceRef.current);
}
};
}, [commit, draft, markDirty, multiline, multilineFocused, nullable, reset, value]);
}, [clearDirty, commit, draft, markDirty, multiline, multilineFocused, nullable, reset, value]);
if (multiline) {
const previewValue = autosaveState === "saved" || autosaveState === "idle" ? draft : value;
@@ -486,6 +509,8 @@ function OwnedInlineEditor({
<textarea
ref={inputRef}
value={draft}
aria-invalid={autosaveState === "error" || undefined}
aria-description={autosaveState === "error" ? autosave.label ?? undefined : undefined}
rows={1}
onChange={(e) => {
changeDraft(e.target.value);
+35 -3
View File
@@ -8,6 +8,8 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "../lib/queryKeys";
import { markCachedSession } from "../lib/cached-session";
import { startPersistedQueryCache } from "../lib/persisted-query-cache";
import { trackRecentAssignee, trackRecentAssigneeUser } from "../lib/recent-assignees";
import { getLastProjectId, trackRecentProject } from "../lib/recent-projects";
import { rememberComposerEffort } from "../lib/recent-composer-effort";
@@ -339,8 +341,8 @@ async function waitForAssertion(assertion: () => void, attempts = 20) {
throw lastError;
}
function renderDialog(container: HTMLDivElement, hiddenSettings: string[] = []) {
const queryClient = new QueryClient({
function renderDialog(container: HTMLDivElement, hiddenSettings: string[] = [], client?: QueryClient) {
const queryClient = client ?? new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
@@ -1784,6 +1786,36 @@ describe("NewIssueDialog", () => {
} finally { act(() => root.unmount()); queryClient.clear(); }
});
it.each(["user-1", "user-2"])("waits for fresh session verification before restoring a pending create (%s)", async verifiedUser => {
dialogState.newIssueDefaults = { title: "Saved task" };
localStorage.setItem("paperclip:issue-draft:pending-create:user-1", JSON.stringify({ version: 1,
key: "paperclip:issue-draft:pending-create:user-1", idempotencyKey: "unverified-create-key", createdAt: Date.now(),
payload: { fingerprint: "{}", data: { companyId: "company-1", title: "Saved task" }, stagedFileNames: [] } }));
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
const cache = startPersistedQueryCache(client, { storage: null, buildId: "test", eventTarget: null });
markCachedSession(client);
let complete!: (value: { user: { id: string } }) => void;
mockAuthApi.getSession.mockReturnValue(new Promise(resolve => { complete = resolve; }));
const { root } = renderDialog(container, [], client);
try {
await flush();
const resend = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Resend now");
if (resend) act(() => resend.click());
await waitForAssertion(() => expect(mockAuthApi.getSession).toHaveBeenCalled());
expect(mockIssuesApi.create).not.toHaveBeenCalled();
await act(async () => { complete({ user: { id: verifiedUser } }); });
if (verifiedUser === "user-1") {
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalledTimes(1));
expect(mockIssuesApi.create.mock.calls[0][1].idempotencyKey).toBe("unverified-create-key");
} else {
await flush();
expect(mockIssuesApi.create).not.toHaveBeenCalled();
expect(mockIssuesApi.uploadAttachment).not.toHaveBeenCalled();
}
} finally { act(() => root.unmount()); cache.dispose(); client.clear(); }
});
it.each([false, true])("retains a restored task backup after the debounce (session known=%s)", async (sessionKnown) => {
if (!sessionKnown) mockAuthApi.getSession.mockReturnValue(new Promise(() => {}));
localStorage.setItem("paperclip:issue-draft:user-1", JSON.stringify({
@@ -1929,7 +1961,7 @@ describe("NewIssueDialog", () => {
await flush(); await flush();
finish(during === "create" ? created : {});
await waitForAssertion(() => {
const mutation = queryClient.getMutationCache().getAll().find(item => (item.state.variables as { idempotencyKey?: string } | undefined)?.idempotencyKey === key);
const mutation = queryClient.getMutationCache().getAll().find(item => (item.state.variables as { input?: { idempotencyKey?: string } } | undefined)?.input?.idempotencyKey === key);
expect(mutation?.state.status).toMatch(/^(success|error)$/);
});
expect(mockIssuesApi.uploadAttachment).toHaveBeenCalledTimes(during === "create" ? 0 : 1);
+9 -7
View File
@@ -1,7 +1,8 @@
import { usePrimaryAgentPresentation } from "./primary-agent/PrimaryAgentPresentation";
import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls";
import { useState, useEffect, useRef, useCallback, useMemo, type CSSProperties } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAccountBoundMutation } from "@/hooks/useAccountBoundMutation";
import type { AgentEnvConfig, EnvBinding, IssueWorkMode } from "@paperclipai/shared";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
@@ -611,25 +612,26 @@ function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean })
const assertCurrentOwner = () => {
if (!active.current || draftOwner === undefined || readOwner() !== draftOwner) throw new PendingSendAccountChangedError();
};
const createIssue = useMutation({
const createIssue = useAccountBoundMutation({
mutationFn: async ({
companyId,
stagedFiles: pendingStagedFiles,
navigateOnCreate,
...data
}: { companyId: string; stagedFiles: StagedIssueFile[]; navigateOnCreate?: boolean } & Record<string, unknown>) => {
assertCurrentOwner();
}: { companyId: string; stagedFiles: StagedIssueFile[]; navigateOnCreate?: boolean } & Record<string, unknown>, { assertCurrentAccount }) => {
const assertVerifiedOwner = () => { assertCurrentAccount(); assertCurrentOwner(); };
assertVerifiedOwner();
const issue = await issuesApi.create(companyId, data);
assertCurrentOwner();
assertVerifiedOwner();
const failures = await addStagedFilesToIssue({
companyId,
issueId: issue.id,
stagedFiles: pendingStagedFiles,
assertCurrentOwner,
assertCurrentOwner: assertVerifiedOwner,
idempotencyKey: typeof data.idempotencyKey === "string" ? data.idempotencyKey : undefined,
});
assertCurrentOwner();
assertVerifiedOwner();
return { issue, companyId, failures, navigateOnCreate };
},
onSuccess: ({ issue, companyId, failures, navigateOnCreate }) => {
+26
View File
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "@/context/ThemeContext";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ConnectivityProvider, createConnectivityStore } from "@/lib/connectivity";
import { TaskChatThread } from "./TaskChatThread";
import { IssueChatThread } from "./IssueChatThread";
import type {
@@ -721,6 +722,31 @@ it("prepares comment-link history and its composer before reveal", async () => {
expect(container.querySelector('[data-thread-anchor="comment-1"]')).toBe(row);
});
it.each(["offline", "reconnecting"] as const)("reveals a cached task header while history waits through %s and keeps it after recovery", async status => {
const store = createConnectivityStore({ probe: () => new Promise(() => {}) });
store.setBrowserOnline(false);
if (status === "reconnecting") store.setBrowserOnline(true);
const show = (pending: boolean) => render(<ConnectivityProvider store={store}>
<TaskChatThread issueId="cached-task" comments={[]} onAdd={async () => {}} initialHistoryPending={pending}
threadHeader={<h2>Cached task title</h2>} emptyMessage="No comments yet" />
</ConnectivityProvider>);
try {
show(true);
const header = container.querySelector('[data-testid="task-chat-thread-header"]');
expect(header?.textContent).toContain("Cached task title");
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).toBeNull();
expect(container.textContent).toContain("Loading task history…");
expect(container.textContent).not.toContain("No comments yet");
await act(async () => { store.setBrowserOnline(true); store.reportSuccess(); });
expect(container.querySelector('[data-testid="task-chat-thread-header"]')).toBe(header);
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).toBeNull();
show(false);
await settleThreadLayout();
expect(container.textContent).not.toContain("Loading task history…");
expect(container.textContent).toContain("No comments yet");
} finally { store.dispose(); }
});
it.each([true, false])("reveals saved conversation when initial history stalls (mobile=%s)", async (mobile) => {
vi.useFakeTimers();
try {
+9 -3
View File
@@ -16,6 +16,7 @@ import { useProjectCreatedItems } from "@/hooks/useProjectCreatedItems";
import { skillCreatedItems } from "@/components/task-chat/skill-created-items";
import { taskCreatedItems } from "@/components/task-chat/task-created-items";
import { useCoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation";
import { useConnectivity } from "@/lib/connectivity";
import { CoordinatorChatHandoffStatus, CoordinatorChatTaskHandoff, standaloneChatHandoffs, useCoordinatorChatTaskHandoffs, useLiveChatHandoffIds } from "@/components/coordinator/CoordinatorChatTaskHandoffs";
import { CoordinatorChatReceiptStatus, useCoordinatorChatDecisionReceipts } from "@/components/coordinator/CoordinatorChatDecisionReceipts";
import { CoordinatorChatQuestionStatus, useCoordinatorChatPendingQuestions } from "@/components/coordinator/CoordinatorChatPendingQuestions";
@@ -508,6 +509,7 @@ function durableInputLabel(
*/
export function TaskChatThread(props: TaskChatThreadProps) {
const coordinatorEnabled = useCoordinatorPresentation();
const { status: connectionStatus } = useConnectivity();
const { enabled: streamlinedPreference } = useStreamlinedUiEnabled();
const streamlinedUiEnabled = coordinatorEnabled || streamlinedPreference;
const {
@@ -3142,7 +3144,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
);
const historyKey = conversationMode && draftKey ? draftKey : issueId;
const [revealedHistoryKey, setRevealedHistoryKey] = useState<string | null | undefined>(undefined);
const historyRevealed = revealedHistoryKey === historyKey;
const revealCachedHeader = connectionStatus !== "online" && Boolean(threadHeader);
const historyRevealed = revealedHistoryKey === historyKey || revealCachedHeader;
useEffect(() => {
if (revealCachedHeader) setRevealedHistoryKey(historyKey);
}, [revealCachedHeader, historyKey]);
const historyShellRef = useRef<HTMLDivElement>(null);
const [expiredHistoryWait, setExpiredHistoryWait] = useState<{ historyKey: typeof historyKey } | null>(null);
const historyWaitExpired = expiredHistoryWait !== null && expiredHistoryWait.historyKey === historyKey;
@@ -3273,8 +3279,8 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<CoordinatorChatHandoffStatus query={taskHandoffs} scroll={!!scrollingHistory} />
</div>
) : null}
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
{emptyMessage}
<div role={historyPending ? "status" : undefined} className="px-3 py-10 text-center text-sm text-muted-foreground">
{historyPending ? "Loading task history…" : emptyMessage}
</div>
{bottomBlockerLinks ? (
<div
@@ -1,5 +1,7 @@
// @vitest-environment jsdom
import { act } from "react";
import { queryKeys } from "@/lib/queryKeys";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -7,6 +9,10 @@ import type { Agent } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentBulkRoster } from "./AgentBulkRoster";
const api = vi.hoisted(() => ({ get: vi.fn() }));
vi.mock("@/api/agents", () => ({ agentsApi: api }));
vi.mock("./AgentBulkSettingsSheet", () => ({ AgentBulkSettingsSheet: ({ agents }: { agents: Agent[] }) => <div data-testid="bulk-editor">{JSON.stringify(agents.map(a => a.runtimeConfig))}</div> }));
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: {
list: vi.fn(async () => ({ currentUserId: "board", connections: [] })),
} }));
@@ -21,6 +27,8 @@ describe("pending agent roster display", () => {
document.body.append(container);
root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "alice" } });
api.get.mockReset();
});
afterEach(() => {
@@ -38,6 +46,87 @@ describe("pending agent roster display", () => {
return [...container.querySelectorAll("tbody tr")][0] as HTMLTableRowElement;
}
function renderEditable() {
const agent = { id: "agent-one", companyId: "company", name: "Atlas", adapterType: "codex_local", adapterConfig: { model: "old-model" }, runtimeConfig: {}, status: "idle" } as unknown as Agent;
flushSync(() => root.render(<QueryClientProvider client={client}>
<AgentBulkRoster companyId="company" agents={[agent]} visibleAgents={[agent]} renderAgent={item => <span>{item.name}</span>} />
</QueryClientProvider>));
flushSync(() => container.querySelector<HTMLButtonElement>('[aria-label="Select Atlas"]')!.click());
return agent;
}
const editButton = () => [...container.querySelectorAll<HTMLButtonElement>("button")].find(b => b.textContent?.includes("Edit connection & model"))!;
it("loads complete current agents before opening bulk edits from a restored roster", async () => {
const display = renderEditable();
let finish!: (value: Agent) => void;
api.get.mockImplementation(() => new Promise<Agent>(resolve => { finish = resolve; }));
await act(async () => editButton().click());
expect(api.get).toHaveBeenCalledWith("agent-one", "company", { cache: "no-store", fresh: true });
expect(container.querySelector('[data-testid="bulk-editor"]')).toBeNull();
expect(container.textContent).toContain("Loading current agent settings");
const runtimeConfig: Agent["runtimeConfig"] = { aiConnection: { provider: "openai", method: "api_key", mode: "shared", connectionId: "connection-one", grantId: "grant-one" } };
await act(async () => { finish({ ...display, runtimeConfig }); });
await vi.waitFor(() => expect(container.querySelector('[data-testid="bulk-editor"]')?.textContent).toContain("connection-one"));
expect(container.textContent).not.toContain("Loading current agent settings");
});
it("keeps the editor closed after a failed complete-agent read and supports retry", async () => {
const display = renderEditable();
api.get.mockRejectedValueOnce(new TypeError("Failed to fetch")).mockResolvedValue(display);
await act(async () => editButton().click());
expect(container.querySelector('[data-testid="bulk-editor"]')).toBeNull();
expect(container.querySelector('[role="alert"]')?.textContent).toBeTruthy();
await act(async () => editButton().click());
await vi.waitFor(() => expect(container.querySelector('[data-testid="bulk-editor"]')).not.toBeNull());
expect(api.get).toHaveBeenCalledTimes(2);
});
it("rejects a complete-agent response delivered after the account switches", async () => {
const display = renderEditable();
let finish!: (value: Agent) => void;
api.get.mockImplementation(() => new Promise<Agent>(resolve => { finish = resolve; }));
await act(async () => editButton().click());
await act(async () => {
client.setQueryData(queryKeys.auth.session, { user: { id: "bob" } });
finish(display);
});
expect(container.querySelector('[data-testid="bulk-editor"]')).toBeNull();
});
describe.each(["success", "failure"] as const)("selection changes during a %s read", outcome => {
it.each(["Clear selection", "Select Atlas", "Select all visible editable agents"])("discards a pending edit after %s", async label => {
const display = renderEditable();
let finish!: (value: Agent) => void, reject!: (error: Error) => void;
api.get.mockReturnValue(new Promise<Agent>((resolve, fail) => { finish = resolve; reject = fail; }));
await act(async () => editButton().click());
expect(api.get).toHaveBeenCalledTimes(1);
await act(async () => container.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`)!.click());
await act(async () => {
if (outcome === "success") finish(display);
else reject(new TypeError("Failed to fetch"));
});
expect(container.querySelector('[data-testid="bulk-editor"]')).toBeNull();
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.textContent).not.toContain("Loading current agent settings");
});
});
it("retires a pending edit even when the same agent is selected again before the read finishes", async () => {
const display = renderEditable();
let finish!: (value: Agent) => void;
api.get.mockReturnValueOnce(new Promise<Agent>(resolve => { finish = resolve; })).mockResolvedValue(display);
await act(async () => editButton().click());
const checkbox = container.querySelector<HTMLButtonElement>('[aria-label="Select Atlas"]')!;
await act(async () => checkbox.click());
await act(async () => checkbox.click());
await act(async () => finish(display));
expect(container.querySelector('[data-testid="bulk-editor"]')).toBeNull();
await act(async () => editButton().click());
await vi.waitFor(() => expect(container.querySelector('[data-testid="bulk-editor"]')).not.toBeNull());
expect(api.get).toHaveBeenCalledTimes(2);
});
it("retains the pending harness label while keeping the placeholder unavailable for bulk editing", () => {
const row = render({ setupAdapterPending: true,
setupAdapterChoice: { adapterType: "paperclip_runner", runnerProvider: "antigravity", runner: "auto" } });
+44 -7
View File
@@ -1,7 +1,10 @@
import { lazy, Suspense, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { lazy, Suspense, useRef, useState, type ReactNode, type SetStateAction } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Filter, Pencil, X } from "lucide-react";
import type { Agent } from "@paperclipai/shared";
import { agentsApi } from "@/api/agents";
import { describeError } from "@/api/errors";
import { bindAccount } from "@/lib/account-scope";
import { aiConnectionsApi } from "@/api/ai-connections";
import { getAdapterLabel } from "@/adapters/adapter-display-registry";
import { agentConnection, agentDefaultModel, bulkAgentDisabledReason, bulkAgentHarness, bulkConnectionName } from "@/lib/bulk-agent-settings";
@@ -17,7 +20,16 @@ const AgentBulkSettingsSheet = lazy(() => import("./AgentBulkSettingsSheet").the
export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent }: {
companyId: string; agents: Agent[]; visibleAgents: Agent[]; renderAgent: (agent: Agent) => ReactNode;
}) {
const client = useQueryClient();
const openingRef = useRef(false);
const [opening, setOpening] = useState(false);
const [openError, setOpenError] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const selectionRevision = useRef(0);
const changeSelection = (next: SetStateAction<string[]>) => {
selectionRevision.current += 1;
setSelected(next);
};
const [filter, setFilter] = useState("all");
const [editing, setEditing] = useState<Agent[] | null>(null);
const [notice, setNotice] = useState("");
@@ -30,7 +42,30 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent
const all = selectable.length > 0 && selectable.every(a => selected.includes(a.id));
const some = selectable.some(a => selected.includes(a.id));
const hidden = selectedAgents.filter(a => !visible.includes(a)).length;
const toggleAll = () => setSelected(all ? selected.filter(id => !selectable.some(a => a.id === id)) : [...new Set([...selected, ...selectable.map(a => a.id)])]);
const toggleAll = () => changeSelection(all ? selected.filter(id => !selectable.some(a => a.id === id)) : [...new Set([...selected, ...selectable.map(a => a.id)])]);
async function openEditor(harness: string) {
if (openingRef.current) return;
openingRef.current = true; setOpening(true); setOpenError("");
const account = bindAccount(client);
const revision = selectionRevision.current;
try {
await account.whenAccountReady();
if (!account.isCurrentAccount() || revision !== selectionRevision.current) return;
const complete = await Promise.all(selectedAgents.filter(a => bulkAgentHarness(a) === harness)
.map(a => agentsApi.get(a.id, companyId, { cache: "no-store", fresh: true })));
if (!account.isCurrentAccount() || revision !== selectionRevision.current) return;
// The display cache excludes private runtime fields. Only complete,
// freshly authorized agents can become the editor's optimistic base.
if (complete.some(a => a.companyId !== companyId || bulkAgentHarness(a) !== harness || bulkAgentDisabledReason(a))) {
setOpenError("The selected agents changed. Refresh the roster and select them again.");
return;
}
setEditing(complete);
} catch (error) {
if (account.isCurrentScope() && revision === selectionRevision.current) setOpenError(describeError(error).body);
} finally { openingRef.current = false; setOpening(false); }
}
return <div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -62,10 +97,12 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent
<span className="text-sm font-medium">{selectedAgents.length} selected</span>
{hidden > 0 && <span className="text-xs text-muted-foreground">{hidden} hidden by filters</span>}
{groups.length > 1 && <span className="text-xs text-muted-foreground">Choose a harness to edit</span>}
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">{groups.map(h => <Button key={h} size="sm" variant="outline" onClick={() => setEditing(selectedAgents.filter(a => bulkAgentHarness(a) === h))}>
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">{groups.map(h => <Button key={h} size="sm" variant="outline" disabled={opening} onClick={() => void openEditor(h)}>
<Pencil className="size-3.5" aria-hidden />{groups.length === 1 ? "Edit connection & model" : `Edit ${selectedAgents.filter(a => bulkAgentHarness(a) === h).length} ${getAdapterLabel(h)}`}
</Button>)}<Button size="icon-sm" variant="ghost" aria-label="Clear selection" onClick={() => setSelected([])}><X className="size-4" /></Button></div>
</Button>)}<Button size="icon-sm" variant="ghost" aria-label="Clear selection" onClick={() => changeSelection([])}><X className="size-4" /></Button></div>
</div>}
{opening && <p role="status" className="text-sm text-muted-foreground">Loading current agent settings…</p>}
{openError && <p role="alert" className="text-sm text-destructive">{openError}</p>}
{notice && <p role="status" className="flex items-center gap-2 text-sm"><Check className="size-4" aria-hidden />{notice}</p>}
{accounts.error && <p className="text-xs text-destructive">Connection names couldn’t be loaded. <Button variant="link" size="sm" onClick={() => void accounts.refetch()}>Retry</Button></p>}
<div className="overflow-x-auto"><table aria-label="Agent roster" className="w-full text-left text-sm">
@@ -76,7 +113,7 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent
<tbody>{visible.map(agent => {
const pendingSetup = pendingAgentSetupDisplayAdapter(agent);
return <tr key={agent.id} data-state={selected.includes(agent.id) ? "selected" : undefined} className="border-t border-border data-[state=selected]:bg-accent/30 [&_td]:px-3">
<td title={bulkAgentDisabledReason(agent)}><Checkbox aria-label={`Select ${agent.name}`} checked={selectedAgents.some(a => a.id === agent.id)} disabled={!!bulkAgentDisabledReason(agent)} onCheckedChange={value => setSelected(value ? [...new Set([...selected, agent.id])] : selected.filter(id => id !== agent.id))} /></td>
<td title={bulkAgentDisabledReason(agent)}><Checkbox aria-label={`Select ${agent.name}`} checked={selectedAgents.some(a => a.id === agent.id)} disabled={!!bulkAgentDisabledReason(agent)} onCheckedChange={value => changeSelection(value ? [...new Set([...selected, agent.id])] : selected.filter(id => id !== agent.id))} /></td>
<td className="min-w-64 p-0">{renderAgent(agent)}</td>
<td className="whitespace-nowrap text-xs">{getAdapterLabel(pendingSetup ?? bulkAgentHarness(agent))}</td>
<td className="text-xs">{pendingSetup ? "Not configured" : ["http", "process"].includes(agent.adapterType) ? "Managed externally" : bulkConnectionName(agentConnection(agent), accounts.data)}</td>
@@ -88,7 +125,7 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent
{editing && <Suspense fallback={<p role="status" className="text-sm text-muted-foreground">Loading editor…</p>}><AgentBulkSettingsSheet companyId={companyId} agents={editing} totalSelected={selectedAgents.length} onClose={(updatedIds, complete) => {
setEditing(null);
if (updatedIds.length) {
setSelected(ids => ids.filter(id => !(complete ? editing.map(a => a.id) : updatedIds).includes(id)));
changeSelection(ids => ids.filter(id => !(complete ? editing.map(a => a.id) : updatedIds).includes(id)));
setNotice(`Updated ${updatedIds.length} ${getAdapterLabel(bulkAgentHarness(editing[0]))} ${updatedIds.length === 1 ? "agent" : "agents"}.`);
}
}} /></Suspense>}
+67 -1
View File
@@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PrimaryAgentProvider } from "./PrimaryAgentProvider";
import { usePrimaryAgentPresentation } from "@/components/primary-agent/PrimaryAgentPresentation";
import { queryKeys } from "@/lib/queryKeys";
import { startPersistedQueryCache } from "@/lib/persisted-query-cache";
import { ApiError } from "@/api/client";
const state = vi.hoisted(() => ({
companyId: "company-a", get: vi.fn(), set: vi.fn(), session: vi.fn(), toast: vi.fn(),
@@ -20,6 +22,7 @@ vi.mock("@/api/agents", () => ({ agentsApi: { list: async (companyId: string) =>
] } }));
let root: Root, container: HTMLDivElement, client: QueryClient;
let cache: ReturnType<typeof startPersistedQueryCache> | undefined;
const preference = (companyId = "company-a", userId = "user-a", primaryAgentId: string | null = "maia") => ({
companyId, userId, primaryAgentId, initialized: true,
});
@@ -45,6 +48,7 @@ async function waitForOutput(text: string) {
});
}
beforeEach(() => {
localStorage.clear();
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
state.companyId = "company-a";
state.get.mockReset().mockResolvedValue(preference());
@@ -54,10 +58,53 @@ beforeEach(() => {
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount()); client.clear(); container.remove(); vi.unstubAllGlobals();
await act(async () => root.unmount()); cache?.dispose(); cache = undefined; client.clear(); container.remove(); localStorage.clear(); vi.unstubAllGlobals();
});
function restorePreference() {
const source = new QueryClient();
source.setQueryData(queryKeys.auth.session, { user: { id: "user-a" } });
source.setQueryData(queryKeys.companies.list("user-a"), { companies: [{ id: "company-a", name: "Example" }], unauthorized: false });
source.setQueryData(queryKeys.primaryAgent.mine("company-a", "user-a"), preference());
const saved = startPersistedQueryCache(source, { storage: localStorage, buildId: "test", eventTarget: null });
saved.flush(); saved.dispose(); source.clear();
cache = startPersistedQueryCache(client, { storage: localStorage, buildId: "test", eventTarget: null });
expect(client.getQueryData(queryKeys.primaryAgent.mine("company-a", "user-a"))).toEqual(preference());
}
describe("primary agent persistence provider", () => {
it.each(["user-a", "user-b", null])("waits for fresh identity before changing a restored primary agent (verified=%s)", async verified => {
let finish!: (value: { user: { id: string } } | null) => void;
state.session.mockReturnValue(new Promise(resolve => { finish = resolve; }));
state.set.mockResolvedValue(preference("company-a", "user-a", "alex"));
restorePreference();
await render(); await waitForOutput("maia");
await act(async () => container.querySelector("button")!.click());
try {
expect(state.set).not.toHaveBeenCalled();
expect(client.getQueryData(queryKeys.primaryAgent.mine("company-a", "user-a"))).toEqual(preference());
} finally {
state.get.mockResolvedValue(preference("company-a", verified ?? "local-board", verified === "user-a" ? "alex" : null));
await act(async () => finish(verified ? { user: { id: verified } } : null));
}
await vi.waitFor(() => expect(client.isMutating()).toBe(0));
expect(state.set).toHaveBeenCalledTimes(verified === "user-a" ? 1 : 0);
if (verified === "user-a") await waitForOutput("alex");
else expect(state.toast).not.toHaveBeenCalled();
});
it("does not change a restored primary agent when fresh session verification is denied", async () => {
let deny!: (error: Error) => void;
state.session.mockReturnValue(new Promise((_resolve, reject) => { deny = reject; }));
restorePreference();
await render(); await waitForOutput("maia");
await act(async () => container.querySelector("button")!.click());
await act(async () => deny(new ApiError("Sign in again", 401, {})));
await vi.waitFor(() => expect(client.isMutating()).toBe(0));
expect(state.set).not.toHaveBeenCalled();
expect(state.toast).not.toHaveBeenCalled();
});
it("optimistically switches, restores the previous crown on failure, and offers a working retry", async () => {
let reject!: (error: Error) => void;
state.set.mockImplementationOnce(() => new Promise((_resolve, onReject) => { reject = onReject; }));
@@ -118,6 +165,25 @@ describe("primary agent persistence provider", () => {
expect(state.toast).not.toHaveBeenCalled();
});
it.each(["success", "failure"] as const)("preserves a newer crown read when an old account's save finishes with %s", async outcome => {
let finish!: (value: ReturnType<typeof preference>) => void, reject!: (error: Error) => void;
state.set.mockReturnValue(new Promise((resolve, fail) => { finish = resolve; reject = fail; }));
await render(); await waitForOutput("maia");
await act(async () => container.querySelector("button")!.click());
await waitForOutput("alex");
const key = queryKeys.primaryAgent.mine("company-a", "user-a");
await act(async () => {
client.setQueryData(queryKeys.auth.session, { user: { id: "user-b" } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-a" } });
client.setQueryData(key, preference("company-a", "user-a", "alex"));
if (outcome === "success") finish(preference("company-a", "user-a", "alex"));
else reject(new Error("Old request failed"));
});
await vi.waitFor(() => expect(client.isMutating()).toBe(0));
expect(client.getQueryData(key)).toEqual(preference("company-a", "user-a", "alex"));
expect(state.toast).not.toHaveBeenCalled();
});
it("retries session failures before fetching a personal preference", async () => {
state.session.mockRejectedValueOnce(new Error("Session unavailable"));
await render(); await waitForOutput("error");
+17 -5
View File
@@ -1,5 +1,7 @@
import { useRef, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAccountBoundMutation } from "@/hooks/useAccountBoundMutation";
import { bindAccount } from "@/lib/account-scope";
import type { PrimaryAgentPreference } from "@paperclipai/shared";
import { agentsApi } from "@/api/agents";
import { primaryAgentApi } from "@/api/primaryAgent";
@@ -21,22 +23,32 @@ export function PrimaryAgentProvider({ children }: { children: ReactNode }) {
const keyFor = (choice: Choice) => queryKeys.primaryAgent.mine(choice.companyId, choice.userId);
const isCurrentScope = (choice: Choice) => choice.companyId === scope.current.companyId && choice.userId === scope.current.userId;
const roster = useQuery({ queryKey: queryKeys.agents.list(companyId!), queryFn: () => agentsApi.list(companyId!), enabled: !!companyId });
const mutation = useMutation({
const mutation = useAccountBoundMutation({
mutationFn: (choice: Choice) => primaryAgentApi.set(choice.companyId, { primaryAgentId: choice.agentId }),
onMutate: async choice => {
const account = bindAccount(client);
const key = keyFor(choice);
await client.cancelQueries({ queryKey: key });
if (!account.isCurrentAccount()) return;
const previous = client.getQueryData<PrimaryAgentPreference>(key);
client.setQueryData<PrimaryAgentPreference>(key, { companyId: choice.companyId, userId: choice.userId, primaryAgentId: choice.agentId, initialized: true });
return { previous };
const optimistic = { companyId: choice.companyId, userId: choice.userId, primaryAgentId: choice.agentId, initialized: true };
client.setQueryData<PrimaryAgentPreference>(key, optimistic);
return { previous, optimistic: client.getQueryData(key), optimisticRevision: client.getQueryState(key)?.dataUpdateCount, account };
},
onError: (error, choice, context) => {
client.setQueryData(keyFor(choice), context?.previous);
if (context) client.setQueryData(keyFor(choice), context.previous);
if (!isCurrentScope(choice)) return;
pushToast({ title: "Couldn't change your primary agent.", body: describeError(error).body, tone: "error",
action: { label: "Retry", onClick: () => { if (isCurrentScope(choice)) mutation.mutate(choice); } } });
},
onSuccess: (data, choice) => client.setQueryData(keyFor(choice), data),
onFinished: (choice, context) => {
if (context && !context.account.isCurrentScope()
&& client.getQueryData(keyFor(choice)) === context.optimistic
&& client.getQueryState(keyFor(choice))?.dataUpdateCount === context.optimisticRevision) {
client.setQueryData(keyFor(choice), context.previous);
}
},
onSettled: (_data, _error, choice) => {
void client.invalidateQueries({ queryKey: keyFor(choice) });
void client.invalidateQueries({ queryKey: queryKeys.resourceMemberships.mine(choice.companyId) });
+4 -2
View File
@@ -8,6 +8,7 @@ import {
type UseMutationOptions,
type UseMutationResult,
type MutationFunctionContext,
type QueryClient,
} from "@tanstack/react-query";
import { PendingSendAccountChangedError } from "@/lib/pending-send";
import { bindAccount } from "@/lib/account-scope";
@@ -25,8 +26,9 @@ export function useAccountBoundMutation<TData = unknown, TError = DefaultError,
waitForTurn?: (variables: TVariables) => Promise<void> | undefined;
onFinished?: (variables: NoInfer<TVariables>, context: NoInfer<TContext> | undefined) => void;
},
queryClient?: QueryClient,
): UseMutationResult<TData, TError, TVariables, TContext> {
const client = useQueryClient();
const client = useQueryClient(queryClient);
type OwnedVariables = { input: TVariables; operation: typeof options } & ReturnType<typeof bindAccount>;
const { mutationFn, onMutate, onSuccess, onError, onSettled, waitForTurn, onFinished, ...rest } = options;
const mutation = useMutation<TData, TError, OwnedVariables, TContext | undefined>({
@@ -57,7 +59,7 @@ export function useAccountBoundMutation<TData = unknown, TError = DefaultError,
operation.onFinished?.(input, result);
if (isCurrentScope()) await operation.onSettled?.(data, error, input, result, context);
},
});
}, client);
const wrapOptions = (callbacks?: MutateOptions<TData, TError, TVariables, TContext>): MutateOptions<TData, TError, OwnedVariables, TContext | undefined> => ({
onSuccess: (data, { input, isCurrentAccount }, result, context) => { if (isCurrentAccount()) callbacks?.onSuccess?.(data, input, result, context); },
onError: (error, { input, isCurrentScope }, result, context) => { if (isCurrentScope()) callbacks?.onError?.(error, input, result, context); },
+12 -7
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { Company } from "@paperclipai/shared";
import { sidebarPreferencesApi } from "../api/sidebarPreferences";
import { queryKeys } from "../lib/queryKeys";
import { useAccountBoundMutation } from "./useAccountBoundMutation";
function areEqual(a: string[], b: string[]) {
if (a.length !== b.length) return false;
@@ -60,8 +61,16 @@ export function useCompanyOrder({ companies, userId }: UseCompanyOrderParams) {
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
}, [companies, data?.orderedIds]);
const mutation = useMutation({
// The server saves for the request's user, so a restored sidebar must not
// send (or optimistically apply) an order before the session is verified.
const mutation = useAccountBoundMutation({
mutationFn: (nextIds: string[]) => sidebarPreferencesApi.updateCompanyOrder({ orderedIds: nextIds }),
onMutate: (nextIds) => {
queryClient.setQueryData(queryKey, (current: { orderedIds?: string[]; updatedAt?: Date | null } | undefined) => ({
orderedIds: nextIds,
updatedAt: current?.updatedAt ?? null,
}));
},
onSuccess: (preference) => {
queryClient.setQueryData(queryKey, preference);
},
@@ -83,13 +92,9 @@ export function useCompanyOrder({ companies, userId }: UseCompanyOrderParams) {
setOrderedIds((current) => (areEqual(current, filtered) ? current : filtered));
if (!userId) return;
queryClient.setQueryData(queryKey, (current: { orderedIds?: string[]; updatedAt?: Date | null } | undefined) => ({
orderedIds: filtered,
updatedAt: current?.updatedAt ?? null,
}));
mutation.mutate(filtered);
},
[companies, mutation, queryClient, queryKey, userId],
[companies, mutation, userId],
);
return {
@@ -5,6 +5,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useCoordinatorWorkspaceEnabled } from "./useCoordinatorWorkspaceEnabled";
import { queryKeys } from "@/lib/queryKeys";
import { startPersistedQueryCache } from "@/lib/persisted-query-cache";
import { ApiError } from "@/api/client";
const mocks = vi.hoisted(() => ({ session: vi.fn(), health: vi.fn(), get: vi.fn(), update: vi.fn() }));
vi.mock("@/api/auth", () => ({ authApi: { getSession: mocks.session } }));
@@ -22,7 +24,9 @@ async function flush() {
describe("useCoordinatorWorkspaceEnabled", () => {
let root: Root, container: HTMLDivElement, client: QueryClient;
let cache: ReturnType<typeof startPersistedQueryCache> | undefined;
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
mocks.session.mockResolvedValue(session("a"));
mocks.health.mockResolvedValue({ status: "ok", deploymentMode: "authenticated" });
@@ -30,11 +34,48 @@ describe("useCoordinatorWorkspaceEnabled", () => {
client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } });
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
});
afterEach(() => { flushSync(() => root.unmount()); client.clear(); container.remove(); vi.unstubAllGlobals(); });
afterEach(() => { flushSync(() => root.unmount()); cache?.dispose(); cache = undefined; client.clear(); container.remove(); localStorage.clear(); vi.unstubAllGlobals(); });
async function mount(provider = true) {
flushSync(() => root.render(provider ? <QueryClientProvider client={client}><Probe /></QueryClientProvider> : <Probe />));
await flush();
}
function restorePreference() {
const source = new QueryClient();
source.setQueryData(queryKeys.auth.session, session("a"));
source.setQueryData(queryKeys.companies.list("a"), { companies: [{ id: "company", name: "Example" }], unauthorized: false });
source.setQueryData(queryKeys.workspacePreferences.mine("a"), preference());
const saved = startPersistedQueryCache(source, { storage: localStorage, buildId: "test", eventTarget: null });
saved.flush(); saved.dispose(); source.clear();
cache = startPersistedQueryCache(client, { storage: localStorage, buildId: "test", eventTarget: null });
expect(client.getQueryData(queryKeys.workspacePreferences.mine("a"))).toEqual(preference());
}
it.each(["a", "b", null])("waits for fresh identity before changing a restored workspace preference (verified=%s)", async verified => {
let finish!: (value: ReturnType<typeof session> | null) => void;
mocks.session.mockReturnValue(new Promise(resolve => { finish = resolve; }));
mocks.update.mockResolvedValue(preference("a", true));
restorePreference();
await mount();
expect(current.userId).toBe("a");
const pending = current.setEnabled("company", true).catch(error => error);
await flush();
try {
expect(mocks.session).toHaveBeenCalled();
expect(mocks.update).not.toHaveBeenCalled();
} finally { finish(verified ? session(verified) : null); }
const result = await pending;
expect(mocks.update).toHaveBeenCalledTimes(verified === "a" ? 1 : 0);
if (verified === "a") expect(result).toEqual(preference("a", true));
else expect(result).toMatchObject({ name: "AbortError" });
});
it("does not save restored workspace preferences after session verification is denied", async () => {
mocks.session.mockRejectedValue(new ApiError("Sign in again", 401, {}));
restorePreference();
await mount();
await expect(current.setEnabled("company", true)).rejects.toBeDefined();
expect(mocks.update).not.toHaveBeenCalled();
});
it("defaults off in detached hosts without fetching", async () => {
await mount(false);
@@ -45,9 +86,11 @@ describe("useCoordinatorWorkspaceEnabled", () => {
await mount();
let resolve!: (value: ReturnType<typeof preference>) => void;
mocks.update.mockImplementationOnce(() => new Promise(r => { resolve = r; }));
const pending = current.setEnabled("company", true);
const pending = current.setEnabled("company", true).catch(error => error);
await flush();
expect(mocks.update).toHaveBeenCalledTimes(1);
await flush(); expect(current.enabled).toBe(false);
resolve(preference("a", true)); await pending; await flush();
resolve(preference("a", true)); expect(await pending).toEqual(preference("a", true)); await flush();
expect(current.enabled).toBe(true);
mocks.update.mockRejectedValueOnce(new Error("Save failed"));
await expect(current.setEnabled("company", false)).rejects.toThrow("Save failed"); await flush();
@@ -57,12 +100,14 @@ describe("useCoordinatorWorkspaceEnabled", () => {
await mount();
let resolve!: (value: ReturnType<typeof preference>) => void;
mocks.update.mockImplementationOnce(() => new Promise(r => { resolve = r; }));
const pending = current.setEnabled("company", true);
const pending = current.setEnabled("company", true).catch(error => error);
await flush();
expect(mocks.update).toHaveBeenCalledTimes(1);
mocks.get.mockResolvedValue(preference("b", false));
client.setQueryData(queryKeys.auth.session, session("b")); await flush();
resolve(preference("a", true)); await pending; await flush();
resolve(preference("a", true)); expect(await pending).toMatchObject({ name: "AbortError" }); await flush();
expect(current).toMatchObject({ userId: "b", enabled: false });
expect(client.getQueryData(queryKeys.workspacePreferences.mine("a"))).toMatchObject({ coordinatorWorkspaceEnabled: true });
expect(client.getQueryData(queryKeys.workspacePreferences.mine("a"))).toMatchObject({ coordinatorWorkspaceEnabled: false });
});
it("loads the new account's saved choice while the previous account's real GET is still pending", async () => {
const { workspacePreferencesApi } = await vi.importActual<typeof import("@/api/workspacePreferences")>("@/api/workspacePreferences");
@@ -1,5 +1,6 @@
import { useContext } from "react";
import { QueryClient, QueryClientContext, useMutation, useQuery } from "@tanstack/react-query";
import { QueryClient, QueryClientContext, useQuery } from "@tanstack/react-query";
import { useAccountBoundMutation } from "./useAccountBoundMutation";
import { authApi } from "@/api/auth";
import { healthApi } from "@/api/health";
import { workspacePreferencesApi } from "@/api/workspacePreferences";
@@ -32,15 +33,14 @@ export function useCoordinatorWorkspaceEnabled() {
queryFn: () => workspacePreferencesApi.get(),
enabled: contextClient != null && !!userId,
}, client);
const mutation = useMutation({
const mutation = useAccountBoundMutation({
mutationFn: async ({ companyId, enabled, owner }: { companyId: string; enabled: boolean; owner: string }) => {
const result = await workspacePreferencesApi.update(companyId, { coordinatorWorkspaceEnabled: enabled });
if (result.userId !== owner) throw new Error("Your account changed. Reload your workspace preference and try again.");
return result;
},
onSuccess: (result) => {
// A pending response from a previous account may update only that
// account's cache. Never optimistically flip the active shell.
// Only a response in its original verified account may update the cache.
client.setQueryData(queryKeys.workspacePreferences.mine(result.userId), result);
},
}, client);
+158
View File
@@ -3,11 +3,15 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { hasPendingWrites, subscribePendingWrites } from "@/lib/reload-safety";
import { startServiceWorkerUpdates } from "@/lib/service-worker-updates";
import { ApiError } from "@/api/client";
import { authApi } from "@/api/auth";
import { useSignOut } from "./useSignOut";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
import { markCachedSession } from "@/lib/cached-session";
import { startPersistedQueryCache } from "@/lib/persisted-query-cache";
import {
ConnectivityProvider,
createConnectivityStore,
@@ -64,6 +68,160 @@ describe("useDurableAutosave", () => {
await vi.advanceTimersByTimeAsync(ms);
});
it("defers a hidden-tab update across online autosave retries until the document is saved", async () => {
let reject!: (error: unknown) => void;
const save = vi.fn().mockImplementationOnce(() => new Promise((_, no) => { reject = no; }))
.mockRejectedValueOnce(new ApiError("Try again", 429, null)).mockResolvedValue(undefined);
render({ save });
let pending!: ReturnType<typeof current.run>;
act(() => { pending = current.run("Keep this document"); });
const worker = Object.assign(new EventTarget(), { controller: {}, register: vi.fn().mockResolvedValue({ update: vi.fn() }) });
const doc = Object.assign(new EventTarget(), { visibilityState: "hidden" });
const reload = vi.fn();
const stop = startServiceWorkerUpdates({ container: worker as unknown as ServiceWorkerContainer,
documentRef: doc as unknown as Document, reload,
canReload: () => store.getSnapshot().pendingWrites === 0 && !hasPendingWrites(),
subscribeReloadSafety: listener => {
const cleanups = [store.subscribe(listener), subscribePendingWrites(listener)];
return () => cleanups.forEach(cleanup => cleanup());
} });
try {
worker.dispatchEvent(new Event("controllerchange"));
await act(async () => { reject(new ApiError("Try again", 429, null)); await pending; });
expect(store.getSnapshot().status).toBe("online");
expect(reload).not.toHaveBeenCalled();
await advance(1_000);
expect(save).toHaveBeenCalledTimes(2);
expect(reload).not.toHaveBeenCalled();
await advance(2_000);
expect(save).toHaveBeenCalledTimes(3);
expect(current.status).toBe("saved");
expect(reload).toHaveBeenCalledOnce();
} finally { stop(); }
});
it("cancels a restored write if the account changes during its fresh-base check", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
let finish!: () => void;
const beforeSave = vi.fn(() => new Promise<void>(resolve => { finish = resolve; }));
const save = vi.fn().mockResolvedValue(undefined);
act(() => root.render(<QueryClientProvider client={client}><ConnectivityProvider store={store}><Harness sourceId="base-check" save={save} /></ConnectivityProvider></QueryClientProvider>));
let pending!: ReturnType<typeof current.run>;
act(() => { pending = current.run("Old account draft", { beforeSave }); });
expect(beforeSave).toHaveBeenCalledOnce();
await act(async () => { client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } }); finish(); await pending; });
expect(save).not.toHaveBeenCalled();
client.clear();
});
it("rechecks the saved base before retrying a restored write", async () => {
const beforeSave = vi.fn<() => Promise<void>>().mockResolvedValueOnce(undefined).mockRejectedValue(conflict());
const save = vi.fn().mockRejectedValue(outage());
render({ save });
await act(async () => { expect(await current.run("Restored draft", { beforeSave })).toEqual({ kind: "waiting" }); });
await advance(1_000);
expect(beforeSave).toHaveBeenCalledTimes(2);
expect(save).toHaveBeenCalledOnce();
expect(current.status).toBe("error");
});
it("blocks background reload during debounce and until the newest edit is saved", async () => {
let complete!: () => void;
const save = vi.fn().mockImplementationOnce(() => new Promise<void>(resolve => { complete = resolve; })).mockResolvedValue(undefined);
render({ save });
act(() => current.markDirty());
expect(hasPendingWrites()).toBe(true);
let saving!: ReturnType<typeof current.run>;
act(() => { saving = current.run("First draft"); });
act(() => current.markDirty());
await act(async () => { complete(); await saving; });
expect(hasPendingWrites()).toBe(true);
await act(async () => { await current.run("Second draft"); });
expect(hasPendingWrites()).toBe(false);
act(() => current.markDirty());
act(() => current.reset());
expect(hasPendingWrites()).toBe(false);
});
it.each(["draft", "saving", "waiting"] as const)("releases a departed target's dirty token while preserving %s work", async mode => {
let finish!: () => void;
const save = vi.fn<() => Promise<void>>();
if (mode === "saving") save.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
else if (mode === "waiting") save.mockRejectedValueOnce(outage());
save.mockResolvedValue(undefined);
render({ targetKey: "task-a:description", save });
act(() => current.markDirty());
let pending: ReturnType<typeof current.run> | undefined;
if (mode === "saving") act(() => { pending = current.run("Admitted A draft"); });
if (mode === "waiting") await act(async () => { await current.run("Admitted A draft"); });
render({ targetKey: "task-b:description", save });
expect(current.status).toBe("idle");
expect(hasPendingWrites()).toBe(mode === "saving");
expect(store.getSnapshot().pendingWrites).toBe(mode === "draft" ? 0 : 1);
if (mode === "saving") await act(async () => { finish(); await pending; });
if (mode === "waiting") await act(async () => { current.resendNow(); });
expect(hasPendingWrites()).toBe(false);
expect(store.getSnapshot().pendingWrites).toBe(0);
expect(current.status).toBe("idle");
expect(save).toHaveBeenCalledTimes(mode === "draft" ? 0 : mode === "saving" ? 1 : 2);
if (mode !== "draft") expect(save).toHaveBeenCalledWith("Admitted A draft");
});
it.each(["user-1", "user-2"])("verifies a restored identity before a mounted plain autosave (%s)", async verifiedUser => {
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false, enabled: false } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
const cache = startPersistedQueryCache(client, { storage: null, buildId: "test", eventTarget: null });
markCachedSession(client);
let complete!: (id: string) => void;
const lookup = vi.spyOn(authApi, "getSession").mockReturnValue(new Promise(resolve => {
complete = id => resolve({ user: { id } } as never);
}));
const save = vi.fn().mockResolvedValue(undefined);
try {
act(() => root.render(<QueryClientProvider client={client}><ConnectivityProvider store={store}>
<Harness sourceId="cached-document" save={save} />
</ConnectivityProvider></QueryClientProvider>));
let saving!: ReturnType<typeof current.run>;
act(() => { saving = current.run("Private text"); });
expect(lookup).toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
await act(async () => { complete(verifiedUser); await saving; });
expect(save).toHaveBeenCalledTimes(verifiedUser === "user-1" ? 1 : 0);
} finally {
act(() => root.unmount()); root = createRoot(container);
cache.dispose(); client.clear(); lookup.mockRestore();
}
});
it("shows waiting after restored-session verification fails without sending the edit", async () => {
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false, enabled: false } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
const cache = startPersistedQueryCache(client, { storage: null, buildId: "test", eventTarget: null });
markCachedSession(client);
const lookup = vi.spyOn(authApi, "getSession").mockRejectedValue(new ApiError("session_lookup_failed", 500, null));
const save = vi.fn().mockResolvedValue(undefined);
try {
act(() => root.render(<QueryClientProvider client={client}><ConnectivityProvider store={store}>
<Harness sourceId="waiting-for-session" save={save} />
</ConnectivityProvider></QueryClientProvider>));
let saving!: ReturnType<typeof current.run>;
act(() => { saving = current.run("Retained edit"); });
await advance(750);
await act(async () => { expect(await saving).toEqual({ kind: "waiting" }); });
await advance(200);
expect(current.status).toBe("waiting");
expect(current.label).toBe("Will save when reconnected");
expect(save).not.toHaveBeenCalled();
lookup.mockResolvedValue({ user: { id: "user-1" } } as never);
await advance(2_000);
expect(save).toHaveBeenCalledExactlyOnceWith("Retained edit");
} finally {
act(() => root.unmount()); root = createRoot(container);
cache.dispose(); client.clear(); lookup.mockRestore();
}
});
it("keeps a conflict verification read pending through an outage", async () => {
const save = vi.fn().mockRejectedValue(conflict());
const matchesServer = vi.fn().mockRejectedValueOnce(outage()).mockResolvedValue(true);
+58 -9
View File
@@ -7,6 +7,8 @@ import { useConnectivityStore } from "@/lib/connectivity";
import { classifySendFailure, MAX_ONLINE_RESENDS, resendDelayFor, type SendOutcome } from "@/lib/pending-send";
import { useAutoResend } from "./useDurableSubmit";
import { useDraftOwner, useDraftOwnerReader } from "./useDraftOwner";
import { trackPendingWrite } from "@/lib/reload-safety";
import { hasUnverifiedSession } from "@/lib/cached-session";
export type DurableAutosaveStatus =
| "idle"
@@ -60,6 +62,8 @@ export interface DurableAutosaveOptions<T> {
/** Per-save overrides: a caller can bind the request and the 409 check to one draft. */
export interface DurableAutosaveJob<T> {
/** Revalidate a restored draft against fresh data before every write attempt. */
beforeSave?: (draft: T) => Promise<void>;
save?: (draft: T) => Promise<unknown>;
matchesServer?: (error: unknown, draft: T) => Promise<boolean> | boolean;
/**
@@ -77,6 +81,8 @@ export interface DurableAutosave<T> {
run: (draft: T, job?: DurableAutosaveJob<T>) => Promise<DurableAutosaveResult>;
/** The draft changed; drop a stale "Saved" or error. */
markDirty: () => void;
/** Release an unchanged draft without dismissing the last successful save. */
clearDirty: () => void;
/** Forget pending work and go back to idle (the user cancelled or reverted). */
reset: () => void;
/** Write the current draft to storage so a reload or crash keeps it. */
@@ -123,7 +129,18 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
const targetKey = options.targetKey ?? options.sourceId;
const targetRef = useRef(targetKey);
targetRef.current = targetKey;
type Job = { token: symbol; registryKey: string; resends: number; saving?: boolean; id: number; target: string; owner: string | undefined; account: ReturnType<typeof bindAccount> | null; draft: T;
const dirtyWrites = useRef(new Map<string, { version: symbol; release: () => void }>());
const releaseDirty = (target: string, version: symbol | undefined) => {
const dirty = dirtyWrites.current.get(target);
if (dirty && dirty.version === version) { dirtyWrites.current.delete(target); dirty.release(); }
};
useEffect(() => () => {
// A target change cancels its unused editor debounce. Admitted jobs keep
// their independent pending-write tracking until they settle.
releaseDirty(targetKey, dirtyWrites.current.get(targetKey)?.version);
}, [targetKey]);
type Job = { token: symbol; dirtyVersion?: symbol; registryKey: string; resends: number; saving?: boolean; id: number; target: string; owner: string | undefined; account: ReturnType<typeof bindAccount> | null; draft: T;
beforeSave?: (draft: T) => Promise<void>;
save: (draft: T) => Promise<unknown>; matchesServer?: DurableAutosaveOptions<T>["matchesServer"];
onRetryFailed?: DurableAutosaveJob<T>["onRetryFailed"]; storageKey: string | null; action: string };
const jobs = useRef(new Map<string, Job>());
@@ -139,6 +156,11 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
});
}, []);
const store = useConnectivityStore();
// Waiting work must remain visible to reload safety before React renders
// the next retry state, including the interval between network attempts.
const reportPendingJobs = () => {
if (mounted.current) store.setPendingWrites(optionsRef.current.sourceId, jobs.current.size);
};
const registry = autosaveRegistry(store);
const releaseJob = (job: Job) => {
const entry = registry.get(job.registryKey);
@@ -170,7 +192,11 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
savingTimer.current = null;
savedTimer.current = null;
}, []);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; clearTimers(); detachedWork.current(); }; }, [clearTimers]);
useEffect(() => { mounted.current = true; return () => {
mounted.current = false; clearTimers(); detachedWork.current();
for (const dirty of dirtyWrites.current.values()) dirty.release();
dirtyWrites.current.clear();
}; }, [clearTimers]);
const previousOwner = useRef(owner);
useEffect(() => {
const previous = previousOwner.current;
@@ -186,10 +212,14 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
}
}
refresh(value => value + 1);
reportPendingJobs();
return;
}
for (const job of jobs.current.values()) releaseJob(job);
jobs.current.clear(); generations.current.clear(); clearTimers();
reportPendingJobs();
for (const dirty of dirtyWrites.current.values()) dirty.release();
dirtyWrites.current.clear();
setView({ target: targetRef.current, status: "idle", error: null });
refresh(value => value + 1);
}, [owner, clearTimers]);
@@ -214,7 +244,9 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
};
if (!stillOwned()) {
if (jobs.current.get(job.target) === job) jobs.current.delete(job.target);
reportPendingJobs();
releaseJob(job);
releaseDirty(job.target, job.dirtyVersion);
if (mounted.current) refresh(value => value + 1);
return { kind: "waiting" };
}
@@ -223,11 +255,14 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
if (store.getSnapshot().status !== "online") { publish("waiting"); return { kind: "waiting" }; }
if (retry) job.resends += 1;
job.saving = true;
const finishWrite = trackPendingWrite();
if (visible()) savingTimer.current = setTimeout(() => publish("saving"), SAVING_DELAY_MS);
refresh(value => value + 1);
const remove = () => {
releaseJob(job);
if (jobs.current.get(job.target) === job) jobs.current.delete(job.target);
reportPendingJobs();
releaseDirty(job.target, job.dirtyVersion);
if (mounted.current) refresh(value => value + 1);
};
const done = (): DurableAutosaveResult => {
@@ -248,16 +283,22 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
return { kind: "saved" };
};
try {
if (!mounted.current && job.account && !job.account.isCurrentAccount()) {
if (job.account && (!mounted.current || (client && hasUnverifiedSession(client))) && !job.account.isCurrentAccount()) {
try { await job.account.whenAccountReady(); }
catch {
// Session discovery has not attempted the write. Keep its draft within
// the existing retry budget unless an account transition invalidated it.
if (!stillOwned()) remove();
else { if (visible()) clearTimers(); publish("waiting"); }
return { kind: "waiting" };
}
}
if (client && hasUnverifiedSession(client)) { if (visible()) clearTimers(); publish("waiting"); return { kind: "waiting" }; }
if (!stillOwned()) { remove(); return { kind: "waiting" }; }
if (job.beforeSave) {
await job.beforeSave(job.draft);
if (!stillOwned()) { remove(); return { kind: "waiting" }; }
}
await job.save(job.draft);
return done();
}
@@ -275,8 +316,8 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
publish("error", describeError(cause, { action: job.action }));
remove();
return { kind: "failed", error: cause };
} finally { job.saving = false; if (mounted.current) refresh(value => value + 1); else detachedWake.current?.(); }
}, [clearTimers, store, readOwner]);
} finally { job.saving = false; finishWrite(); if (mounted.current) refresh(value => value + 1); else detachedWake.current?.(); }
}, [clearTimers, store, readOwner, client]);
const run = useCallback(async (draft: T, overrides: DurableAutosaveJob<T> = {}) => {
const current = optionsRef.current;
const target = current.targetKey ?? current.sourceId;
@@ -284,8 +325,8 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
// A newer known-account edit supersedes an older startup job even if this
// save finishes before that job can adopt the account's registry key.
if (admittedOwner !== undefined) registry.delete(JSON.stringify([undefined, target]));
const job: Job = { token: Symbol(), registryKey: JSON.stringify([admittedOwner, target]), resends: 0, id: ++sequence.current, target, owner: admittedOwner, account: client ? bindAccount(client) : null, draft,
save: overrides.save ?? current.save, matchesServer: overrides.matchesServer ?? current.matchesServer,
const job: Job = { token: Symbol(), dirtyVersion: dirtyWrites.current.get(target)?.version, registryKey: JSON.stringify([admittedOwner, target]), resends: 0, id: ++sequence.current, target, owner: admittedOwner, account: client ? bindAccount(client) : null, draft,
beforeSave: overrides.beforeSave, save: overrides.save ?? current.save, matchesServer: overrides.matchesServer ?? current.matchesServer,
onRetryFailed: overrides.onRetryFailed, storageKey: current.storageKey ?? null, action: current.action ?? "save" };
const previous = jobs.current.get(target);
if (previous) releaseJob(previous);
@@ -293,11 +334,12 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
if (!revision) { revision = { latest: job.token, active: new Set() }; registry.set(job.registryKey, revision); }
revision.latest = job.token; revision.active.add(job.token);
generations.current.set(target, job.id); jobs.current.set(target, job);
reportPendingJobs();
refresh(value => value + 1);
return execute(job);
}, [client, execute, readOwner]);
const waitingJobs = [...jobs.current.values()].filter(job => !job.saving).length;
const auto = useAutoResend({ sourceId: options.sourceId, active: waitingJobs > 0, pendingCount: waitingJobs,
const auto = useAutoResend({ sourceId: options.sourceId, active: waitingJobs > 0, pendingCount: jobs.current.size,
resend: async (): Promise<SendOutcome> => {
for (const job of [...jobs.current.values()]) {
if (jobs.current.get(job.target) !== job) continue;
@@ -360,10 +402,16 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
};
const markDirty = useCallback(() => {
const dirty = dirtyWrites.current.get(targetRef.current);
dirtyWrites.current.set(targetRef.current, { version: Symbol(), release: dirty?.release ?? trackPendingWrite() });
clearTimers();
setView(current => ({ target: targetRef.current, status: jobs.current.has(targetRef.current) ? "waiting" : "idle", error: null }));
}, [clearTimers]);
const clearDirty = useCallback(() => {
releaseDirty(targetRef.current, dirtyWrites.current.get(targetRef.current)?.version);
}, []);
const reset = useCallback(() => {
releaseDirty(targetRef.current, dirtyWrites.current.get(targetRef.current)?.version);
const job = jobs.current.get(targetRef.current);
if (job) {
const revision = registry.get(job.registryKey);
@@ -371,11 +419,12 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
releaseJob(job);
}
generations.current.delete(targetRef.current); const removed = jobs.current.delete(targetRef.current); clearTimers();
reportPendingJobs();
setView({ target: targetRef.current, status: "idle", error: null }); if (removed) refresh(value => value + 1);
}, [clearTimers]);
const baseStatus = view.target === targetKey ? view.status : jobs.current.has(targetKey) ? "waiting" : "idle";
const status = baseStatus === "waiting" && auto.stalled ? "stalled" : baseStatus;
const error = view.target === targetKey ? view.error : null;
return { status, error, label: autosaveLabel(status, error), run, markDirty, reset,
return { status, error, label: autosaveLabel(status, error), run, markDirty, clearDirty, reset,
persistDraft, readStoredDraft, clearStoredDraft, resendNow: auto.resendNow };
}
+40
View File
@@ -15,6 +15,8 @@ import {
} from "@/lib/connectivity";
import { MAX_ONLINE_RESENDS, pendingSendKeyFor, readPendingSend, writePendingSend } from "@/lib/pending-send";
import { queryKeys } from "@/lib/queryKeys";
import { hasPendingWrites, subscribePendingWrites } from "@/lib/reload-safety";
import { startServiceWorkerUpdates } from "@/lib/service-worker-updates";
import { DETACHED_DRAFT_OWNER } from "./useDraftOwner";
import { useAutoResend, useDurableSubmit, type AutoResendState, type DurableSubmit, type DurableSubmitOptions } from "./useDurableSubmit";
@@ -96,6 +98,44 @@ describe("useDurableSubmit", () => {
await vi.advanceTimersByTimeAsync(ms);
});
it("defers a hidden-tab update across online 429 retries until the keyed create finishes", async () => {
const client = new QueryClient();
let reject!: (error: unknown) => void;
const create = vi.fn<(payload: Payload) => Promise<{ id: string; body: string }>>()
.mockImplementationOnce(() => new Promise((_, no) => { reject = no; }))
.mockRejectedValueOnce(new ApiError("Try again", 429, null))
.mockResolvedValue({ id: "created-task", body: "Keep my attachment" });
const send = vi.fn((payload: Payload, _key: string) => client.getMutationCache().build(client, { mutationFn: create }).execute(payload));
render({ send });
let pending!: ReturnType<typeof current.submit>;
act(() => { pending = current.submit({ body: "Keep my attachment" }); });
const worker = Object.assign(new EventTarget(), { controller: {}, register: vi.fn().mockResolvedValue({ update: vi.fn() }) });
const doc = Object.assign(new EventTarget(), { visibilityState: "hidden" });
const reload = vi.fn();
const stop = startServiceWorkerUpdates({ container: worker as unknown as ServiceWorkerContainer,
documentRef: doc as unknown as Document, reload,
canReload: () => store.getSnapshot().pendingWrites === 0 && client.isMutating() === 0 && !hasPendingWrites(),
subscribeReloadSafety: listener => {
const cleanups = [store.subscribe(listener), client.getMutationCache().subscribe(listener), subscribePendingWrites(listener)];
return () => cleanups.forEach(cleanup => cleanup());
} });
try {
worker.dispatchEvent(new Event("controllerchange"));
await act(async () => { await Promise.resolve(); reject(new ApiError("Try again", 429, null)); await pending; });
expect(store.getSnapshot().status).toBe("online");
expect(client.isMutating()).toBe(0);
expect(reload).not.toHaveBeenCalled();
await advance(1_000);
expect(create).toHaveBeenCalledTimes(2);
expect(reload).not.toHaveBeenCalled();
await advance(2_000);
expect(create).toHaveBeenCalledTimes(3);
expect(new Set(send.mock.calls.map(call => call[1])).size).toBe(1);
expect(current.pending).toBeNull();
expect(reload).toHaveBeenCalledOnce();
} finally { stop(); client.clear(); }
});
it("reports memory-only retention when browser storage rejects the request", async () => {
const storage = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("Storage full"); });
render({ send: vi.fn().mockRejectedValue(outage()) });
+9 -3
View File
@@ -20,6 +20,7 @@ export interface AutoResendOptions {
sourceId: string;
/** A send is waiting to be resent. */
active: boolean;
/** Total unresolved writes, including an attempt currently in flight. */
pendingCount?: number;
/** Resend the waiting payload with its original idempotency key. */
resend: () => Promise<SendOutcome>;
@@ -44,10 +45,10 @@ export interface AutoResendState {
* single failed request does not always move the connectivity store). Nothing
* is sent while the store reports an outage.
*/
export function useAutoResend({ sourceId, active, resend, auto = true, pendingCount = 1 }: AutoResendOptions): AutoResendState {
export function useAutoResend({ sourceId, active, resend, auto = true, pendingCount }: AutoResendOptions): AutoResendState {
const store = useConnectivityStore();
const online = useConnectivity().status === "online";
usePendingWritesReporter(sourceId, active ? pendingCount : 0);
usePendingWritesReporter(sourceId, pendingCount ?? (active ? 1 : 0));
const resendRef = useRef(resend);
resendRef.current = resend;
@@ -183,6 +184,7 @@ export interface DurableSubmit<T, R> {
*/
export function useDurableSubmit<T, R>(options: DurableSubmitOptions<T, R>): DurableSubmit<T, R> {
const { sourceId, storage = "local", autoResend = true } = options;
const store = useConnectivityStore();
// Records are kept per signed-in account, so another account in this
// browser never sees or resends them. Until the account is known nothing is
// restored, and a send stays in memory.
@@ -203,7 +205,10 @@ export function useDurableSubmit<T, R>(options: DurableSubmitOptions<T, R>): Dur
if (!next) setPersisted(false);
pendingRef.current = next;
setPendingState(next);
}, []);
// A mutation may settle before React reports its waiting state. Keep the
// entire keyed operation visible to reload safety between attempts.
store.setPendingWrites(sourceId, next ? 1 : 0);
}, [sourceId, store]);
const loadedKey = useRef(storageKey);
useEffect(() => {
@@ -282,6 +287,7 @@ export function useDurableSubmit<T, R>(options: DurableSubmitOptions<T, R>): Dur
const auto = useAutoResend({
sourceId,
active: pending !== null && !sending,
pendingCount: pending ? 1 : 0,
auto: autoResend,
resend: async () => {
const record = pendingRef.current;
+12 -7
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
import { sidebarPreferencesApi } from "../api/sidebarPreferences";
import { sortProjectsByStoredOrder } from "../lib/project-order";
import { queryKeys } from "../lib/queryKeys";
import { useAccountBoundMutation } from "./useAccountBoundMutation";
type UseProjectOrderParams = {
projects: Project[];
@@ -45,8 +46,16 @@ export function useProjectOrder({ projects, companyId, userId }: UseProjectOrder
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
}, [data?.orderedIds, projects]);
const mutation = useMutation({
// The server saves for the request's user, so a restored sidebar must not
// send (or optimistically apply) an order before the session is verified.
const mutation = useAccountBoundMutation({
mutationFn: (nextIds: string[]) => sidebarPreferencesApi.updateProjectOrder(companyId!, { orderedIds: nextIds }),
onMutate: (nextIds) => {
queryClient.setQueryData(queryKey, (current: { orderedIds?: string[]; updatedAt?: Date | null } | undefined) => ({
orderedIds: nextIds,
updatedAt: current?.updatedAt ?? null,
}));
},
onSuccess: (preference) => {
queryClient.setQueryData(queryKey, preference);
},
@@ -68,13 +77,9 @@ export function useProjectOrder({ projects, companyId, userId }: UseProjectOrder
setOrderedIds((current) => (areEqual(current, filtered) ? current : filtered));
if (!companyId || !userId) return;
queryClient.setQueryData(queryKey, (current: { orderedIds?: string[]; updatedAt?: Date | null } | undefined) => ({
orderedIds: filtered,
updatedAt: current?.updatedAt ?? null,
}));
mutation.mutate(filtered);
},
[companyId, mutation, projects, queryClient, queryKey, userId],
[companyId, mutation, projects, userId],
);
return {
@@ -0,0 +1,104 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Company, Project } from "@paperclipai/shared";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { queryKeys } from "@/lib/queryKeys";
import { markCachedSession } from "@/lib/cached-session";
import { startPersistedQueryCache } from "@/lib/persisted-query-cache";
import { useCompanyOrder } from "./useCompanyOrder";
import { useProjectOrder } from "./useProjectOrder";
const getSession = vi.hoisted(() => vi.fn());
const sidebarApi = vi.hoisted(() => ({
getCompanyOrder: vi.fn(),
updateCompanyOrder: vi.fn(),
getProjectOrder: vi.fn(),
updateProjectOrder: vi.fn(),
}));
vi.mock("@/api/auth", () => ({ authApi: { getSession } }));
vi.mock("../api/sidebarPreferences", () => ({ sidebarPreferencesApi: sidebarApi }));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const companies = [{ id: "company-1" }, { id: "company-2" }] as Company[];
const projects = [{ id: "project-1" }, { id: "project-2" }] as Project[];
let persistOrder: (ids: string[]) => void;
function CompanyHarness() {
persistOrder = useCompanyOrder({ companies, userId: "user-1" }).persistOrder;
return null;
}
function ProjectHarness() {
persistOrder = useProjectOrder({ projects, companyId: "company-1", userId: "user-1" }).persistOrder;
return null;
}
const cases = [
{ name: "company", Harness: CompanyHarness, ids: ["company-2", "company-1"], update: sidebarApi.updateCompanyOrder,
key: queryKeys.sidebarPreferences.companyOrder("user-1") },
{ name: "project", Harness: ProjectHarness, ids: ["project-2", "project-1"], update: sidebarApi.updateProjectOrder,
key: queryKeys.sidebarPreferences.projectOrder("company-1", "user-1") },
];
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
for (const fn of Object.values(sidebarApi)) fn.mockReset();
sidebarApi.getCompanyOrder.mockResolvedValue({ orderedIds: [], updatedAt: null });
sidebarApi.getProjectOrder.mockResolvedValue({ orderedIds: [], updatedAt: null });
sidebarApi.updateCompanyOrder.mockImplementation(async (data: { orderedIds: string[] }) => ({ ...data, updatedAt: null }));
sidebarApi.updateProjectOrder.mockImplementation(async (_companyId: string, data: { orderedIds: string[] }) => ({ ...data, updatedAt: null }));
getSession.mockReset();
});
afterEach(() => {
act(() => root.unmount());
host.remove();
});
it.each(cases.flatMap(entry => ["user-1", "user-2"].map(verifiedUser => ({ ...entry, verifiedUser }))))(
"holds a restored $name order until the session is verified ($verifiedUser)",
async ({ Harness, ids, update, key, verifiedUser }) => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
const cache = startPersistedQueryCache(client, { storage: null, buildId: "test", eventTarget: null });
markCachedSession(client);
let complete!: (value: unknown) => void;
getSession.mockReturnValue(new Promise(resolve => { complete = resolve; }));
try {
await act(async () => root.render(<QueryClientProvider client={client}><Harness /></QueryClientProvider>));
await act(async () => {});
const before = client.getQueryData(key);
await act(async () => persistOrder(ids));
await act(async () => {});
expect(getSession).toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
expect(client.getQueryData(key)).toEqual(before);
await act(async () => { complete({ user: { id: verifiedUser } }); });
await act(async () => {});
await act(async () => {});
if (verifiedUser === "user-1") {
expect(update).toHaveBeenCalledTimes(1);
expect(update.mock.calls[0].at(-1)).toEqual({ orderedIds: ids });
expect(client.getQueryData<{ orderedIds: string[] }>(key)?.orderedIds).toEqual(ids);
} else {
expect(update).not.toHaveBeenCalled();
expect(client.getQueryData<{ orderedIds?: string[] }>(key)?.orderedIds ?? []).not.toEqual(ids);
}
} finally {
cache.dispose();
client.clear();
}
},
);
+42
View File
@@ -7,6 +7,8 @@ import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-quer
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "@/lib/queryKeys";
import { isAccountScopedQueryKey, useSignOut } from "./useSignOut";
import { bindAccount } from "@/lib/account-scope";
import { QUERY_CACHE_PREFIX, startPersistedQueryCache } from "@/lib/persisted-query-cache";
const mockAuthApi = vi.hoisted(() => ({ signOut: vi.fn() }));
const mockNavigateTopLevel = vi.hoisted(() => vi.fn());
@@ -53,6 +55,46 @@ describe("useSignOut", () => {
return root;
}
it.each([
["authenticated", "build-one"], ["authenticated", null],
["cloud", "build-one"], ["cloud", null],
] as const)("finishes %s sign-out and revokes saved reads on plain HTTP (build=%s)", async (deploymentMode, buildId) => {
localStorage.clear();
queryClient.setQueryData(queryKeys.health, { status: "ok", deploymentMode, cloud: deploymentMode === "cloud" ? { managed: true } : undefined });
queryClient.setQueryData(queryKeys.auth.session, { user: { id: "alice" } });
queryClient.setQueryData(queryKeys.companies.list("alice"), { companies: [{ id: "company-a" }] });
const taskKey = [...queryKeys.issues.detail("task-a"), "core"];
queryClient.setQueryData(taskKey, { id: "task-a", companyId: "company-a", title: "Old account task" });
const stamped = startPersistedQueryCache(queryClient, { buildId: "build-one", eventTarget: null });
stamped.flush(); stamped.dispose();
expect(localStorage.getItem(`${QUERY_CACHE_PREFIX}alice:company-a`)).toContain("Old account task");
localStorage.setItem("paperclip:private-draft", "Keep my draft");
const cache = startPersistedQueryCache(queryClient, { buildId, eventTarget: null });
const account = bindAccount(queryClient);
const onSignedOut = vi.fn();
mockAuthApi.signOut.mockResolvedValue(undefined);
const root = renderHarness(onSignedOut);
vi.stubGlobal("crypto", { getRandomValues: globalThis.crypto.getRandomValues.bind(globalThis.crypto) });
try {
await act(async () => { await captured!.mutateAsync(); });
expect(onSignedOut).toHaveBeenCalledOnce();
expect(account.isCurrentScope()).toBe(false);
expect(Object.keys(localStorage).filter(key => key.startsWith(QUERY_CACHE_PREFIX))).toEqual([]);
expect(localStorage.getItem("paperclip:private-draft")).toBe("Keep my draft");
if (deploymentMode === "cloud") {
expect(mockNavigateTopLevel).toHaveBeenCalledWith("/cloud/logout");
expect(mockAuthApi.signOut).not.toHaveBeenCalled();
} else {
expect(mockAuthApi.signOut).toHaveBeenCalledOnce();
expect(queryClient.getQueryData(taskKey)).toBeUndefined();
expect(queryClient.getQueryData(queryKeys.auth.session)).toBeUndefined();
}
} finally {
vi.unstubAllGlobals(); cache.dispose();
act(() => root.unmount()); localStorage.clear();
}
});
it("closes caller chrome and navigates through Cloud without local sign-out", async () => {
const onSignedOut = vi.fn();
queryClient.setQueryData(queryKeys.health, {
+3
View File
@@ -3,6 +3,7 @@ import { authApi } from "@/api/auth";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { queryKeys } from "@/lib/queryKeys";
import { invalidateAccountScope } from "@/lib/account-scope";
import { clearPersistedQueryCache } from "@/lib/persisted-query-cache";
import { useCloudInstance } from "./useCloudInstance";
const CLOUD_SIGN_OUT_PATH = "/cloud/logout";
@@ -48,6 +49,7 @@ export function useSignOut({ onSignedOut }: UseSignOutOptions = {}) {
return useMutation({
mutationFn: async () => {
if (cloud) {
clearPersistedQueryCache(queryClient);
invalidateAccountScope(queryClient);
onSignedOut?.();
navigateTopLevel(CLOUD_SIGN_OUT_PATH);
@@ -60,6 +62,7 @@ export function useSignOut({ onSignedOut }: UseSignOutOptions = {}) {
onSuccess: async (target) => {
if (target === "cloud") return;
clearPersistedQueryCache(queryClient);
invalidateAccountScope(queryClient);
onSignedOut?.();
+7 -6
View File
@@ -2,6 +2,7 @@ import type { QueryClient } from "@tanstack/react-query";
import { queryKeys } from "./queryKeys";
import { authApi } from "@/api/auth";
import { classifySendFailure } from "./pending-send";
import { hasUnverifiedSession } from "./cached-session";
type AccountGeneration = { owner: string | undefined; generation: number };
const accounts = new WeakMap<QueryClient, AccountGeneration>();
@@ -34,24 +35,24 @@ export function bindAccount(client: QueryClient) {
}
const { owner, generation } = account;
const isCurrentScope = () => account.generation === generation && (owner === undefined ? generation === 0 : account.owner === owner);
const isCurrentAccount = () => account.owner !== undefined && isCurrentScope();
const isCurrentAccount = () => account.owner !== undefined && !hasUnverifiedSession(client) && isCurrentScope();
const whenAccountReady = async () => {
if (account.owner !== undefined || !isCurrentScope()) return;
if ((account.owner !== undefined && !hasUnverifiedSession(client)) || !isCurrentScope()) return;
let stopped = false;
let unsubscribe = () => {};
const ready = new Promise<void>(resolve => {
unsubscribe = client.getQueryCache().subscribe(() => {
if (account.owner !== undefined || !isCurrentScope()) resolve();
if ((account.owner !== undefined && !hasUnverifiedSession(client)) || !isCurrentScope()) resolve();
});
});
const lookup = async () => {
for (let attempt = 0; attempt < 3; attempt += 1) {
if (stopped || account.owner !== undefined || !isCurrentScope()) return;
if (stopped || (account.owner !== undefined && !hasUnverifiedSession(client)) || !isCurrentScope()) return;
try {
await client.fetchQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), retry: false });
await client.fetchQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), retry: false, staleTime: 0 });
return;
} catch (error) {
if (stopped || account.owner !== undefined || !isCurrentScope()) return;
if (stopped || (account.owner !== undefined && !hasUnverifiedSession(client)) || !isCurrentScope()) return;
if (attempt === 2 || classifySendFailure(error) !== "pending") throw error;
await new Promise(resolve => setTimeout(resolve, 250 * 2 ** attempt));
}
+15
View File
@@ -0,0 +1,15 @@
import type { QueryClient } from "@tanstack/react-query";
const unverifiedSessions = new WeakSet<QueryClient>();
export function markCachedSession(client: QueryClient) {
unverifiedSessions.add(client);
}
export function verifyCachedSession(client: QueryClient) {
unverifiedSessions.delete(client);
}
export function hasUnverifiedSession(client: QueryClient): boolean {
return unverifiedSessions.has(client);
}
+12
View File
@@ -75,6 +75,18 @@ describe("inboxArchiveCache", () => {
expect(filterLocalInboxArchivedIssues("company-1", issues)).toEqual(issues);
});
it("does not let a completed archive release a newer operation's hide flag", () => {
const releaseOld = beginLocalInboxArchive("company-1", "issue-a");
const releaseNew = beginLocalInboxArchive("company-1", "issue-a");
releaseOld();
expect(getLocalInboxArchiveIssueIds("company-1").has("issue-a")).toBe(true);
releaseNew();
expect(getLocalInboxArchiveIssueIds("company-1").has("issue-a")).toBe(false);
releaseOld();
releaseNew();
expect(getLocalInboxArchiveIssueIds("company-1").size).toBe(0);
});
it("does not expire an in-flight archive before post-settle bounding starts", () => {
vi.useFakeTimers();
beginLocalInboxArchive("company-1", "issue-a");
+8
View File
@@ -11,6 +11,7 @@ const EMPTY_ARCHIVED_ISSUE_IDS: ReadonlySet<string> = new Set();
type InboxArchiveGuardState = {
issueIds: ReadonlySet<string>;
tokens: Map<string, symbol>;
listeners: Set<() => void>;
confirmationTimers: Map<string, ReturnType<typeof setTimeout>>;
maximumTimers: Map<string, ReturnType<typeof setTimeout>>;
@@ -36,6 +37,7 @@ function getInboxArchiveGuard(companyId: string): InboxArchiveGuardState {
const created: InboxArchiveGuardState = {
issueIds: EMPTY_ARCHIVED_ISSUE_IDS,
tokens: new Map(),
listeners: new Set(),
confirmationTimers: new Map(),
maximumTimers: new Map(),
@@ -60,12 +62,17 @@ function clearArchiveGuardTimer(
export function beginLocalInboxArchive(companyId: string, issueId: string) {
const state = getInboxArchiveGuard(companyId);
const token = Symbol();
state.tokens.set(issueId, token);
clearArchiveGuardTimer(state.confirmationTimers, issueId);
clearArchiveGuardTimer(state.maximumTimers, issueId);
const issueIds = new Set(state.issueIds);
issueIds.add(issueId);
publishInboxArchiveGuard(state, issueIds);
return () => {
if (state.tokens.get(issueId) === token) clearLocalInboxArchive(companyId, issueId);
};
}
export function boundLocalInboxArchive(companyId: string, issueId: string) {
@@ -91,6 +98,7 @@ export function confirmLocalInboxArchive(companyId: string, issueId: string) {
export function clearLocalInboxArchive(companyId: string, issueId: string) {
const state = inboxArchiveGuards.get(companyId);
if (!state) return;
state.tokens.delete(issueId);
clearArchiveGuardTimer(state.confirmationTimers, issueId);
clearArchiveGuardTimer(state.maximumTimers, issueId);
if (!state.issueIds.has(issueId)) {
+622
View File
@@ -0,0 +1,622 @@
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import type { Agent } from "@paperclipai/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiError, api, detachInflightGets } from "@/api/client";
import { bindAccount } from "./account-scope";
import { hasUnverifiedSession, markCachedSession } from "./cached-session";
import { queryKeys } from "./queryKeys";
import { readComposerRunSettings } from "../components/task-chat/composer-run-settings";
import { agentConnection, agentDefaultModel } from "./bulk-agent-settings";
import { clearPersistedQueryCache, persistedQueryScope, QUERY_CACHE_ACTIVE, QUERY_CACHE_CLEARED, QUERY_CACHE_MAX_AGE, QUERY_CACHE_PREFIX, startPersistedQueryCache } from "./persisted-query-cache";
const auth = vi.hoisted(() => ({ getSession: vi.fn() }));
vi.mock("@/api/auth", () => ({ authApi: auth }));
class MemoryStorage {
values = new Map<string, string>();
get length() { return this.values.size; }
key(index: number) { return [...this.values.keys()][index] ?? null; }
getItem(key: string) { return this.values.get(key) ?? null; }
setItem(key: string, value: string) { this.values.set(key, value); }
removeItem(key: string) { this.values.delete(key); }
}
const session = (id = "alice") => ({ session: { id: `session-${id}`, userId: id, token: "never-store-session-token" },
user: { id, name: id, email: `${id}@example.test`, image: null }, sentryDsn: "never-store-dsn", boardBootstrap: { private: "not-allowlisted" } });
const handles: Array<() => void> = [];
function setup(storage = new MemoryStorage(), now?: () => number, client = new QueryClient({ defaultOptions: { queries: { retry: false } } }), buildId: string | null = "build-one") {
const handle = startPersistedQueryCache(client, { storage, now, buildId, eventTarget: null, debounceMs: 100_000 });
handles.push(() => { handle.dispose(); client.clear(); });
return { client, handle, storage };
}
function seed(client: QueryClient, userId = "alice", companyId = "company-one") {
client.setQueryData(queryKeys.auth.session, session(userId));
client.setQueryData(queryKeys.companies.list(userId), { companies: [{ id: companyId, name: "Example" }], unauthorized: false });
client.setQueryData(queryKeys.health, { status: "ok", deploymentMode: "authenticated" });
client.setQueryData([...queryKeys.access.currentBoardAccess, userId], { userId, companyIds: [companyId], isInstanceAdmin: false });
client.setQueryData([...queryKeys.issues.detail("task-one"), "core"], { id: "task-one", companyId, title: "Saved task" });
}
afterEach(() => { for (const dispose of handles.splice(0)) dispose(); vi.useRealTimers(); vi.clearAllMocks(); });
describe("persisted read cache", () => {
it.each([null, "", " "])("skips hydration and persistence without a build stamp (%s)", (buildId) => {
const first = setup(); seed(first.client); first.handle.flush();
const saved = new Map(first.storage.values);
const restored = setup(first.storage, undefined, undefined, buildId);
expect(restored.client.getQueryData(queryKeys.auth.session)).toBeUndefined();
expect(restored.client.getQueryData([...queryKeys.issues.detail("task-one"), "core"])).toBeUndefined();
seed(restored.client); restored.handle.flush();
expect(first.storage.values).toEqual(saved);
clearPersistedQueryCache(restored.client);
expect([...first.storage.values.keys()].some(key => key.startsWith(QUERY_CACHE_PREFIX))).toBe(false);
});
it.each([QUERY_CACHE_CLEARED, "paperclip:query-cache-revision:alice"])("revokes a stamped tab through exported sign-out cleanup in an unstamped build (%s)", async eventKey => {
const first = setup(); seed(first.client); first.handle.flush();
first.storage.setItem("paperclip:private-draft", "draft retained");
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const events = new EventTarget();
const other = startPersistedQueryCache(client, { storage: first.storage, buildId: "build-one", eventTarget: events as Window });
handles.push(() => { other.dispose(); client.clear(); });
await client.fetchQuery({ queryKey: queryKeys.auth.session, queryFn: async () => session(), staleTime: 0 });
const account = bindAccount(client);
const unstamped = setup(first.storage, undefined, undefined, null);
unstamped.client.setQueryData(queryKeys.auth.session, session());
clearPersistedQueryCache(unstamped.client);
expect(first.storage.getItem(eventKey)).not.toBeNull();
expect([...first.storage.values.keys()].filter(key => key.startsWith(QUERY_CACHE_PREFIX))).toEqual([]);
events.dispatchEvent(Object.assign(new Event("storage"), { key: eventKey, newValue: first.storage.getItem(eventKey) }));
expect(client.getQueryData([...queryKeys.issues.detail("task-one"), "core"])).toBeUndefined();
expect(client.getQueryData(queryKeys.auth.session)).toBeNull();
expect(account.isCurrentScope()).toBe(false);
other.flush();
expect(first.storage.getItem(QUERY_CACHE_ACTIVE)).toBeNull();
expect(first.storage.getItem("paperclip:private-draft")).toBe("draft retained");
});
it("rejects a stamped tab's delayed flush after unstamped sign-out before storage events arrive", async () => {
const first = setup(); seed(first.client); first.handle.flush();
const other = setup(first.storage);
await other.client.fetchQuery({ queryKey: queryKeys.auth.session, queryFn: async () => session(), staleTime: 0 });
const unstamped = setup(first.storage, undefined, undefined, null);
unstamped.client.setQueryData(queryKeys.auth.session, session());
clearPersistedQueryCache(unstamped.client);
other.handle.flush();
expect(other.client.getQueryData(queryKeys.auth.session)).toBeNull();
expect([...first.storage.values.keys()].filter(key => key.startsWith(QUERY_CACHE_PREFIX))).toEqual([]);
});
it("keeps recent task reads and company membership after actual five-minute memory garbage collection", async () => {
vi.useFakeTimers();
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } });
const key = [...queryKeys.issues.detail("task-one"), "core"];
client.setQueryDefaults(key, { gcTime: 5 * 60_000 });
client.setQueryDefaults(queryKeys.companies.list("alice"), { gcTime: 5 * 60_000 });
const first = setup(undefined, undefined, client); seed(client); first.handle.flush();
const observer = new QueryObserver(client, { queryKey: key, enabled: false });
const unsubscribe = observer.subscribe(() => {});
unsubscribe();
await vi.advanceTimersByTimeAsync(5 * 60_000 + 1);
expect(client.getQueryData(key)).toBeUndefined();
expect(client.getQueryData(queryKeys.companies.list("alice"))).toBeUndefined();
client.setQueryData(queryKeys.health, { status: "ok", restarted: true });
first.handle.flush();
const restored = setup(first.storage);
expect(restored.client.getQueryData(key)).toMatchObject({ title: "Saved task" });
expect(restored.client.getQueryData(queryKeys.companies.list("alice"))).toMatchObject({ companies: [{ id: "company-one" }] });
});
it("expires retained task data from its original read time despite later snapshot writes", () => {
vi.useFakeTimers();
let now = Date.now();
const first = setup(undefined, () => now); seed(first.client); first.handle.flush();
const key = [...queryKeys.issues.detail("task-one"), "core"];
first.client.removeQueries({ queryKey: key, exact: true });
now += 5 * 60_000;
first.handle.flush();
expect([...first.storage.values.values()].join(" ")).toContain("Saved task");
now += QUERY_CACHE_MAX_AGE;
first.client.setQueryData(queryKeys.auth.session, session(), { updatedAt: now });
first.client.setQueryData(queryKeys.companies.list("alice"), { companies: [{ id: "company-one" }] }, { updatedAt: now });
first.handle.flush();
expect([...first.storage.values.values()].join(" ")).not.toContain("Saved task");
expect(setup(first.storage, () => now).client.getQueryData(key)).toBeUndefined();
});
it("applies the twenty-task limit across retained disk reads and newer live reads", () => {
vi.useFakeTimers();
const now = Date.now();
const first = setup(undefined, () => now); seed(first.client);
first.client.removeQueries({ queryKey: queryKeys.issues.detail("task-one") });
for (let i = 0; i < 20; i += 1) first.client.setQueryData([...queryKeys.issues.detail(`task-${i}`), "core"],
{ id: `task-${i}`, companyId: "company-one", title: `Retained task ${i}` }, { updatedAt: now - 20 + i });
first.handle.flush();
first.client.removeQueries({ queryKey: ["issues", "detail"] });
first.client.setQueryData([...queryKeys.issues.detail("new-task"), "core"], { id: "new-task", companyId: "company-one", title: "Newest task" }, { updatedAt: now });
first.handle.flush();
const restored = setup(first.storage, () => now);
const details = restored.client.getQueryCache().getAll().filter(query => query.queryKey[0] === "issues" && query.queryKey[1] === "detail");
expect(details).toHaveLength(20);
expect(restored.client.getQueryData([...queryKeys.issues.detail("task-0"), "core"])).toBeUndefined();
expect(restored.client.getQueryData([...queryKeys.issues.detail("new-task"), "core"])).toMatchObject({ title: "Newest task" });
});
it.each([403, 404])("scrubs saved aliases and inbox rows after a %s denial following memory GC, even before session verification", async status => {
const first = setup(); seed(first.client);
const task = { id: "task-one", identifier: "TST-1", companyId: "company-one", title: "Denied task body" };
const keys = [queryKeys.issues.detail(task.id), [...queryKeys.issues.detail(task.id), "core"],
queryKeys.issues.detail(task.identifier), [...queryKeys.issues.detail(task.identifier), "core"]];
for (const key of keys) first.client.setQueryData(key, task);
const inboxKey = [...queryKeys.issues.listMineByMe("company-one"), "index", "with-routine-executions", "live-descendant-summary", 100, "alice"];
first.client.setQueryData(inboxKey, [task, { id: "allowed-task", title: "Other task" }]);
first.handle.flush();
first.client.removeQueries({ queryKey: ["issues"] });
markCachedSession(first.client);
await first.client.fetchQuery({ queryKey: [...queryKeys.issues.detail("tst-1"), "core"], queryFn: async () => { throw new ApiError("Denied", status, null); } }).catch(() => {});
expect(hasUnverifiedSession(first.client)).toBe(true);
expect([...first.storage.values.values()].join(" ")).not.toContain("Denied task body");
const restored = setup(first.storage);
for (const key of keys) expect(restored.client.getQueryData(key)).toBeUndefined();
expect(restored.client.getQueryData(inboxKey)).toEqual([{ id: "allowed-task", title: "Other task" }]);
});
it("removes retained company reads after fresh membership denial and does not resurrect them after memory GC", async () => {
const first = setup(); seed(first.client); first.handle.flush();
first.client.removeQueries({ queryKey: ["issues", "detail"] });
const membership = queryKeys.companies.list("alice");
await first.client.fetchQuery({ queryKey: membership, queryFn: async () => { throw new ApiError("Forbidden", 403, null); } }).catch(() => {});
first.client.removeQueries({ queryKey: membership, exact: true });
first.handle.flush();
expect([...first.storage.values.values()].join(" ")).not.toContain("Saved task");
expect(setup(first.storage).client.getQueryData([...queryKeys.issues.detail("task-one"), "core"])).toBeUndefined();
});
it("replaces a retained disk task with a newer live read without persisting private fields", () => {
vi.useFakeTimers();
const now = Date.now();
const first = setup(undefined, () => now); seed(first.client); first.handle.flush();
const key = [...queryKeys.issues.detail("task-one"), "core"];
first.client.removeQueries({ queryKey: key, exact: true });
first.client.setQueryData(key, { id: "task-one", companyId: "company-one", title: "Newer task", planDocument: { body: "excluded-new-document" } }, { updatedAt: now });
first.handle.flush();
const restored = setup(first.storage, () => now);
expect(restored.client.getQueryData(key)).toMatchObject({ title: "Newer task" });
expect(restored.client.getQueryData(key)).not.toHaveProperty("planDocument");
expect([...first.storage.values.values()].join(" ")).not.toContain("excluded-new-document");
});
it("retains visible task run preferences while excluding private override options", () => {
const first = setup(); seed(first.client);
first.client.setQueryData([...queryKeys.issues.detail("task-one"), "core"], {
id: "task-one", companyId: "company-one", title: "Saved task",
assigneeAdapterOverrides: { useProjectWorkspace: true, adapterConfig: {
model: "display-model", modelReasoningEffort: "high", fastMode: true,
chrome: true, command: "excluded-command", env: { PRIVATE_VALUE: "excluded-env" },
} },
});
first.handle.flush();
expect([...first.storage.values.values()].join(" ")).not.toMatch(/excluded-|chrome/);
const restored = setup(first.storage);
const task = restored.client.getQueryData<{ assigneeAdapterOverrides: { adapterConfig: Record<string, unknown> } }>([...queryKeys.issues.detail("task-one"), "core"])!;
expect(readComposerRunSettings(task.assigneeAdapterOverrides, "codex_local")).toEqual({ model: "display-model", effort: "high", fast: true });
expect(task.assigneeAdapterOverrides.adapterConfig).not.toHaveProperty("command");
expect(task.assigneeAdapterOverrides.adapterConfig).not.toHaveProperty("env");
});
it.each(["account-switch", "sign-out"])("does not share or persist another account's pending private-project request after %s", async boundary => {
const first = setup(); seed(first.client); first.handle.flush();
let finishAlice!: (value: Response) => void;
const bobProjects = [{ id: "project-bob", companyId: "company-one", name: "Bob project" }];
const fetch = vi.fn().mockImplementationOnce(() => new Promise<Response>(resolve => { finishAlice = resolve; }))
.mockResolvedValue(new Response(JSON.stringify(bobProjects), { headers: { "Content-Type": "application/json" } }));
vi.stubGlobal("fetch", fetch);
try {
const key = queryKeys.projects.list("company-one");
const path = "/companies/company-one/projects";
// Match sidebar reads that do not forward React Query's AbortSignal.
const oldRead = first.client.fetchQuery({ queryKey: key, queryFn: () => api.get(path) }).catch(() => undefined);
expect(fetch).toHaveBeenCalledTimes(1);
if (boundary === "sign-out") { clearPersistedQueryCache(first.client); first.client.clear(); }
await first.client.fetchQuery({ queryKey: queryKeys.auth.session, queryFn: async () => session("bob"), staleTime: 0 });
seed(first.client, "bob");
const currentRead = first.client.fetchQuery({ queryKey: key, queryFn: () => api.get(path) });
expect(fetch).toHaveBeenCalledTimes(2);
await expect(currentRead).resolves.toEqual(bobProjects);
finishAlice(new Response(JSON.stringify([{ id: "project-alice", companyId: "company-one", name: "Alice private project" }]), { headers: { "Content-Type": "application/json" } }));
await oldRead;
await Promise.resolve();
first.handle.flush();
expect(first.client.getQueryData(key)).toEqual(bobProjects);
expect([...first.storage.values.values()].join(" ")).not.toContain("Alice private project");
expect(setup(first.storage).client.getQueryData(key)).toEqual(bobProjects);
} finally {
finishAlice?.(new Response("[]", { headers: { "Content-Type": "application/json" } }));
detachInflightGets(); vi.unstubAllGlobals();
}
});
it("preserves another account published while this tab is flushing", () => {
const first = setup(); seed(first.client); first.handle.flush();
const write = first.storage.setItem.bind(first.storage);
const bobKey = `${QUERY_CACHE_PREFIX}bob:root`;
const bobRecord = JSON.stringify({ userId: "bob", revision: null, state: { queries: [] } });
let published = false;
vi.spyOn(first.storage, "setItem").mockImplementation((key, value) => {
write(key, value);
if (!published && key === `${QUERY_CACHE_PREFIX}alice:root`) {
published = true;
write(bobKey, bobRecord);
write(QUERY_CACHE_ACTIVE, "bob");
}
});
first.handle.flush();
expect(first.storage.getItem(bobKey)).toBe(bobRecord);
expect(first.storage.getItem(QUERY_CACHE_ACTIVE)).toBe("bob");
expect(first.client.getQueryData(queryKeys.auth.session)).toBeNull();
expect(first.client.getQueryData([...queryKeys.issues.detail("task-one"), "core"])).toBeUndefined();
});
it("removes every saved account record when native storage reorders keys during deletion", () => {
const storage = new MemoryStorage();
const remove = storage.removeItem.bind(storage);
vi.spyOn(storage, "removeItem").mockImplementation(key => {
remove(key);
const entries = [...storage.values.entries()];
if (entries.length > 1) entries.push(entries.shift()!);
storage.values = new Map(entries);
});
const first = setup(storage); seed(first.client);
first.client.setQueryData(queryKeys.companies.list("alice"), { companies: Array.from({ length: 8 }, (_, i) => ({ id: `company-${i}` })) });
first.handle.flush();
expect([...storage.values.keys()].filter(key => key.startsWith(QUERY_CACHE_PREFIX))).toHaveLength(10);
first.handle.clear();
expect([...storage.values.keys()].filter(key => key.startsWith(QUERY_CACHE_PREFIX))).toEqual([]);
expect(storage.getItem(QUERY_CACHE_CLEARED)).toContain('"alice"');
});
it("refetches an active session after rejecting a pre-sign-out response without restoring saved reads", async () => {
const first = setup(); seed(first.client); first.handle.flush();
const second = setup(first.storage);
let release!: (value: ReturnType<typeof session>) => void;
const lookup = vi.fn().mockImplementationOnce(() => new Promise<ReturnType<typeof session>>(resolve => { release = resolve; })).mockResolvedValue(null);
const observer = new QueryObserver(second.client, { queryKey: queryKeys.auth.session, queryFn: lookup, staleTime: 0, retry: false });
const unsubscribe = observer.subscribe(() => {});
try {
first.handle.clear();
release(session());
await vi.waitFor(() => expect(lookup).toHaveBeenCalledTimes(2));
await vi.waitFor(() => { expect(observer.getCurrentResult().data).toBeNull(); expect(observer.getCurrentResult().isFetching).toBe(false); });
second.handle.flush();
expect(second.client.getQueryData([...queryKeys.issues.detail("task-one"), "core"])).toBeUndefined();
expect([...first.storage.values.keys()].filter(key => key.startsWith(QUERY_CACHE_PREFIX))).toEqual([]);
} finally { unsubscribe(); }
});
it.each(["sign-out", "session-ended", "local-sign-out", "revision-only"])("rejects an older session response delivered before the %s storage event", async ended => {
const first = setup(); seed(first.client); first.handle.flush();
const second = ended === "local-sign-out" ? first : setup(first.storage);
let release!: (value: ReturnType<typeof session>) => void;
const read = second.client.fetchQuery({ queryKey: queryKeys.auth.session, staleTime: 0,
queryFn: () => new Promise<ReturnType<typeof session>>(resolve => { release = resolve; }) });
if (ended === "revision-only") {
const write = first.storage.setItem.bind(first.storage);
vi.spyOn(first.storage, "setItem").mockImplementation((key, value) => {
if (key === QUERY_CACHE_CLEARED) throw new Error("Broadcast write failed");
write(key, value);
});
}
if (ended === "session-ended") await first.client.fetchQuery({ queryKey: queryKeys.auth.session, staleTime: 0, queryFn: async () => null });
else first.handle.clear();
release(session());
await read;
second.handle.flush();
expect(second.client.getQueryData([...queryKeys.issues.detail("task-one"), "core"])).toBeUndefined();
expect(second.client.getQueryData(queryKeys.auth.session)).toBeNull();
expect([...first.storage.values.values()].join(" ")).not.toContain("Saved task");
const cold = setup(first.storage);
expect(cold.client.getQueryData([...queryKeys.issues.detail("task-one"), "core"])).toBeUndefined();
await second.client.fetchQuery({ queryKey: queryKeys.auth.session, staleTime: 0, queryFn: async () => session() });
seed(second.client);
second.client.setQueryData([...queryKeys.issues.detail("task-one"), "core"], { id: "task-one", companyId: "company-one", title: "Freshly authorized task" });
second.handle.flush();
expect([...first.storage.values.values()].join(" ")).toContain("Freshly authorized task");
});
it("restores a bounded first page of all legacy Inbox task queries", () => {
const first = setup(); seed(first.client);
const keys = [queryKeys.issues.list("company-one"), queryKeys.issues.listMineByMe("company-one"), queryKeys.issues.listTouchedByMe("company-one")]
.map(key => [...key, "compact", "with-routine-executions", "live-descendant-summary", 500]);
const rows = Array.from({ length: 500 }, (_, i) => ({ id: `legacy-${i}`, companyId: "company-one", title: `Legacy task ${i}`, planDocument: { body: "excluded-legacy-document" } }));
for (const key of keys) first.client.setQueryData(key, rows);
first.handle.flush();
const restored = setup(first.storage);
for (const key of keys) {
expect(first.client.getQueryData(key)).toHaveLength(500);
expect(restored.client.getQueryData(key)).toHaveLength(100);
expect(restored.client.getQueryData(key)).toContainEqual({ id: "legacy-0", companyId: "company-one", title: "Legacy task 0" });
expect(persistedQueryScope([...key, "extra"], rows, "alice", ["company-one"])).toBeUndefined();
}
expect([...first.storage.values.values()].join(" ")).not.toContain("excluded-legacy-document");
const recordKey = `${QUERY_CACHE_PREFIX}alice:company-one`;
const record = JSON.parse(first.storage.getItem(recordKey)!);
for (const query of record.state.queries) if (query.queryKey.includes("compact")) query.state.data = rows;
first.storage.setItem(recordKey, JSON.stringify(record));
const older = setup(first.storage);
for (const key of keys) expect(older.client.getQueryData(key)).toHaveLength(100);
});
it("restores the approved shell and company reads without persisting authentication material", () => {
const first = setup();
seed(first.client);
first.client.setQueryData(["secrets", "company-one"], { token: "not-allowed" });
first.client.setQueryData(["issues", "browser", "task-one"], { url: "https://viewer.example.test/capability" });
first.client.setQueryData(["issues", "documents", "task-one"], [{ body: "not-allowlisted" }]);
const inboxKey = [...queryKeys.issues.listMineByMe("company-one"), "index", "with-routine-executions", "live-descendant-summary", 100, "alice"];
first.client.setQueryData(inboxKey, [{ id: "inbox-task", title: "First page" }]);
first.client.setQueryData(queryKeys.plugins.uiContributions, [{ pluginId: "example-plugin", displayName: "Example extension" }]);
first.handle.flush();
const disk = [...first.storage.values.values()].join(" ");
expect(disk).not.toMatch(/never-store|not-allowed|not-allowlisted|viewer\.example/);
const restored = setup(first.storage);
expect(restored.client.getQueryData([...queryKeys.issues.detail("task-one"), "core"])).toMatchObject({ title: "Saved task" });
expect(restored.client.getQueryData(queryKeys.companies.list("alice"))).toMatchObject({ companies: [{ id: "company-one" }] });
expect(restored.client.getQueryData(inboxKey)).toEqual([{ id: "inbox-task", title: "First page" }]);
expect(restored.client.getQueryData(queryKeys.plugins.uiContributions)).toMatchObject([{ displayName: "Example extension" }]);
expect(hasUnverifiedSession(restored.client)).toBe(true);
expect(bindAccount(restored.client).isCurrentAccount()).toBe(false);
});
it("does not hydrate another user's data into an already authenticated client", () => {
const first = setup(); seed(first.client); first.handle.flush();
const client = new QueryClient();
client.setQueryData(queryKeys.auth.session, session("bob"));
setup(first.storage, undefined, client);
expect(client.getQueryData([...queryKeys.issues.detail("task-one"), "core"])).toBeUndefined();
expect(client.getQueryData(queryKeys.auth.session)).toMatchObject({ user: { id: "bob" } });
});
it("strips embedded documents and capabilities from real task-shaped responses and nested inbox rows", () => {
const first = setup(); seed(first.client);
const task = { id: "task-one", companyId: "company-one", title: "Saved task", description: "Approved task description",
planDocument: { id: "plan-one", key: "plan", format: "markdown", body: "excluded-plan-body" },
legacyPlanDocument: { body: "excluded-legacy-body" }, documentSummaries: [{ key: "plan" }],
workProducts: [{ type: "preview_url", url: "https://viewer.example.test/excluded-capability" }] };
first.client.setQueryData([...queryKeys.issues.detail(task.id), "core"], task);
first.client.setQueryData([...queryKeys.issues.listMineByMe("company-one"), "index", "with-routine-executions", "live-descendant-summary", 100, "alice"], [task]);
first.client.setQueryData(queryKeys.agents.list("company-one"), [{ id: "agent-one", name: "Saved agent", adapterConfig: { provider: "codex", model: "display-model", Authorization: "excluded-header", access_token: "excluded-token" }, runtimeConfig: { env: { OPENAI_API_KEY: "excluded-env-token" } } }]);
first.handle.flush();
expect([...first.storage.values.values()].join(" ")).not.toMatch(/excluded-/);
const key = `${QUERY_CACHE_PREFIX}alice:company-one`;
const record = JSON.parse(first.storage.getItem(key)!);
record.state.queries.find((query: { queryKey: string[] }) => query.queryKey[1] === "detail").state.data.planDocument = { body: "excluded-old-body" };
first.storage.setItem(key, JSON.stringify(record));
const restored = setup(first.storage);
const cached = restored.client.getQueryData([...queryKeys.issues.detail(task.id), "core"]);
expect(cached).toMatchObject({ description: "Approved task description" });
expect(cached).not.toHaveProperty("planDocument");
expect(cached).not.toHaveProperty("legacyPlanDocument");
expect(cached).not.toHaveProperty("workProducts");
const cachedAgents = restored.client.getQueryData<Agent[]>(queryKeys.agents.list("company-one"))!;
expect(cachedAgents).toEqual([{ id: "agent-one", name: "Saved agent", adapterConfig: { provider: "codex", model: "display-model" }, runtimeConfig: {} }]);
expect(agentDefaultModel(cachedAgents[0]!)).toBe("display-model");
expect(agentConnection(cachedAgents[0]!)).toBeUndefined();
});
it("counts a task's UUID and identifier reads as one recent task", () => {
const first = setup(); seed(first.client);
for (let i = 0; i < 20; i += 1) {
const task = { id: `task-${i}`, companyId: "company-one", title: `Recent task ${i}` };
first.client.setQueryData([...queryKeys.issues.detail(task.id), "core"], task);
first.client.setQueryData([...queryKeys.issues.detail(`TST-${i}`), "core"], task);
}
first.handle.flush();
const restored = setup(first.storage);
const details = restored.client.getQueryCache().getAll().filter(query => query.queryKey[0] === "issues" && query.queryKey[1] === "detail");
expect(new Set(details.map(query => (query.state.data as { id: string }).id)).size).toBe(20);
expect(details.length).toBeGreaterThan(20);
});
it("allows account-bound writes only after a fresh matching session", async () => {
const first = setup(); seed(first.client); first.handle.flush();
const restored = setup(first.storage);
const account = bindAccount(restored.client);
let release!: (value: ReturnType<typeof session>) => void;
auth.getSession.mockImplementation(() => new Promise(resolve => { release = resolve; }));
const ready = account.whenAccountReady();
expect(account.isCurrentAccount()).toBe(false);
release(session());
await ready;
expect(hasUnverifiedSession(restored.client)).toBe(false);
expect(account.isCurrentAccount()).toBe(true);
});
it("cancels the old owner's write scope and notifies observers when session verification finds another user", async () => {
const first = setup(); seed(first.client); first.handle.flush();
const restored = setup(first.storage);
const account = bindAccount(restored.client);
const observer = new QueryObserver(restored.client, { queryKey: [...queryKeys.issues.detail("task-one"), "core"], enabled: false });
const changes: unknown[] = [];
const unsubscribe = observer.subscribe(result => changes.push(result.data));
auth.getSession.mockResolvedValue(session("bob"));
await account.whenAccountReady();
expect(account.isCurrentAccount()).toBe(false);
expect(account.isCurrentScope()).toBe(false);
expect(restored.client.getQueryData([...queryKeys.issues.detail("task-one"), "core"])).toBeUndefined();
expect(changes).toContain(undefined);
expect([...restored.storage.values.values()].join(" ")).not.toContain("Saved task");
unsubscribe();
});
it("enforces company ownership, exact allowlist entries and the recent-issue bound", () => {
const first = setup(); seed(first.client);
for (let i = 0; i < 25; i += 1) first.client.setQueryData([...queryKeys.issues.detail(`task-${i}`), "core"], { id: `task-${i}`, companyId: "company-one", title: `Task ${i}` });
first.client.setQueryData([...queryKeys.issues.detail("foreign"), "core"], { id: "foreign", companyId: "other-company", title: "Private elsewhere" });
first.handle.flush();
const restored = setup(first.storage);
const details = restored.client.getQueryCache().getAll().filter(query => query.queryKey[0] === "issues" && query.queryKey[1] === "detail");
expect(details).toHaveLength(20);
expect(restored.client.getQueryData([...queryKeys.issues.detail("foreign"), "core"])).toBeUndefined();
expect(persistedQueryScope(["agents", "company-one", "keys"], [], "alice", ["company-one"])).toBeUndefined();
expect(persistedQueryScope(["agent-chats", "company-one", "bob"], [], "alice", ["company-one"])).toBeUndefined();
});
it.each(["build", "expiry"])("discards snapshots after %s changes", kind => {
const startedAt = Date.now();
const first = setup(undefined, () => startedAt); seed(first.client); first.handle.flush();
const restored = setup(first.storage, () => kind === "expiry" ? startedAt + QUERY_CACHE_MAX_AGE + 1 : startedAt, undefined, kind === "build" ? "build-two" : "build-one");
expect(restored.client.getQueryData(queryKeys.auth.session)).toBeUndefined();
expect([...restored.storage.values.keys()].filter(key => key.startsWith(QUERY_CACHE_PREFIX))).toHaveLength(0);
});
it("clears query records on sign-out, retains drafts and resumes caching only after a new verified login", async () => {
const first = setup(); seed(first.client); first.handle.flush();
first.storage.setItem("paperclip:private-draft", "draft retained");
clearPersistedQueryCache(first.client);
first.client.setQueryData(queryKeys.health, { status: "ok" });
first.handle.flush();
expect(first.storage.getItem(QUERY_CACHE_ACTIVE)).toBeNull();
expect(first.storage.getItem("paperclip:private-draft")).toBe("draft retained");
await first.client.fetchQuery({ queryKey: queryKeys.auth.session, queryFn: async () => session("bob"), staleTime: 0 });
first.handle.flush();
expect(first.storage.getItem(QUERY_CACHE_ACTIVE)).toBe("bob");
expect([...first.storage.values.values()].join(" ")).not.toContain("Saved task");
});
it("keeps last good data for transient failures and removes a definitively denied task immediately", async () => {
const first = setup(); seed(first.client); first.handle.flush();
const key = [...queryKeys.issues.detail("task-one"), "core"];
await first.client.fetchQuery({ queryKey: key, queryFn: async () => { throw new ApiError("Restarting", 503, { error: "tenant_app_unavailable" }); } }).catch(() => {});
first.handle.flush();
expect([...first.storage.values.values()].join(" ")).toContain("Saved task");
await first.client.fetchQuery({ queryKey: key, queryFn: async () => { throw new ApiError("Forbidden", 403, null); } }).catch(() => {});
expect([...first.storage.values.values()].join(" ")).not.toContain("Saved task");
});
it.each([{ status: 403, uncachedAlias: false }, { status: 404, uncachedAlias: false }, { status: 403, uncachedAlias: true }])("evicts UUID, identifier, core and inbox copies after a $status task denial (uncached alias=$uncachedAlias)", async ({ status, uncachedAlias }) => {
const first = setup(); seed(first.client);
const task = { id: "task-one", identifier: "TST-1", companyId: "company-one", title: "Denied task body" };
const keys = [queryKeys.issues.detail(task.id), [...queryKeys.issues.detail(task.id), "core"],
queryKeys.issues.detail(task.identifier), [...queryKeys.issues.detail(task.identifier), "core"]];
for (const key of keys) first.client.setQueryData(key, task);
first.client.setQueryData([...queryKeys.issues.listMineByMe("company-one"), "index", "with-routine-executions", "live-descendant-summary", 100, "alice"], [task]);
first.handle.flush();
await first.client.fetchQuery({ queryKey: uncachedAlias ? [...queryKeys.issues.detail("tst-1"), "core"] : keys[3]!, queryFn: async () => { throw new ApiError("Denied", status, null); } }).catch(() => {});
for (const key of keys) expect(first.client.getQueryData(key)).toBeUndefined();
expect([...first.storage.values.values()].join(" ")).not.toContain("Denied task body");
const restored = setup(first.storage);
for (const key of keys) expect(restored.client.getQueryData(key)).toBeUndefined();
await first.client.fetchQuery({ queryKey: keys[3]!, queryFn: async () => ({ ...task, title: "Access restored" }) });
first.handle.flush();
expect([...first.storage.values.values()].join(" ")).toContain("Access restored");
});
it("ignores malformed saved queries while retaining valid recent reads on the next flush", () => {
const first = setup(); seed(first.client); first.handle.flush();
const key = `${QUERY_CACHE_PREFIX}alice:company-one`;
const record = JSON.parse(first.storage.getItem(key)!);
record.state.queries.push(null, {}, { queryKey: ["unapproved"], state: {} });
first.storage.setItem(key, JSON.stringify(record));
first.client.removeQueries({ queryKey: ["issues", "detail"] });
expect(() => first.handle.flush()).not.toThrow();
expect(setup(first.storage).client.getQueryData([...queryKeys.issues.detail("task-one"), "core"])).toMatchObject({ title: "Saved task" });
});
it("tolerates storage enumeration becoming unavailable before a flush", () => {
const first = setup(); seed(first.client); first.handle.flush();
vi.spyOn(first.storage, "length", "get").mockImplementation(() => { throw new Error("Storage unavailable"); });
expect(() => first.handle.flush()).not.toThrow();
});
it("tolerates unavailable or corrupt storage", () => {
const storage = new MemoryStorage();
storage.setItem(QUERY_CACHE_ACTIVE, "alice");
storage.setItem(`${QUERY_CACHE_PREFIX}alice:root`, "not json");
expect(() => setup(storage)).not.toThrow();
const client = new QueryClient();
const handle = startPersistedQueryCache(client, { buildId: "build", storage: null, eventTarget: null });
expect(() => { handle.flush(); handle.clear(); handle.dispose(); }).not.toThrow();
client.clear();
});
it("clears saved account data and publishes revocation even when storage is initially full", () => {
const first = setup(); seed(first.client); first.handle.flush();
let available = false;
const set = first.storage.setItem.bind(first.storage);
const remove = first.storage.removeItem.bind(first.storage);
vi.spyOn(first.storage, "setItem").mockImplementation((key, value) => {
if (!available) throw new Error("Quota exceeded");
set(key, value);
});
vi.spyOn(first.storage, "removeItem").mockImplementation(key => { remove(key); available = true; });
first.handle.clear();
expect([...first.storage.values.keys()].filter(key => key.startsWith(QUERY_CACHE_PREFIX))).toHaveLength(0);
expect(first.storage.getItem(QUERY_CACHE_CLEARED)).toContain('"alice"');
});
it("clears saved account reads after a definitive session denial", async () => {
const first = setup(); seed(first.client); first.handle.flush();
const account = bindAccount(first.client);
await first.client.fetchQuery({ queryKey: queryKeys.auth.session, staleTime: 0,
queryFn: async () => { throw new ApiError("Forbidden", 403, null); } }).catch(() => {});
expect(first.client.getQueryData([...queryKeys.issues.detail("task-one"), "core"])).toBeUndefined();
expect(first.client.getQueryData(queryKeys.auth.session)).toBeNull();
expect(account.isCurrentScope()).toBe(false);
expect([...first.storage.values.keys()].filter(key => key.startsWith(QUERY_CACHE_PREFIX))).toHaveLength(0);
});
it("does not restore fresh task data behind an expired saved identity", () => {
const first = setup(); seed(first.client); first.handle.flush();
const rootKey = `${QUERY_CACHE_PREFIX}alice:root`;
const root = JSON.parse(first.storage.getItem(rootKey)!);
root.state.queries.find((query: { queryKey: string[] }) => query.queryKey[0] === "auth").state.dataUpdatedAt = Date.now() - QUERY_CACHE_MAX_AGE - 1;
first.storage.setItem(rootKey, JSON.stringify(root));
const restored = setup(first.storage);
expect(restored.client.getQueryData([...queryKeys.issues.detail("task-one"), "core"])).toBeUndefined();
expect(hasUnverifiedSession(restored.client)).toBe(false);
});
it.each([QUERY_CACHE_CLEARED, "paperclip:query-cache-revision:alice"])("removes another tab's restored account data on the %s sign-out event", eventKey => {
const first = setup(); seed(first.client); first.handle.flush();
const client = new QueryClient();
const events = new EventTarget();
const handle = startPersistedQueryCache(client, { storage: first.storage, buildId: "build-one", eventTarget: events as Window });
handles.push(() => { handle.dispose(); client.clear(); });
const account = bindAccount(client);
clearPersistedQueryCache(first.client);
events.dispatchEvent(Object.assign(new Event("storage"), { key: eventKey, newValue: first.storage.getItem(eventKey) }));
expect(client.getQueryData([...queryKeys.issues.detail("task-one"), "core"])).toBeUndefined();
expect(client.getQueryData(queryKeys.auth.session)).toBeNull();
expect(account.isCurrentScope()).toBe(false);
handle.flush();
expect(first.storage.getItem(QUERY_CACHE_ACTIVE)).toBeNull();
});
it.each(["sign-out", "session-ended"])("rejects a delayed tab flush and stale records after %s, while preserving a newer account", async ended => {
const first = setup(); seed(first.client); first.handle.flush();
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const events = new EventTarget();
const second = startPersistedQueryCache(client, { storage: first.storage, buildId: "build-one", eventTarget: events as Window });
handles.push(() => { second.dispose(); client.clear(); });
await client.fetchQuery({ queryKey: queryKeys.auth.session, queryFn: async () => session(), staleTime: 0 });
second.flush();
const oldRecords = [...first.storage.values].filter(([key]) => key.startsWith(QUERY_CACHE_PREFIX));
if (ended === "sign-out") first.handle.clear();
else await first.client.fetchQuery({ queryKey: queryKeys.auth.session, queryFn: async () => null, staleTime: 0 });
const token = first.storage.getItem(QUERY_CACHE_CLEARED);
second.flush();
expect(first.storage.getItem(QUERY_CACHE_ACTIVE)).toBeNull();
for (const [key, value] of oldRecords) first.storage.setItem(key, value);
const cold = setup(first.storage);
expect(cold.client.getQueryData([...queryKeys.issues.detail("task-one"), "core"])).toBeUndefined();
for (const [key, value] of oldRecords) first.storage.setItem(key, value);
events.dispatchEvent(Object.assign(new Event("storage"), { key: QUERY_CACHE_CLEARED, newValue: token }));
expect([...first.storage.values.keys()].filter(key => key.startsWith(QUERY_CACHE_PREFIX))).toHaveLength(0);
const bob = new QueryClient();
bob.setQueryData(queryKeys.auth.session, session("bob"));
const fresh = setup(first.storage, undefined, bob);
seed(bob, "bob", "company-two");
fresh.handle.flush();
events.dispatchEvent(Object.assign(new Event("storage"), { key: QUERY_CACHE_CLEARED, newValue: token }));
expect(first.storage.getItem(QUERY_CACHE_ACTIVE)).toBe("bob");
expect(first.storage.getItem(`${QUERY_CACHE_PREFIX}bob:root`)).toContain('"bob"');
});
});
+479
View File
@@ -0,0 +1,479 @@
import { dehydrate, hashKey, hydrate, type DehydratedState, type Query, type QueryClient } from "@tanstack/react-query";
import { classifyError, isTransientError } from "@/api/errors";
import { detachInflightGets } from "@/api/client";
import { markCachedSession, verifyCachedSession, hasUnverifiedSession } from "./cached-session";
import { queryKeys } from "./queryKeys";
import { createUuid } from "./uuid";
export const QUERY_CACHE_PREFIX = "paperclip:query-cache:v1:";
export const QUERY_CACHE_ACTIVE = `${QUERY_CACHE_PREFIX}active`;
export const QUERY_CACHE_CLEARED = "paperclip:query-cache-cleared";
export const QUERY_CACHE_MAX_AGE = 24 * 60 * 60 * 1000;
export const QUERY_CACHE_MAX_ISSUES = 20;
const MAX_RECORD_BYTES = 2 * 1024 * 1024;
const REVISION_PREFIX = "paperclip:query-cache-revision:";
const controllers = new WeakMap<QueryClient, { clear: () => void }>();
type CacheStorage = Pick<Storage, "getItem" | "setItem" | "removeItem" | "key" | "length">;
type Envelope = { version: 1; userId: string; companyId: string | null; buildId: string; revision: string | null; savedAt: number; state: DehydratedState };
const object = (value: unknown): Record<string, unknown> | null => value != null && typeof value === "object" ? value as Record<string, unknown> : null;
const userIdOf = (value: unknown) => {
const id = object(object(value)?.user)?.id;
return typeof id === "string" && id ? id : null;
};
const recordKey = (userId: string, companyId: string | null) => `${QUERY_CACHE_PREFIX}${encodeURIComponent(userId)}:${companyId === null ? "root" : encodeURIComponent(companyId)}`;
const issueIdentity = (query: { queryKey: readonly unknown[]; state: { data: unknown } }) => String(object(query.state.data)?.id ?? query.queryKey[2]).toLowerCase();
const issueRefs = (query: { queryKey: readonly unknown[]; state: { data: unknown } }) => [query.queryKey[2], object(query.state.data)?.id, object(query.state.data)?.identifier]
.filter((id): id is string => typeof id === "string").map(id => id.toLowerCase());
const excludedFields = new Set(["planDocument", "legacyPlanDocument", "documentSummaries", "documents", "comments", "workProducts", "browsers", "pairingCode", "viewerUrl", "accessToken", "refreshToken", "sessionToken", "token", "password", "apiKey", "privateKey", "authorization", "access_token", "refresh_token", "id_token", "clientSecret", "client_secret", "api_key", "private_key", "credentials", "env"].map(key => key.toLowerCase()));
function displayData(value: unknown, denied: ReadonlySet<string>): unknown {
if (Array.isArray(value)) return value.map(item => displayData(item, denied)).filter(item => item !== undefined);
if (!value || typeof value !== "object" || value instanceof Date) return value;
const row = value as Record<string, unknown>;
if (typeof row.id === "string" && denied.has(row.id.toLowerCase())) return undefined;
return Object.fromEntries(Object.entries(row).filter(([key]) => !excludedFields.has(key.toLowerCase()))
.map(([key, item]) => {
if (key === "runtimeConfig") return [key, {}];
if (key === "adapterConfig") {
const config = object(item);
return [key, Object.fromEntries(["provider", "model", "agent", "acpxAgent", "effort", "modelReasoningEffort", "reasoningEffort", "thinking", "variant"]
.filter(field => typeof config?.[field] === "string").map(field => [field, config![field]]).concat(typeof config?.fastMode === "boolean" ? [["fastMode", config.fastMode]] : []))];
}
return [key, displayData(item, denied)];
}));
}
function isLegacyInboxQuery(key: readonly unknown[]): boolean {
const suffix = key[2] === "mine-by-me" || key[2] === "touched-by-me" ? key.slice(3) : key.slice(2);
return key[0] === "issues" && suffix.length === 4 && suffix[0] === "compact"
&& suffix[1] === "with-routine-executions" && suffix[2] === "live-descendant-summary" && suffix[3] === 500;
}
function queryDisplayData(key: readonly unknown[], data: unknown, denied: ReadonlySet<string>): unknown {
return displayData(isLegacyInboxQuery(key) && Array.isArray(data) ? data.slice(0, 100) : data, denied);
}
function browserStorage(): CacheStorage | null {
try { return typeof window === "undefined" ? null : window.localStorage; } catch { return null; }
}
// Native Storage can change enumeration order after a deletion.
const storageKeys = (storage: CacheStorage) => Array.from({ length: storage.length }, (_, i) => storage.key(i))
.filter((key): key is string => key !== null);
function removeRecords(storage: CacheStorage) {
for (const key of storageKeys(storage)) {
if (key.startsWith(QUERY_CACHE_PREFIX)) storage.removeItem(key);
}
}
function removeUserRecords(storage: CacheStorage, userId: string, keepRevision?: string | null) {
const prefix = `${QUERY_CACHE_PREFIX}${encodeURIComponent(userId)}:`;
for (const key of storageKeys(storage)) {
if (!key.startsWith(prefix)) continue;
if (keepRevision !== undefined) {
try { if ((JSON.parse(storage.getItem(key) ?? "null")?.revision ?? null) === keepRevision) continue; } catch {}
}
storage.removeItem(key);
}
if (storage.getItem(QUERY_CACHE_ACTIVE) === userId && !storage.getItem(recordKey(userId, null))) storage.removeItem(QUERY_CACHE_ACTIVE);
}
export function clearPersistedQueryCache(client: QueryClient) {
controllers.get(client)?.clear();
}
function companyIdsFor(client: QueryClient, userId: string): string[] {
const result = client.getQueryData<{ companies?: Array<{ id: string }> }>(queryKeys.companies.list(userId));
return (result?.companies ?? []).map(company => company.id).filter(id => typeof id === "string" && id.length > 0);
}
export function persistedQueryScope(key: readonly unknown[], data: unknown, userId: string, companyIds: readonly string[]): string | null | undefined {
const [root, second, third] = key;
if (hashKey(key) === hashKey(queryKeys.auth.session)) return userIdOf(data) === userId ? null : undefined;
if (key.length === 1 && root === "health") return null;
if (root === "companies" && second === "list" && third === userId && key.length === 3) return null;
if (root === "access" && second === "current-board-access" && third === userId && key.length === 3 && object(data)?.userId === userId) return null;
if (root === "instance" && key.length === 2 && ["experimental-settings", "general-settings"].includes(String(second))) return null;
if (root === "workspace-preferences" && second === userId && key.length === 2 && object(data)?.userId === userId) return null;
if (root === "sidebar-preferences" && second === "company-order" && third === userId && key.length === 3) return null;
if (root === "plugins" && second === "ui-contributions" && key.length === 2) return null;
if (root === "issues" && second === "detail" && (key.length === 3 || (key.length === 4 && key[3] === "core"))) {
const companyId = object(data)?.companyId;
return typeof companyId === "string" && companyIds.includes(companyId) ? companyId : undefined;
}
if (typeof second !== "string" || !companyIds.includes(second)) return undefined;
if (isLegacyInboxQuery(key) && Array.isArray(data)) return second;
if (["sidebar-badges", "agents", "built-in-agents"].includes(String(root)) && key.length === 2) return second;
if (root === "projects" && key.length <= 3) return second;
if (root === "agent-chats" && key.length === 3 && third === userId) return second;
if (root === "primary-agent" && third === userId && key.length === 3) return second;
if (root === "issues" && third === "mine-context" && key.length === 4 && key[3] === userId && object(data)?.userId === userId) return second;
if (root === "issues" && ["mine-by-me", "touched-by-me"].includes(String(third))
&& key[3] === "index" && key[4] === "with-routine-executions" && key[5] === "live-descendant-summary"
&& key[6] === 100 && (third === "mine-by-me" ? key.length === 8 && key[7] === userId : key.length === 7)) return second;
return undefined;
}
function clearAccountData(client: QueryClient) {
detachInflightGets();
const keep = (query: Query) => hashKey(query.queryKey) === hashKey(queryKeys.health) || hashKey(query.queryKey) === hashKey(queryKeys.auth.session);
void client.cancelQueries({ predicate: query => !keep(query) }, { revert: false });
for (const query of client.getQueryCache().getAll()) {
if (keep(query)) continue;
query.setState({ data: undefined, dataUpdatedAt: 0, error: null, status: "pending", fetchStatus: "idle", dataUpdateCount: 0 });
client.getQueryCache().remove(query);
}
}
export function startPersistedQueryCache(client: QueryClient, options: {
buildId: string | null;
storage?: CacheStorage | null;
now?: () => number;
eventTarget?: Window | null;
debounceMs?: number;
}): { flush: () => void; clear: () => void; dispose: () => void } {
const storage = options.storage === undefined ? browserStorage() : options.storage;
const buildId = options.buildId;
const now = options.now ?? Date.now;
const target = options.eventTarget === undefined ? (typeof window === "undefined" ? null : window) : options.eventTarget;
let owner: string | null = null;
let ownerRevision: string | null = null;
let timer: ReturnType<typeof setTimeout> | undefined;
let suspended = false;
let disposed = false;
let sessionEpoch = 0;
const sessionReads = new WeakMap<Query, { epoch: number; clear: string | null; userId: string | null; revisions: Map<string, string | null> }>();
const visits = new Map<string, number>();
const deniedIssues = new Set<string>();
const blockedQueries = new Set<string>();
const clearToken = () => { try { return storage?.getItem(QUERY_CACHE_CLEARED) ?? null; } catch { return null; } };
let observedClear = clearToken();
const revisionKey = (userId: string) => `${REVISION_PREFIX}${encodeURIComponent(userId)}`;
const revisionFor = (userId: string) => { try { return storage?.getItem(revisionKey(userId)) ?? null; } catch { return null; } };
const captureSessionRead = (query: Query) => {
const userId = owner ?? userIdOf(query.state.data);
const revisions = new Map<string, string | null>();
try {
if (storage) for (let i = 0; i < storage.length; i += 1) {
const key = storage.key(i);
if (key?.startsWith(REVISION_PREFIX)) revisions.set(key, storage.getItem(key));
}
} catch {}
sessionReads.set(query, { epoch: sessionEpoch, clear: clearToken(), userId, revisions });
};
const safeRemove = () => { try { if (storage) removeRecords(storage); } catch {} };
const removeUser = (userId: string | null, preserveCurrent = false) => {
try { if (storage && userId) removeUserRecords(storage, userId, preserveCurrent ? revisionFor(userId) : undefined); } catch {}
};
const cancelTimer = () => { if (timer !== undefined) clearTimeout(timer); timer = undefined; };
const clear = () => {
detachInflightGets();
sessionEpoch += 1;
cancelTimer();
const previous = owner ?? userIdOf(client.getQueryData(queryKeys.auth.session));
const token = createUuid();
const publish = () => {
if (previous) storage?.setItem(revisionKey(previous), token);
observedClear = JSON.stringify({ token, userId: previous });
storage?.setItem(QUERY_CACHE_CLEARED, observedClear);
};
try { publish(); }
catch {
if (previous) removeUser(previous);
else safeRemove();
try { publish(); } catch {}
}
if (previous) removeUser(previous, revisionFor(previous) === token);
else safeRemove();
suspended = true;
owner = null;
ownerRevision = null;
verifyCachedSession(client);
};
// Unstamped bundles do not hydrate or save reads, but sign-out still uses
// the same revocation and cleanup path as a stamped bundle.
if (!buildId?.trim()) {
controllers.set(client, { clear });
return { flush: () => {}, clear, dispose: () => { controllers.delete(client); } };
}
const suspendAccount = () => {
sessionEpoch += 1;
removeUser(owner, true);
suspended = true;
cancelTimer();
owner = null;
ownerRevision = null;
verifyCachedSession(client);
clearAccountData(client);
client.setQueryData(queryKeys.auth.session, null);
void client.refetchQueries({ queryKey: queryKeys.auth.session, type: "active" });
};
const observeStorage = () => {
const token = clearToken();
if (token !== observedClear) {
observedClear = token;
let clearedUser: string | null = null;
try { const id = object(JSON.parse(token ?? "null"))?.userId; clearedUser = typeof id === "string" ? id : null; } catch {}
removeUser(clearedUser, true);
if (!clearedUser) { removeUser(owner); suspendAccount(); return true; }
if (clearedUser === owner && ownerRevision !== revisionFor(clearedUser)) { suspendAccount(); return true; }
}
try {
const active = storage?.getItem(QUERY_CACHE_ACTIVE);
if (owner && active && active !== owner) { suspendAccount(); return true; }
} catch {}
return false;
};
const read = (userId: string, companyId: string | null): Envelope | null => {
if (!storage) return null;
try {
const key = recordKey(userId, companyId);
const raw = storage.getItem(key);
if (!raw) return null;
const entry = JSON.parse(raw) as Envelope;
if (raw.length > MAX_RECORD_BYTES || entry.version !== 1 || entry.userId !== userId || entry.companyId !== companyId
|| entry.buildId !== buildId || (entry.revision ?? null) !== revisionFor(userId) || !Number.isFinite(entry.savedAt) || now() - entry.savedAt < 0
|| now() - entry.savedAt > QUERY_CACHE_MAX_AGE || !Array.isArray(entry.state?.queries)) {
storage.removeItem(key);
return null;
}
return entry;
} catch { return null; }
};
const fresh = (updatedAt: number) => Number.isFinite(updatedAt) && now() - updatedAt >= 0 && now() - updatedAt <= QUERY_CACHE_MAX_AGE;
const savedQueries = (entry: Envelope, companyIds: string[]) => entry.state.queries.filter(query => query?.state?.data !== undefined && query.state.status === "success"
&& fresh(query.state.dataUpdatedAt) && Array.isArray(query.queryKey) && !blockedQueries.has(hashKey(query.queryKey))
&& persistedQueryScope(query.queryKey, query.state.data, entry.userId, companyIds) === entry.companyId);
const readSaved = (userId: string): Envelope[] => {
if (!storage) return [];
const prefix = `${QUERY_CACHE_PREFIX}${encodeURIComponent(userId)}:`;
try {
return storageKeys(storage).flatMap(key => {
if (!key.startsWith(prefix)) return [];
try {
const suffix = key.slice(prefix.length);
const entry = read(userId, suffix === "root" ? null : decodeURIComponent(suffix));
return entry ? [entry] : [];
} catch { return []; }
});
} catch { return []; }
};
const approvedCompanies = (userId: string, entries: Envelope[]) => {
const key = queryKeys.companies.list(userId);
const live = client.getQueryState(key);
if (blockedQueries.has(hashKey(key)) || (live?.status === "error" && !isTransientError(live.error))) return [];
const saved = entries.find(entry => entry.companyId === null);
const cached = saved && savedQueries(saved, []).find(query => hashKey(query.queryKey) === hashKey(key));
const data = live?.data === undefined ? cached?.state.data : live.data;
const companies = object(data)?.companies;
return Array.isArray(companies) ? companies.flatMap(company => {
const id = object(company)?.id;
return typeof id === "string" && id ? [id] : [];
}) : [];
};
type SavedQuery = DehydratedState["queries"][number];
const projectQuery = (query: SavedQuery, userId: string): SavedQuery => {
let data = queryDisplayData(query.queryKey, query.state.data, deniedIssues);
if (hashKey(query.queryKey) === hashKey(queryKeys.auth.session)) {
const user = object(object(data)?.user)!;
data = { session: { userId }, user: { id: userId, name: user.name, email: user.email, image: user.image }, sentryDsn: null };
} else if (Array.isArray(object(data)?.pages)) {
const value = object(data)!;
data = { ...value, pages: (value.pages as unknown[]).slice(0, 1), pageParams: (value.pageParams as unknown[] | undefined)?.slice(0, 1) };
}
return { queryKey: query.queryKey, queryHash: hashKey(query.queryKey), state: { ...query.state, data, error: null, status: "success", fetchStatus: "idle", fetchFailureReason: null, fetchMeta: null } };
};
const allowedIssue = (query: { queryKey: readonly unknown[]; state: { data: unknown } }) => !(query.queryKey[0] === "issues" && query.queryKey[1] === "detail"
&& issueRefs(query).some(id => deniedIssues.has(id)));
const scrubSaved = (userId: string, entries: Envelope[]) => {
if (!storage || ownerRevision !== revisionFor(userId)) return;
const companies = approvedCompanies(userId, entries);
for (const entry of entries) {
if (ownerRevision !== revisionFor(userId)) return;
const key = recordKey(userId, entry.companyId);
const queries = savedQueries(entry, companies).filter(allowedIssue).map(query => projectQuery(query, userId)).filter(query => query.state.data !== undefined);
try { storage.setItem(key, JSON.stringify({ ...entry, state: { mutations: [], queries } })); }
catch { try { storage.removeItem(key); } catch {} }
}
};
const restoreState = (entry: Envelope, companyIds: string[]) => {
const queries = savedQueries(entry, companyIds)
.map(query => ({ queryKey: query.queryKey, queryHash: hashKey(query.queryKey), state: {
data: queryDisplayData(query.queryKey, query.state.data, deniedIssues), dataUpdatedAt: query.state.dataUpdatedAt, dataUpdateCount: 1,
error: null, errorUpdateCount: 0, errorUpdatedAt: 0, fetchFailureCount: 0, fetchFailureReason: null,
fetchMeta: null, isInvalidated: true, status: "success" as const, fetchStatus: "idle" as const,
} }));
hydrate(client, { mutations: [], queries });
};
try {
const current = client.getQueryState(queryKeys.auth.session);
const active = current?.status === "success" ? userIdOf(current.data) : storage?.getItem(QUERY_CACHE_ACTIVE);
const entry = active ? read(active, null) : null;
if (!entry) safeRemove();
const cachedSession = entry?.state.queries.find(query => hashKey(query.queryKey) === hashKey(queryKeys.auth.session));
if (entry && cachedSession?.state.status === "success" && fresh(cachedSession.state.dataUpdatedAt) && userIdOf(cachedSession.state.data) === active) {
owner = active!;
ownerRevision = entry.revision ?? null;
if (current?.data === undefined) markCachedSession(client);
restoreState(entry, []);
const companyIds = companyIdsFor(client, owner);
for (const companyId of companyIds) {
const company = read(owner, companyId);
if (company) restoreState(company, companyIds);
}
} else if (entry) safeRemove();
} catch {}
const pendingSession = client.getQueryCache().find({ queryKey: queryKeys.auth.session, exact: true });
if (pendingSession && pendingSession.state.fetchStatus !== "idle") captureSessionRead(pendingSession);
const flush = () => {
cancelTimer();
if (observeStorage()) return;
if (!storage || disposed || suspended || hasUnverifiedSession(client)) return;
const session = client.getQueryData(queryKeys.auth.session);
const userId = userIdOf(session);
if (!userId) return;
if (owner !== userId) { owner = userId; ownerRevision = revisionFor(userId); }
if (ownerRevision !== revisionFor(userId)) { suspendAccount(); return; }
const entries = readSaved(userId);
const companies = approvedCompanies(userId, entries);
const eligible = (query: Query) => query.state.data !== undefined
&& (query.state.status === "success" || isTransientError(query.state.error))
&& !blockedQueries.has(hashKey(query.queryKey))
&& fresh(query.state.dataUpdatedAt)
&& persistedQueryScope(query.queryKey, query.state.data, userId, companies) !== undefined;
// Memory GC is independent of the on-disk lifetime. Retain valid saved
// reads, prefer newer data, and apply the same limits to the merged set.
const merged = new Map<string, SavedQuery>();
for (const entry of entries) for (const query of savedQueries(entry, companies)) merged.set(hashKey(query.queryKey), query);
for (const query of client.getQueryCache().getAll()) {
if (query.state.status === "error" && !isTransientError(query.state.error)) merged.delete(hashKey(query.queryKey));
}
const state = dehydrate(client, { shouldDehydrateMutation: () => false, shouldDehydrateQuery: eligible });
for (const query of state.queries) {
const key = hashKey(query.queryKey);
if (!merged.has(key) || merged.get(key)!.state.dataUpdatedAt <= query.state.dataUpdatedAt) merged.set(key, query);
}
const recent = [...merged.values()].filter(query => query.queryKey[0] === "issues" && query.queryKey[1] === "detail" && allowedIssue(query))
.sort((a, b) => (visits.get(hashKey(b.queryKey)) ?? b.state.dataUpdatedAt) - (visits.get(hashKey(a.queryKey)) ?? a.state.dataUpdatedAt));
const issueIds = new Set<string>();
for (const query of recent) if (issueIds.size < QUERY_CACHE_MAX_ISSUES) issueIds.add(issueIdentity(query));
const queries = [...merged.values()].filter(query => allowedIssue(query)
&& !(query.queryKey[0] === "issues" && query.queryKey[1] === "detail" && !issueIds.has(issueIdentity(query))))
.map(query => projectQuery(query, userId)).filter(query => query.state.data !== undefined);
try {
const expected = new Set([QUERY_CACHE_ACTIVE]);
for (const companyId of [null, ...companies]) {
const key = recordKey(userId, companyId);
expected.add(key);
const envelope: Envelope = { version: 1, userId, companyId, buildId, revision: ownerRevision, savedAt: now(),
state: { mutations: [], queries: queries.filter(query => persistedQueryScope(query.queryKey, query.state.data, userId, companies) === companyId) } };
const serialized = JSON.stringify(envelope);
if (serialized.length <= MAX_RECORD_BYTES) storage.setItem(key, serialized);
else storage.removeItem(key);
}
const userPrefix = `${QUERY_CACHE_PREFIX}${encodeURIComponent(userId)}:`;
for (const key of storageKeys(storage)) {
if (key.startsWith(userPrefix) && !expected.has(key)) storage.removeItem(key);
}
const active = storage.getItem(QUERY_CACHE_ACTIVE);
if (ownerRevision !== revisionFor(userId) || (active && active !== userId)) { suspendAccount(); return; }
if (active !== userId) storage.setItem(QUERY_CACHE_ACTIVE, userId);
} catch {}
};
const unsubscribe = client.getQueryCache().subscribe(event => {
if (disposed) return;
const sessionEvent = hashKey(event.query.queryKey) === hashKey(queryKeys.auth.session);
const completedRead = sessionEvent && event.type === "updated" && event.action.type === "success" && !event.action.manual
? sessionReads.get(event.query) : undefined;
if (sessionEvent && event.type === "updated" && event.action.type === "fetch") captureSessionRead(event.query);
if (sessionEvent && event.type === "updated" && event.action.type === "success" && !event.action.manual) {
const read = completedRead;
sessionReads.delete(event.query);
// A delayed response cannot verify identity across sign-out, including
// before another tab's storage event has reached this event loop.
if (observeStorage()) return;
let clearedUser: string | null = null;
try { const id = object(JSON.parse(clearToken() ?? "null"))?.userId; clearedUser = typeof id === "string" ? id : null; } catch {}
const next = userIdOf(event.query.state.data);
if (read && (read.epoch !== sessionEpoch
|| [read.userId, next].some(id => id && (read.revisions.get(revisionKey(id)) ?? null) !== revisionFor(id))
|| (read.clear !== clearToken() && (!clearedUser || clearedUser === read.userId || clearedUser === userIdOf(event.query.state.data))))) {
suspendAccount(); return;
}
}
if (sessionEvent && event.type === "updated" && event.action.type === "error"
&& ["auth", "forbidden"].includes(classifyError(event.action.error))) {
clearAccountData(client);
clear();
client.setQueryData(queryKeys.auth.session, null);
return;
}
if (suspended) {
if (!sessionEvent || event.type !== "updated" || event.action.type !== "success" || event.action.manual || !userIdOf(event.query.state.data)) return;
suspended = false;
}
if (sessionEvent && event.type === "updated" && event.action.type === "success") {
if (!event.action.manual) { observedClear = completedRead ? completedRead.clear : clearToken(); verifyCachedSession(client); }
const next = userIdOf(event.query.state.data);
if (owner && next !== owner) {
if (next) removeUser(owner);
clearAccountData(client);
}
if (!next) { clear(); return; }
if (next && (!event.action.manual || next !== owner)) ownerRevision = completedRead
? completedRead.revisions.get(revisionKey(next)) ?? null : revisionFor(next);
owner = next;
}
if (event.type === "observerAdded" && event.query.queryKey[0] === "issues" && event.query.queryKey[1] === "detail") visits.set(event.query.queryHash, now());
const issueDetail = event.query.queryKey[0] === "issues" && event.query.queryKey[1] === "detail";
if (event.type === "updated" && event.action.type === "success" && !event.action.manual) {
blockedQueries.delete(hashKey(event.query.queryKey));
if (issueDetail) for (const id of issueRefs(event.query)) deniedIssues.delete(id);
}
if (event.type === "updated" && event.action.type === "error" && !isTransientError(event.action.error)) {
blockedQueries.add(hashKey(event.query.queryKey));
const userId = owner ?? userIdOf(client.getQueryData(queryKeys.auth.session));
const saved = userId ? readSaved(userId) : [];
if (issueDetail && ["auth", "forbidden", "not_found"].includes(classifyError(event.action.error))) {
const ids = new Set(issueRefs(event.query));
const matches = (query: Query) => query.queryKey[0] === "issues" && query.queryKey[1] === "detail"
&& issueRefs(query).some(id => ids.has(id));
for (const query of [...client.getQueryCache().getAll(), ...saved.flatMap(entry => entry.state.queries)].filter(query => Array.isArray(query?.queryKey) && query.queryKey[0] === "issues" && query.queryKey[1] === "detail" && issueRefs(query).some(id => ids.has(id)))) {
for (const id of issueRefs(query)) ids.add(id);
}
for (const id of ids) deniedIssues.add(id);
void client.cancelQueries({ predicate: matches }, { revert: false });
for (const query of client.getQueryCache().getAll().filter(matches)) query.setState({ data: undefined, dataUpdatedAt: 0,
error: event.action.error, status: "error", fetchStatus: "idle" });
}
if (userId) scrubSaved(userId, saved);
flush(); return;
}
cancelTimer();
timer = setTimeout(flush, options.debounceMs ?? 250);
});
const onStorage = (event: StorageEvent) => {
if (owner && event.key === revisionKey(owner) && event.newValue !== ownerRevision) { suspendAccount(); return; }
if (event.key !== QUERY_CACHE_CLEARED && !(event.key === QUERY_CACHE_ACTIVE && event.newValue !== owner)) return;
if (event.key === QUERY_CACHE_CLEARED && event.newValue !== clearToken()) return;
if (event.key === QUERY_CACHE_CLEARED) {
try { const id = object(JSON.parse(event.newValue ?? "null"))?.userId; if (typeof id === "string") removeUser(id, true); } catch {}
}
observeStorage();
};
const onPageHide = () => flush();
target?.addEventListener("storage", onStorage);
target?.addEventListener("pagehide", onPageHide);
const dispose = () => {
disposed = true;
cancelTimer();
unsubscribe();
target?.removeEventListener("storage", onStorage);
target?.removeEventListener("pagehide", onPageHide);
controllers.delete(client);
};
controllers.set(client, { clear });
return { flush, clear, dispose };
}
+21
View File
@@ -0,0 +1,21 @@
const active = new Set<symbol>();
const listeners = new Set<() => void>();
export function trackPendingWrite(): () => void {
const token = Symbol();
active.add(token);
for (const listener of listeners) listener();
return () => {
if (!active.delete(token)) return;
for (const listener of listeners) listener();
};
}
export function hasPendingWrites(): boolean {
return active.size > 0;
}
export function subscribePendingWrites(listener: () => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
+39
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startServiceWorkerUpdates } from "./service-worker-updates";
import { hasPendingWrites, subscribePendingWrites, trackPendingWrite } from "./reload-safety";
type Listener = () => void;
@@ -42,6 +43,44 @@ function fakeDocument(initialVisibility: DocumentVisibilityState = "visible") {
}
describe("startServiceWorkerUpdates", () => {
it("keeps the installed page while a durable write is in flight and reloads once it settles", () => {
const { container, emit } = fakeContainer({ controlled: true });
const { doc } = fakeDocument("hidden");
const reload = vi.fn();
const finish = trackPendingWrite();
const stop = startServiceWorkerUpdates({ container, documentRef: doc, reload,
canReload: () => !hasPendingWrites(), subscribeReloadSafety: subscribePendingWrites });
emit("controllerchange");
expect(reload).not.toHaveBeenCalled();
finish();
expect(reload).toHaveBeenCalledOnce();
finish();
expect(reload).toHaveBeenCalledOnce();
stop();
});
it("defers an update through an outage and still waits for a hidden tab after recovery", () => {
const { container, emit } = fakeContainer({ controlled: true });
const documentState = fakeDocument("hidden");
const reload = vi.fn();
let safe = false;
let notify = () => {};
const unsubscribe = vi.fn();
const stop = startServiceWorkerUpdates({ container, documentRef: documentState.doc, reload,
canReload: () => safe, subscribeReloadSafety: listener => { notify = listener; return unsubscribe; } });
emit("controllerchange");
expect(reload).not.toHaveBeenCalled();
documentState.doc.visibilityState = "visible";
safe = true;
notify();
expect(reload).not.toHaveBeenCalled();
documentState.doc.visibilityState = "hidden";
documentState.emit("visibilitychange");
expect(reload).toHaveBeenCalledOnce();
stop();
expect(unsubscribe).toHaveBeenCalledOnce();
});
beforeEach(() => {
vi.useFakeTimers();
});
+10
View File
@@ -26,6 +26,8 @@ export function startServiceWorkerUpdates(
documentRef?: Document;
reload?: () => void;
updateIntervalMs?: number;
canReload?: () => boolean;
subscribeReloadSafety?: (listener: () => void) => () => void;
} = {},
): () => void {
const container =
@@ -51,6 +53,10 @@ export function startServiceWorkerUpdates(
let reloadPending = false;
const applyUpdate = () => {
if (reloaded) return;
if (documentRef.visibilityState !== "hidden" || options.canReload?.() === false) {
reloadPending = true;
return;
}
reloaded = true;
reload();
};
@@ -66,6 +72,9 @@ export function startServiceWorkerUpdates(
}
};
container.addEventListener("controllerchange", onControllerChange);
const unsubscribeReloadSafety = options.subscribeReloadSafety?.(() => {
if (reloadPending) applyUpdate();
});
let registration: ServiceWorkerRegistration | undefined;
const checkForUpdates = () => {
@@ -97,5 +106,6 @@ export function startServiceWorkerUpdates(
container.removeEventListener("controllerchange", onControllerChange);
documentRef.removeEventListener("visibilitychange", onVisibilityChange);
clearInterval(intervalId);
unsubscribeReloadSafety?.();
};
}
+15 -8
View File
@@ -29,6 +29,8 @@ import { startServiceWorkerUpdates } from "./lib/service-worker-updates";
import { connectivity } from "./lib/connectivity";
import { bindConnectivity, createAppQueryClient } from "./lib/query-client";
import { captureBrowserException } from "./lib/sentry";
import { startPersistedQueryCache } from "./lib/persisted-query-cache";
import { hasPendingWrites, subscribePendingWrites } from "./lib/reload-safety";
import "@mdxeditor/editor/style.css";
import "./index.css";
@@ -41,14 +43,6 @@ startPerfMeasureReaper();
startChunkLoadRecovery();
// Parked SPA tabs never navigate, so beyond registering the worker this also
// re-checks /sw.js on tab focus and hourly, and applies a discovered update
// with one reload while the tab is hidden — otherwise an old worker and its
// cached shell can outlive a deploy indefinitely.
window.addEventListener("load", () => {
startServiceWorkerUpdates();
});
// The global mutation error toast is raised from the MutationCache, outside
// React; the bridge below hands it the ToastProvider's pushToast.
let pushMutationErrorToast: ((input: ToastInput) => void) | null = null;
@@ -88,6 +82,19 @@ const queryClient = createAppQueryClient(
// Outages pause queries and replayable mutations instead of failing them, and
// live queries refresh once when the server answers again.
bindConnectivity(queryClient, connectivity);
startPersistedQueryCache(queryClient, {
buildId: typeof __PAPERCLIP_BUILD_COMMIT__ === "string" ? __PAPERCLIP_BUILD_COMMIT__ : null,
});
window.addEventListener("load", () => {
startServiceWorkerUpdates({
canReload: () => connectivity.getSnapshot().status === "online"
&& connectivity.getSnapshot().pendingWrites === 0 && queryClient.isMutating() === 0 && !hasPendingWrites(),
subscribeReloadSafety: listener => {
const cleanups = [connectivity.subscribe(listener), queryClient.getMutationCache().subscribe(listener), subscribePendingWrites(listener)];
return () => { for (const cleanup of cleanups) cleanup(); };
},
});
});
function CompanyAwareBreadcrumbProvider({ children }: { children: React.ReactNode }) {
const { selectedCompany } = useCompany();
+301 -1
View File
@@ -40,6 +40,9 @@ import {
shouldScrollIssueDetailToTopOnNavigation,
} from "./IssueDetail";
import { queryKeys } from "../lib/queryKeys";
import { markCachedSession } from "../lib/cached-session";
import { startPersistedQueryCache } from "../lib/persisted-query-cache";
import { clearLocalInboxArchive, filterLocalInboxArchivedIssues, getLocalInboxArchiveIssueIds } from "../lib/inboxArchiveCache";
import { taskPluginPanelActivationKey } from "@/hooks/useTaskPluginPanels";
import {
armIssueDetailInboxQuickArchive,
@@ -1536,6 +1539,239 @@ describe("IssueDetail", () => {
vi.restoreAllMocks();
});
describe.each(["read", "archive"] as const)("cached task %s account gate", action => {
it.each(["cached-owner", "other-owner", null])("waits for fresh identity before applying a write (verified=%s)", async verified => {
const issue = createIssue({ title: "Restored task" });
const verification = createDeferred<{ user: { id: string } } | null>();
mockAuthApi.getSession.mockReturnValue(verification.promise);
queryClient.setQueryData(queryKeys.auth.session, { user: { id: "cached-owner" } });
const cache = startPersistedQueryCache(queryClient, { storage: null, buildId: "test", eventTarget: null });
markCachedSession(queryClient);
mockIssuesApi.get.mockReturnValue(new Promise(() => {}));
const ref = mockRouteParams.issueId!;
queryClient.setQueryData(queryKeys.issues.detail(ref), issue);
queryClient.setQueryData([...queryKeys.issues.detail(ref), "core"], issue);
const inboxKey = [...queryKeys.issues.listMineByMe(issue.companyId), "with-routine-executions", "live-descendant-summary"];
queryClient.setQueryData(inboxKey, [issue]);
mockLocation.state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
mockIssuesApi.markRead.mockClear();
mockIssuesApi.archiveFromInbox.mockClear();
mockNavigate.mockClear();
try {
await act(async () => renderIssueView());
await waitForAssertion(() => expect(container.textContent).toContain(issue.title));
if (action === "archive") {
await waitForAssertion(() => expect(container.querySelector('button[aria-label="More task actions"]')).not.toBeNull());
const more = container.querySelector<HTMLButtonElement>('button[aria-label="More task actions"]');
await act(async () => more!.click());
const archive = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button"))
.find(button => button.textContent?.trim() === "Archive from inbox");
expect(archive).toBeDefined();
await act(async () => archive!.click());
await flushReact();
expect(mockIssuesApi.archiveFromInbox).not.toHaveBeenCalled();
expect(queryClient.getQueryData(inboxKey)).toEqual([issue]);
} else {
await flushReact();
expect(mockIssuesApi.markRead).not.toHaveBeenCalled();
}
expect(mockNavigate).not.toHaveBeenCalled();
await act(async () => { verification.resolve(verified ? { user: { id: verified } } : null); });
await waitForAssertion(() => expect(queryClient.isMutating()).toBe(0));
expect(mockIssuesApi.markRead).toHaveBeenCalledTimes(verified === "cached-owner" ? 1 : 0);
expect(mockIssuesApi.archiveFromInbox).toHaveBeenCalledTimes(action === "archive" && verified === "cached-owner" ? 1 : 0);
if (verified !== "cached-owner") expect(mockNavigate).not.toHaveBeenCalled();
} finally {
verification.resolve(null);
await flushReact();
cache.dispose();
}
});
});
describe.each([false, true])("pending archive account cleanup (unmounted=%s)", unmounted => {
it.each(["success", "failure"] as const)("releases the old account's guard after %s without changing the new account's rows", async outcome => {
const issue = createIssue({ title: "Old account task" });
const freshIssue = { ...issue, title: "Fresh account task" };
const archive = createDeferred<{ id: string; archivedAt: Date }>();
mockAuthApi.getSession.mockResolvedValue({ user: { id: "old-owner" } });
queryClient.setQueryData(queryKeys.auth.session, { user: { id: "old-owner" } });
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.archiveFromInbox.mockReturnValue(archive.promise);
const inboxKey = queryKeys.issues.listMineByMe(issue.companyId);
queryClient.setQueryData(inboxKey, [issue]);
mockLocation.state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
try {
await act(async () => renderIssueView());
await waitForAssertion(() => expect(container.querySelector('button[aria-label="More task actions"]')).not.toBeNull());
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="More task actions"]')!.click());
const button = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button"))
.find(entry => entry.textContent?.trim() === "Archive from inbox")!;
await act(async () => button.click());
await waitForAssertion(() => expect(getLocalInboxArchiveIssueIds(issue.companyId).has(issue.id)).toBe(true));
await waitForAssertion(() => expect(mockIssuesApi.archiveFromInbox).toHaveBeenCalledWith(issue.id));
mockNavigate.mockClear();
mockPushToast.mockClear();
await act(async () => {
queryClient.setQueryData(queryKeys.auth.session, null);
queryClient.setQueryData(queryKeys.auth.session, { user: { id: "new-owner" } });
queryClient.setQueryData(inboxKey, [freshIssue]);
if (unmounted) root.unmount();
});
await act(async () => {
if (outcome === "success") archive.resolve({ id: issue.id, archivedAt: new Date() });
else archive.reject(new Error("Old account archive failed"));
});
await waitForAssertion(() => expect(queryClient.isMutating()).toBe(0));
expect(getLocalInboxArchiveIssueIds(issue.companyId).has(issue.id)).toBe(false);
expect(filterLocalInboxArchivedIssues(issue.companyId, [freshIssue])).toEqual([freshIssue]);
expect(queryClient.getQueryData(inboxKey)).toEqual([freshIssue]);
expect(mockNavigate).not.toHaveBeenCalled();
expect(mockPushToast).not.toHaveBeenCalled();
} finally {
archive.resolve({ id: issue.id, archivedAt: new Date() });
clearLocalInboxArchive(issue.companyId, issue.id);
}
});
});
it.each(["cached-owner", "other-owner", null])("retries an opened task's read receipt after session recovery only for its original account (verified=%s)", async verified => {
const issue = createIssue({ title: "Task awaiting a verified read receipt" });
queryClient.setQueryData(queryKeys.auth.session, { user: { id: "cached-owner" } });
const cache = startPersistedQueryCache(queryClient, { storage: null, buildId: "test", eventTarget: null });
markCachedSession(queryClient);
mockAuthApi.getSession.mockRejectedValue(new ApiError("Try again later", 429, {}));
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.markRead.mockClear();
try {
await act(async () => renderIssueView());
await waitForAssertion(() => expect(queryClient.isMutating()).toBeGreaterThan(0));
await act(async () => {
await vi.waitFor(() => expect(queryClient.isMutating()).toBe(0), { timeout: 3_000, interval: 10 });
});
expect(mockAuthApi.getSession.mock.calls.length).toBeGreaterThanOrEqual(3);
expect(mockIssuesApi.markRead).not.toHaveBeenCalled();
const attempts = mockAuthApi.getSession.mock.calls.length;
await act(async () => renderIssueView());
await flushReact();
expect(queryClient.isMutating()).toBe(0);
expect(mockAuthApi.getSession).toHaveBeenCalledTimes(attempts);
mockAuthApi.getSession.mockResolvedValue(verified ? { user: { id: verified } } : null);
await act(async () => {
await queryClient.fetchQuery({ queryKey: queryKeys.auth.session, queryFn: () => mockAuthApi.getSession(), retry: false, staleTime: 0 });
});
await flushReact();
await waitForAssertion(() => expect(mockIssuesApi.markRead).toHaveBeenCalledTimes(verified === "cached-owner" ? 1 : 0));
await waitForAssertion(() => expect(queryClient.isMutating()).toBe(0));
expect(mockIssuesApi.markRead).toHaveBeenCalledTimes(verified === "cached-owner" ? 1 : 0);
if (verified === "cached-owner") {
await act(async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(mockRouteParams.issueId!) });
await queryClient.fetchQuery({ queryKey: queryKeys.auth.session, queryFn: () => mockAuthApi.getSession(), staleTime: 0 });
});
await flushReact();
expect(mockIssuesApi.markRead).toHaveBeenCalledTimes(1);
}
} finally { cache.dispose(); }
});
it.each(["success", "failure"] as const)("marks every opened cached task before an earlier read finishes with %s", async outcome => {
const tasks = [createIssue(), createIssue({ id: "second-task", identifier: "TASK-2" }), createIssue({ id: "third-task", identifier: "TASK-3" })];
const reads = tasks.map(() => createDeferred<{ id: string; lastReadAt: string }>());
mockAuthApi.getSession.mockResolvedValue({ user: { id: "reader" } });
queryClient.setQueryData(queryKeys.auth.session, { user: { id: "reader" } });
mockIssuesApi.get.mockImplementation(async ref => tasks.find(task => task.id === ref || task.identifier === ref)!);
mockIssuesApi.markRead.mockReset().mockImplementation(id => reads[tasks.findIndex(task => task.id === id)].promise);
try {
for (const [index, task] of tasks.entries()) {
queryClient.setQueryData(queryKeys.issues.detail(task.id), task);
queryClient.setQueryData([...queryKeys.issues.detail(task.id), "core"], task);
mockRouteParams.issueId = task.id;
mockLocation.pathname = `/issues/${task.id}`;
await act(async () => renderIssueView());
await waitForAssertion(() => expect(mockIssuesApi.markRead).toHaveBeenNthCalledWith(index + 1, task.id));
}
expect(queryClient.isMutating()).toBe(3);
await act(async () => root.unmount());
await act(async () => {
if (outcome === "failure") reads[0].reject(new ApiError("Try again later", 429, {}));
else reads[0].resolve({ id: tasks[0].id, lastReadAt: new Date().toISOString() });
for (let index = 1; index < reads.length; index++) reads[index].resolve({ id: tasks[index].id, lastReadAt: new Date().toISOString() });
});
await waitForAssertion(() => expect(queryClient.isMutating()).toBe(0));
expect(mockIssuesApi.markRead).toHaveBeenCalledTimes(3);
} finally {
reads.forEach((read, index) => read.resolve({ id: tasks[index].id, lastReadAt: new Date().toISOString() }));
await flushReact();
}
});
it("reuses an earlier task's pending read and recovers it independently of a later task's read", async () => {
const first = createIssue();
const second = createIssue({ id: "second-task", identifier: "TASK-2" });
const firstRead = createDeferred<{ id: string; lastReadAt: string }>();
const secondRead = createDeferred<{ id: string; lastReadAt: string }>();
mockAuthApi.getSession.mockResolvedValue({ user: { id: "reader" } });
queryClient.setQueryData(queryKeys.auth.session, { user: { id: "reader" } });
mockIssuesApi.get.mockImplementation(async ref => ref === second.id ? second : first);
mockIssuesApi.markRead.mockReset()
.mockReturnValueOnce(firstRead.promise)
.mockReturnValueOnce(secondRead.promise)
.mockResolvedValue({ id: first.id, lastReadAt: new Date().toISOString() });
const openTask = async (task: Issue) => {
queryClient.setQueryData(queryKeys.issues.detail(task.id), task);
queryClient.setQueryData([...queryKeys.issues.detail(task.id), "core"], task);
mockRouteParams.issueId = task.id;
mockLocation.pathname = `/issues/${task.id}`;
await act(async () => renderIssueView());
};
try {
await openTask(first);
await waitForAssertion(() => expect(mockIssuesApi.markRead).toHaveBeenNthCalledWith(1, first.id));
await openTask(second);
await waitForAssertion(() => expect(mockIssuesApi.markRead).toHaveBeenNthCalledWith(2, second.id));
await openTask(first);
await flushReact();
expect(mockIssuesApi.markRead).toHaveBeenCalledTimes(2);
await act(async () => {
await queryClient.fetchQuery({ queryKey: queryKeys.auth.session, queryFn: () => mockAuthApi.getSession(), staleTime: 0 });
});
await flushReact();
expect(mockIssuesApi.markRead).toHaveBeenCalledTimes(2);
await act(async () => firstRead.reject(new ApiError("Try again later", 429, {})));
await waitForAssertion(() => expect(mockIssuesApi.markRead).toHaveBeenNthCalledWith(3, first.id));
await waitForAssertion(() => expect(queryClient.isMutating()).toBe(1));
expect(mockIssuesApi.markRead).toHaveBeenCalledTimes(3);
await act(async () => secondRead.resolve({ id: second.id, lastReadAt: new Date().toISOString() }));
await waitForAssertion(() => expect(queryClient.isMutating()).toBe(0));
expect(mockIssuesApi.markRead).toHaveBeenCalledTimes(3);
} finally {
firstRead.resolve({ id: first.id, lastReadAt: new Date().toISOString() });
secondRead.resolve({ id: second.id, lastReadAt: new Date().toISOString() });
await flushReact();
}
});
it("does not loop a failed read receipt and retries it after a new successful session read", async () => {
const issue = createIssue();
mockAuthApi.getSession.mockResolvedValue({ user: { id: "reader" } });
queryClient.setQueryData(queryKeys.auth.session, { user: { id: "reader" } });
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.markRead.mockClear();
mockIssuesApi.markRead.mockRejectedValueOnce(new ApiError("Try again later", 429, {}));
await act(async () => renderIssueView());
await waitForAssertion(() => expect(mockIssuesApi.markRead).toHaveBeenCalledTimes(1));
await waitForAssertion(() => expect(queryClient.isMutating()).toBe(0));
await act(async () => renderIssueView());
await flushReact();
expect(mockIssuesApi.markRead).toHaveBeenCalledTimes(1);
await act(async () => {
await queryClient.fetchQuery({ queryKey: queryKeys.auth.session, queryFn: () => mockAuthApi.getSession(), staleTime: 0 });
});
await waitForAssertion(() => expect(mockIssuesApi.markRead).toHaveBeenCalledTimes(2));
await waitForAssertion(() => expect(queryClient.isMutating()).toBe(0));
});
it.each(["new-project", null])("remembers project %s only after its task update succeeds", async (projectId) => {
const issue = createIssue({ projectId: "original-project" });
trackRecentProject("original-project", issue.companyId);
@@ -1579,6 +1815,31 @@ describe("IssueDetail", () => {
await waitForAssertion(() => expect(mockIssueChatThreadRender).toHaveBeenCalled());
});
it("keeps a restored title draft when a fresh task read finds a newer title", async () => {
inlineEditorFixture.real = true;
coordinatorPreference.loaded = false;
const issue = createIssue({ title: "Cached title A" });
const fresh = { ...issue, title: "New title C" };
const owner = "title-draft-user";
queryClient.setQueryData(queryKeys.auth.session, { user: { id: owner } });
mockAuthApi.getSession.mockResolvedValue({ user: { id: owner } });
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.getCore.mockImplementation(async (_id, options) => options?.cache === "no-store" ? fresh : issue);
mockIssuesApi.update.mockClear();
const key = `paperclip:inline-draft:v2:${owner}:issue:${issue.id}:title`;
localStorage.setItem(key, JSON.stringify({ version: 1, draft: "Restored title B", base: issue.title }));
await act(async () => renderIssueView());
await waitForAssertion(() => expect(mockIssuesApi.getCore).toHaveBeenCalledWith(issue.id, { cache: "no-store", fresh: true }));
expect(mockIssuesApi.update).not.toHaveBeenCalled();
await act(async () => {
queryClient.setQueryData(queryKeys.issues.detail(issue.identifier!), fresh);
queryClient.setQueryData([...queryKeys.issues.detail(issue.identifier!), "core"], fresh);
});
expect(container.querySelector<HTMLTextAreaElement>('[data-testid="issue-detail-header"] textarea')?.value).toBe("Restored title B");
expect(localStorage.getItem(key)).toContain("Restored title B");
expect(mockIssuesApi.update).not.toHaveBeenCalled();
});
it.each(["save", "failure", "escape", "unchanged", "navigate"])("retains the early title through preference readiness until %s", async (exit) => {
inlineEditorFixture.real = true;
coordinatorPreference.loaded = false;
@@ -1908,13 +2169,52 @@ describe("IssueDetail", () => {
await act(async () => renderIssueView(<TaskDetailSurface conversation={{ agent, issue: existing ? canonical : null, ensureIssue }} />));
await flushReact();
const props = mockIssueChatThreadRender.mock.calls.at(-1)![0];
mockIssuesApi.getCore.mockResolvedValueOnce({ ...canonical, assigneeAdapterOverrides: { useProjectWorkspace: true, adapterConfig: { chrome: true, command: "preserved-command", model: "old-model" } } });
await act(async () => props.onAdd("Outline the launch", undefined, undefined, undefined, "model-message", { model: "gpt-6-astra", effort: "high", fast: false }));
expect(mockIssuesApi.getCore).toHaveBeenCalledWith(canonical.id, { cache: "no-store", fresh: true });
expect(ensureIssue).toHaveBeenCalledOnce();
expect(mockIssuesApi.update).toHaveBeenCalledWith(canonical.id, { assigneeAdapterOverrides: { adapterConfig: { model: "gpt-6-astra", modelReasoningEffort: "high" } } });
expect(mockIssuesApi.update).toHaveBeenCalledWith(canonical.id, { assigneeAdapterOverrides: { useProjectWorkspace: true, adapterConfig: { chrome: true, command: "preserved-command", model: "gpt-6-astra", modelReasoningEffort: "high" } } });
expect(mockIssuesApi.addComment).toHaveBeenCalledWith(canonical.id, "Outline the launch", undefined, undefined, undefined, "model-message");
expect(calls).toEqual(["settings", "comment"]);
});
it("preserves complete task options when sending with model preferences outside a conversation", async () => {
const canonical = createIssue({ assigneeAgentId: "agent-1", conversationAgentId: null });
mockAgentsApi.list.mockResolvedValue([createAgent()]);
mockIssuesApi.get.mockResolvedValue(canonical);
await act(async () => renderIssueView());
await flushReact();
mockIssuesApi.update.mockClear().mockResolvedValue(canonical);
mockIssuesApi.getCore.mockResolvedValueOnce({ ...canonical, assigneeAdapterOverrides: { adapterConfig: { chrome: true, command: "preserved-command" } } });
const props = mockIssueChatThreadRender.mock.calls.at(-1)![0];
await act(async () => props.onAdd("Use this model", undefined, undefined, undefined, "task-model-message", { model: "gpt-6-astra", effort: "high", fast: false }));
expect(mockIssuesApi.getCore).toHaveBeenCalledWith(canonical.identifier, { cache: "no-store", fresh: true });
expect(mockIssuesApi.update).toHaveBeenCalledWith(canonical.identifier, expect.objectContaining({
comment: "Use this model",
assigneeAdapterOverrides: { adapterConfig: { chrome: true, command: "preserved-command", model: "gpt-6-astra", modelReasoningEffort: "high" } },
}));
});
it.each(["failed", "changed-account"])("does not replace settings or send after a %s fresh task read", async (outcome) => {
const agent = createAgent();
const canonical = createIssue({ conversationAgentId: agent.id, conversationUserId: "account-a" });
mockAuthApi.getSession.mockResolvedValue({ user: { id: "account-a" } });
mockIssuesApi.get.mockResolvedValue(canonical);
await act(async () => renderIssueView(<TaskDetailSurface conversation={{ agent, issue: canonical, ensureIssue: async () => canonical }} />));
await flushReact();
mockIssuesApi.update.mockClear();
mockIssuesApi.addComment.mockClear();
mockIssuesApi.getCore.mockImplementationOnce(async () => {
if (outcome === "failed") throw new Error("Task refresh failed");
queryClient.setQueryData(queryKeys.auth.session, { user: { id: "account-b" } });
return canonical;
});
const props = mockIssueChatThreadRender.mock.calls.at(-1)![0];
await act(async () => expect(props.onAdd("Outline the launch", undefined, undefined, undefined, "fresh-task-message", { model: "gpt-6-astra", effort: "high", fast: false })).rejects.toThrow());
expect(mockIssuesApi.update).not.toHaveBeenCalled();
expect(mockIssuesApi.addComment).not.toHaveBeenCalled();
});
it("does not send a chat message when its selected model preferences fail to save", async () => {
const agent = createAgent();
const canonical = createIssue({ conversationAgentId: agent.id, conversationUserId: "user-1" });
+85 -31
View File
@@ -7,6 +7,7 @@ import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceE
import { CoordinatorAgentSwitcher } from "@/components/CoordinatorAgentSwitcher";
import { TaskDecisionRecord } from "@/components/TaskDecisionRecord";
import { useAccountBoundMutation } from "@/hooks/useAccountBoundMutation";
import { bindAccount } from "@/lib/account-scope";
import { mergeWindowComments, useIssueHistoryWindow } from "../hooks/useIssueHistoryWindow";
import type { IssueQueuedCommentQueue } from "@paperclipai/shared";
import { useTaskRunLiveUpdates } from "../context/LiveUpdatesProvider";
@@ -3291,7 +3292,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] =
useState(0);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
const readReceiptRef = useRef<{
issueId: string;
account: ReturnType<typeof bindAccount>;
attemptedSessionAt?: number;
confirmed: boolean;
} | null>(null);
const pendingReadReceiptsRef = useRef(new Map<string, NonNullable<typeof readReceiptRef.current>>());
const [readReceiptRevision, setReadReceiptRevision] = useState(0);
const lastScrollIssueIdRef = useRef<string | undefined>(undefined);
const commentComposerRef = useRef<IssueChatComposerHandle | null>(null);
const cancelledQueuedOptimisticCommentIdsRef = useRef(new Set<string>());
@@ -3729,7 +3737,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
),
});
const { data: session, isFetched: sessionResolved } = useQuery({
const { data: session, isFetched: sessionResolved, dataUpdatedAt: sessionUpdatedAt } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
@@ -4504,9 +4512,13 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
[queryClient, selectedCompanyId],
);
const markIssueRead = useMutation({
mutationFn: (id: string) => issuesApi.markRead(id),
onSuccess: () => {
const markIssueRead = useAccountBoundMutation({
mutationFn: (receipt: NonNullable<typeof readReceiptRef.current>) => {
receipt.attemptedSessionAt = queryClient.getQueryState(queryKeys.auth.session)?.dataUpdatedAt ?? 0;
return issuesApi.markRead(receipt.issueId);
},
onSuccess: (_data, receipt) => {
receipt.confirmed = true;
if (selectedCompanyId) {
queryClient.invalidateQueries({
queryKey: queryKeys.issues.listMineByMe(selectedCompanyId),
@@ -4522,6 +4534,11 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
});
}
},
onFinished: (receipt) => {
pendingReadReceiptsRef.current.delete(receipt.issueId);
// An older read can settle after navigation has changed the mutation observer.
if (readReceiptRef.current === receipt) setReadReceiptRevision(revision => revision + 1);
},
});
const updateIssueMutation = useUpdateIssueMutation({
@@ -5012,9 +5029,13 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
// Conversation messages use the ordered, idempotent comments endpoint.
// Save the conversation's run preferences before admitting the next message.
if (runSettings && issue?.conversationAgentId) {
// Persisted task data contains display fields only. Read the complete
// overrides before replacing them so unrelated options survive a send.
const freshIssue = await issuesApi.getCore(writableIssueId, { cache: "no-store", fresh: true });
assertCurrentAccount();
const updated = await issuesApi.update(writableIssueId, {
assigneeAdapterOverrides: mergeComposerRunSettings(
issue.assigneeAdapterOverrides,
freshIssue.assigneeAdapterOverrides,
(conversation?.agent ?? agentMap.get(issue.conversationAgentId))?.adapterType,
runSettings,
),
@@ -5406,8 +5427,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
},
});
const addCommentAndReassign = useMutation({
mutationFn: ({
const addCommentAndReassign = useAccountBoundMutation({
mutationFn: async ({
body,
reopen,
interrupt,
@@ -5423,8 +5444,12 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
attachmentIds?: string[];
clientRequestId?: string;
runSettings?: ComposerRunSettings;
}) =>
issuesApi.update(issueId!, {
}, { assertCurrentAccount }) => {
const freshIssue = runSettings && !reassignment
? await issuesApi.getCore(issueId!, { cache: "no-store", fresh: true })
: issue;
assertCurrentAccount();
return issuesApi.update(issueId!, {
comment: body,
commentClientRequestId: clientRequestId,
...(attachmentIds?.length ? { attachmentIds } : {}),
@@ -5435,8 +5460,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
...(runSettings || reassignment ? {
assigneeAdapterOverrides: runSettings
? mergeComposerRunSettings(
issue?.assigneeAdapterOverrides,
agentMap.get(reassignment?.assigneeAgentId ?? issue?.assigneeAgentId ?? "")?.adapterType,
freshIssue?.assigneeAdapterOverrides,
agentMap.get(reassignment?.assigneeAgentId ?? freshIssue?.assigneeAgentId ?? "")?.adapterType,
runSettings,
Boolean(reassignment),
)
@@ -5444,7 +5469,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
} : {}),
...(reopen ? { status: "todo" } : {}),
...(interrupt ? { interrupt } : {}),
}),
});
},
onMutate: async ({ body, reopen, reassignment, interrupt }) => {
// Cache cancellation can wait on an active request for several seconds.
// Start it now, but paint the optimistic echo before awaiting it so a
@@ -5853,19 +5879,29 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
},
});
const archiveFromInbox = useMutation({
const archiveFromInbox = useAccountBoundMutation({
mutationFn: (id: string) => issuesApi.archiveFromInbox(id),
onMutate: async (id) => {
if (!selectedCompanyId)
return { previousData: [] as InboxIssueCacheSnapshot };
beginLocalInboxArchive(selectedCompanyId, id);
await cancelInboxIssueQueries(queryClient, selectedCompanyId);
const account = bindAccount(queryClient);
const release = beginLocalInboxArchive(selectedCompanyId, id);
try {
await cancelInboxIssueQueries(queryClient, selectedCompanyId);
} catch (error) {
release();
throw error;
}
if (!account.isCurrentScope()) {
release();
return { companyId: selectedCompanyId, previousData: [] as InboxIssueCacheSnapshot, account, release };
}
const previousData = snapshotInboxIssueCaches(
queryClient,
selectedCompanyId,
);
removeIssueFromInboxCaches(queryClient, selectedCompanyId, id);
return { companyId: selectedCompanyId, previousData };
return { companyId: selectedCompanyId, previousData, account, release };
},
onSuccess: (_data, id, context) => {
if (selectedCompanyId) {
@@ -5894,7 +5930,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
});
},
onError: (err, id, context) => {
if (context?.companyId) clearLocalInboxArchive(context.companyId, id);
context?.release?.();
if (context?.previousData) {
restoreIssueToInboxCaches(queryClient, context.previousData, id);
}
@@ -5907,18 +5943,25 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
tone: "error",
});
},
onFinished: (_id, context) => {
if (context?.account && !context.account.isCurrentScope()) context.release();
},
onSettled: async (_data, error, id, context) => {
if (!context?.companyId) return;
if (!error) boundLocalInboxArchive(context.companyId, id);
await invalidateInboxIssueQueries(queryClient, context.companyId);
if (!error) {
const presence = getIssuePresenceInActiveInboxCaches(
queryClient,
context.companyId,
id,
);
if (presence !== "unknown")
confirmLocalInboxArchive(context.companyId, id);
try {
await invalidateInboxIssueQueries(queryClient, context.companyId);
if (!error && context.account?.isCurrentScope()) {
const presence = getIssuePresenceInActiveInboxCaches(
queryClient,
context.companyId,
id,
);
if (presence !== "unknown")
confirmLocalInboxArchive(context.companyId, id);
}
} finally {
if (context.account && !context.account.isCurrentScope()) context.release();
}
},
});
@@ -6083,10 +6126,17 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
useEffect(() => {
if (!issueId || !issue?.id) return;
if (issue.conversationAgentId) return;
if (lastMarkedReadIssueIdRef.current === issue.id) return;
lastMarkedReadIssueIdRef.current = issue.id;
markIssueRead.mutate(issue.id);
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const receipt = readReceiptRef.current?.issueId === issue.id
? readReceiptRef.current
: (readReceiptRef.current = pendingReadReceiptsRef.current.get(issue.id)
?? { issueId: issue.id, account: bindAccount(queryClient), confirmed: false });
if (receipt.confirmed || pendingReadReceiptsRef.current.has(issue.id) || !receipt.account.isCurrentScope()) return;
// Retry after a successful session read, without looping on a failed check.
if (receipt.attemptedSessionAt === sessionUpdatedAt) return;
receipt.attemptedSessionAt = sessionUpdatedAt;
pendingReadReceiptsRef.current.set(issue.id, receipt);
markIssueRead.mutate(receipt);
}, [issueId, issue?.id, issue?.conversationAgentId, sessionUpdatedAt, readReceiptRevision]); // eslint-disable-line react-hooks/exhaustive-deps
const mediaGalleryItems = useMemo<GalleryMediaItem[]>(() => {
const items: GalleryMediaItem[] = [];
@@ -7353,6 +7403,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
onSave={(title) => updateIssue.mutateAsync({ title })}
onEditingChange={handleLoadingTitleEditingChange}
draftKey={`issue:${issue.id}:title`}
readCurrentValue={async () => (await issuesApi.getCore(issue.id, { cache: "no-store", fresh: true })).title}
as="h2"
className="text-xl font-semibold leading-normal"
/>
@@ -7563,6 +7614,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
value={issue.title}
onSave={(title) => updateIssue.mutateAsync({ title })}
draftKey={`issue:${issue.id}:title`}
readCurrentValue={async () => (await issuesApi.getCore(issue.id, { cache: "no-store", fresh: true })).title}
as="h2"
className={cn(
"min-w-0 text-balance",
@@ -7985,6 +8037,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
value={issue.title}
onSave={(title) => updateIssue.mutateAsync({ title })}
draftKey={`issue:${issue.id}:title`}
readCurrentValue={async () => (await issuesApi.getCore(issue.id, { cache: "no-store", fresh: true })).title}
as="h2"
className={
taskChatShellEnabled
@@ -8013,6 +8066,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
value={issue.description ?? ""}
onSave={(description) => updateIssue.mutateAsync({ description })}
draftKey={`issue:${issue.id}:description`}
readCurrentValue={async () => (await issuesApi.getCore(issue.id, { cache: "no-store", fresh: true })).description ?? ""}
as="p"
className="text-sm leading-7 text-foreground"
placeholder="Add a description..."
+44
View File
@@ -7,6 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { sanitizeAssetNamespace } from "@paperclipai/shared";
import { ApiError } from "@/api/client";
import { queryKeys } from "../lib/queryKeys";
import { markCachedSession } from "../lib/cached-session";
import { startPersistedQueryCache } from "../lib/persisted-query-cache";
import { ProfileSettings } from "./ProfileSettings";
const mockAuthApi = vi.hoisted(() => ({
@@ -266,4 +268,46 @@ describe("ProfileSettings", () => {
await act(async () => root.unmount());
});
it.each(["user-1", "user-2"])("waits for session verification before saving a restored profile (%s)", async (verifiedUser) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } });
queryClient.setQueryData(queryKeys.auth.session, {
session: { id: "session-1", userId: "user-1" },
user: { id: "user-1", name: "Jane Example", email: "jane@example.com", image: null },
});
const cache = startPersistedQueryCache(queryClient, { storage: null, buildId: "test", eventTarget: null });
markCachedSession(queryClient);
let complete!: (value: unknown) => void;
mockAuthApi.getSession.mockReturnValue(new Promise((resolve) => { complete = resolve; }));
const root = await renderPage(queryClient);
try {
expect(container.textContent).toContain("Jane Example");
await act(async () => {
container.querySelector("form")?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
await flushReact();
expect(mockAuthApi.getSession).toHaveBeenCalled();
expect(mockAuthApi.updateProfile).not.toHaveBeenCalled();
await act(async () => {
complete({
session: { id: "session-2", userId: verifiedUser },
user: { id: verifiedUser, name: "Verified User", email: "verified@example.com", image: null },
});
});
await flushReact();
await flushReact();
if (verifiedUser === "user-1") {
expect(mockAuthApi.updateProfile).toHaveBeenCalledTimes(1);
expect(mockAuthApi.updateProfile).toHaveBeenCalledWith({ name: "Jane Example", image: null });
} else {
expect(mockAuthApi.updateProfile).not.toHaveBeenCalled();
}
} finally {
await act(async () => root.unmount());
cache.dispose();
queryClient.clear();
}
});
});
+15 -9
View File
@@ -1,8 +1,9 @@
import { useEffect, useId, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Camera, LoaderCircle, Save, Trash2, UserRoundPen } from "lucide-react";
import type { AuthSession, CurrentUserProfile, UpdateCurrentUserProfile } from "@paperclipai/shared";
import { authApi } from "@/api/auth";
import { useAccountBoundMutation } from "@/hooks/useAccountBoundMutation";
import { assetsApi } from "@/api/assets";
import { QueryErrorState, useQueryView } from "@/components/QueryView";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
@@ -64,8 +65,13 @@ export function ProfileSettings() {
});
}
async function persistProfile(input: UpdateCurrentUserProfile) {
// A restored session can show one account while the cookie belongs to
// another, and the server saves for the cookie's user. Account-bound
// mutations wait for a verified session and abort if the user changed.
async function persistProfile(input: UpdateCurrentUserProfile, assertCurrentAccount: () => void) {
assertCurrentAccount();
const profile = await authApi.updateProfile(input);
assertCurrentAccount();
syncSessionProfile(profile);
return profile;
}
@@ -74,8 +80,8 @@ export function ProfileSettings() {
return name.trim() || sessionQuery.data?.user.name || "Board";
}
const updateMutation = useMutation({
mutationFn: (input: UpdateCurrentUserProfile) => persistProfile(input),
const updateMutation = useAccountBoundMutation({
mutationFn: (input: UpdateCurrentUserProfile, { assertCurrentAccount }) => persistProfile(input, assertCurrentAccount),
onSuccess: (profile) => {
setActionError(null);
setName(profile.name ?? "");
@@ -86,8 +92,8 @@ export function ProfileSettings() {
},
});
const uploadAvatarMutation = useMutation({
mutationFn: async (file: File) => {
const uploadAvatarMutation = useAccountBoundMutation({
mutationFn: async (file: File, { assertCurrentAccount }) => {
if (!selectedCompanyId) {
throw new Error("Select an organization before uploading a profile avatar.");
}
@@ -97,7 +103,7 @@ export function ProfileSettings() {
file,
`profiles/${sessionQuery.data?.user.id ?? "board-user"}`,
);
return persistProfile({ name: resolveProfileName(), image: asset.contentPath });
return persistProfile({ name: resolveProfileName(), image: asset.contentPath }, assertCurrentAccount);
},
onSuccess: (profile) => {
setActionError(null);
@@ -109,8 +115,8 @@ export function ProfileSettings() {
},
});
const removeAvatarMutation = useMutation({
mutationFn: () => persistProfile({ name: resolveProfileName(), image: null }),
const removeAvatarMutation = useAccountBoundMutation({
mutationFn: (_input, { assertCurrentAccount }) => persistProfile({ name: resolveProfileName(), image: null }, assertCurrentAccount),
onSuccess: (profile) => {
setActionError(null);
setName(profile.name ?? "");